#13 Conversion to slash commands

Yhdistetty
ialbert yhdistetty 24 committia lähteestä slash kohteeseen main 2 kuukautta sitten

+ 2
- 2
config.sample.py Näytä tiedosto

@@ -7,8 +7,6 @@ CONFIG = {
7 7
 
8 8
 	# Client token obtained from the Discord application dashboard. See setup documentation.
9 9
 	'client_token': '<REQUIRED>',
10
-    # Bot commands will be invoked by posting a message with this prefix.
11
-	'command_prefix': '$rb_',
12 10
     # Path to a directory where guild-specific preferences will be stored.
13 11
     # Each guild's settings will be saved as a JSON file. Path should end with a slash.
14 12
 	'config_path': 'config/',
@@ -46,6 +44,8 @@ CONFIG = {
46 44
 	'info_emoji': 'ℹ️',
47 45
 	# Logging something that happened on the server (e.g. a user joined the server)
48 46
 	'log_emoji': '📋',
47
+	# A task is in progress
48
+	'wait_emoji': '⏳',
49 49
 
50 50
     # -----------------------------------------------------------------------
51 51
     # Default values for cog-specific settings that a guild has not overridden

+ 47
- 15
rocketbot/bot.py Näytä tiedosto

@@ -5,22 +5,27 @@ from discord import Intents
5 5
 from discord.ext import commands
6 6
 
7 7
 from config import CONFIG
8
-from rocketbot.utils import bot_log
8
+from rocketbot.cogsetting import CogSetting
9
+from rocketbot.utils import bot_log, dump_stacktrace
10
+
9 11
 
10 12
 class Rocketbot(commands.Bot):
11 13
 	"""
12 14
 	Bot subclass
13 15
 	"""
14
-	def __init__(self, command_prefix, **kwargs):
15
-		super().__init__(command_prefix, **kwargs)
16
+	def __init__(self, **kwargs):
17
+		# Bot requires command_prefix, even though we're only using slash commands,
18
+		# not commands in message content. Giving an unlikely prefix of private-use
19
+		# Unicode characters. This isn't a security thing, just avoiding showing a
20
+		# "command not found" error if someone happens to start a message with
21
+		# something more common.
22
+		super().__init__(command_prefix='\uED1E\uEA75\uF00D', **kwargs)
23
+		self.__commands_set_up = False
24
+		self.__first_ready = True
16 25
 
17 26
 	async def on_command_error(self, context: commands.Context, exception: BaseException) -> None:
18 27
 		bot_log(None, None, f'Command error')
19
-		ex = exception
20
-		while ex is not None:
21
-			print(f'Caused by {ex}')
22
-			traceback.print_exception(type(ex), ex, ex.__traceback__)
23
-			ex = ex.__cause__ if ex.__cause__ != ex else None
28
+		dump_stacktrace(exception)
24 29
 		if context.guild is None or \
25 30
 				context.message.channel is None or \
26 31
 				context.message.author.bot:
@@ -43,6 +48,32 @@ class Rocketbot(commands.Bot):
43 48
 			f'	event kwargs: {kwargs}' + \
44 49
 			traceback.format_exc())
45 50
 
51
+	async def on_ready(self):
52
+		if self.__first_ready:
53
+			self.__first_ready = False
54
+			if not self.__commands_set_up:
55
+				await self.__set_up_commands()
56
+			bot_log(None, None, 'Bot done initializing')
57
+			bot_log(None, None, f"Type /help in Discord for available commands.")
58
+			print('----------------------------------------------------------')
59
+
60
+	async def __set_up_commands(self):
61
+		if self.__commands_set_up:
62
+			return
63
+		self.__commands_set_up = True
64
+		for cog in self.cogs.values():
65
+			from rocketbot.cogs.basecog import BaseCog
66
+			if isinstance(cog, BaseCog):
67
+				bcog: BaseCog = cog
68
+				if len(bcog.settings) > 0:
69
+					CogSetting.set_up_all(bcog, self, bcog.settings)
70
+		try:
71
+			synced_commands = await self.tree.sync()
72
+			for command in synced_commands:
73
+				bot_log(None, None, f'Synced command: /{command.name}')
74
+		except Exception as e:
75
+			dump_stacktrace(e)
76
+
46 77
 # Current active bot instance
47 78
 rocketbot: Optional[Rocketbot] = None
48 79
 
@@ -52,18 +83,19 @@ def __create_bot():
52 83
 		return
53 84
 	bot_log(None, None, 'Creating bot...')
54 85
 	intents = Intents.default()
55
-	intents.messages = True  # pylint: disable=assigning-non-slot
56
-	intents.message_content = True  # pylint: disable=assigning-non-slot
57
-	intents.members = True  # pylint: disable=assigning-non-slot
86
+	intents.messages = True
87
+	intents.message_content = True
88
+	intents.members = True
58 89
 	intents.presences = True
59
-	rocketbot = Rocketbot(command_prefix=CONFIG['command_prefix'], intents=intents)
90
+	rocketbot = Rocketbot(intents=intents)
60 91
 __create_bot()
61 92
 
62 93
 from rocketbot.cogs.autokickcog import AutoKickCog
63 94
 from rocketbot.cogs.configcog import ConfigCog
64 95
 from rocketbot.cogs.crosspostcog import CrossPostCog
96
+from rocketbot.cogs.gamescog import GamesCog
65 97
 from rocketbot.cogs.generalcog import GeneralCog
66
-from rocketbot.cogs.joinagecog import JoinAgeCog
98
+from rocketbot.cogs.helpcog import HelpCog
67 99
 from rocketbot.cogs.joinraidcog import JoinRaidCog
68 100
 from rocketbot.cogs.logcog import LoggingCog
69 101
 from rocketbot.cogs.patterncog import PatternCog
@@ -72,7 +104,6 @@ from rocketbot.cogs.usernamecog import UsernamePatternCog
72 104
 
73 105
 async def start_bot():
74 106
 	bot_log(None, None, 'Bot initializing...')
75
-	bot_log(None, None, f"Type {CONFIG['command_prefix']}help in Discord for available commands.")
76 107
 
77 108
 	# Core
78 109
 	await rocketbot.add_cog(GeneralCog(rocketbot))
@@ -81,7 +112,8 @@ async def start_bot():
81 112
 	# Optional
82 113
 	await rocketbot.add_cog(AutoKickCog(rocketbot))
83 114
 	await rocketbot.add_cog(CrossPostCog(rocketbot))
84
-	await rocketbot.add_cog(JoinAgeCog(rocketbot))
115
+	await rocketbot.add_cog(GamesCog(rocketbot))
116
+	await rocketbot.add_cog(HelpCog(rocketbot))
85 117
 	await rocketbot.add_cog(JoinRaidCog(rocketbot))
86 118
 	await rocketbot.add_cog(LoggingCog(rocketbot))
87 119
 	await rocketbot.add_cog(PatternCog(rocketbot))

+ 39
- 35
rocketbot/cogs/autokickcog.py Näytä tiedosto

@@ -1,7 +1,8 @@
1 1
 from datetime import datetime, timedelta
2 2
 
3 3
 from discord import Guild, Member, Status
4
-from discord.ext import commands, tasks
4
+from discord.ext import tasks
5
+from discord.ext.commands import Cog
5 6
 from discord.ext.tasks import Loop
6 7
 
7 8
 from config import CONFIG
@@ -32,28 +33,42 @@ class AutoKickCog(BaseCog, name='Auto Kick'):
32 33
 	"""
33 34
 	Cog for automatically kicking ALL new joins. For temporary use during join raids.
34 35
 	"""
35
-	SETTING_ENABLED = CogSetting('enabled', bool,
36
+	SETTING_ENABLED = CogSetting(
37
+		'enabled',
38
+		bool,
39
+		default_value=False,
36 40
 		brief='autokick',
37
-		description='Whether this cog is enabled for a guild.')
38
-	SETTING_BAN_COUNT = CogSetting('bancount', int,
39
-			brief='number of repeat kicks before a ban',
40
-			description='The number of times a user can join and be kicked ' + \
41
-					'before the next rejoin will result in a ban. A value of 0 ' + \
41
+		description='Whether this module is enabled for a guild.',
42
+	)
43
+	SETTING_BAN_COUNT = CogSetting(
44
+		'bancount',
45
+		int,
46
+		default_value=0,
47
+		brief='number of repeat kicks before a ban',
48
+		description='The number of times a user can join and be kicked '
49
+					'before the next rejoin will result in a ban. A value of 0 '
42 50
 					'disables this feature (only kick, never ban).',
43
-			usage='<count:int>',
44
-			min_value=0)
45
-	SETTING_OFFLINE_ONLY = CogSetting('offlineonly', bool,
46
-			brief='whether to only kick users whose status is offline',
47
-			description='Compromised accounts may have a status of offline. ' + \
48
-					'If this setting is enabled, the user\'s status will be ' + \
49
-					'checked a few seconds after joining. If it is offline ' + \
51
+		min_value=0,
52
+	)
53
+	SETTING_OFFLINE_ONLY = CogSetting(
54
+		'offlineonly',
55
+		bool,
56
+		default_value=False,
57
+		brief='whether to only kick users whose status is offline',
58
+		description='Compromised accounts may have a status of offline. '
59
+					'If this setting is enabled, the user\'s status will be '
60
+					'checked a few seconds after joining. If it is offline '
50 61
 					'they will be kicked.',
51
-			usage='<true|false>')
62
+	)
52 63
 
53 64
 	STATE_KEY_RECENT_KICKS = "AutoKickCog.recent_joins"
54 65
 
55 66
 	def __init__(self, bot):
56
-		super().__init__(bot)
67
+		super().__init__(
68
+			bot,
69
+			config_prefix='autokick',
70
+			short_description='Automatically kicks all new users as soon as they join.',
71
+		)
57 72
 		self.add_setting(AutoKickCog.SETTING_ENABLED)
58 73
 		self.add_setting(AutoKickCog.SETTING_BAN_COUNT)
59 74
 		self.add_setting(AutoKickCog.SETTING_OFFLINE_ONLY)
@@ -61,17 +76,7 @@ class AutoKickCog(BaseCog, name='Auto Kick'):
61 76
 		timer: Loop = self.status_check_timer
62 77
 		timer.start()
63 78
 
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
-	@commands.Cog.listener()
79
+	@Cog.listener()
75 80
 	async def on_member_join(self, member: Member) -> None:
76 81
 		"""Event handler"""
77 82
 		guild: Guild = member.guild
@@ -118,9 +123,8 @@ class AutoKickCog(BaseCog, name='Auto Kick'):
118 123
 		else:
119 124
 			context.record_kick(datetime.now())
120 125
 		max_kick_count: int = self.get_guild_setting(guild, self.SETTING_BAN_COUNT)
121
-		disable_help = f'To disable this feature: `{CONFIG["command_prefix"]}autokick disable`.'
122
-		ban_help = f'To configure ban threshold: `{CONFIG["command_prefix"]}autokick ' + \
123
-			'setbancount #` (0 to disable).'
126
+		disable_help = f'To disable this feature: `/disable autokick`.'
127
+		ban_help = f'To configure ban threshold: `/set autokick_bancount #` (0 to disable)'
124 128
 		if max_kick_count > 0 and context.kick_count > max_kick_count:
125 129
 			await member.ban(reason=f'Rocketbot: Ban after {context.kick_count} joins',
126 130
 				delete_message_days=0)
@@ -134,11 +138,11 @@ class AutoKickCog(BaseCog, name='Auto Kick'):
134 138
 		else:
135 139
 			await member.kick(reason='Rocketbot: Autokick enabled.')
136 140
 			msg = BotMessage(guild,
137
-							text=f'Autokicked {member.mention} ({member.id}) ' + \
138
-								f'({AutoKickCog.ordinal(context.kick_count)} time). ' + \
139
-								disable_help + ' ' + ban_help,
140
-							type=BotMessage.TYPE_INFO,
141
-							context=None)
141
+				text=f'Autokicked {member.mention} ({member.id}) ' + \
142
+					f'({AutoKickCog.ordinal(context.kick_count)} time). ' + \
143
+					disable_help + ' ' + ban_help,
144
+				type=BotMessage.TYPE_INFO,
145
+				context=None)
142 146
 			await self.post_message(msg)
143 147
 			self.log(guild, f'Autokicked {member.name} ' + \
144 148
 				f'({AutoKickCog.ordinal(context.kick_count)} time)')

+ 81
- 17
rocketbot/cogs/basecog.py Näytä tiedosto

@@ -4,9 +4,12 @@ Base cog class and helper classes.
4 4
 from datetime import datetime, timedelta, timezone
5 5
 from typing import Optional
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.bot import Rocketbot
@@ -14,24 +17,79 @@ from rocketbot.botmessage import BotMessage, BotMessageReaction
14 17
 from rocketbot.cogsetting import CogSetting
15 18
 from rocketbot.collections import AgeBoundDict
16 19
 from rocketbot.storage import Storage
17
-from rocketbot.utils import bot_log
20
+from rocketbot.utils import bot_log, dump_stacktrace
18 21
 
19 22
 class WarningContext:
20 23
 	def __init__(self, member: Member, warn_time: datetime):
21 24
 		self.member = member
22 25
 		self.last_warned = warn_time
23 26
 
24
-class BaseCog(commands.Cog):
27
+class BaseCog(Cog):
25 28
 	STATE_KEY_RECENT_WARNINGS = "BaseCog.recent_warnings"
26 29
 
27 30
 	"""
28 31
 	Superclass for all Rocketbot cogs. Provides lots of conveniences for
29 32
 	common tasks.
30 33
 	"""
31
-	def __init__(self, bot):
34
+	def __init__(
35
+			self,
36
+			bot: Rocketbot,
37
+			config_prefix: Optional[str],
38
+			short_description: str,
39
+			long_description: Optional[str] = None,
40
+	):
41
+		"""
42
+		Parameters
43
+		----------
44
+		bot: Rocketbot
45
+		config_prefix: str
46
+		    Prefix to show on variables in /set and /get commands to namespace
47
+		    configuration variables. E.g. if config_prefix is "foo", a config
48
+		    variable named "bar" in that cog will show as "foo.bar". If None,
49
+		    config variable acts as a top-level variable with no prefix.
50
+		"""
32 51
 		self.bot: Rocketbot = bot
33
-		self.are_settings_setup = False
34
-		self.settings = []
52
+		self.are_settings_setup: bool = False
53
+		self.settings: list[CogSetting] = []
54
+		self.config_prefix: Optional[str] = config_prefix
55
+		self.short_description: str = short_description
56
+		self.long_description: str = long_description
57
+
58
+	async def cog_app_command_error(self, interaction: Interaction, error: AppCommandError) -> None:
59
+		if isinstance(error, CommandInvokeError):
60
+			error = error.original
61
+		dump_stacktrace(error)
62
+		message = f"\nException: {error.__class__.__name__}, "\
63
+				  f"Command: {interaction.command.qualified_name if interaction.command else None}, "\
64
+				  f"User: {interaction.user}, "\
65
+				  f"Time: {discord.utils.format_dt(interaction.created_at, style='F')}"
66
+		try:
67
+			await interaction.response.send_message(f"An error occurred: {message}", ephemeral=True)
68
+		except discord.InteractionResponded:
69
+			await interaction.followup.send(f"An error occurred: {message}", ephemeral=True)
70
+
71
+	@property
72
+	def basecogs(self) -> list['BaseCog']:
73
+		"""
74
+		List of BaseCog instances. Cogs that do not inherit from BaseCog are omitted.
75
+		"""
76
+		return [
77
+			bcog
78
+			for bcog in sorted(self.bot.cogs.values(), key=lambda c: c.qualified_name)
79
+			if isinstance(bcog, BaseCog)
80
+		]
81
+
82
+	@property
83
+	def basecog_map(self) -> dict[str, 'BaseCog']:
84
+		"""
85
+		Map of qualified names to BaseCog instances. Cogs that do not inherit
86
+		from BaseCog are omitted.
87
+		"""
88
+		return {
89
+			qname: bcog
90
+			for qname, bcog in self.bot.cogs.items()
91
+			if isinstance(bcog, BaseCog)
92
+		}
35 93
 
36 94
 	# Config
37 95
 
@@ -65,7 +123,7 @@ class BaseCog(commands.Cog):
65 123
 
66 124
 	@classmethod
67 125
 	def get_guild_setting(cls,
68
-			guild: Guild,
126
+			guild: Optional[Guild],
69 127
 			setting: CogSetting,
70 128
 			use_cog_default_if_not_set: bool = True):
71 129
 		"""
@@ -74,11 +132,17 @@ class BaseCog(commands.Cog):
74 132
 		unless the optional `use_cog_default_if_not_set` is `False`, then
75 133
 		`None` will be returned.
76 134
 		"""
77
-		key = f'{cls.__name__}.{setting.name}'
78
-		value = Storage.get_config_value(guild, key)
79
-		if value is None and use_cog_default_if_not_set:
80
-			value = cls.get_cog_default(setting.name)
81
-		return value
135
+		if guild:
136
+			key = f'{cls.__name__}.{setting.name}'
137
+			value = Storage.get_config_value(guild, key)
138
+			if value is not None:
139
+				return value
140
+		if use_cog_default_if_not_set:
141
+			config_default = cls.get_cog_default(setting.name)
142
+			if config_default is not None:
143
+				return None
144
+			return setting.default_value
145
+		return None
82 146
 
83 147
 	@classmethod
84 148
 	def set_guild_setting(cls,
@@ -96,8 +160,8 @@ class BaseCog(commands.Cog):
96 160
 		key = f'{cls.__name__}.{setting.name}'
97 161
 		Storage.set_config_value(guild, key, new_value)
98 162
 
99
-	@commands.Cog.listener()
100
-	async def on_ready(self):
163
+	# @commands.Cog.listener()
164
+	async def __on_ready(self):
101 165
 		"""Event listener"""
102 166
 		if not self.are_settings_setup:
103 167
 			self.are_settings_setup = True
@@ -138,7 +202,7 @@ class BaseCog(commands.Cog):
138 202
 			BaseCog.STATE_KEY_RECENT_WARNINGS)
139 203
 		if recent_warns is None:
140 204
 			recent_warns = AgeBoundDict(timedelta(seconds=CONFIG['squelch_warning_seconds']),
141
-				lambda i, context : context.last_warned)
205
+				lambda i, context0 : context0.last_warned)
142 206
 			Storage.set_state_value(member.guild, BaseCog.STATE_KEY_RECENT_WARNINGS, recent_warns)
143 207
 		context: WarningContext = recent_warns.get(member.id)
144 208
 		if context is None:
@@ -177,7 +241,7 @@ class BaseCog(commands.Cog):
177 241
 		await message.update()
178 242
 		return message.is_sent()
179 243
 
180
-	@commands.Cog.listener()
244
+	@Cog.listener()
181 245
 	async def on_raw_reaction_add(self, payload: RawReactionActionEvent):
182 246
 		"""Event handler"""
183 247
 		# Avoid any unnecessary requests. Gets called for every reaction

+ 85
- 60
rocketbot/cogs/configcog.py Näytä tiedosto

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

+ 63
- 44
rocketbot/cogs/crosspostcog.py Näytä tiedosto

@@ -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
@@ -35,67 +35,96 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
35 35
 	"""
36 36
 	Detects a user posting in multiple channels in a short period
37 37
 	of time: a common pattern for spammers.
38
-
39
-	These used to be identical text, but more recent attacks have had small
40
-	variations, such as different imgur URLs. It's reasonable to treat
41
-	posting in many channels in a short period as suspicious on its own,
42
-	regardless of whether they are identical.
43
-
44
-	Repeated posts in the same channel aren't currently detected, as this can
45
-	often be for a reason or due to trying a failed post when connectivity is
46
-	poor. Minimum message length can be enforced for detection.
47 38
 	"""
48
-	SETTING_ENABLED = CogSetting('enabled', bool,
39
+	SETTING_ENABLED = CogSetting(
40
+		'enabled',
41
+		bool,
42
+		default_value=False,
49 43
 		brief='crosspost detection',
50
-		description='Whether crosspost detection is enabled.')
51
-	SETTING_WARN_COUNT = CogSetting('warncount', int,
44
+		description='Whether crosspost detection is enabled.',
45
+	)
46
+	SETTING_WARN_COUNT = CogSetting(
47
+		'warncount',
48
+		int,
49
+		default_value=5,
52 50
 		brief='number of messages to trigger a warning',
53 51
 		description='The number of unique channels messages are ' + \
54 52
 			'posted in by the same user to trigger a mod warning. The ' + \
55 53
 			'messages need not be identical (see dupewarncount).',
56
-		usage='<count:int>',
57
-		min_value=2)
58
-	SETTING_DUPE_WARN_COUNT = CogSetting('dupewarncount', int,
54
+		min_value=2,
55
+	)
56
+	SETTING_DUPE_WARN_COUNT = CogSetting(
57
+		'dupewarncount',
58
+		int,
59
+		default_value=3,
59 60
 		brief='number of identical messages to trigger a warning',
60 61
 		description='The number of unique channels identical messages are ' + \
61 62
 			'posted in by the same user to trigger a mod warning.',
62
-		usage='<count:int>',
63
-		min_value=2)
64
-	SETTING_BAN_COUNT = CogSetting('bancount', int,
63
+		min_value=2,
64
+	)
65
+	SETTING_BAN_COUNT = CogSetting(
66
+		'bancount',
67
+		int,
68
+		default_value=9999,
65 69
 		brief='number of messages to trigger a ban',
66 70
 		description='The number of unique channels messages are ' + \
67 71
 			'posted in by the same user to trigger an automatic ban. The ' + \
68 72
 			'messages need not be identical (see dupebancount). Set ' + \
69 73
 			'to a large value to effectively disable, e.g. 9999.',
70
-		usage='<count:int>',
71
-		min_value=2)
72
-	SETTING_DUPE_BAN_COUNT = CogSetting('dupebancount', int,
74
+		min_value=2,
75
+	)
76
+	SETTING_DUPE_BAN_COUNT = CogSetting(
77
+		'dupebancount',
78
+		int,
79
+		default_value=9999,
73 80
 		brief='number of identical messages to trigger a ban',
74 81
 		description='The number of unique channels identical messages are ' + \
75 82
 			'posted in by the same user to trigger an automatic ban. Set ' + \
76 83
 			'to a large value to effectively disable, e.g. 9999.',
77
-		usage='<count:int>',
78
-		min_value=2)
79
-	SETTING_MIN_LENGTH = CogSetting('minlength', int,
84
+		min_value=2,
85
+	)
86
+	SETTING_MIN_LENGTH = CogSetting(
87
+		'minlength',
88
+		int,
89
+		default_value=1,
80 90
 		brief='minimum message length',
81 91
 		description='The minimum number of characters in a message to be ' + \
82 92
 			'checked for duplicates. This can help ignore common short ' + \
83 93
 			'messages like "lol" or a single emoji.',
84
-		usage='<character_count:int>',
85
-		min_value=1)
86
-	SETTING_TIMESPAN = CogSetting('timespan', float,
94
+		min_value=1,
95
+	)
96
+	SETTING_TIMESPAN = CogSetting(
97
+		'timespan',
98
+		timedelta,
99
+		default_value=timedelta(seconds=60),
87 100
 		brief='time window to look for dupe messages',
88
-		description='The number of seconds of message history to look at ' + \
89
-			'when looking for duplicates. Shorter values are preferred, ' + \
101
+		description='The number of seconds of message history to look at '
102
+			'when looking for duplicates. Shorter values are preferred, '
90 103
 			'both to detect bots and avoid excessive memory usage.',
91
-		usage='<seconds:int>',
92
-		min_value=1)
104
+		min_value=timedelta(seconds=1),
105
+	)
93 106
 
94 107
 	STATE_KEY_RECENT_MESSAGES = "CrossPostCog.recent_messages"
95 108
 	STATE_KEY_SPAM_CONTEXT = "CrossPostCog.spam_context"
96 109
 
97 110
 	def __init__(self, bot):
98
-		super().__init__(bot)
111
+		super().__init__(
112
+			bot,
113
+			config_prefix='crosspost',
114
+			short_description='Manages crosspost detection and handling.',
115
+			long_description='Detects a user posting in multiple channels in a short period of '
116
+							 'time: a common pattern for spammers.\n'
117
+							 '\n'
118
+							 "These used to be identical text, but more recent attacks have had "
119
+							 "small variations, such as different imgur URLs. It's reasonable to "
120
+							 "treat posting in many channels in a short period as suspicious on its "
121
+							 "own, regardless of whether they are identical.\n"
122
+							 "\n"
123
+							 "Repeated posts in the same channel aren't currently detected, as "
124
+							 "this can often be for a reason or due to trying a failed post when "
125
+							 "connectivity is poor. Minimum message length can be enforced for "
126
+							 "detection.",
127
+		)
99 128
 		self.add_setting(CrossPostCog.SETTING_ENABLED)
100 129
 		self.add_setting(CrossPostCog.SETTING_WARN_COUNT)
101 130
 		self.add_setting(CrossPostCog.SETTING_DUPE_WARN_COUNT)
@@ -328,7 +357,7 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
328 357
 		# print(message)
329 358
 		pass
330 359
 
331
-	@commands.Cog.listener()
360
+	@Cog.listener()
332 361
 	async def on_message(self, message: Message):
333 362
 		"""Event handler"""
334 363
 		if message.author is None or \
@@ -340,13 +369,3 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
340 369
 			return
341 370
 		self.__trace("--ON MESSAGE--")
342 371
 		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()

+ 158
- 0
rocketbot/cogs/gamescog.py Näytä tiedosto

@@ -0,0 +1,158 @@
1
+import math
2
+import random
3
+from time import perf_counter
4
+
5
+from discord import Interaction, UnfurledMediaItem
6
+from discord.app_commands import command, Range, guild_only
7
+from discord.ui import LayoutView, Section, TextDisplay, Thumbnail, Container
8
+
9
+from rocketbot.bot import Rocketbot
10
+from rocketbot.cogs.basecog import BaseCog
11
+
12
+
13
+class GamesCog(BaseCog, name="Games"):
14
+	"""Some really basic games or approximations thereof."""
15
+	def __init__(self, bot: Rocketbot):
16
+		super().__init__(
17
+			bot,
18
+			config_prefix='games',
19
+			short_description='Some stupid, low effort games.',
20
+		)
21
+
22
+	@command(
23
+		description='Rolls a die and gives the result. Provides seconds of amusement.'
24
+	)
25
+	@guild_only
26
+	async def roll(self, interaction: Interaction, sides: Range[int, 2, 100], share: bool = False):
27
+		"""
28
+		Rolls a die.
29
+
30
+		Parameters
31
+		----------
32
+		sides : Range[int, 2, 100]
33
+			how many sides on the die
34
+		share : bool
35
+			whether to show the result to everyone in chat, otherwise only shown to you
36
+		"""
37
+		result = random.randint(1, sides)
38
+		who = interaction.user.mention if share else 'You'
39
+		text = f'## :game_die: D{sides} rolled\n'\
40
+			   '\n'\
41
+			   f'    {who} rolled a **{result}**.'
42
+		if result == 69:
43
+			text += ' :smirk:'
44
+		if sides == 20 and result == 1:
45
+			text += ' :slight_frown:'
46
+		if sides == 20 and result == 20:
47
+			text += ' :tada:'
48
+
49
+		# This is a lot of work for a dumb joke
50
+		others = set()
51
+		for _ in range(min(sides - 1, 3)):
52
+			r = random.randint(1, sides)
53
+			while r in others or r == result:
54
+				r = random.randint(1, sides)
55
+			others.add(r)
56
+		text += f'\n\n-# Users who rolled {result} also liked {listed_items(list(others))}'
57
+
58
+		await interaction.response.send_message(text, ephemeral=not share)
59
+
60
+	@command(
61
+		description='Creates a really terrible Minesweeper puzzle.'
62
+	)
63
+	@guild_only
64
+	async def minesweeper(self, interaction: Interaction):
65
+		MINE = -1
66
+		BLANK = 0
67
+
68
+		# Generate grid
69
+		width, height = 8, 8  # about as big as you can get. 10x10 has too many spoiler blocks and Discord doesn't parse them.
70
+		mine_count = 10
71
+		grid = [[BLANK for _ in range(width)] for _ in range(height)]
72
+		all_positions = [(x, y) for y in range(height) for x in range(width)]
73
+
74
+		# Place mines randomly
75
+		random.shuffle(all_positions)
76
+		for x, y in all_positions[:mine_count]:
77
+			grid[x][y] = MINE
78
+
79
+		# Update free spaces with mine counts
80
+		for y in range(height):
81
+			for x in range(width):
82
+				if grid[x][y] == MINE:
83
+					continue
84
+				nearby_mine_count = 0
85
+				for y0 in range(y - 1, y + 2):
86
+					for x0 in range(x - 1, x + 2):
87
+						if 0 <= x0 < width and 0 <= y0 < height and grid[x0][y0] < 0:
88
+							nearby_mine_count += 1
89
+				grid[x][y] = nearby_mine_count
90
+
91
+		# Pick a non-mine starting tile to reveal (favoring 0s)
92
+		start_x, start_y = 0, 0
93
+		for n in range(8):
94
+			start_positions = [ pt for pt in all_positions if BLANK <= grid[pt[0]][pt[1]] <= n ]
95
+			if len(start_positions) > 0:
96
+				# sort by closeness to center
97
+				start_positions.sort(key=lambda pt: math.fabs(pt[0] - width / 2) + math.fabs(pt[1] - height / 2))
98
+				# pick a random one from the top 10
99
+				start_x, start_y = random.choice(start_positions[:10])
100
+				break
101
+
102
+		# Render
103
+		puzzle = ''
104
+		symbols = [
105
+			'\u274C',  # cross mark (red X)
106
+			'0\uFE0F\u20E3',  # 0 + variation selector 16 + combining enclosing keycap
107
+			'1\uFE0F\u20E3',
108
+			'2\uFE0F\u20E3',
109
+			'3\uFE0F\u20E3',
110
+			'4\uFE0F\u20E3',
111
+			'5\uFE0F\u20E3',
112
+			'6\uFE0F\u20E3',
113
+			'7\uFE0F\u20E3',
114
+			'8\uFE0F\u20E3',
115
+		]
116
+		for y in range(height):
117
+			puzzle += '    '
118
+			for x in range(width):
119
+				is_revealed = x == start_x and y == start_y
120
+				if not is_revealed:
121
+					puzzle += '||'
122
+				val = grid[x][y]
123
+				puzzle += symbols[val + 1]
124
+				if not is_revealed:
125
+					puzzle += '||'
126
+				puzzle += ' '
127
+			puzzle += '\n'
128
+
129
+		text = "## Minesweeper (kinda)\n"\
130
+			   f"Here's a really terrible randomized Minesweeper puzzle. Sorry. I did my best.¹\n"\
131
+			   "\n"\
132
+			   f"Uncover everything except the {mine_count} :x: mines. There's no game logic or anything. Just a markdown parlor trick. Police yourselves.\n"\
133
+			   "\n"\
134
+			   f"{puzzle}"\
135
+			   "\n"\
136
+			   "-# ¹ I didn't really do my best."
137
+		await interaction.response.send_message(
138
+			view=_MinesweeperLayout(text),
139
+			ephemeral=True,
140
+		)
141
+
142
+class _MinesweeperLayout(LayoutView):
143
+	def __init__(self, text_content: str):
144
+		super().__init__()
145
+		text = TextDisplay(text_content)
146
+		thumb = Thumbnail(UnfurledMediaItem('https://static.rksp.net/rocketbot/games/minesweeper/icon.png'), description='Minesweeper icon')
147
+		section = Section(text, accessory=thumb)
148
+		container = Container(section, accent_color=0xff0000)
149
+		self.add_item(container)
150
+
151
+def listed_items(items: list) -> str:
152
+	if len(items) == 0:
153
+		return 'nothing'
154
+	if len(items) == 1:
155
+		return f'{items[0]}'
156
+	if len(items) == 2:
157
+		return f'{items[0]} and {items[1]}'
158
+	return (', '.join([ str(i) for i in items[:-1]])) + f', and {items[-1]}'

+ 100
- 99
rocketbot/cogs/generalcog.py Näytä tiedosto

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

+ 521
- 0
rocketbot/cogs/helpcog.py Näytä tiedosto

@@ -0,0 +1,521 @@
1
+"""Provides help commands for getting info on using other commands and configuration."""
2
+import re
3
+import time
4
+from typing import Union, Optional, TypedDict
5
+
6
+from discord import Interaction, Permissions, AppCommandType, ButtonStyle, InteractionResponse
7
+from discord.app_commands import Group, Command, autocomplete, guild_only, command, Choice
8
+from discord.ui import ActionRow, Button, LayoutView, TextDisplay
9
+
10
+from config import CONFIG
11
+from rocketbot.bot import Rocketbot
12
+from rocketbot.cogs.basecog import BaseCog
13
+from rocketbot.utils import MOD_PERMISSIONS, dump_stacktrace
14
+
15
+HelpTopic = Union[Command, Group, BaseCog]
16
+class HelpMeta(TypedDict):
17
+	id: str
18
+	text: str
19
+	topic: HelpTopic
20
+
21
+# Potential place to break text neatly in large help content
22
+PAGE_BREAK = '\f'
23
+
24
+def choice_from_topic(topic: HelpTopic, include_full_command: bool = False) -> Choice:
25
+	if isinstance(topic, BaseCog):
26
+		return Choice(name=f'⚙ {topic.qualified_name}', value=f'cog:{topic.qualified_name}')
27
+	if isinstance(topic, Group):
28
+		return Choice(name=f'/{topic.name}', value=f'cmd:{topic.name}')
29
+	if isinstance(topic, Command):
30
+		if topic.parent:
31
+			if include_full_command:
32
+				return Choice(name=f'/{topic.parent.name} {topic.name}', value=f'subcmd:{topic.parent.name}.{topic.name}')
33
+			return Choice(name=f'{topic.name}', value=f'subcmd:{topic.name}')
34
+		return Choice(name=f'/{topic.name}', value=f'cmd:{topic.name}')
35
+	return Choice(name='', value='')
36
+
37
+async def search_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
38
+	try:
39
+		if len(current) == 0:
40
+			return [
41
+				choice_from_topic(topic, include_full_command=True)
42
+				for topic in HelpCog.shared.all_accessible_topics(interaction.permissions)
43
+			]
44
+		return [
45
+			choice_from_topic(topic, include_full_command=True)
46
+			for topic in HelpCog.shared.topics_for_keywords(current, interaction.permissions)
47
+		][:25]
48
+	except BaseException as e:
49
+		dump_stacktrace(e)
50
+		return []
51
+
52
+class HelpCog(BaseCog, name='Help'):
53
+	shared: Optional['HelpCog'] = None
54
+
55
+	def __init__(self, bot: Rocketbot):
56
+		super().__init__(
57
+			bot,
58
+			config_prefix='help',
59
+			short_description='Provides help on using this bot.'
60
+		)
61
+		HelpCog.shared = self
62
+
63
+	def __create_help_index(self) -> None:
64
+		"""
65
+		Populates self.id_to_topic and self.keyword_index. Bails if already
66
+		populated. Intended to be run on demand so all cogs and commands have
67
+		had time to get set up and synced.
68
+		"""
69
+		if getattr(self, 'id_to_topic', None) is not None:
70
+			return
71
+		self.id_to_topic: dict[str, HelpTopic] = {}
72
+		self.topics: list[HelpMeta] = []
73
+
74
+		def process_text(t: str) -> str:
75
+			return ' '.join([
76
+				word
77
+				for word in re.split(r"[^a-z']+", t.lower())
78
+				if word not in trivial_words
79
+			]).strip()
80
+
81
+		cmds = self.all_commands()
82
+		for cmd in cmds:
83
+			key = f'cmd:{cmd.name}'
84
+			self.id_to_topic[key] = cmd
85
+			self.id_to_topic[f'/{cmd.name}'] = cmd
86
+			text = cmd.name
87
+			if cmd.description:
88
+				text += f' {cmd.description}'
89
+			if cmd.extras.get('long_description', None):
90
+				text += f' {cmd.extras["long_description"]}'
91
+			self.topics.append({ 'id': key, 'text': process_text(text), 'topic': cmd })
92
+			if isinstance(cmd, Group):
93
+				for subcmd in cmd.commands:
94
+					key = f'subcmd:{cmd.name}.{subcmd.name}'
95
+					self.id_to_topic[key] = subcmd
96
+					self.id_to_topic[f'/{cmd.name} {subcmd.name}'] = subcmd
97
+					text = cmd.name
98
+					text += f' {subcmd.name}'
99
+					if subcmd.description:
100
+						text += f' {subcmd.description}'
101
+					if subcmd.extras.get('long_description', None):
102
+						text += f' {subcmd.extras["long_description"]}'
103
+					self.topics.append({ 'id': key, 'text': process_text(text), 'topic': subcmd })
104
+		for cog_qname, cog in self.bot.cogs.items():
105
+			if not isinstance(cog, BaseCog):
106
+				continue
107
+			key = f'cog:{cog_qname}'
108
+			self.id_to_topic[key] = cog
109
+			text = cog.qualified_name
110
+			if cog.short_description:
111
+				text += f' {cog.short_description}'
112
+			if cog.long_description:
113
+				text += f' {cog.long_description}'
114
+			self.topics.append({ 'id': key, 'text': process_text(text), 'topic': cog })
115
+
116
+	def topic_for_help_symbol(self, symbol: str) -> Optional[HelpTopic]:
117
+		self.__create_help_index()
118
+		return self.id_to_topic.get(symbol, None)
119
+
120
+	def all_commands(self) -> list[Union[Command, Group]]:
121
+		# PyCharm not interpreting conditional return type correctly.
122
+		# noinspection PyTypeChecker
123
+		cmds: list[Union[Command, Group]] = self.bot.tree.get_commands(type=AppCommandType.chat_input)
124
+		return sorted(cmds, key=lambda cmd: cmd.name)
125
+
126
+	def all_accessible_commands(self, permissions: Optional[Permissions]) -> list[Union[Command, Group]]:
127
+		return [
128
+			cmd
129
+			for cmd in self.all_commands()
130
+			if can_use_command(cmd, permissions)
131
+		]
132
+
133
+	def all_accessible_subcommands(self, permissions: Optional[Permissions]) -> list[Command]:
134
+		cmds = self.all_accessible_commands(permissions)
135
+		subcmds: list[Command] = []
136
+		for cmd in cmds:
137
+			if isinstance(cmd, Group):
138
+				for subcmd in sorted(cmd.commands, key=lambda cmd: cmd.name):
139
+					if can_use_command(subcmd, permissions):
140
+						subcmds.append(subcmd)
141
+		return subcmds
142
+
143
+	def all_accessible_cogs(self, permissions: Optional[Permissions]) -> list[BaseCog]:
144
+		return [
145
+			cog
146
+			for cog in self.basecogs
147
+			if can_use_cog(cog, permissions)
148
+		]
149
+
150
+	def all_accessible_topics(self, permissions: Optional[Permissions], *,
151
+							  include_cogs: bool = True,
152
+							  include_commands: bool = True,
153
+							  include_subcommands: bool = True) -> list[HelpTopic]:
154
+		topics = []
155
+		if include_cogs:
156
+			topics += self.all_accessible_cogs(permissions)
157
+		if include_commands:
158
+			topics += self.all_accessible_commands(permissions)
159
+		if include_subcommands:
160
+			topics += self.all_accessible_subcommands(permissions)
161
+		return topics
162
+
163
+	def topics_for_keywords(self, search: str, permissions: Optional[Permissions]) -> list[HelpTopic]:
164
+		start_time = time.perf_counter()
165
+		self.__create_help_index()
166
+
167
+		# Break into words (or word fragments)
168
+		words: list[str] = [
169
+			word
170
+			for word in re.split(r"[^a-z']+", search.lower())
171
+		]
172
+
173
+		# Find matches
174
+		def topic_matches(meta: HelpMeta) -> bool:
175
+			for word in words:
176
+				if word not in meta['text']:
177
+					return False
178
+			return True
179
+		matching_topics: list[HelpTopic] = [ topic['topic'] for topic in self.topics if topic_matches(topic) ]
180
+
181
+		# Filter by accessibility
182
+		accessible_topics = [
183
+			topic
184
+			for topic in matching_topics
185
+			if ((isinstance(topic, Command) or isinstance(topic, Group)) and can_use_command(topic, permissions)) or \
186
+			   (isinstance(topic, BaseCog) and can_use_cog(topic, permissions))
187
+		]
188
+
189
+		# Sort and return
190
+		result = sorted(accessible_topics, key=lambda topic: (
191
+			isinstance(topic, Command),
192
+			isinstance(topic, BaseCog),
193
+			topic.qualified_name if isinstance(topic, BaseCog) else topic.name
194
+		))
195
+		duration = time.perf_counter() - start_time
196
+		if duration > 0.01:
197
+			self.log(None, f'search "{search}" took {duration} seconds')
198
+		return result
199
+
200
+	@command(
201
+		name='help',
202
+		description='Shows help for using commands and module configuration.',
203
+		extras={
204
+			'long_description': '`/help` will show a list of top-level topics.\n'
205
+								'\n'
206
+								"`/help /<command_name>` will show help about a specific command or list a command's subcommands.\n"
207
+								'\n'
208
+								'`/help /<command_name> <subcommand_name>` will show help about a specific subcommand.\n'
209
+								'\n'
210
+								'`/help <module_name>` will show help about configuring a module.\n'
211
+								'\n'
212
+								'`/help <keywords>` will do a text search for topics.',
213
+		}
214
+	)
215
+	@guild_only()
216
+	@autocomplete(search=search_autocomplete)
217
+	async def help_command(self, interaction: Interaction, search: Optional[str]) -> None:
218
+		"""
219
+		Shows help for using commands and subcommands and configuring modules.
220
+
221
+		Parameters
222
+		----------
223
+		interaction: Interaction
224
+		search: Optional[str]
225
+			search terms
226
+		"""
227
+		if search is None:
228
+			await self.__send_general_help(interaction)
229
+			return
230
+		topic = self.topic_for_help_symbol(search)
231
+		if topic:
232
+			await self.__send_topic_help(interaction, topic)
233
+			return
234
+		matches = self.topics_for_keywords(search, interaction.permissions)
235
+		await self.__send_keyword_help(interaction, matches)
236
+
237
+	async def __send_topic_help(self, interaction: Interaction, topic: HelpTopic) -> None:
238
+		if isinstance(topic, Command):
239
+			await self.__send_command_help(interaction, topic)
240
+			return
241
+		if isinstance(topic, Group):
242
+			await self.__send_command_help(interaction, topic)
243
+			return
244
+		if isinstance(topic, BaseCog):
245
+			await self.__send_cog_help(interaction, topic)
246
+			return
247
+		self.log(interaction.guild, f'No help for topic object {topic}')
248
+		await interaction.response.send_message(
249
+			f'{CONFIG["failure_emoji"]} Failed to get help info.',
250
+			ephemeral=True,
251
+			delete_after=10,
252
+		)
253
+
254
+	def get_command_list(self, permissions: Optional[Permissions] = None) -> dict[str, Union[Command, Group]]:
255
+		return { cmd.name: cmd for cmd in self.bot.tree.get_commands() if can_use_command(cmd, permissions) }
256
+
257
+	def get_subcommand_list(self, cmd: Group, permissions: Optional[Permissions] = None) -> dict[str, Command]:
258
+		return {
259
+			subcmd.name: subcmd
260
+			for subcmd in cmd.commands
261
+			if can_use_command(subcmd, permissions)
262
+		} if can_use_command(cmd, permissions) else {}
263
+
264
+	async def __send_general_help(self, interaction: Interaction) -> None:
265
+		user_permissions: Permissions = interaction.permissions
266
+		all_commands = sorted(self.get_command_list(user_permissions).items())
267
+		all_cog_tuples: list[tuple[str, BaseCog]] = [
268
+			cog_tuple
269
+			for cog_tuple in sorted(self.basecog_map.items())
270
+			if can_use_cog(cog_tuple[1], user_permissions) and \
271
+			   (len(cog_tuple[1].settings) > 0)
272
+		]
273
+
274
+		text = f'## :information_source: Help'
275
+		if len(all_commands) + len(all_cog_tuples) == 0:
276
+			text = 'Nothing available for your permissions!'
277
+
278
+		if len(all_commands) > 0:
279
+			text += '\n### Commands'
280
+			text += '\nType `/help /commandname` for more information.'
281
+			for cmd_name, cmd in sorted(self.get_command_list(user_permissions).items()):
282
+				text += f'\n- `/{cmd_name}`: {cmd.description}'
283
+				if isinstance(cmd, Group):
284
+					subcommand_count = len(cmd.commands)
285
+					text += f' ({subcommand_count} subcommands)'
286
+			text += PAGE_BREAK
287
+
288
+		if len(all_cog_tuples) > 0:
289
+			text += '\n### Module Configuration'
290
+			for cog_name, cog in all_cog_tuples:
291
+				has_enabled = next((s for s in cog.settings if s.name == 'enabled'), None) is not None
292
+				text += f'\n- **{cog_name}**: {cog.short_description}'
293
+				if has_enabled:
294
+					text += f'\n   - `/enable {cog.config_prefix}` or `/disable {cog.config_prefix}`'
295
+				for setting in cog.settings:
296
+					if setting.name == 'enabled':
297
+						continue
298
+					text += f'\n   - `/get` or `/set {cog.config_prefix}_{setting.name}`'
299
+				text += PAGE_BREAK
300
+
301
+		await self.__send_paged_help(interaction, text)
302
+
303
+	async def __send_keyword_help(self, interaction: Interaction, matching_topics: Optional[list[HelpTopic]]) -> None:
304
+		matching_commands = [
305
+			cmd
306
+			for cmd in matching_topics or []
307
+			if isinstance(cmd, Command) or isinstance(cmd, Group)
308
+		]
309
+		matching_cogs = [
310
+			cog
311
+			for cog in matching_topics or []
312
+			if isinstance(cog, BaseCog)
313
+		]
314
+		if len(matching_commands) + len(matching_cogs) == 0:
315
+			await interaction.response.send_message(
316
+				f'{CONFIG["failure_emoji"]} No available help topics found.',
317
+				ephemeral=True,
318
+				delete_after=10,
319
+			)
320
+			return
321
+		if len(matching_topics) == 1:
322
+			topic = matching_topics[0]
323
+			await self.__send_topic_help(interaction, topic)
324
+			return
325
+
326
+		text = '## :information_source: Matching Help Topics'
327
+		if len(matching_commands) > 0:
328
+			text += '\n### Commands'
329
+			for cmd in matching_commands:
330
+				if cmd.parent:
331
+					text += f'\n- `/{cmd.parent.name} {cmd.name}`'
332
+				else:
333
+					text += f'\n- `/{cmd.name}`'
334
+		if len(matching_cogs) > 0:
335
+			text += '\n### Modules'
336
+			for cog in matching_cogs:
337
+				text += f'\n- {cog.qualified_name}'
338
+
339
+		await self.__send_paged_help(interaction, text)
340
+
341
+	async def __send_command_help(self, interaction: Interaction, command_or_group: Union[Command, Group], addendum: Optional[str] = None) -> None:
342
+		text = ''
343
+		if addendum is not None:
344
+			text += addendum + '\n\n'
345
+		if command_or_group.parent:
346
+			text += f'## :information_source: Subcommand Help'
347
+			text += f'\n`/{command_or_group.parent.name} {command_or_group.name}`'
348
+		else:
349
+			text += f'## :information_source: Command Help'
350
+			if isinstance(command_or_group, Group):
351
+				text += f'\n`/{command_or_group.name} subcommand_name`'
352
+			else:
353
+				text += f'\n`/{command_or_group.name}`'
354
+		if isinstance(command_or_group, Command):
355
+			optional_nesting = 0
356
+			for param in command_or_group.parameters:
357
+				text += '  '
358
+				if not param.required:
359
+					text += '['
360
+					optional_nesting += 1
361
+				text += f'_{param.name}_'
362
+			if optional_nesting > 0:
363
+				text += ']' * optional_nesting
364
+		text += f'\n\n{command_or_group.description}'
365
+		if command_or_group.extras.get('long_description'):
366
+			text += f'\n\n{command_or_group.extras["long_description"]}'
367
+		if isinstance(command_or_group, Group):
368
+			subcmds: dict[str, Command] = self.get_subcommand_list(command_or_group, permissions=interaction.permissions)
369
+			if len(subcmds) > 0:
370
+				text += '\n### Subcommands:'
371
+				for subcmd_name, subcmd in sorted(subcmds.items()):
372
+					text += f'\n- `{subcmd_name}`: {subcmd.description}'
373
+				text += f'\n-# To use a subcommand, type it after the command. e.g. `/{command_or_group.name} subcommand_name`'
374
+				text += f'\n-# Get help on a subcommand by typing `/help /{command_or_group.name} subcommand_name`'
375
+		else:
376
+			params = command_or_group.parameters
377
+			if len(params) > 0:
378
+				text += '\n### Parameters:'
379
+				for param in params:
380
+					text += f'\n- `{param.name}`: {param.description}'
381
+					if not param.required:
382
+						text += ' (optional)'
383
+
384
+		await self.__send_paged_help(interaction, text)
385
+
386
+	async def __send_cog_help(self, interaction: Interaction, cog: BaseCog) -> None:
387
+		text = f'## :information_source: Module Help'
388
+		text += f'\n**{cog.qualified_name}** module'
389
+		if cog.short_description is not None:
390
+			text += f'\n\n{cog.short_description}'
391
+		if cog.long_description is not None:
392
+			text += f'\n\n{cog.long_description}'
393
+
394
+		cmds = [
395
+			cmd
396
+			for cmd in sorted(cog.get_app_commands(), key=lambda c: c.name)
397
+			if can_use_command(cmd, interaction.permissions)
398
+		]
399
+		if len(cmds) > 0:
400
+			text += '\n### Commands:'
401
+			for cmd in cmds:
402
+				text += f'\n- `/{cmd.name}` - {cmd.description}'
403
+				if isinstance(cmd, Group):
404
+					subcmds = [ subcmd for subcmd in cmd.commands if can_use_command(subcmd, interaction.permissions) ]
405
+					if len(subcmds) > 0:
406
+						text += f' ({len(subcmds)} subcommands)'
407
+
408
+		settings = cog.settings
409
+		if len(settings) > 0:
410
+			text += '\n### Configuration'
411
+			enabled_setting = next((s for s in settings if s.name == 'enabled'), None)
412
+			if enabled_setting is not None:
413
+				text += f'\n- `/enable {cog.config_prefix}` or `/disable {cog.config_prefix}`'
414
+			for setting in sorted(settings, key=lambda s: s.name):
415
+				if setting.name == 'enabled':
416
+					continue
417
+				text += f'\n- `/get` or `/set {cog.config_prefix}_{setting.name}` - {setting.brief}'
418
+
419
+		await self.__send_paged_help(interaction, text)
420
+
421
+	async def __send_paged_help(self, interaction: Interaction, text: str) -> None:
422
+		pages = _paginate(text)
423
+		if len(pages) == 1:
424
+			await interaction.response.send_message(pages[0], ephemeral=True)
425
+		else:
426
+			await _update_paged_help(interaction, None, 0, pages)
427
+
428
+def _paginate(text: str) -> list[str]:
429
+	max_page_size = 2000
430
+	chunks = text.split(PAGE_BREAK)
431
+	pages = [ '' ]
432
+	for chunk in chunks:
433
+		if len(chunk) > max_page_size:
434
+			raise ValueError('Help content needs more page breaks! One chunk is too big for message.')
435
+		if len(pages[-1] + chunk) < max_page_size:
436
+			pages[-1] += chunk
437
+		else:
438
+			pages.append(chunk)
439
+	page_count = len(pages)
440
+	if page_count == 1:
441
+		return pages
442
+
443
+	# Do another pass and try to even out the page lengths
444
+	indices = [ i * len(chunks) // page_count for i in range(page_count + 1) ]
445
+	even_pages = [
446
+		''.join(chunks[indices[i]:indices[i + 1]])
447
+		for i in range(page_count)
448
+	]
449
+	for page in even_pages:
450
+		if len(page) > max_page_size:
451
+			# We made a page too big. Give up.
452
+			return pages
453
+	return even_pages
454
+
455
+async def _update_paged_help(interaction: Interaction, original_interaction: Optional[Interaction], current_page: int, pages: list[str]) -> None:
456
+	try:
457
+		view = _PagingLayoutView(current_page, pages, original_interaction or interaction)
458
+		resolved = interaction
459
+		if original_interaction is not None:
460
+			# We have an original interaction from the initial command and a
461
+			# new one from the button press. Use the original to swap in the
462
+			# new page in place, then acknowledge the new one to satisfy the
463
+			# API that we didn't fail.
464
+			await original_interaction.edit_original_response(
465
+				view=view,
466
+			)
467
+			if interaction is not original_interaction:
468
+				await interaction.response.defer(ephemeral=True, thinking=False)
469
+		else:
470
+			# Initial send
471
+			await resolved.response.send_message(
472
+				view=view,
473
+				ephemeral=True,
474
+				delete_after=60,
475
+			)
476
+	except BaseException as e:
477
+		dump_stacktrace(e)
478
+
479
+class _PagingLayoutView(LayoutView):
480
+	def __init__(self, current_page: int, pages: list[str], original_interaction: Optional[Interaction]):
481
+		super().__init__()
482
+		self.current_page: int = current_page
483
+		self.pages: list[str] = pages
484
+		self.text.content = self.pages[self.current_page]
485
+		self.original_interaction = original_interaction
486
+		if current_page <= 0:
487
+			self.handle_prev_button.disabled = True
488
+		if current_page >= len(self.pages) - 1:
489
+			self.handle_next_button.disabled = True
490
+
491
+	text = TextDisplay('')
492
+
493
+	row = ActionRow()
494
+
495
+	@row.button(label='< Prev')
496
+	async def handle_prev_button(self, interaction: Interaction, button: Button) -> None:
497
+		new_page = max(0, self.current_page - 1)
498
+		await _update_paged_help(interaction, self.original_interaction, new_page, self.pages)
499
+
500
+	@row.button(label='Next >')
501
+	async def handle_next_button(self, interaction: Interaction, button: Button) -> None:
502
+		new_page = min(len(self.pages) - 1, self.current_page + 1)
503
+		await _update_paged_help(interaction, self.original_interaction, new_page, self.pages)
504
+
505
+# Exclusions from keyword indexing
506
+trivial_words = {
507
+	'a', 'an', 'and', 'are', "aren't", 'as', 'by', 'can', 'for', 'have', 'if', 'in',
508
+	'is', 'it', 'its', "it's", 'not', 'of', 'on', 'or', 'than', 'that', 'the', 'then',
509
+	'there', 'them', 'they', "they're", 'this', 'to', 'when', 'with',
510
+}
511
+
512
+def can_use_command(cmd: Union[Group, Command], user_permissions: Optional[Permissions]) -> bool:
513
+	if user_permissions is None:
514
+		return False
515
+	if cmd.parent and not can_use_command(cmd.parent, user_permissions):
516
+		return False
517
+	return cmd.default_permissions is None or cmd.default_permissions.is_subset(user_permissions)
518
+
519
+def can_use_cog(cog: BaseCog, user_permissions: Optional[Permissions]) -> bool:
520
+	# "Using" a cog for now means configuring it, and only mods can configure cogs.
521
+	return user_permissions is not None and MOD_PERMISSIONS.is_subset(user_permissions)

+ 0
- 150
rocketbot/cogs/joinagecog.py Näytä tiedosto

@@ -1,150 +0,0 @@
1
-import weakref
2
-
3
-from datetime import datetime, timedelta, timezone
4
-from discord import Guild, Member
5
-from discord.ext import commands
6
-
7
-from config import CONFIG
8
-from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
9
-from rocketbot.collections import AgeBoundList
10
-from rocketbot.storage import Storage
11
-from rocketbot.utils import timedelta_from_str
12
-
13
-class JoinAgeQueryContext:
14
-	"""
15
-	Data about a join age query
16
-	"""
17
-	def __init__(self, join_members: list, timespan: str):
18
-		self.join_members = list(join_members)
19
-		self.timespan = timespan
20
-		self.kicked_members = set()
21
-		self.banned_members = set()
22
-		self.results_message_ref = None
23
-
24
-class JoinAgeCog(BaseCog, name='Join Age'):
25
-	"""
26
-	Cog for finding users by when they joined.
27
-	"""
28
-	SETTING_ENABLED = CogSetting('enabled', bool,
29
-		brief='join age',
30
-		description='Whether this cog is enabled for a guild.')
31
-	SETTING_JOIN_TIME = CogSetting('jointime', float,
32
-		brief='maximum length of time to track new joins',
33
-		description='The number of seconds of join history to maintain.',
34
-		usage='<seconds:float>',
35
-		min_value=1.0)
36
-
37
-	STATE_KEY_RECENT_JOINS = "JoinAgeCog.recent_joins"
38
-
39
-	def __init__(self, bot):
40
-		super().__init__(bot)
41
-		self.add_setting(JoinAgeCog.SETTING_ENABLED)
42
-		self.add_setting(JoinAgeCog.SETTING_JOIN_TIME)
43
-
44
-	@commands.group(
45
-		brief='Tracks recently joined users with options to mass kick or ban',
46
-	)
47
-	@commands.has_permissions(ban_members=True)
48
-	@commands.guild_only()
49
-	async def joinage(self, context: commands.Context):
50
-		"""Join age tracking"""
51
-		if context.invoked_subcommand is None:
52
-			await context.send_help()
53
-
54
-	@joinage.command(
55
-		brief='Queries for users who joined in the past span of time',
56
-		description='Searches for users who joined the server recently. ' + \
57
-			'Can use time spans like 30s, 5m, 1h, 7d, etc.',
58
-		usage='<time_period>'
59
-	)
60
-	async def search(self, context: commands.Context, timespan: str):
61
-		"""Command handler"""
62
-		guild: Guild = context.guild
63
-		recent_joins: AgeBoundList[Member, datetime, timedelta] = Storage.get_state_value(guild, self.STATE_KEY_RECENT_JOINS)
64
-		if recent_joins is None:
65
-			max_age: timedelta = timedelta(seconds=self.get_guild_setting(guild, self.SETTING_JOIN_TIME))
66
-			recent_joins = AgeBoundList(max_age, lambda i, member0 : member0.joined_at)
67
-			Storage.set_state_value(guild, self.STATE_KEY_RECENT_JOINS, recent_joins)
68
-		results: list = []
69
-		ts: timedelta = timedelta_from_str(timespan)
70
-		cutoff: datetime = datetime.now(timezone.utc) - ts
71
-		for member in recent_joins:
72
-			if member.joined_at > cutoff:
73
-				results.append(member)
74
-		ctx = JoinAgeQueryContext(results, timespan)
75
-		msg = BotMessage(guild,
76
-						text='',
77
-						type=BotMessage.TYPE_INFO,
78
-						context=ctx)
79
-		ctx.results_message_ref = weakref.ref(msg)
80
-		await self.__update_results_message(ctx)
81
-		await self.post_message(msg)
82
-
83
-	async def on_mod_react(self,
84
-			bot_message: BotMessage,
85
-			reaction: BotMessageReaction,
86
-			reacted_by: Member) -> None:
87
-		guild: Guild = bot_message.guild
88
-		ctx: JoinAgeQueryContext = bot_message.context
89
-		if reaction.emoji == CONFIG['kick_emoji']:
90
-			to_kick = set(ctx.join_members) - ctx.kicked_members
91
-			for member in to_kick:
92
-				await member.kick(
93
-					reason=f'Rocketbot: Mass kick based on join age, by {reacted_by.name}.')
94
-			ctx.kicked_members |= to_kick
95
-			await self.__update_results_message(ctx)
96
-			self.log(guild, f'Users kicked by {reacted_by.name}.')
97
-		elif reaction.emoji == CONFIG['ban_emoji']:
98
-			to_ban = set(ctx.join_members) - ctx.banned_members
99
-			for member in to_ban:
100
-				await member.ban(
101
-					reason=f'Rocketbot: Mass ban based on join age, by {reacted_by.name}.',
102
-					delete_message_days=0)
103
-			ctx.banned_members |= to_ban
104
-			await self.__update_results_message(ctx)
105
-			self.log(guild, f'Users banned by {reacted_by.name}')
106
-
107
-	@commands.Cog.listener()
108
-	async def on_member_join(self, member: Member) -> None:
109
-		"""Event handler"""
110
-		guild: Guild = member.guild
111
-		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
112
-			return
113
-		recent_joins: AgeBoundList[Member, datetime, timedelta] = Storage.get_state_value(guild, self.STATE_KEY_RECENT_JOINS)
114
-		if recent_joins is None:
115
-			max_age: timedelta = timedelta(seconds=self.get_guild_setting(guild, self.SETTING_JOIN_TIME))
116
-			recent_joins = AgeBoundList(max_age, lambda i, member : member.joined_at)
117
-			Storage.set_state_value(guild, self.STATE_KEY_RECENT_JOINS, recent_joins)
118
-		recent_joins.append(member)
119
-
120
-	async def __update_results_message(self, context: JoinAgeQueryContext) -> None:
121
-		if context.results_message_ref is None:
122
-			return
123
-		bot_message = context.results_message_ref()
124
-		if bot_message is None:
125
-			return
126
-		text = f'The following members joined in the last {context.timespan}\n\n'
127
-		if len(context.join_members) > 0:
128
-			max_members = CONFIG['max_members_per_message']
129
-			for member in context.join_members[:max_members]:
130
-				text += '\n• '
131
-				if member in context.banned_members:
132
-					text += f'~~{member.mention} ({member.id})~~ - banned'
133
-				elif member in context.kicked_members:
134
-					text += f'~~{member.mention} ({member.id})~~ - kicked'
135
-				else:
136
-					text += f'{member.mention} ({member.id})'
137
-			if len(context.join_members) > max_members:
138
-				text += f'\n• {len(context.join_members) - max_members} more'
139
-		else:
140
-			text += 'No members found. If the bot was recently restarted ' + \
141
-				'or JoinAgeCog just enabled, only new joins will be tracked.'
142
-		await bot_message.set_text(text)
143
-		if len(context.join_members) > 0:
144
-			member_count = len(context.join_members)
145
-			kick_count = len(context.kicked_members)
146
-			ban_count = len(context.banned_members)
147
-			await bot_message.set_reactions(BotMessageReaction.standard_set(
148
-				did_kick=kick_count >= member_count,
149
-				did_ban=ban_count >= member_count,
150
-				user_count=member_count))

+ 37
- 30
rocketbot/cogs/joinraidcog.py Näytä tiedosto

@@ -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
@@ -29,43 +29,50 @@ class JoinRaidCog(BaseCog, name='Join Raids'):
29 29
 	"""
30 30
 	Cog for monitoring member joins and detecting potential bot raids.
31 31
 	"""
32
-	SETTING_ENABLED = CogSetting('enabled', bool,
33
-			brief='join raid detection',
34
-			description='Whether this cog is enabled for a guild.')
35
-	SETTING_JOIN_COUNT = CogSetting('joincount', int,
36
-			brief='number of joins to trigger a warning',
37
-			description='The number of joins occuring within the time ' + \
38
-				'window to trigger a mod warning.',
39
-			usage='<count:int>',
40
-			min_value=2)
41
-	SETTING_JOIN_TIME = CogSetting('jointime', float,
42
-			brief='time window length to look for joins',
43
-			description='The number of seconds of join history to look ' + \
44
-				'at when counting recent joins. If joincount or more ' + \
45
-				'joins occur within jointime seconds a mod warning is issued.',
46
-			usage='<seconds:float>',
47
-			min_value=1.0,
48
-			max_value=900.0)
32
+	SETTING_ENABLED = CogSetting(
33
+		'enabled',
34
+		bool,
35
+		default_value=False,
36
+		brief='join raid detection',
37
+		description='Whether this module is enabled for a guild.',
38
+	)
39
+	SETTING_JOIN_COUNT = CogSetting(
40
+		'joincount',
41
+		int,
42
+		default_value=5,
43
+		brief='number of joins to trigger a warning',
44
+		description='The number of joins occurring within the time '
45
+					'window to trigger a mod warning.',
46
+		min_value=2,
47
+	)
48
+	SETTING_JOIN_TIME = CogSetting(
49
+		'jointime',
50
+		timedelta,
51
+		default_value=timedelta(seconds=5),
52
+		brief='time window length to look for joins',
53
+		description='The number of seconds of join history to look '
54
+					'at when counting recent joins. If joincount or more '
55
+				    'joins occur within jointime seconds a mod warning is issued.',
56
+		min_value=timedelta(seconds=1.0),
57
+		max_value=timedelta(seconds=900.0),
58
+	)
49 59
 
50 60
 	STATE_KEY_RECENT_JOINS = "JoinRaidCog.recent_joins"
51 61
 	STATE_KEY_LAST_RAID = "JoinRaidCog.last_raid"
52 62
 
53 63
 	def __init__(self, bot):
54
-		super().__init__(bot)
64
+		super().__init__(
65
+			bot,
66
+			config_prefix='joinraid',
67
+			short_description='Manages join raid detection and handling.',
68
+			long_description='Join raids consist of an unusual number of users joining in '
69
+							 'a short period of time and have shown a pattern of DMing '
70
+							 'members with spam or scams.'
71
+		)
55 72
 		self.add_setting(JoinRaidCog.SETTING_ENABLED)
56 73
 		self.add_setting(JoinRaidCog.SETTING_JOIN_COUNT)
57 74
 		self.add_setting(JoinRaidCog.SETTING_JOIN_TIME)
58 75
 
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 76
 	async def on_mod_react(self,
70 77
 			bot_message: BotMessage,
71 78
 			reaction: BotMessageReaction,
@@ -90,7 +97,7 @@ class JoinRaidCog(BaseCog, name='Join Raids'):
90 97
 			await self.__update_warning_message(raid)
91 98
 			self.log(guild, f'Join raid users banned by {reacted_by.name}')
92 99
 
93
-	@commands.Cog.listener()
100
+	@Cog.listener()
94 101
 	async def on_member_join(self, member: Member) -> None:
95 102
 		"""Event handler"""
96 103
 		guild: Guild = member.guild

+ 42
- 43
rocketbot/cogs/logcog.py Näytä tiedosto

@@ -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
@@ -37,14 +38,22 @@ class LoggingCog(BaseCog, name='Logging'):
37 38
 	"""
38 39
 	Cog for logging notable events to a designated logging channel.
39 40
 	"""
40
-	SETTING_ENABLED = CogSetting('enabled', bool,
41
-			brief='logging',
42
-			description='Whether this cog is enabled for a guild.')
41
+	SETTING_ENABLED = CogSetting(
42
+		'enabled',
43
+		bool,
44
+		default_value=False,
45
+		brief='logging',
46
+		description='Whether this module is enabled for a guild.',
47
+	)
43 48
 
44 49
 	STATE_EVENT_BUFFER = 'LoggingCog.eventBuffer'
45 50
 
46 51
 	def __init__(self, bot):
47
-		super().__init__(bot)
52
+		super().__init__(
53
+			bot,
54
+			config_prefix='logging',
55
+			short_description='Manages event logging.',
56
+		)
48 57
 		self.add_setting(LoggingCog.SETTING_ENABLED)
49 58
 		self.flush_buffers.start()
50 59
 		self.buffered_guilds: set[Guild] = set()
@@ -52,19 +61,9 @@ class LoggingCog(BaseCog, name='Logging'):
52 61
 	def cog_unload(self) -> None:
53 62
 		self.flush_buffers.cancel()
54 63
 
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 64
 	# Events - Channels
66 65
 
67
-	@commands.Cog.listener()
66
+	@Cog.listener()
68 67
 	async def on_guild_channel_delete(self, channel: GuildChannel) -> None:
69 68
 		"""
70 69
 		Called whenever a guild channel is deleted or created.
@@ -80,7 +79,7 @@ class LoggingCog(BaseCog, name='Logging'):
80 79
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
81 80
 		await bot_message.update()
82 81
 
83
-	@commands.Cog.listener()
82
+	@Cog.listener()
84 83
 	async def on_guild_channel_create(self, channel: GuildChannel) -> None:
85 84
 		"""
86 85
 		Called whenever a guild channel is deleted or created.
@@ -96,7 +95,7 @@ class LoggingCog(BaseCog, name='Logging'):
96 95
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
97 96
 		await bot_message.update()
98 97
 
99
-	@commands.Cog.listener()
98
+	@Cog.listener()
100 99
 	async def on_guild_channel_update(self, before: GuildChannel, after: GuildChannel) -> None:
101 100
 		"""
102 101
 		Called whenever a guild channel is updated. e.g. changed name, topic,
@@ -129,27 +128,27 @@ class LoggingCog(BaseCog, name='Logging'):
129 128
 
130 129
 	# Events - Guilds
131 130
 
132
-	@commands.Cog.listener()
131
+	@Cog.listener()
133 132
 	async def on_guild_available(self, guild: Guild) -> None:
134 133
 		pass
135 134
 
136
-	@commands.Cog.listener()
135
+	@Cog.listener()
137 136
 	async def on_guild_unavailable(self, guild: Guild) -> None:
138 137
 		pass
139 138
 
140
-	@commands.Cog.listener()
139
+	@Cog.listener()
141 140
 	async def on_guild_update(self, before: Guild, after: Guild) -> None:
142 141
 		pass
143 142
 
144
-	@commands.Cog.listener()
143
+	@Cog.listener()
145 144
 	async def on_guild_emojis_update(self, guild: Guild, before: Sequence[Emoji], after: Sequence[Emoji]) -> None:
146 145
 		pass
147 146
 
148
-	@commands.Cog.listener()
147
+	@Cog.listener()
149 148
 	async def on_guild_stickers_update(self, guild: Guild, before: Sequence[GuildSticker], after: Sequence[GuildSticker]) -> None:
150 149
 		pass
151 150
 
152
-	@commands.Cog.listener()
151
+	@Cog.listener()
153 152
 	async def on_invite_create(self, invite: Invite) -> None:
154 153
 		"""
155 154
 		Called when an `Invite` is created. You must have manage_channels to receive this.
@@ -167,7 +166,7 @@ class LoggingCog(BaseCog, name='Logging'):
167 166
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
168 167
 		await bot_message.update()
169 168
 
170
-	@commands.Cog.listener()
169
+	@Cog.listener()
171 170
 	async def on_invite_delete(self, invite: Invite) -> None:
172 171
 		"""
173 172
 		Called when an `Invite` is deleted. You must have manage_channels to receive this.
@@ -186,7 +185,7 @@ class LoggingCog(BaseCog, name='Logging'):
186 185
 
187 186
 	# Events - Members
188 187
 
189
-	@commands.Cog.listener()
188
+	@Cog.listener()
190 189
 	async def on_member_join(self, member: Member) -> None:
191 190
 		"""
192 191
 		Called when a Member joins a Guild.
@@ -250,7 +249,7 @@ class LoggingCog(BaseCog, name='Logging'):
250 249
 			bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
251 250
 		await bot_message.update()
252 251
 
253
-	@commands.Cog.listener()
252
+	@Cog.listener()
254 253
 	async def on_member_remove(self, member: Member) -> None:
255 254
 		"""
256 255
 		Called when a Member leaves a Guild.
@@ -284,7 +283,7 @@ class LoggingCog(BaseCog, name='Logging'):
284 283
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
285 284
 		await bot_message.update()
286 285
 
287
-	@commands.Cog.listener()
286
+	@Cog.listener()
288 287
 	async def on_member_update(self, before: Member, after: Member) -> None:
289 288
 		"""
290 289
 		Called when a Member updates their profile.
@@ -346,7 +345,7 @@ class LoggingCog(BaseCog, name='Logging'):
346 345
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
347 346
 		await bot_message.update()
348 347
 
349
-	@commands.Cog.listener()
348
+	@Cog.listener()
350 349
 	async def on_user_update(self, before: User, after: User) -> None:
351 350
 		"""
352 351
 		Called when a User updates their profile.
@@ -378,7 +377,7 @@ class LoggingCog(BaseCog, name='Logging'):
378 377
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
379 378
 		await bot_message.update()
380 379
 
381
-	@commands.Cog.listener()
380
+	@Cog.listener()
382 381
 	async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None:
383 382
 		"""
384 383
 		Called when user gets banned from a Guild.
@@ -419,7 +418,7 @@ class LoggingCog(BaseCog, name='Logging'):
419 418
 				return entry
420 419
 		return None
421 420
 
422
-	@commands.Cog.listener()
421
+	@Cog.listener()
423 422
 	async def on_member_unban(self, guild: Guild, user: Union[User, Member]) -> None:
424 423
 		"""
425 424
 		Called when a User gets unbanned from a Guild.
@@ -472,7 +471,7 @@ class LoggingCog(BaseCog, name='Logging'):
472 471
 	async def before_flush_buffers_start(self) -> None:
473 472
 		await self.bot.wait_until_ready()
474 473
 
475
-	@commands.Cog.listener()
474
+	@Cog.listener()
476 475
 	async def on_message(self, message: Message) -> None:
477 476
 		"""
478 477
 		Called when a Message is created and sent.
@@ -481,7 +480,7 @@ class LoggingCog(BaseCog, name='Logging'):
481 480
 		"""
482 481
 		# print(f"Saw message {message.id} \"{message.content}\"")
483 482
 
484
-	@commands.Cog.listener()
483
+	@Cog.listener()
485 484
 	async def on_message_edit(self, before: Message, after: Message) -> None:
486 485
 		"""
487 486
 		Called when a Message receives an update event. If the message is not
@@ -512,7 +511,7 @@ class LoggingCog(BaseCog, name='Logging'):
512 511
 
513 512
 		self.__buffer_event(guild, 'edit', BufferedMessageEditEvent(guild, channel, before, after))
514 513
 
515
-	@commands.Cog.listener()
514
+	@Cog.listener()
516 515
 	async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None:
517 516
 		"""
518 517
 		Called when a message is edited. Unlike on_message_edit(), this is called
@@ -632,7 +631,7 @@ class LoggingCog(BaseCog, name='Logging'):
632 631
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG, suppress_embeds=True)
633 632
 		await bot_message.update()
634 633
 
635
-	@commands.Cog.listener()
634
+	@Cog.listener()
636 635
 	async def on_raw_message_delete(self, payload: RawMessageDeleteEvent) -> None:
637 636
 		"""
638 637
 		Called when a message is deleted. Unlike on_message_delete(), this is
@@ -660,7 +659,7 @@ class LoggingCog(BaseCog, name='Logging'):
660 659
 			return
661 660
 		self.__buffer_event(guild, 'delete', BufferedMessageDeleteEvent(guild, channel, payload.message_id, message))
662 661
 
663
-	@commands.Cog.listener()
662
+	@Cog.listener()
664 663
 	async def on_raw_bulk_message_delete(self, payload: RawBulkMessageDeleteEvent) -> None:
665 664
 		"""
666 665
 		Called when a bulk delete is triggered. Unlike on_bulk_message_delete(),
@@ -740,7 +739,7 @@ class LoggingCog(BaseCog, name='Logging'):
740 739
 
741 740
 	# Events - Roles
742 741
 
743
-	@commands.Cog.listener()
742
+	@Cog.listener()
744 743
 	async def on_guild_role_create(self, role: Role) -> None:
745 744
 		"""
746 745
 		Called when a Guild creates or deletes a new Role.
@@ -756,7 +755,7 @@ class LoggingCog(BaseCog, name='Logging'):
756 755
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
757 756
 		await bot_message.update()
758 757
 
759
-	@commands.Cog.listener()
758
+	@Cog.listener()
760 759
 	async def on_guild_role_delete(self, role: Role) -> None:
761 760
 		"""
762 761
 		Called when a Guild creates or deletes a new Role.
@@ -772,7 +771,7 @@ class LoggingCog(BaseCog, name='Logging'):
772 771
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
773 772
 		await bot_message.update()
774 773
 
775
-	@commands.Cog.listener()
774
+	@Cog.listener()
776 775
 	async def on_guild_role_update(self, before: Role, after: Role) -> None:
777 776
 		"""
778 777
 		Called when a Role is changed guild-wide.
@@ -815,15 +814,15 @@ class LoggingCog(BaseCog, name='Logging'):
815 814
 
816 815
 	# Events - Threads
817 816
 
818
-	@commands.Cog.listener()
817
+	@Cog.listener()
819 818
 	async def on_thread_create(self, thread: Thread) -> None:
820 819
 		pass
821 820
 
822
-	@commands.Cog.listener()
821
+	@Cog.listener()
823 822
 	async def on_thread_update(self, before: Thread, after: Thread) -> None:
824 823
 		pass
825 824
 
826
-	@commands.Cog.listener()
825
+	@Cog.listener()
827 826
 	async def on_thread_delete(self, thread: Thread) -> None:
828 827
 		pass
829 828
 

+ 211
- 60
rocketbot/cogs/patterncog.py Näytä tiedosto

@@ -2,18 +2,23 @@
2 2
 Cog for matching messages against guild-configurable criteria and taking
3 3
 automated actions on them.
4 4
 """
5
+import re
5 6
 from datetime import datetime
6 7
 from typing import Optional
7 8
 
8
-from discord import Guild, Member, Message, utils as discordutils
9
-from discord.ext import commands
9
+from discord import Guild, Member, Message, utils as discordutils, Interaction
10
+from discord.app_commands import Choice, Group, autocomplete
11
+from discord.ext.commands import Cog
10 12
 
11 13
 from config import CONFIG
14
+from rocketbot.bot import Rocketbot
12 15
 from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction
13 16
 from rocketbot.cogsetting import CogSetting
14 17
 from rocketbot.pattern import PatternCompiler, PatternDeprecationError, \
15 18
 	PatternError, PatternStatement
16 19
 from rocketbot.storage import Storage
20
+from rocketbot.utils import dump_stacktrace, MOD_PERMISSIONS
21
+
17 22
 
18 23
 class PatternContext:
19 24
 	"""
@@ -27,15 +32,106 @@ class PatternContext:
27 32
 		self.is_kicked = False
28 33
 		self.is_banned = False
29 34
 
35
+async def pattern_name_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
36
+	choices: list[Choice[str]] = []
37
+	try:
38
+		if interaction.guild is None:
39
+			return []
40
+		patterns: dict[str, PatternStatement] = PatternCog.shared.get_patterns(interaction.guild)
41
+		current_normal = current.lower().strip()
42
+		for name in sorted(patterns.keys()):
43
+			if len(current_normal) == 0 or current_normal.startswith(name.lower()):
44
+				choices.append(Choice(name=name, value=name))
45
+	except BaseException as e:
46
+		dump_stacktrace(e)
47
+	return choices
48
+
49
+async def action_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
50
+	# FIXME: WORK IN PROGRESS
51
+	print(f'autocomplete action - current = "{current}"')
52
+	regex = re.compile('^(.*?)([a-zA-Z]+)$')
53
+	match: Optional[re.Match[str]] = regex.match(current)
54
+	initial: str = ''
55
+	stub: str = current
56
+	if match:
57
+		initial = match.group(1).strip()
58
+		stub = match.group(2)
59
+	if PatternCompiler.ACTION_TO_ARGS.get(stub, None) is not None:
60
+		# Matches perfectly. Suggest another instead of completing the current.
61
+		initial = current.strip() + ', '
62
+		stub = ''
63
+	print(f'initial = "{initial}", stub = "{stub}"')
64
+
65
+	options: list[Choice[str]] = []
66
+	for action in sorted(PatternCompiler.ACTION_TO_ARGS.keys()):
67
+		if len(stub) == 0 or action.startswith(stub.lower()):
68
+			arg_types = PatternCompiler.ACTION_TO_ARGS[action]
69
+			arg_type_strs = []
70
+			for arg_type in arg_types:
71
+				if arg_type == PatternCompiler.TYPE_TEXT:
72
+					arg_type_strs.append('"message"')
73
+				else:
74
+					raise ValueError(f'Argument type {arg_type} not yet supported')
75
+			suffix = '' if len(arg_type_strs) == 0 else ' ' + (' '.join(arg_type_strs))
76
+			options.append(Choice(name=action, value=f'{initial.strip()} {action}{suffix}'))
77
+	return options
78
+
79
+async def priority_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
80
+	return [
81
+		Choice(name='very low (50)', value=50),
82
+		Choice(name='low (75)', value=75),
83
+		Choice(name='normal (100)', value=100),
84
+		Choice(name='high (125)', value=125),
85
+		Choice(name='very high (150)', value=150),
86
+	]
87
+
88
+_long_help = \
89
+"""Patterns are a powerful but complex topic. See <https://git.rixafrix.com/ialbert/python-app-rocketbot/src/branch/main/docs/patterns.md> for full documentation.
90
+
91
+### Quick cheat sheet
92
+
93
+> `/pattern add` _pattern\\_name_ _action\\_list_ `if` _expression_
94
+
95
+- _pattern\\_name_ is a brief name for identifying the pattern later (not shown to user)
96
+- _action\\_list_ is a comma-delimited list of actions to take on matching messages and is any of:
97
+   - `ban`
98
+   - `delete`
99
+   - `kick`
100
+   - `modinfo` - logs a message but doesn't tag mods
101
+   - `modwarn` - tags mods
102
+   - `reply` "message text"
103
+- _expression_ determines which messages match, of the form _field_ _op_ _value_.
104
+   - Fields:
105
+      - `content.markdown`: string
106
+      - `content.plain`: string
107
+      - `author`: user
108
+      - `author.id`: id
109
+      - `author.joinage`: timespan
110
+      - `author.name`: string
111
+      - `lastmatched`: timespan
112
+   - Operators: `==`, `!=`, `<`, `>`, `<=`, `>=`, `contains`, `!contains`, `matches`, `!matches`, `containsword`, `!containsword`
113
+   - Can combine multiple expressions with `!`, `and`, `or`, and parentheses."""
114
+
30 115
 class PatternCog(BaseCog, name='Pattern Matching'):
31 116
 	"""
32 117
 	Highly flexible cog for performing various actions on messages that match
33 118
 	various criteria. Patterns can be defined by mods for each guild.
34 119
 	"""
35 120
 
36
-	SETTING_PATTERNS = CogSetting('patterns', None)
121
+	SETTING_PATTERNS = CogSetting('patterns', None, default_value=None)
122
+
123
+	shared: Optional['PatternCog'] = None
124
+
125
+	def __init__(self, bot: Rocketbot):
126
+		super().__init__(
127
+			bot,
128
+			config_prefix='patterns',
129
+			short_description='Manages message pattern matching.',
130
+			long_description=_long_help
131
+		)
132
+		PatternCog.shared = self
37 133
 
38
-	def __get_patterns(self, guild: Guild) -> dict[str, PatternStatement]:
134
+	def get_patterns(self, guild: Guild) -> dict[str, PatternStatement]:
39 135
 		"""
40 136
 		Returns a name -> PatternStatement lookup for the guild.
41 137
 		"""
@@ -80,7 +176,7 @@ class PatternCog(BaseCog, name='Pattern Matching'):
80 176
 			Storage.set_state_value(guild, 'PatternCog.last_matched', last_matched)
81 177
 		last_matched[name] = time
82 178
 
83
-	@commands.Cog.listener()
179
+	@Cog.listener()
84 180
 	async def on_message(self, message: Message) -> None:
85 181
 		"""Event listener"""
86 182
 		if message.author is None or \
@@ -94,7 +190,7 @@ class PatternCog(BaseCog, name='Pattern Matching'):
94 190
 			# Ignore mods
95 191
 			return
96 192
 
97
-		patterns = self.__get_patterns(message.guild)
193
+		patterns = self.get_patterns(message.guild)
98 194
 		for statement in sorted(patterns.values(), key=lambda s : s.priority, reverse=True):
99 195
 			other_fields = {
100 196
 				'last_matched': self.__get_last_matched(message.guild, statement.name),
@@ -189,93 +285,148 @@ class PatternCog(BaseCog, name='Pattern Matching'):
189 285
 			did_kick=context.is_kicked,
190 286
 			did_ban=context.is_banned))
191 287
 
192
-	@commands.group(
193
-		brief='Manages message pattern matching',
288
+	pattern = Group(
289
+		name='pattern',
290
+		description='Manages message pattern matching.',
291
+		guild_only=True,
292
+		default_permissions=MOD_PERMISSIONS,
293
+		extras={
294
+			'long_description': _long_help,
295
+		},
194 296
 	)
195
-	@commands.has_permissions(ban_members=True)
196
-	@commands.guild_only()
197
-	async def pattern(self, context: commands.Context):
198
-		"""Message pattern matching command group"""
199
-		if context.invoked_subcommand is None:
200
-			await context.send_help()
201 297
 
202 298
 	@pattern.command(
203
-		brief='Adds a custom pattern',
204
-		description='Adds a custom pattern. Patterns use a simplified ' + \
205
-			'expression language. Full documentation found here: ' + \
206
-			'https://git.rixafrix.com/ialbert/python-app-rocketbot/src/' + \
207
-			'branch/main/docs/patterns.md',
208
-		usage='<pattern_name> <expression...>',
209
-		ignore_extra=True
299
+		description='Adds or updates a custom pattern.',
300
+		extras={
301
+			'long_description': _long_help,
302
+		},
210 303
 	)
211
-	async def add(self, context: commands.Context, name: str):
212
-		"""Command handler"""
213
-		pattern_str = PatternCompiler.expression_str_from_context(context, name)
304
+	@autocomplete(
305
+		name=pattern_name_autocomplete,
306
+		# actions=action_autocomplete
307
+	)
308
+	async def add(
309
+			self,
310
+			interaction: Interaction,
311
+			name: str,
312
+			actions: str,
313
+			expression: str
314
+	) -> None:
315
+		"""
316
+		Adds a custom pattern.
317
+
318
+		Parameters
319
+		----------
320
+		interaction : Interaction
321
+		name : str
322
+			a name for the new or existing pattern
323
+		actions : str
324
+			actions to take when a message matches
325
+		expression : str
326
+			criteria for matching chat messages
327
+		"""
328
+		pattern_str = f'{actions} if {expression}'
329
+		guild = interaction.guild
214 330
 		try:
215 331
 			statement = PatternCompiler.parse_statement(name, pattern_str)
216 332
 			statement.check_deprecations()
217
-			patterns = self.__get_patterns(context.guild)
333
+			patterns = self.get_patterns(guild)
218 334
 			patterns[name] = statement
219
-			self.__save_patterns(context.guild, patterns)
220
-			await context.message.reply(
335
+			self.__save_patterns(guild, patterns)
336
+			await interaction.response.send_message(
221 337
 				f'{CONFIG["success_emoji"]} Pattern `{name}` added.',
222
-				mention_author=False)
338
+				ephemeral=True,
339
+			)
223 340
 		except PatternError as e:
224
-			await context.message.reply(
341
+			await interaction.response.send_message(
225 342
 				f'{CONFIG["failure_emoji"]} Error parsing statement. {e}',
226
-				mention_author=False)
343
+				ephemeral=True,
344
+			)
227 345
 
228 346
 	@pattern.command(
229
-		brief='Removes a custom pattern',
230
-		usage='<pattern_name>'
347
+		description='Removes a custom pattern.',
348
+		extras={
349
+			'usage': '<pattern_name>',
350
+		},
231 351
 	)
232
-	async def remove(self, context: commands.Context, name: str):
233
-		"""Command handler"""
234
-		patterns = self.__get_patterns(context.guild)
352
+	@autocomplete(name=pattern_name_autocomplete)
353
+	async def remove(self, interaction: Interaction, name: str):
354
+		"""
355
+		Removes a custom pattern.
356
+
357
+		Parameters
358
+		----------
359
+		interaction: Interaction
360
+		name: str
361
+			name of the pattern to remove
362
+		"""
363
+		guild = interaction.guild
364
+		patterns = self.get_patterns(guild)
235 365
 		if patterns.get(name) is not None:
236 366
 			del patterns[name]
237
-			self.__save_patterns(context.guild, patterns)
238
-			await context.message.reply(
367
+			self.__save_patterns(guild, patterns)
368
+			await interaction.response.send_message(
239 369
 				f'{CONFIG["success_emoji"]} Pattern `{name}` deleted.',
240
-				mention_author=False)
370
+				ephemeral=True,
371
+			)
241 372
 		else:
242
-			await context.message.reply(
373
+			await interaction.response.send_message(
243 374
 				f'{CONFIG["failure_emoji"]} No pattern named `{name}`.',
244
-				mention_author=False)
375
+				ephemeral=True,
376
+			)
245 377
 
246 378
 	@pattern.command(
247
-		brief='Lists all patterns'
379
+		description='Lists all patterns.',
248 380
 	)
249
-	async def list(self, context: commands.Context) -> None:
250
-		"""Command handler"""
251
-		patterns = self.__get_patterns(context.guild)
381
+	async def list(self, interaction: Interaction) -> None:
382
+		guild = interaction.guild
383
+		patterns = self.get_patterns(guild)
252 384
 		if len(patterns) == 0:
253
-			await context.message.reply('No patterns defined.', mention_author=False)
385
+			await interaction.response.send_message(
386
+				'No patterns defined.',
387
+				ephemeral=True,
388
+			)
254 389
 			return
255 390
 		msg = ''
256 391
 		for name, statement in sorted(patterns.items()):
257 392
 			msg += f'Pattern `{name}` (priority={statement.priority}):\n```\n{statement.original}\n```\n'
258
-		await context.message.reply(msg, mention_author=False)
393
+		await interaction.response.send_message(msg, ephemeral=True)
259 394
 
260 395
 	@pattern.command(
261
-		brief='Sets a pattern\'s priority level',
262
-		description='Sets the priority for a pattern. Messages are checked ' +
263
-			'against patterns with the highest priority first. Patterns with ' +
264
-			'the same priority may be checked in arbitrary order. Default ' +
265
-			'priority is 100.',
396
+		description="Sets a pattern's priority level.",
397
+		extras={
398
+			'long_description': 'Messages are checked against patterns with the '
399
+								'highest priority first. Patterns with the same '
400
+								'priority may be checked in arbitrary order. Default '
401
+								'priority is 100.',
402
+		},
266 403
 	)
267
-	async def setpriority(self, context: commands.Context, name: str, priority: int) -> None:
268
-		"""Command handler"""
269
-		patterns = self.__get_patterns(context.guild)
404
+	@autocomplete(name=pattern_name_autocomplete, priority=priority_autocomplete)
405
+	async def setpriority(self, interaction: Interaction, name: str, priority: int) -> None:
406
+		"""
407
+		Sets a pattern's priority level.
408
+
409
+		Parameters
410
+		----------
411
+		interaction: Interaction
412
+		name: str
413
+			the name of the pattern
414
+		priority: int
415
+			evaluation priority
416
+		"""
417
+		guild = interaction.guild
418
+		patterns = self.get_patterns(guild)
270 419
 		statement = patterns.get(name)
271 420
 		if statement is None:
272
-			await context.message.reply(
421
+			await interaction.response.send_message(
273 422
 				f'{CONFIG["failure_emoji"]} No such pattern `{name}`',
274
-				mention_author=False)
423
+				ephemeral=True,
424
+			)
275 425
 			return
276 426
 		statement.priority = priority
277
-		self.__save_patterns(context.guild, patterns)
278
-		await context.message.reply(
427
+		self.__save_patterns(guild, patterns)
428
+		await interaction.response.send_message(
279 429
 			f'{CONFIG["success_emoji"]} Priority for pattern `{name}` ' + \
280
-			f'updated to `{priority}`.',
281
-			mention_author=False)
430
+				f'updated to `{priority}`.',
431
+			ephemeral=True,
432
+		)

+ 49
- 38
rocketbot/cogs/urlspamcog.py Näytä tiedosto

@@ -3,8 +3,10 @@ Cog for detecting URLs posted by new users.
3 3
 """
4 4
 import re
5 5
 from datetime import timedelta
6
+from typing import Literal
7
+
6 8
 from discord import Member, Message, utils as discordutils
7
-from discord.ext import commands
9
+from discord.ext.commands import Cog
8 10
 from discord.utils import escape_markdown
9 11
 
10 12
 from config import CONFIG
@@ -27,48 +29,57 @@ class URLSpamCog(BaseCog, name='URL Spam'):
27 29
 	Can be configured to take immediate action or just warn the mods.
28 30
 	"""
29 31
 
30
-	SETTING_ENABLED = CogSetting('enabled', bool,
31
-			brief='URL spam detection',
32
-			description='Whether URLs posted soon after joining are flagged.')
33
-	SETTING_ACTION = CogSetting('action', str,
34
-			brief='action to take on spam',
35
-			description='The action to take on detected URL spam.',
36
-			enum_values={'nothing', 'modwarn', 'delete', 'kick', 'ban'})
37
-	SETTING_JOIN_AGE = CogSetting('joinage', float,
38
-			brief='seconds since member joined',
39
-			description='The minimum seconds since the user joined the ' + \
40
-				'server before they can post URLs. URLs posted by users ' + \
41
-				'who joined too recently will be flagged. Keep in mind ' + \
42
-				'many servers have a minimum 10 minute cooldown before ' + \
43
-				'new members can say anything. Setting to 0 effectively ' + \
44
-				'disables URL spam detection.',
45
-			usage='<seconds:int>',
46
-			min_value=0)
47
-	SETTING_DECEPTIVE_ACTION = CogSetting('deceptiveaction', str,
48
-			brief='action to take on deceptive link markdown',
49
-			description='The action to take on chat messages with links ' + \
50
-				'where the text looks like a different URL than the actual link.',
51
-			enum_values={'nothing', 'modwarn', 'modwarndelete',
52
-				'chatwarn', 'chatwarndelete', 'delete', 'kick', 'ban'})
32
+	SETTING_ENABLED = CogSetting(
33
+		'enabled',
34
+		bool,
35
+		default_value=False,
36
+		brief='URL spam detection',
37
+		description='Whether URLs posted soon after joining are flagged.',
38
+	)
39
+	SETTING_ACTION = CogSetting(
40
+		'action',
41
+		Literal['nothing', 'modwarn', 'delete', 'kick', 'ban'],
42
+		default_value='nothing',
43
+		brief='action to take on spam',
44
+		description='The action to take on detected URL spam.',
45
+		enum_values={'nothing', 'modwarn', 'delete', 'kick', 'ban'},
46
+	)
47
+	SETTING_JOIN_AGE = CogSetting(
48
+		'joinage',
49
+		timedelta,
50
+		default_value=timedelta(minutes=15),
51
+		brief='seconds since member joined',
52
+		description='The minimum seconds since the user joined the '
53
+					'server before they can post URLs. URLs posted by users '
54
+				    'who joined too recently will be flagged. Keep in mind '
55
+				    'many servers have a minimum 10 minute cooldown before '
56
+				    'new members can say anything. Setting to 0 effectively '
57
+				    'disables URL spam detection.',
58
+		min_value=timedelta(seconds=0),
59
+	)
60
+	SETTING_DECEPTIVE_ACTION = CogSetting(
61
+		'deceptiveaction',
62
+		Literal['nothing', 'modwarn', 'modwarndelete', 'chatwarn', 'chatwarndelete', 'delete', 'kick', 'ban'],
63
+		default_value='nothing',
64
+		brief='action to take on deceptive link markdown',
65
+		description='The action to take on chat messages with links '
66
+					'where the text looks like a different URL than the actual link.',
67
+		enum_values={'nothing', 'modwarn', 'modwarndelete',
68
+					 'chatwarn', 'chatwarndelete', 'delete', 'kick', 'ban'},
69
+	)
53 70
 
54 71
 	def __init__(self, bot):
55
-		super().__init__(bot)
72
+		super().__init__(
73
+			bot,
74
+			config_prefix='urlspam',
75
+			short_description='Manages URL spam detection.',
76
+		)
56 77
 		self.add_setting(URLSpamCog.SETTING_ENABLED)
57 78
 		self.add_setting(URLSpamCog.SETTING_ACTION)
58 79
 		self.add_setting(URLSpamCog.SETTING_JOIN_AGE)
59 80
 		self.add_setting(URLSpamCog.SETTING_DECEPTIVE_ACTION)
60 81
 
61
-	@commands.group(
62
-		brief='Manages URL spam detection',
63
-	)
64
-	@commands.has_permissions(ban_members=True)
65
-	@commands.guild_only()
66
-	async def urlspam(self, context: commands.Context):
67
-		"""URL spam command group"""
68
-		if context.invoked_subcommand is None:
69
-			await context.send_help()
70
-
71
-	@commands.Cog.listener()
82
+	@Cog.listener()
72 83
 	async def on_message(self, message: Message):
73 84
 		"""Event listener"""
74 85
 		if message.author is None or \
@@ -226,7 +237,7 @@ class URLSpamCog(BaseCog, name='URL Spam'):
226 237
 					return True
227 238
 		return False
228 239
 
229
-	def is_url(self, s: str):
240
+	def is_url(self, s: str) -> bool:
230 241
 		"""Tests if a string is strictly a URL"""
231 242
 		ipv6_host_pattern = r'\[[0-9a-fA-F:]+\]'
232 243
 		ipv4_host_pattern = r'[0-9\.]+'
@@ -237,7 +248,7 @@ class URLSpamCog(BaseCog, name='URL Spam'):
237 248
 		pattern = r'^http[s]?://' + host_pattern + port_pattern + path_pattern + '$'
238 249
 		return re.match(pattern, s, re.IGNORECASE) is not None
239 250
 
240
-	def is_casual_url(self, s: str):
251
+	def is_casual_url(self, s: str) -> bool:
241 252
 		"""Tests if a string is a "casual URL" with no scheme included"""
242 253
 		ipv6_host_pattern = r'\[[0-9a-fA-F:]+\]'
243 254
 		ipv4_host_pattern = r'[0-9\.]+'

+ 91
- 38
rocketbot/cogs/usernamecog.py Näytä tiedosto

@@ -3,12 +3,15 @@ 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
11 12
 from rocketbot.storage import Storage
13
+from rocketbot.utils import MOD_PERMISSIONS
14
+
12 15
 
13 16
 class UsernamePatternContext:
14 17
 	"""
@@ -44,14 +47,24 @@ class UsernamePatternCog(BaseCog, name='Username Pattern'):
44 47
 	message on a match.
45 48
 	"""
46 49
 
47
-	SETTING_ENABLED = CogSetting('enabled', bool,
48
-			brief='username pattern detection',
49
-			description='Whether new users are checked for common patterns.')
50
+	SETTING_ENABLED = CogSetting(
51
+		'enabled',
52
+		bool,
53
+		default_value=False,
54
+		brief='username pattern detection',
55
+		description='Whether new users are checked for username patterns.',
56
+	)
50 57
 
51
-	SETTING_PATTERNS = CogSetting('patterns', None)
58
+	SETTING_PATTERNS = CogSetting('patterns', None, default_value=None)
52 59
 
53 60
 	def __init__(self, bot):
54
-		super().__init__(bot)
61
+		super().__init__(
62
+			bot,
63
+			config_prefix='username',
64
+			short_description='Manages username pattern detection.',
65
+			long_description='When new users join, if their username matches '
66
+							 'a configured pattern the mods will be alerted.'
67
+		)
55 68
 		self.add_setting(UsernamePatternCog.SETTING_ENABLED)
56 69
 
57 70
 	def __get_patterns(self, guild: Guild) -> list[str]:
@@ -73,64 +86,104 @@ class UsernamePatternCog(BaseCog, name='Username Pattern'):
73 86
 		"""
74 87
 		cls.set_guild_setting(guild, cls.SETTING_PATTERNS, patterns)
75 88
 
76
-	@commands.group(
77
-		brief='Manages username pattern detection'
89
+	username = Group(
90
+		name='username',
91
+		description='Manages username pattern detection.',
92
+		guild_only=True,
93
+		default_permissions=MOD_PERMISSIONS,
94
+		extras={
95
+			'long_description': 'When new users join, if their username matches '
96
+								'a configured pattern the mods will be alerted.',
97
+		},
78 98
 	)
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 99
 
86 100
 	@username.command(
87
-		brief='Adds a username pattern',
88 101
 		description='Adds a username pattern.',
89
-		usage='<pattern>'
102
+		extras={
103
+			'long_description': 'When a user joins the server, if their username '
104
+								'matches a configured pattern the mods will be alerted. '
105
+								'Matching is currently a simple substring test.',
106
+			'usage': '<pattern>',
107
+		},
90 108
 	)
91
-	async def add(self, context: commands.Context, pattern: str) -> None:
92
-		"""Command handler"""
109
+	async def add(self, interaction: Interaction, pattern: str) -> None:
110
+		"""
111
+		Adds a username pattern to match against new members.
112
+
113
+		Parameters
114
+		----------
115
+		interaction : Interaction
116
+		pattern : str
117
+			a substring to look for in usernames
118
+		"""
93 119
 		norm_pattern = pattern.lower()
94
-		patterns: list[str] = self.__get_patterns(context.guild)
120
+		patterns: list[str] = self.__get_patterns(interaction.guild)
95 121
 		if norm_pattern in patterns:
96
-			await context.reply(f'Pattern `{norm_pattern}` already added.', mention_author=False)
122
+			await interaction.response.send_message(
123
+				f'{CONFIG["warning_emoji"]} Pattern `{norm_pattern}` already added.',
124
+				ephemeral=True
125
+			)
97 126
 			return
98 127
 		patterns.append(norm_pattern)
99
-		self.__save_patterns(context.guild, patterns)
100
-		await context.reply(f'Pattern `{norm_pattern}` added.', mention_author=False)
128
+		self.__save_patterns(interaction.guild, patterns)
129
+		await interaction.response.send_message(
130
+			f'{CONFIG["success_emoji"]} Pattern `{norm_pattern}` added.',
131
+			ephemeral=True
132
+		)
101 133
 
102 134
 	@username.command(
103
-		brief='Removes a username pattern',
104
-		description='Removes an existing username pattern',
105
-		usage='<pattern>'
135
+		description='Removes a username pattern.',
136
+		extras={
137
+			'usage': '<pattern>',
138
+		},
106 139
 	)
107
-	async def remove(self, context: commands.Context, pattern: str) -> None:
108
-		"""Command handler"""
140
+	async def remove(self, interaction: Interaction, pattern: str) -> None:
141
+		"""
142
+		Removes a username pattern.
143
+
144
+		Parameters
145
+		----------
146
+		interaction : Interaction
147
+		pattern : str
148
+		    the existing username pattern to remove
149
+		"""
109 150
 		norm_pattern = pattern.lower()
110
-		guild: Guild = context.guild
151
+		guild: Guild = interaction.guild
111 152
 		patterns: list[str] = self.__get_patterns(guild)
112 153
 		len_before = len(patterns)
113 154
 		patterns = list(filter(lambda p: p != norm_pattern, patterns))
114 155
 		if len(patterns) == len_before:
115
-			await context.reply(f'Pattern `{norm_pattern}` not found.', mention_author=False)
156
+			await interaction.response.send_message(
157
+				f'{CONFIG["warning_emoji"]} Pattern `{norm_pattern}` not found.',
158
+				ephemeral=True,
159
+			)
116 160
 			return
117 161
 		self.__save_patterns(guild, patterns)
118
-		await context.reply(f'Pattern `{norm_pattern}` removed.', mention_author=False)
162
+		await interaction.response.send_message(
163
+			f'{CONFIG["success_emoji"]} Pattern `{norm_pattern}` removed.',
164
+			ephemeral=True,
165
+		)
119 166
 
120 167
 	@username.command(
121
-		brief='Lists username patterns'
168
+		description='Lists existing username patterns.'
122 169
 	)
123
-	async def list(self, context: commands.Context) -> None:
170
+	async def list(self, interaction: Interaction) -> None:
124 171
 		"""Command handler"""
125
-		guild: Guild = context.guild
172
+		guild: Guild = interaction.guild
126 173
 		patterns: list[str] = self.__get_patterns(guild)
127 174
 		if len(patterns) == 0:
128
-			await context.reply('No patterns defined', mention_author=False)
175
+			await interaction.response.send_message(
176
+				f'{CONFIG["success_emoji"]} No patterns defined.',
177
+				ephemeral=True,
178
+			)
129 179
 		else:
130
-			msg = 'Patterns:\n\n> `' + '`\n> `'.join(patterns) + '`'
131
-			await context.reply(msg, mention_author=False)
180
+			msg = f'{CONFIG["info_emoji"]} Patterns:\n\n> `' + '`\n> `'.join(patterns) + '`'
181
+			await interaction.response.send_message(
182
+				msg,
183
+				ephemeral=True,
184
+			)
132 185
 
133
-	@commands.Cog.listener()
186
+	@Cog.listener()
134 187
 	async def on_member_join(self, member: Member) -> None:
135 188
 		"""Event handler"""
136 189
 		for pattern in self.__get_patterns(member.guild):

+ 344
- 158
rocketbot/cogsetting.py Näytä tiedosto

@@ -1,78 +1,98 @@
1 1
 """
2 2
 A guild configuration setting available for editing via bot commands.
3 3
 """
4
-from typing import Any, Callable, Coroutine, Optional, Type
4
+from datetime import timedelta
5
+from typing import Any, Optional, Type, Literal, Union
5 6
 
6
-from discord.ext import commands
7
-from discord.ext.commands import Bot, Command, Context, Group, Cog
7
+from discord import Interaction, Permissions
8
+from discord.app_commands import Range, Transform, describe
9
+from discord.app_commands.commands import Command, Group, CommandCallback, rename
10
+from discord.ext.commands import Bot
8 11
 
9 12
 from config import CONFIG
10 13
 from rocketbot.storage import Storage
11
-from rocketbot.utils import first_command_group
14
+from rocketbot.utils import bot_log, TimeDeltaTransformer, MOD_PERMISSIONS, dump_stacktrace, str_from_timedelta
12 15
 
13
-def _fix_command(command: Command) -> None:
14
-	"""
15
-	HACK: Fixes bug in discord.py 2.3.2 where it's requiring the user to
16
-	supply the context argument. This removes that argument from the list.
17
-	"""
18
-	params = command.params
19
-	del params['context']
20
-	command.params = params
16
+
17
+def describe_type(datatype: Type) -> str:
18
+	if datatype is int:
19
+		return 'integer'
20
+	if datatype is float:
21
+		return 'float'
22
+	if datatype is str:
23
+		return 'string'
24
+	if datatype is bool:
25
+		return 'boolean'
26
+	if datatype is timedelta:
27
+		return 'timespan'
28
+	if getattr(datatype, '__origin__', None) is Union:
29
+		return '|'.join([ describe_type(a) for a in datatype.__args__ ])
30
+	if getattr(datatype, '__origin__', None) is Literal:
31
+		return '"' + ('"|"'.join(datatype.__args__)) + '"'
32
+	return datatype.__class__.__name__
21 33
 
22 34
 class CogSetting:
23 35
 	"""
24 36
 	Describes a configuration setting for a guild that can be edited by the
25
-	mods of those guilds. BaseCog can generate "get" and "set" commands (or
26
-	"enable" and "disable" commands for boolean values) automatically, reducing
37
+	mods of those guilds. BaseCog can generate "/get" and "/set" commands (or
38
+	"/enable" and "/disable" commands for boolean values) automatically, reducing
27 39
 	the boilerplate of generating commands manually. Offers simple validation rules.
28 40
 	"""
41
+
42
+	permissions: Permissions = Permissions(Permissions.manage_messages.flag)
43
+
29 44
 	def __init__(self,
30 45
 			name: str,
31 46
 			datatype: Optional[Type],
47
+			default_value: Any,
32 48
 			brief: Optional[str] = None,
33 49
 			description: Optional[str] = None,
34
-			usage: Optional[str] = None,
35 50
 			min_value: Optional[Any] = None,
36 51
 			max_value: Optional[Any] = None,
37 52
 			enum_values: Optional[set[Any]] = None):
38 53
 		"""
39
-		Params:
40
-		- name         Setting identifier. Must follow variable naming
41
-		               conventions.
42
-		- datatype     Datatype of the setting. E.g. int, float, str
43
-		- brief        Description of the setting, starting with lower case.
44
-		               Will be inserted into phrases like "Sets <brief>" and
45
-					   "Gets <brief".
46
-		- description  Long-form description. Min, max, and enum values will be
47
-		               appended to the end, so does not need to include these.
48
-		- usage        Description of the value argument in a set command, e.g.
49
-		               "<maxcount:int>"
50
-		- min_value    Smallest allowable value. Must be of the same datatype as
51
-		               the value. None for no minimum.
52
-		- max_value    Largest allowable value. None for no maximum.
53
-		- enum_values  Set of allowed values. None if unconstrained.
54
+		Parameters
55
+		----------
56
+		name: str
57
+			Setting identifier. Must follow variable naming conventions.
58
+		datatype: Optional[Type]
59
+		    Datatype of the setting. E.g. int, float, str
60
+		default_value: Any
61
+			Value to use if a guild has not yet configured one.
62
+		brief: Optional[str]
63
+			Description of the setting, starting with lower case.
64
+			Will be inserted into phrases like "Sets <brief>" and
65
+			"Gets <brief>".
66
+		description: Optional[str]
67
+		  	Long-form description. Min, max, and enum values will be
68
+		    appended to the end, so does not need to include these.
69
+		min_value: Optional[Any]
70
+		    Smallest allowable value. Must be of the same datatype as
71
+		    the value. None for no minimum.
72
+		max_value: Optional[Any]
73
+		    Largest allowable value. None for no maximum.
74
+		enum_values: Optional[set[Any]]
75
+		  	Set of allowed values. None if unconstrained.
54 76
 		"""
55 77
 		self.name: str = name
56 78
 		self.datatype: Type = datatype
79
+		self.default_value = default_value
57 80
 		self.brief: Optional[str] = brief
58 81
 		self.description: str = description or ''  # Can't be None
59
-		self.usage: Optional[str] = usage
60 82
 		self.min_value: Optional[Any] = min_value
61 83
 		self.max_value: Optional[Any] = max_value
62 84
 		self.enum_values: Optional[set[Any]] = enum_values
63
-		if self.enum_values or self.min_value is not None or self.max_value is not None:
64
-			self.description += '\n'
65 85
 		if self.enum_values:
66
-			allowed_values = '`' + ('`, `'.join(enum_values)) + '`'
67
-			self.description += f'\nAllowed values: {allowed_values}'
68
-		if self.min_value is not None:
69
-			self.description += f'\nMin value: {self.min_value}'
70
-		if self.max_value is not None:
71
-			self.description += f'\nMax value: {self.max_value}'
72
-		if self.usage is None:
73
-			self.usage = f'<{self.name}>'
74
-
75
-	def validate_value(self, new_value) -> None:
86
+			value_list = '`' + ('`, `'.join(self.enum_values)) + '`'
87
+			self.description += f' (Permitted values: {value_list})'
88
+		elif self.min_value is not None and self.max_value is not None:
89
+			self.description += f' (Value must be between `{self.min_value}` and `{self.max_value}`)'
90
+		elif self.min_value is not None:
91
+			self.description += f' (Minimum value: {self.min_value})'
92
+		elif self.max_value is not None:
93
+			self.description += f' (Maximum value: {self.max_value})'
94
+
95
+	def validate_value(self, new_value: Any) -> None:
76 96
 		"""
77 97
 		Checks if a value is legal for this setting. Raises a ValueError if not.
78 98
 		"""
@@ -84,161 +104,327 @@ class CogSetting:
84 104
 			allowed_values = '`' + ('`, `'.join(self.enum_values)) + '`'
85 105
 			raise ValueError(f'`{self.name}` must be one of {allowed_values}')
86 106
 
87
-	def set_up(self, cog: Cog, bot: Bot, group: Group) -> None:
107
+	def set_up(self, cog: 'BaseCog') -> None:
88 108
 		"""
89 109
 		Sets up getter and setter commands for this setting. This should
90 110
 		usually only be called by BaseCog.
91 111
 		"""
92 112
 		if self.name in ('enabled', 'is_enabled'):
93
-			(group or bot).add_command(self.__make_enable_command(cog))
94
-			(group or bot).add_command(self.__make_disable_command(cog))
113
+			self.__enable_group.add_command(self.__make_enable_command(cog))
114
+			self.__disable_group.add_command(self.__make_disable_command(cog))
95 115
 		else:
96
-			(group or bot).add_command(self.__make_getter_command(cog))
97
-			(group or bot).add_command(self.__make_setter_command(cog))
116
+			self.__get_group.add_command(self.__make_getter_command(cog))
117
+			self.__set_group.add_command(self.__make_setter_command(cog))
98 118
 
99
-	def __make_getter_command(self, cog: Cog) -> Command:
119
+	def to_stored_value(self, native_value: Any) -> Any:
120
+		"""Converts a configuration value to a JSON-compatible datatype."""
121
+		if self.datatype is timedelta:
122
+			return native_value.total_seconds()
123
+		return native_value
124
+
125
+	def to_native_value(self, stored_value: Any) -> Any:
126
+		"""Converts the stored JSON-compatible datatype to its actual value."""
127
+		if self.datatype is timedelta and isinstance(stored_value, (int, float)):
128
+			return timedelta(seconds=stored_value)
129
+		return stored_value
130
+
131
+	@staticmethod
132
+	def native_value_to_str(native_value: Any) -> str:
133
+		"""Formats a native configuration value to a user-presentable string."""
134
+		if native_value is None:
135
+			return '<no value>'
136
+		if isinstance(native_value, timedelta):
137
+			return str_from_timedelta(native_value)
138
+		if isinstance(native_value, bool):
139
+			return 'true' if native_value else 'false'
140
+		return f'{native_value}'
141
+
142
+	def __make_getter_command(self, cog: 'BaseCog') -> Command:
100 143
 		setting: CogSetting = self
101
-		async def getter(cog0: Cog, context: Context) -> None:
102
-			setting_name = setting.name
103
-			if isinstance(context.command.parent, Group):
104
-				setting_name = f'{context.command.parent.name}.{setting_name}'
105
-			key = f'{cog0.__class__.__name__}.{setting.name}'
106
-			value = Storage.get_config_value(context.guild, key)
144
+		setting_name = setting.name
145
+		if cog.config_prefix is not None:
146
+			setting_name = f'{cog.config_prefix}_{setting_name}'
147
+		async def getter(interaction: Interaction) -> None:
148
+			key = f'{cog.__class__.__name__}.{setting.name}'
149
+			value = setting.to_native_value(Storage.get_config_value(interaction.guild, key))
107 150
 			if value is None:
108
-				value = cog0.get_cog_default(setting.name)
109
-				await context.message.reply(
110
-					f'{CONFIG["info_emoji"]} `{setting_name}` is using default of `{value}`',
111
-					mention_author=False)
151
+				value = setting.to_native_value(cog.get_cog_default(setting.name))
152
+				await interaction.response.send_message(
153
+					f'{CONFIG["info_emoji"]} `{setting_name}` is using default of `{CogSetting.native_value_to_str(value)}`',
154
+					ephemeral=True
155
+				)
112 156
 			else:
113
-				await context.message.reply(
114
-					f'{CONFIG["info_emoji"]} `{setting_name}` is set to `{value}`',
115
-					mention_author=False)
157
+				await interaction.response.send_message(
158
+					f'{CONFIG["info_emoji"]} `{setting_name}` is set to `{CogSetting.native_value_to_str(value)}`',
159
+					ephemeral=True
160
+				)
161
+
162
+		bot_log(None, cog.__class__, f"Creating command: /get {setting_name}")
116 163
 		command = Command(
117
-			getter,
118
-			name=f'get{setting.name}',
119
-			brief=f'Shows {setting.brief}',
120
-			description=setting.description,
121
-			checks=[
122
-				commands.has_permissions(ban_members=True),
123
-				commands.guild_only(),
124
-			])
125
-		command.cog = cog
126
-		_fix_command(command)
164
+			name=setting_name,
165
+			description=f'Shows {self.brief}.',
166
+			callback=getter,
167
+			parent=CogSetting.__get_group,
168
+			extras={
169
+				'cog': cog,
170
+				'setting': setting,
171
+				'long_description': setting.description,
172
+			},
173
+		)
127 174
 		return command
128 175
 
129
-	def __make_setter_command(self, cog: Cog) -> Command:
176
+	def __make_setter_command(self, cog: 'BaseCog') -> Command:
130 177
 		setting: CogSetting = self
131
-		async def setter_common(cog0: Cog, context: Context, new_value) -> None:
178
+		setting_name = setting.name
179
+		if cog.config_prefix is not None:
180
+			setting_name = f'{cog.config_prefix}_{setting_name}'
181
+		async def setter_general(interaction: Interaction, new_value) -> None:
132 182
 			try:
133 183
 				setting.validate_value(new_value)
134 184
 			except ValueError as ve:
135
-				await context.message.reply(
185
+				await interaction.response.send_message(
136 186
 					f'{CONFIG["failure_emoji"]} {ve}',
137
-					mention_author=False)
187
+					ephemeral=True
188
+				)
138 189
 				return
139
-			setting_name = setting.name
140
-			if isinstance(context.command.parent, Group):
141
-				setting_name = f'{context.command.parent.name}.{setting_name}'
142
-			key = f'{cog0.__class__.__name__}.{setting.name}'
143
-			Storage.set_config_value(context.guild, key, new_value)
144
-			await context.message.reply(
145
-				f'{CONFIG["success_emoji"]} `{setting_name}` is now set to `{new_value}`',
146
-				mention_author=False)
147
-			await cog0.on_setting_updated(context.guild, setting)
148
-			cog0.log(context.guild, f'{context.author.name} set {key} to {new_value}')
149
-
150
-		async def setter_int(cog1, context, new_value: int):
151
-			await setter_common(cog1, context, new_value)
152
-		async def setter_float(cog2, context, new_value: float):
153
-			await setter_common(cog2, context, new_value)
154
-		async def setter_str(cog3, context, new_value: str):
155
-			await setter_common(cog3, context, new_value)
156
-		async def setter_bool(cog4, context, new_value: bool):
157
-			await setter_common(cog4, context, new_value)
158
-
159
-		setter: Callable[[Cog, Context, Any], Coroutine]
160
-		if setting.datatype == int:
161
-			setter = setter_int
162
-		elif setting.datatype == float:
190
+			key = f'{cog.__class__.__name__}.{setting.name}'
191
+			Storage.set_config_value(interaction.guild, key, setting.to_stored_value(new_value))
192
+			await interaction.response.send_message(
193
+				f'{CONFIG["success_emoji"]} `{setting_name}` is now set to `{setting.to_native_value(new_value)}`',
194
+				ephemeral=True
195
+			)
196
+			await cog.on_setting_updated(interaction.guild, setting)
197
+			cog.log(interaction.guild, f'{interaction.user.name} set {key} to {new_value}')
198
+
199
+		setter: CommandCallback = setter_general
200
+		if self.datatype is int:
201
+			if self.min_value is not None or self.max_value is not None:
202
+				r_min = self.min_value
203
+				r_max = self.max_value
204
+				@rename(new_value=self.name)
205
+				@describe(new_value=self.brief)
206
+				async def setter_range(interaction: Interaction, new_value: Range[int, r_min, r_max]) -> None:
207
+					await setter_general(interaction, new_value)
208
+				setter = setter_range
209
+			else:
210
+				@rename(new_value=self.name)
211
+				@describe(new_value=self.brief)
212
+				async def setter_int(interaction: Interaction, new_value: int) -> None:
213
+					await setter_general(interaction, new_value)
214
+				setter = setter_int
215
+		elif self.datatype is float:
216
+			@rename(new_value=self.name)
217
+			@describe(new_value=self.brief)
218
+			async def setter_float(interaction: Interaction, new_value: float) -> None:
219
+				await setter_general(interaction, new_value)
163 220
 			setter = setter_float
164
-		elif setting.datatype == str:
165
-			setter = setter_str
166
-		elif setting.datatype == bool:
221
+		elif self.datatype is timedelta:
222
+			@rename(new_value=self.name)
223
+			@describe(new_value=f'{self.brief} (e.g. 30s, 5m, 1h30s, 7d)')
224
+			async def setter_timedelta(interaction: Interaction, new_value: Transform[timedelta, TimeDeltaTransformer]) -> None:
225
+				await setter_general(interaction, new_value)
226
+			setter = setter_timedelta
227
+		elif getattr(self.datatype, '__origin__', None) == Literal:
228
+			dt = self.datatype
229
+			@rename(new_value=self.name)
230
+			@describe(new_value=self.brief)
231
+			async def setter_enum(interaction: Interaction, new_value: dt) -> None:
232
+				await setter_general(interaction, new_value)
233
+			setter = setter_enum
234
+		elif self.datatype is str:
235
+			if self.enum_values is not None:
236
+				raise ValueError('Type for a setting with enum values should be typing.Literal')
237
+			else:
238
+				@rename(new_value=self.name)
239
+				@describe(new_value=self.brief)
240
+				async def setter_str(interaction: Interaction, new_value: str) -> None:
241
+					await setter_general(interaction, new_value)
242
+				setter = setter_str
243
+		elif self.datatype is bool:
244
+			@rename(new_value=self.name)
245
+			@describe(new_value=self.brief)
246
+			async def setter_bool(interaction: Interaction, new_value: bool) -> None:
247
+				await setter_general(interaction, new_value)
167 248
 			setter = setter_bool
168
-		else:
169
-			raise ValueError(f'Datatype {setting.datatype} unsupported')
249
+		elif self.datatype is not None:
250
+			raise ValueError(f'Invalid type {self.datatype}')
170 251
 
252
+		bot_log(None, cog.__class__, f"Creating command: /set {setting_name} <{describe_type(self.datatype)}>")
171 253
 		command = Command(
172
-			setter,
173
-			name=f'set{setting.name}',
174
-			brief=f'Sets {setting.brief}',
175
-			description=setting.description,
176
-			usage=setting.usage,
177
-			checks=[
178
-				commands.has_permissions(ban_members=True),
179
-				commands.guild_only(),
180
-			])
181
-		# HACK: Passing `cog` in init gets ignored and set to `None` so set after.
182
-		# This ensures the callback is passed the cog as `self` argument.
183
-		command.cog = cog
184
-		_fix_command(command)
254
+			name=setting_name,
255
+			description=f'Sets {self.brief}.',
256
+			callback=setter,
257
+			parent=CogSetting.__set_group,
258
+			extras={
259
+				'cog': cog,
260
+				'setting': setting,
261
+				'long_description': setting.description,
262
+			},
263
+		)
185 264
 		return command
186 265
 
187
-	def __make_enable_command(self, cog: Cog) -> Command:
266
+	def __make_enable_command(self, cog: 'BaseCog') -> Command:
188 267
 		setting: CogSetting = self
189
-		async def enabler(cog0: Cog, context: Context) -> None:
190
-			key = f'{cog0.__class__.__name__}.{setting.name}'
191
-			Storage.set_config_value(context.guild, key, True)
192
-			await context.message.reply(
268
+		async def enabler(interaction: Interaction) -> None:
269
+			key = f'{cog.__class__.__name__}.{setting.name}'
270
+			Storage.set_config_value(interaction.guild, key, True)
271
+			await interaction.response.send_message(
193 272
 				f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} enabled.',
194
-				mention_author=False)
195
-			await cog0.on_setting_updated(context.guild, setting)
196
-			cog0.log(context.guild, f'{context.author.name} enabled {cog0.__class__.__name__}')
273
+				ephemeral=True
274
+			)
275
+			await cog.on_setting_updated(interaction.guild, setting)
276
+			cog.log(interaction.guild, f'{interaction.user.name} enabled {cog.__class__.__name__}')
197 277
 
278
+		bot_log(None, cog.__class__, f"Creating command: /enable {cog.config_prefix}")
198 279
 		command = Command(
199
-			enabler,
200
-			name='enable',
201
-			brief=f'Enables {setting.brief}',
202
-			description=setting.description,
203
-			checks=[
204
-				commands.has_permissions(ban_members=True),
205
-				commands.guild_only(),
206
-			])
207
-		command.cog = cog
208
-		_fix_command(command)
280
+			name=cog.config_prefix,
281
+			description=f'Enables {cog.qualified_name} functionality.',
282
+			callback=enabler,
283
+			parent=CogSetting.__enable_group,
284
+			extras={
285
+				'cog': cog,
286
+				'setting': setting,
287
+				'long_description': setting.description,
288
+			},
289
+		)
209 290
 		return command
210 291
 
211
-	def __make_disable_command(self, cog: Cog) -> Command:
292
+	def __make_disable_command(self, cog: 'BaseCog') -> Command:
212 293
 		setting: CogSetting = self
213
-		async def disabler(cog0: Cog, context: Context) -> None:
214
-			key = f'{cog0.__class__.__name__}.{setting.name}'
215
-			Storage.set_config_value(context.guild, key, False)
216
-			await context.message.reply(
294
+		async def disabler(interaction: Interaction) -> None:
295
+			key = f'{cog.__class__.__name__}.{setting.name}'
296
+			Storage.set_config_value(interaction.guild, key, False)
297
+			await interaction.response.send_message(
217 298
 				f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} disabled.',
218
-				mention_author=False)
219
-			await cog0.on_setting_updated(context.guild, setting)
220
-			cog0.log(context.guild, f'{context.author.name} disabled {cog0.__class__.__name__}')
299
+				ephemeral=True
300
+			)
301
+			await cog.on_setting_updated(interaction.guild, setting)
302
+			cog.log(interaction.guild, f'{interaction.user.name} disabled {cog.__class__.__name__}')
221 303
 
304
+		bot_log(None, cog.__class__, f"Creating command: /disable {cog.config_prefix}")
222 305
 		command = Command(
223
-			disabler,
224
-			name='disable',
225
-			brief=f'Disables {setting.brief}',
226
-			description=setting.description,
227
-			checks=[
228
-				commands.has_permissions(ban_members=True),
229
-				commands.guild_only(),
230
-			])
231
-		command.cog = cog
232
-		_fix_command(command)
306
+			name=cog.config_prefix,
307
+			description=f'Disables {cog.config_prefix} functionality',
308
+			callback=disabler,
309
+			parent=CogSetting.__disable_group,
310
+			extras={
311
+				'cog': cog,
312
+				'setting': setting,
313
+				'long_description': setting.description,
314
+			},
315
+		)
233 316
 		return command
234 317
 
318
+	__set_group: Group
319
+	__get_group: Group
320
+	__enable_group: Group
321
+	__disable_group: Group
322
+
235 323
 	@classmethod
236
-	def set_up_all(cls, cog: Cog, bot: Bot, settings: list) -> None:
324
+	def set_up_all(cls, cog: 'BaseCog', bot: Bot, settings: list['CogSetting']) -> None:
237 325
 		"""
238 326
 		Sets up editing commands for a list of CogSettings and adds them to a
239 327
 		cog. If the cog has a command Group, commands will be added to it.
240
-		Otherwise they will be added at the top level.
328
+		Otherwise, they will be added at the top level.
241 329
 		"""
242
-		group: Group = first_command_group(cog)
330
+		cls.__set_up_base_commands(bot)
331
+		if len(settings) == 0:
332
+			return
243 333
 		for setting in settings:
244
-			setting.set_up(cog, bot, group)
334
+			setting.set_up(cog)
335
+
336
+	@classmethod
337
+	def __set_up_base_commands(cls, bot: Bot) -> None:
338
+		if getattr(cls, f'_CogSetting__set_group', None) is not None:
339
+			return
340
+		cls.__set_group = Group(
341
+			name='set',
342
+			description='Sets a configuration value for this guild.',
343
+			guild_only=True,
344
+			default_permissions=MOD_PERMISSIONS,
345
+			extras={
346
+				'long_description': 'Settings are guild-specific. If no value is set, a default is used. Use `/get` to '
347
+									'see the current value for this guild.',
348
+			},
349
+		)
350
+		cls.__get_group = Group(
351
+			name='get',
352
+			description='Shows a configuration value for this guild.',
353
+			guild_only=True,
354
+			default_permissions=MOD_PERMISSIONS,
355
+			extras={
356
+				'long_description': 'Settings are guild-specific. If no value is set, a default is used. Use `/set` to '
357
+									'change the value.',
358
+			},
359
+		)
360
+		cls.__enable_group = Group(
361
+			name='enable',
362
+			description='Enables a module for this guild.',
363
+			guild_only=True,
364
+			default_permissions=MOD_PERMISSIONS,
365
+			extras={
366
+				'long_description': 'Modules are enabled on a per-guild basis and are off by default. Use `/disable` '
367
+									'to disable an enabled module.',
368
+			},
369
+		)
370
+		cls.__disable_group = Group(
371
+			name='disable',
372
+			description='Disables a module for this guild.',
373
+			guild_only=True,
374
+			default_permissions=MOD_PERMISSIONS,
375
+			extras={
376
+				'long_description': 'Modules are enabled on a per-guild basis and are off by default. Use `/enable` '
377
+					                're-enable a disabled module.',
378
+			},
379
+		)
380
+		bot.tree.add_command(cls.__set_group)
381
+		bot.tree.add_command(cls.__get_group)
382
+		bot.tree.add_command(cls.__enable_group)
383
+		bot.tree.add_command(cls.__disable_group)
384
+
385
+		from rocketbot.cogs.basecog import BaseCog
386
+		async def show_all(interaction: Interaction) -> None:
387
+			try:
388
+				guild = interaction.guild
389
+				if guild is None:
390
+					await interaction.response.send_message(
391
+						f'{CONFIG["failure_emoji"]} No guild.',
392
+						ephemeral=True,
393
+						delete_after=10,
394
+					)
395
+					return
396
+				text = '## :information_source: Configuration'
397
+				for cog_name, cog in sorted(bot.cogs.items()):
398
+					if not isinstance(cog, BaseCog):
399
+						continue
400
+					bcog: BaseCog = cog
401
+					if len(bcog.settings) == 0:
402
+						continue
403
+					text += f'\n### {bcog.qualified_name} Module'
404
+					for setting in sorted(bcog.settings, key=lambda s: (s.name != 'enabled', s.name)):
405
+						key = f'{bcog.__class__.__name__}.{setting.name}'
406
+						value = setting.to_native_value(Storage.get_config_value(guild, key))
407
+						deflt = setting.to_native_value(bcog.get_cog_default(setting.name))
408
+						if setting.name == 'enabled':
409
+							text += f'\n- Module is '
410
+							if value is not None:
411
+								text += '**' + ('enabled' if value else 'disabled') + '**'
412
+							else:
413
+								text += ('enabled' if deflt else 'disabled') + ' _(default)_'
414
+						else:
415
+							if value is not None:
416
+								text += f'\n- `{bcog.config_prefix}_{setting.name}` = **{CogSetting.native_value_to_str(value)}**'
417
+							else:
418
+								text += f'\n- `{bcog.config_prefix}_{setting.name}` = {CogSetting.native_value_to_str(deflt)} _(using default)_'
419
+				await interaction.response.send_message(
420
+					text,
421
+					ephemeral=True,
422
+				)
423
+			except BaseException as e:
424
+				dump_stacktrace(e)
425
+		show_all_command = Command(
426
+			name='all',
427
+			description='Shows all configuration for this guild.',
428
+			callback=show_all,
429
+		)
430
+		cls.__get_group.add_command(show_all_command)

+ 134
- 81
rocketbot/pattern.py Näytä tiedosto

@@ -5,7 +5,7 @@ to take on them.
5 5
 import re
6 6
 from abc import ABCMeta, abstractmethod
7 7
 from datetime import datetime, timezone
8
-from typing import Any, Union
8
+from typing import Any, Union, Literal
9 9
 
10 10
 from discord import Message, utils as discordutils
11 11
 from discord.ext.commands import Context
@@ -13,6 +13,11 @@ from discord.ext.commands import Context
13 13
 from rocketbot.utils import is_user_id, str_from_quoted_str, timedelta_from_str, \
14 14
 	user_id_from_mention
15 15
 
16
+PatternField = Literal['content.markdown', 'content', 'content.plain', 'author', 'author.id', 'author.joinage', 'author.name', 'lastmatched']
17
+PatternComparisonOperator = Literal['==', '!=', '<', '>', '<=', '>=', 'contains', '!contains', 'matches', '!matches', 'containsword', '!containsword']
18
+PatternBooleanOperator = Literal['!', 'and', 'or']
19
+PatternActionType = Literal['ban', 'delete', 'kick', 'modinfo', 'modwarn', 'reply']
20
+
16 21
 class PatternError(RuntimeError):
17 22
 	"""
18 23
 	Error thrown when parsing a pattern statement.
@@ -27,6 +32,14 @@ class PatternAction:
27 32
 	"""
28 33
 	Describes one action to take on a matched message or its author.
29 34
 	"""
35
+
36
+	TYPE_BAN: PatternActionType = 'ban'
37
+	TYPE_DELETE: PatternActionType = 'delete'
38
+	TYPE_KICK: PatternActionType = 'kick'
39
+	TYPE_INFORM_MODS: PatternActionType = 'modinfo'
40
+	TYPE_WARN_MODS: PatternActionType = 'modwarn'
41
+	TYPE_REPLY: PatternActionType = 'reply'
42
+
30 43
 	def __init__(self, action: str, args: list[Any]):
31 44
 		self.action = action
32 45
 		self.arguments = list(args)
@@ -55,56 +68,81 @@ class PatternSimpleExpression(PatternExpression):
55 68
 	Message matching expression with a simple "<field> <operator> <value>"
56 69
 	structure.
57 70
 	"""
58
-	def __init__(self, field: str, operator: str, value: Any):
71
+
72
+	FIELD_CONTENT_MARKDOWN: PatternField = 'content.markdown'
73
+	FIELD_CONTENT_PLAIN: PatternField = 'content.plain'
74
+	FIELD_AUTHOR_ID: PatternField = 'author.id'
75
+	FIELD_AUTHOR_JOINAGE: PatternField = 'author.joinage'
76
+	FIELD_AUTHOR_NAME: PatternField = 'author.name'
77
+	FIELD_LAST_MATCHED: PatternField = 'lastmatched'
78
+
79
+	# Less preferred but recognized field aliases
80
+	ALIAS_FIELD_CONTENT_MARKDOWN: PatternField = 'content'
81
+	ALIAS_FIELD_AUTHOR_ID: PatternField = 'author'
82
+
83
+	OP_EQUALS: PatternComparisonOperator = '=='
84
+	OP_NOT_EQUALS: PatternComparisonOperator = '!='
85
+	OP_LESS_THAN: PatternComparisonOperator = '<'
86
+	OP_GREATER_THAN: PatternComparisonOperator = '>'
87
+	OP_LESS_THAN_OR_EQUALS: PatternComparisonOperator = '<='
88
+	OP_GREATER_THAN_OR_EQUALS: PatternComparisonOperator = '>='
89
+	OP_CONTAINS: PatternComparisonOperator = 'contains'
90
+	OP_NOT_CONTAINS: PatternComparisonOperator = '!contains'
91
+	OP_MATCHES: PatternComparisonOperator = 'matches'
92
+	OP_NOT_MATCHES: PatternComparisonOperator = '!matches'
93
+	OP_CONTAINS_WORD: PatternComparisonOperator = 'containsword'
94
+	OP_NOT_CONTAINS_WORD: PatternComparisonOperator = '!containsword'
95
+
96
+	def __init__(self, field: PatternField, operator: PatternComparisonOperator, value: Any):
59 97
 		super().__init__()
60
-		self.field: str = field
61
-		self.operator: str = operator
98
+		self.field: PatternField = field
99
+		self.operator: PatternComparisonOperator = operator
62 100
 		self.value: Any = value
63 101
 
64 102
 	def __field_value(self, message: Message, other_fields: dict[str, Any]) -> Any:
65
-		if self.field in ('content.markdown', 'content'):
103
+		cls = PatternSimpleExpression
104
+		if self.field in (cls.FIELD_CONTENT_MARKDOWN, cls.ALIAS_FIELD_CONTENT_MARKDOWN):
66 105
 			return message.content
67
-		if self.field == 'content.plain':
106
+		if self.field == cls.FIELD_CONTENT_PLAIN:
68 107
 			return discordutils.remove_markdown(message.clean_content)
69
-		if self.field == 'author':
108
+		if self.field in (cls.FIELD_AUTHOR_ID, cls.ALIAS_FIELD_AUTHOR_ID):
70 109
 			return str(message.author.id)
71
-		if self.field == 'author.id':
72
-			return str(message.author.id)
73
-		if self.field == 'author.joinage':
110
+		if self.field == cls.FIELD_AUTHOR_JOINAGE:
74 111
 			return message.created_at - message.author.joined_at
75
-		if self.field == 'author.name':
112
+		if self.field == cls.FIELD_AUTHOR_NAME:
76 113
 			return message.author.name
77
-		if self.field == 'lastmatched':
114
+		if self.field == cls.FIELD_LAST_MATCHED:
78 115
 			long_ago = datetime(year=1900, month=1, day=1, hour=0, minute=0, second=0, tzinfo=timezone.utc)
79 116
 			last_matched = other_fields.get('last_matched') or long_ago
80 117
 			return message.created_at - last_matched
81
-		raise ValueError(f'Bad field name {self.field}')
118
+		raise ValueError(f'Bad field name "{self.field}"')
82 119
 
83 120
 	def matches(self, message: Message, other_fields: dict[str, Any]) -> bool:
121
+		cls = PatternSimpleExpression
84 122
 		field_value = self.__field_value(message, other_fields)
85
-		if self.operator == '==':
123
+		if self.operator == cls.OP_EQUALS:
86 124
 			if isinstance(field_value, str) and isinstance(self.value, str):
87 125
 				return field_value.lower() == self.value.lower()
88 126
 			return field_value == self.value
89
-		if self.operator == '!=':
127
+		if self.operator == cls.OP_NOT_EQUALS:
90 128
 			if isinstance(field_value, str) and isinstance(self.value, str):
91 129
 				return field_value.lower() != self.value.lower()
92 130
 			return field_value != self.value
93
-		if self.operator == '<':
131
+		if self.operator == cls.OP_LESS_THAN:
94 132
 			return field_value < self.value
95
-		if self.operator == '>':
133
+		if self.operator == cls.OP_GREATER_THAN:
96 134
 			return field_value > self.value
97
-		if self.operator == '<=':
135
+		if self.operator == cls.OP_LESS_THAN_OR_EQUALS:
98 136
 			return field_value <= self.value
99
-		if self.operator == '>=':
137
+		if self.operator == cls.OP_GREATER_THAN_OR_EQUALS:
100 138
 			return field_value >= self.value
101
-		if self.operator == 'contains':
139
+		if self.operator == cls.OP_CONTAINS:
102 140
 			return self.value.lower() in field_value.lower()
103
-		if self.operator == '!contains':
141
+		if self.operator == cls.OP_NOT_CONTAINS:
104 142
 			return self.value.lower() not in field_value.lower()
105
-		if self.operator in ('matches', 'containsword'):
143
+		if self.operator in (cls.OP_MATCHES, cls.OP_CONTAINS_WORD):
106 144
 			return self.value.search(field_value.lower()) is not None
107
-		if self.operator in ('!matches', '!containsword'):
145
+		if self.operator in (cls.OP_NOT_MATCHES, cls.OP_NOT_CONTAINS_WORD):
108 146
 			return self.value.search(field_value.lower()) is None
109 147
 		raise ValueError(f'Bad operator {self.operator}')
110 148
 
@@ -116,20 +154,24 @@ class PatternCompoundExpression(PatternExpression):
116 154
 	Message matching expression that combines several child expressions with
117 155
 	a boolean operator.
118 156
 	"""
119
-	def __init__(self, operator: str, operands: list[PatternExpression]):
157
+	OP_NOT = '!'
158
+	OP_AND = 'and'
159
+	OP_OR = 'or'
160
+
161
+	def __init__(self, operator: PatternBooleanOperator, operands: list[PatternExpression]):
120 162
 		super().__init__()
121
-		self.operator = operator
163
+		self.operator: PatternBooleanOperator = operator
122 164
 		self.operands = list(operands)
123 165
 
124 166
 	def matches(self, message: Message, other_fields: dict[str, Any]) -> bool:
125
-		if self.operator == '!':
167
+		if self.operator == PatternCompoundExpression.OP_NOT:
126 168
 			return not self.operands[0].matches(message, other_fields)
127
-		if self.operator == 'and':
169
+		if self.operator == PatternCompoundExpression.OP_AND:
128 170
 			for op in self.operands:
129 171
 				if not op.matches(message, other_fields):
130 172
 					return False
131 173
 			return True
132
-		if self.operator == 'or':
174
+		if self.operator == PatternCompoundExpression.OP_OR:
133 175
 			for op in self.operands:
134 176
 				if op.matches(message, other_fields):
135 177
 					return True
@@ -137,7 +179,7 @@ class PatternCompoundExpression(PatternExpression):
137 179
 		raise ValueError(f'Bad operator "{self.operator}"')
138 180
 
139 181
 	def __str__(self) -> str:
140
-		if self.operator == '!':
182
+		if self.operator == PatternCompoundExpression.OP_NOT:
141 183
 			return f'(!( {self.operands[0]} ))'
142 184
 		strs = map(str, self.operands)
143 185
 		joined = f' {self.operator} '.join(strs)
@@ -204,52 +246,63 @@ class PatternCompiler:
204 246
 	"""
205 247
 	Parses a user-provided message filter statement into a PatternStatement.
206 248
 	"""
207
-	TYPE_FLOAT: str = 'float'
208
-	TYPE_ID: str = 'id'
209
-	TYPE_INT: str = 'int'
210
-	TYPE_MEMBER: str = 'Member'
211
-	TYPE_REGEX: str = 'regex'
212
-	TYPE_TEXT: str = 'text'
213
-	TYPE_TIMESPAN: str = 'timespan'
214
-
215
-	FIELD_TO_TYPE: dict[str, str] = {
216
-		'author': TYPE_MEMBER,
217
-		'author.id': TYPE_ID,
218
-		'author.joinage': TYPE_TIMESPAN,
219
-		'author.name': TYPE_TEXT,
220
-		'content': TYPE_TEXT, # deprecated, use content.markdown or content.plain
221
-		'content.markdown': TYPE_TEXT,
222
-		'content.plain': TYPE_TEXT,
223
-		'lastmatched': TYPE_TIMESPAN,
249
+	DATATYPE_FLOAT: str = 'float'
250
+	DATATYPE_ID: str = 'id'
251
+	DATATYPE_INT: str = 'int'
252
+	DATATYPE_MEMBER: str = 'Member'
253
+	DATATYPE_REGEX: str = 'regex'
254
+	DATATYPE_TEXT: str = 'text'
255
+	DATATYPE_TIMESPAN: str = 'timespan'
256
+
257
+	FIELD_TO_DATATYPE: dict[PatternField, str] = {
258
+		PatternSimpleExpression.ALIAS_FIELD_AUTHOR_ID: DATATYPE_MEMBER,
259
+		PatternSimpleExpression.FIELD_AUTHOR_ID: DATATYPE_ID,
260
+		PatternSimpleExpression.FIELD_AUTHOR_JOINAGE: DATATYPE_TIMESPAN,
261
+		PatternSimpleExpression.FIELD_AUTHOR_NAME: DATATYPE_TEXT,
262
+		PatternSimpleExpression.ALIAS_FIELD_CONTENT_MARKDOWN: DATATYPE_TEXT, # deprecated, use content.markdown or content.plain
263
+		PatternSimpleExpression.FIELD_CONTENT_MARKDOWN: DATATYPE_TEXT,
264
+		PatternSimpleExpression.FIELD_CONTENT_PLAIN: DATATYPE_TEXT,
265
+		PatternSimpleExpression.FIELD_LAST_MATCHED: DATATYPE_TIMESPAN,
224 266
 	}
225
-	DEPRECATED_FIELDS: set[str] = { 'content' }
226
-
227
-	ACTION_TO_ARGS: dict[str, list[str]] = {
228
-		'ban': [],
229
-		'delete': [],
230
-		'kick': [],
231
-		'modinfo': [],
232
-		'modwarn': [],
233
-		'reply': [ TYPE_TEXT ],
267
+	DEPRECATED_FIELDS: set[PatternField] = { 'content' }
268
+
269
+	ACTION_TO_ARGS: dict[PatternActionType, list[str]] = {
270
+		PatternAction.TYPE_BAN: [],
271
+		PatternAction.TYPE_DELETE: [],
272
+		PatternAction.TYPE_KICK: [],
273
+		PatternAction.TYPE_INFORM_MODS: [],
274
+		PatternAction.TYPE_WARN_MODS: [],
275
+		PatternAction.TYPE_REPLY: [ DATATYPE_TEXT ],
234 276
 	}
235 277
 
236
-	OPERATORS_IDENTITY: set[str] = { '==', '!=' }
237
-	OPERATORS_COMPARISON: set[str] = { '<', '>', '<=', '>=' }
238
-	OPERATORS_NUMERIC: set[str] = OPERATORS_IDENTITY | OPERATORS_COMPARISON
239
-	OPERATORS_TEXT: set[str] = OPERATORS_IDENTITY | {
240
-		'contains', '!contains',
241
-		'containsword', '!containsword',
242
-		'matches', '!matches',
278
+	OPERATORS_IDENTITY: set[PatternComparisonOperator] = {
279
+		PatternSimpleExpression.OP_EQUALS,
280
+		PatternSimpleExpression.OP_NOT_EQUALS,
281
+	}
282
+	OPERATORS_COMPARISON: set[PatternComparisonOperator] = {
283
+		PatternSimpleExpression.OP_LESS_THAN,
284
+		PatternSimpleExpression.OP_GREATER_THAN,
285
+		PatternSimpleExpression.OP_LESS_THAN_OR_EQUALS,
286
+		PatternSimpleExpression.OP_GREATER_THAN_OR_EQUALS,
287
+	}
288
+	OPERATORS_NUMERIC: set[PatternComparisonOperator] = OPERATORS_IDENTITY | OPERATORS_COMPARISON
289
+	OPERATORS_TEXT: set[PatternComparisonOperator] = OPERATORS_IDENTITY | {
290
+		PatternSimpleExpression.OP_CONTAINS,
291
+		PatternSimpleExpression.OP_NOT_CONTAINS,
292
+		PatternSimpleExpression.OP_CONTAINS_WORD,
293
+		PatternSimpleExpression.OP_NOT_CONTAINS_WORD,
294
+		PatternSimpleExpression.OP_MATCHES,
295
+		PatternSimpleExpression.OP_NOT_MATCHES,
243 296
 	}
244 297
 	OPERATORS_ALL: set[str] = OPERATORS_IDENTITY | OPERATORS_COMPARISON | OPERATORS_TEXT
245 298
 
246
-	TYPE_TO_OPERATORS: dict[str, set[str]] = {
247
-		TYPE_ID: OPERATORS_IDENTITY,
248
-		TYPE_MEMBER: OPERATORS_IDENTITY,
249
-		TYPE_TEXT: OPERATORS_TEXT,
250
-		TYPE_INT: OPERATORS_NUMERIC,
251
-		TYPE_FLOAT: OPERATORS_NUMERIC,
252
-		TYPE_TIMESPAN: OPERATORS_NUMERIC,
299
+	DATATYPE_TO_OPERATORS: dict[str, set[PatternComparisonOperator]] = {
300
+		DATATYPE_ID: OPERATORS_IDENTITY,
301
+		DATATYPE_MEMBER: OPERATORS_IDENTITY,
302
+		DATATYPE_TEXT: OPERATORS_TEXT,
303
+		DATATYPE_INT: OPERATORS_NUMERIC,
304
+		DATATYPE_FLOAT: OPERATORS_NUMERIC,
305
+		DATATYPE_TIMESPAN: OPERATORS_NUMERIC,
253 306
 	}
254 307
 
255 308
 	WHITESPACE_CHARS: str = ' \t\n\r'
@@ -458,7 +511,7 @@ class PatternCompiler:
458 511
 					return subexpressions[0], token_index
459 512
 				return (PatternCompoundExpression(last_compound_operator,
460 513
 					subexpressions), token_index)
461
-			if tokens[token_index] in { "and", "or" }:
514
+			if tokens[token_index] in { PatternCompoundExpression.OP_AND, PatternCompoundExpression.OP_OR }:
462 515
 				compound_operator = tokens[token_index]
463 516
 				if last_compound_operator and \
464 517
 						compound_operator != last_compound_operator:
@@ -468,7 +521,7 @@ class PatternCompiler:
468 521
 					]
469 522
 				last_compound_operator = compound_operator
470 523
 				token_index += 1
471
-			if tokens[token_index] == '!':
524
+			if tokens[token_index] == PatternCompoundExpression.OP_NOT:
472 525
 				(exp, next_index) = cls.__read_expression(tokens,
473 526
 						token_index + 1, depth + 1, one_subexpression=True)
474 527
 				subexpressions.append(PatternCompoundExpression('!', [exp]))
@@ -507,10 +560,10 @@ class PatternCompiler:
507 560
 			raise PatternError('Expression nests too deeply')
508 561
 		if token_index >= len(tokens):
509 562
 			raise PatternError('Expected field name, found EOL')
510
-		field = tokens[token_index]
563
+		field: PatternField = tokens[token_index]
511 564
 		token_index += 1
512 565
 
513
-		datatype = cls.FIELD_TO_TYPE.get(field)
566
+		datatype = cls.FIELD_TO_DATATYPE.get(field, None)
514 567
 		if datatype is None:
515 568
 			raise PatternError(f'No such field "{field}"')
516 569
 
@@ -519,13 +572,13 @@ class PatternCompiler:
519 572
 		op = tokens[token_index]
520 573
 		token_index += 1
521 574
 
522
-		if op == '!':
575
+		if op == PatternCompoundExpression.OP_NOT:
523 576
 			if token_index >= len(tokens):
524 577
 				raise PatternError('Expected operator, found EOL')
525 578
 			op = '!' + tokens[token_index]
526 579
 			token_index += 1
527 580
 
528
-		allowed_ops = cls.TYPE_TO_OPERATORS[datatype]
581
+		allowed_ops = cls.DATATYPE_TO_OPERATORS[datatype]
529 582
 		if op not in allowed_ops:
530 583
 			if op in cls.OPERATORS_ALL:
531 584
 				raise PatternError(f'Operator {op} cannot be used with ' + \
@@ -551,13 +604,13 @@ class PatternCompiler:
551 604
 		"""
552 605
 		Converts a value token to its Python value. Raises ValueError on failure.
553 606
 		"""
554
-		if datatype == cls.TYPE_ID:
607
+		if datatype == cls.DATATYPE_ID:
555 608
 			if not is_user_id(value):
556 609
 				raise ValueError(f'Illegal user id value: {value}')
557 610
 			return value
558
-		if datatype == cls.TYPE_MEMBER:
611
+		if datatype == cls.DATATYPE_MEMBER:
559 612
 			return user_id_from_mention(value)
560
-		if datatype == cls.TYPE_TEXT:
613
+		if datatype == cls.DATATYPE_TEXT:
561 614
 			s = str_from_quoted_str(value)
562 615
 			if op in ('matches', '!matches'):
563 616
 				try:
@@ -570,10 +623,10 @@ class PatternCompiler:
570 623
 				except re.error as e:
571 624
 					raise ValueError(f'Invalid regex: {e}') from e
572 625
 			return s
573
-		if datatype == cls.TYPE_INT:
626
+		if datatype == cls.DATATYPE_INT:
574 627
 			return int(value)
575
-		if datatype == cls.TYPE_FLOAT:
628
+		if datatype == cls.DATATYPE_FLOAT:
576 629
 			return float(value)
577
-		if datatype == cls.TYPE_TIMESPAN:
630
+		if datatype == cls.DATATYPE_TIMESPAN:
578 631
 			return timedelta_from_str(value)
579 632
 		raise ValueError(f'Unhandled datatype {datatype}')

+ 54
- 9
rocketbot/utils.py Näytä tiedosto

@@ -7,25 +7,42 @@ import traceback
7 7
 from datetime import datetime, timedelta
8 8
 from typing import Any, Optional, Type, Union
9 9
 
10
-from discord import Guild
11
-from discord.ext.commands import Cog, Group
10
+import discord
11
+from discord import Guild, Permissions
12
+from discord.ext.commands import Cog
12 13
 
13
-def dump_stacktrace(e: Exception) -> None:
14
+def dump_stacktrace(e: BaseException) -> None:
14 15
 	print(e, file=sys.stderr)
15 16
 	traceback.print_exception(type(e), e, e.__traceback__)
16 17
 
17 18
 def timedelta_from_str(s: str) -> timedelta:
18 19
 	"""
19
-	Parses a timespan. Format examples:
20
+	Parses a timespan.
21
+
22
+	Format examples:
20 23
 	"30m"
21 24
 	"10s"
22 25
 	"90d"
23 26
 	"1h30m"
24 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
25 42
 	"""
26
-	p: re.Pattern = re.compile('^(?:[0-9]+[dhms])+$')
43
+	p: re.Pattern = re.compile('^(?:[0-9]+[a-zA-Z])+$')
27 44
 	if p.match(s) is None:
28
-		raise ValueError("Illegal timespan value '{s}'.")
45
+		raise ValueError(f'Illegal timespan value "{s}". Examples: 30s, 5m, 1h30m, 30d')
29 46
 	p = re.compile('([0-9]+)([dhms])')
30 47
 	days: int = 0
31 48
 	hours: int = 0
@@ -33,7 +50,7 @@ def timedelta_from_str(s: str) -> timedelta:
33 50
 	seconds: int = 0
34 51
 	for m in p.finditer(s):
35 52
 		scalar = int(m.group(1))
36
-		unit = m.group(2)
53
+		unit = m.group(2).lower()
37 54
 		if unit == 'd':
38 55
 			days = scalar
39 56
 		elif unit == 'h':
@@ -42,6 +59,8 @@ def timedelta_from_str(s: str) -> timedelta:
42 59
 			minutes = scalar
43 60
 		elif unit == 's':
44 61
 			seconds = scalar
62
+		else:
63
+			raise ValueError(f'Invalid unit "{unit}". Valid units: "s"=seconds, "m"=minutes, "h"=hours, "d"=days')
45 64
 	return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
46 65
 
47 66
 def str_from_timedelta(td: timedelta) -> str:
@@ -84,11 +103,19 @@ def describe_timedelta(td: timedelta, max_components: int = 2) -> str:
84 103
 		components = components[0:max_components]
85 104
 	return ' '.join(components)
86 105
 
87
-def first_command_group(cog: Cog) -> Optional[Group]:
106
+def _old_first_command_group(cog: Cog) -> Optional[discord.ext.commands.Group]:
88 107
 	"""Returns the first command Group found in a cog."""
89 108
 	for member_name in dir(cog):
90 109
 		member = getattr(cog, member_name)
91
-		if isinstance(member, Group):
110
+		if isinstance(member, discord.ext.commands.Group):
111
+			return member
112
+	return None
113
+
114
+def first_command_group(cog: Cog) -> Optional[discord.app_commands.Group]:
115
+	"""Returns the first slash command Group found in a cog."""
116
+	for member_name in dir(cog):
117
+		member = getattr(cog, member_name)
118
+		if isinstance(member, discord.app_commands.Group):
92 119
 			return member
93 120
 	return None
94 121
 
@@ -143,3 +170,21 @@ def str_from_quoted_str(val: str) -> str:
143 170
 	if len(val) < 2 or val[0:1] not in __QUOTE_CHARS or val[-1:] not in __QUOTE_CHARS:
144 171
 		raise ValueError(f'Not a quoted string: {val}')
145 172
 	return val[1:-1]
173
+
174
+MOD_PERMISSIONS: Permissions = Permissions(Permissions.manage_messages.flag)
175
+
176
+from discord import Interaction
177
+from discord.app_commands import Transformer
178
+from discord.ext.commands import BadArgument
179
+
180
+class TimeDeltaTransformer(Transformer):
181
+	async def transform(self, interaction: Interaction, value: Any) -> timedelta:
182
+		try:
183
+			return timedelta_from_str(str(value))
184
+		except ValueError as e:
185
+			print("Invalid time delta:", e)
186
+			raise BadArgument(str(e))
187
+
188
+	@property
189
+	def _error_display_name(self) -> str:
190
+		return 'timedelta'

Loading…
Peruuta
Tallenna