Pārlūkot izejas kodu

Lots of help refinements

pull/13/head
Rocketsoup 2 mēnešus atpakaļ
vecāks
revīzija
6898a998a2

+ 0
- 1
rocketbot/cogs/autokickcog.py Parādīt failu

@@ -57,7 +57,6 @@ class AutoKickCog(BaseCog, name='Auto Kick'):
57 57
 		super().__init__(
58 58
 			bot,
59 59
 			config_prefix='autokick',
60
-			name='auto-kick',
61 60
 			short_description='Automatically kicks all new users as soon as they join.',
62 61
 		)
63 62
 		self.add_setting(AutoKickCog.SETTING_ENABLED)

+ 0
- 2
rocketbot/cogs/basecog.py Parādīt failu

@@ -35,7 +35,6 @@ class BaseCog(Cog):
35 35
 			self,
36 36
 			bot: Rocketbot,
37 37
 			config_prefix: Optional[str],
38
-			name: str,
39 38
 			short_description: str,
40 39
 			long_description: Optional[str] = None,
41 40
 	):
@@ -53,7 +52,6 @@ class BaseCog(Cog):
53 52
 		self.are_settings_setup: bool = False
54 53
 		self.settings: list[CogSetting] = []
55 54
 		self.config_prefix: Optional[str] = config_prefix
56
-		self.name: str = name
57 55
 		self.short_description: str = short_description
58 56
 		self.long_description: str = long_description
59 57
 

+ 1
- 2
rocketbot/cogs/configcog.py Parādīt failu

@@ -22,13 +22,12 @@ class ConfigCog(BaseCog, name='Configuration'):
22 22
 		super().__init__(
23 23
 			bot,
24 24
 			config_prefix='config',
25
-			name='configuration',
26 25
 			short_description='Manages general bot configuration.',
27 26
 		)
28 27
 
29 28
 	config = Group(
30 29
 		name='config',
31
-		description='Manages general bot configuration',
30
+		description='Manages general bot configuration.',
32 31
 		guild_only=True,
33 32
 		default_permissions=MOD_PERMISSIONS,
34 33
 	)

+ 1
- 2
rocketbot/cogs/crosspostcog.py Parādīt failu

@@ -89,7 +89,7 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
89 89
 			'when looking for duplicates. Shorter values are preferred, ' + \
90 90
 			'both to detect bots and avoid excessive memory usage.',
91 91
 		usage='<seconds:int>',
92
-		min_value=1)
92
+		min_value=timedelta(seconds=1))
93 93
 
94 94
 	STATE_KEY_RECENT_MESSAGES = "CrossPostCog.recent_messages"
95 95
 	STATE_KEY_SPAM_CONTEXT = "CrossPostCog.spam_context"
@@ -98,7 +98,6 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
98 98
 		super().__init__(
99 99
 			bot,
100 100
 			config_prefix='crosspost',
101
-			name='crosspost detection',
102 101
 			short_description='Manages crosspost detection and handling.',
103 102
 		)
104 103
 		self.add_setting(CrossPostCog.SETTING_ENABLED)

+ 253
- 50
rocketbot/cogs/generalcog.py Parādīt failu

@@ -1,11 +1,12 @@
1 1
 """
2 2
 Cog for handling most ungrouped commands and basic behaviors.
3 3
 """
4
+import re
4 5
 from datetime import datetime, timedelta, timezone
5 6
 from typing import Optional, Union
6 7
 
7
-from discord import Interaction, Message, User, Permissions
8
-from discord.app_commands import Command, Group, command, default_permissions, guild_only, Transform, rename, Choice, \
8
+from discord import Interaction, Message, User, Permissions, AppCommandType
9
+from discord.app_commands import Command, Group, command, default_permissions, guild_only, Transform, Choice, \
9 10
 	autocomplete
10 11
 from discord.errors import DiscordException
11 12
 from discord.ext.commands import Cog
@@ -13,9 +14,16 @@ from discord.ext.commands import Cog
13 14
 from config import CONFIG
14 15
 from rocketbot.bot import Rocketbot
15 16
 from rocketbot.cogs.basecog import BaseCog, BotMessage
16
-from rocketbot.utils import describe_timedelta, TimeDeltaTransformer, dump_stacktrace
17
+from rocketbot.utils import describe_timedelta, TimeDeltaTransformer, dump_stacktrace, MOD_PERMISSIONS
17 18
 from rocketbot.storage import ConfigKey, Storage
18 19
 
20
+
21
+trivial_words = {
22
+	'a', 'an', 'and', 'are', "aren't", 'as', 'by', 'can', 'for', 'have', 'if', 'in',
23
+	'is', 'it', 'its', "it's", 'not', 'of', 'on', 'or', 'than', 'that', 'the', 'then',
24
+	'there', 'them', 'they', "they're", 'this', 'to', 'when', 'with',
25
+}
26
+
19 27
 async def command_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
20 28
 	choices: list[Choice] = []
21 29
 	try:
@@ -25,10 +33,10 @@ async def command_autocomplete(interaction: Interaction, current: str) -> list[C
25 33
 		user_permissions = interaction.permissions
26 34
 		cmds = GeneralCog.shared.get_command_list(user_permissions)
27 35
 		return [
28
-			Choice(name=f'/{cmdname}', value=f'/{cmdname}')
36
+			Choice(name=f'/{cmdname} command', value=f'cmd:{cmdname}')
29 37
 			for cmdname in sorted(cmds.keys())
30
-			if len(current) == 0 or cmdname.startswith(current)
31
-		]
38
+			if len(current) == 0 or current in cmdname
39
+		][:25]
32 40
 	except BaseException as e:
33 41
 		dump_stacktrace(e)
34 42
 	return choices
@@ -36,11 +44,11 @@ async def command_autocomplete(interaction: Interaction, current: str) -> list[C
36 44
 async def subcommand_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
37 45
 	try:
38 46
 		current = current.lower().strip()
39
-		cmd_name = interaction.namespace['command']
40
-		if cmd_name.startswith('/'):
41
-			cmd_name = cmd_name[1:]
47
+		cmd_name = interaction.namespace['topic']
48
+		cmd = GeneralCog.shared.object_for_help_symbol(cmd_name)
49
+		if not isinstance(cmd, Group):
50
+			return []
42 51
 		user_permissions = interaction.permissions
43
-		cmd = GeneralCog.shared.get_command_list(user_permissions).get(cmd_name)
44 52
 		if cmd is None or not isinstance(cmd, Group):
45 53
 			print(f'No command found named {cmd_name}')
46 54
 			return []
@@ -50,17 +58,47 @@ async def subcommand_autocomplete(interaction: Interaction, current: str) -> lis
50 58
 			print(f'Subcommands for {cmd_name} was None')
51 59
 			return []
52 60
 		return [
53
-			Choice(name=subcmd_name, value=subcmd_name)
61
+			Choice(name=f'{subcmd_name} subcommand', value=f'subcmd:{cmd_name}.{subcmd_name}')
54 62
 			for subcmd_name in sorted(subcmds.keys())
55
-			if len(current) == 0 or subcmd_name.startswith(current)
63
+			if len(current) == 0 or current in subcmd_name
64
+		][:25]
65
+	except BaseException as e:
66
+		dump_stacktrace(e)
67
+	return []
68
+
69
+async def cog_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
70
+	try:
71
+		current = current.lower().strip()
72
+		return [
73
+			Choice(name=f'⚙ {cog.qualified_name} module', value=f'cog:{cog.qualified_name}')
74
+			for cog in sorted(GeneralCog.shared.bot.cogs.values(), key=lambda c: c.qualified_name)
75
+			if isinstance(cog, BaseCog) and
76
+			   can_use_cog(cog, interaction.permissions) and
77
+			   (len(cog.get_commands()) > 0 or len(cog.settings) > 0) and \
78
+			   (len(current) == 0 or current in cog.qualified_name.lower())
56 79
 		]
57 80
 	except BaseException as e:
58 81
 		dump_stacktrace(e)
59 82
 	return []
60 83
 
84
+async def topic_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
85
+	command_choices = await command_autocomplete(interaction, current)
86
+	cog_choices = await cog_autocomplete(interaction, current)
87
+	return (command_choices + cog_choices)[:25]
88
+
89
+async def subtopic_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
90
+	subcommand_choices = await subcommand_autocomplete(interaction, current)
91
+	return subcommand_choices[:25]
92
+
61 93
 def can_use_command(cmd: Union[Group, Command], user_permissions: Optional[Permissions]) -> bool:
62
-	return user_permissions is not None and \
63
-		(cmd.default_permissions is None or cmd.default_permissions.is_subset(user_permissions))
94
+	if user_permissions is None:
95
+		return False
96
+	if cmd.parent and not can_use_command(cmd.parent, user_permissions):
97
+		return False
98
+	return cmd.default_permissions is None or cmd.default_permissions.is_subset(user_permissions)
99
+
100
+def can_use_cog(cog: BaseCog, user_permissions: Optional[Permissions]) -> bool:
101
+	return user_permissions is not None and MOD_PERMISSIONS.is_subset(user_permissions)
64 102
 
65 103
 class GeneralCog(BaseCog, name='General'):
66 104
 	"""
@@ -74,7 +112,6 @@ class GeneralCog(BaseCog, name='General'):
74 112
 		super().__init__(
75 113
 			bot,
76 114
 			config_prefix=None,
77
-			name='',
78 115
 			short_description='',
79 116
 		)
80 117
 		self.is_connected = False
@@ -196,15 +233,59 @@ class GeneralCog(BaseCog, name='General'):
196 233
 			ephemeral=True,
197 234
 		)
198 235
 
236
+	def __create_help_index(self) -> None:
237
+		if getattr(self, 'obj_index', None) is not None:
238
+			return
239
+		self.obj_index: dict[str, Union[Command, Group, BaseCog]] = {}
240
+		self.keyword_index: dict[str, set[Union[Command, Group, BaseCog]]] = {}
241
+
242
+		def add_text_to_index(obj, text: str):
243
+			words = [
244
+				word
245
+				for word in re.split(r"[^a-zA-Z']+", text.lower())
246
+				if len(word) > 1 and word not in trivial_words
247
+			]
248
+			for word in words:
249
+				matches = self.keyword_index.get(word, set())
250
+				matches.add(obj)
251
+				self.keyword_index[word] = matches
252
+
253
+		for cmd in self.bot.tree.get_commands(type=AppCommandType.chat_input):
254
+			key = f'cmd:{cmd.name}'
255
+			self.obj_index[key] = cmd
256
+			add_text_to_index(cmd, cmd.name)
257
+			if cmd.description:
258
+				add_text_to_index(cmd, cmd.description)
259
+			if isinstance(cmd, Group):
260
+				for subcmd in cmd.commands:
261
+					key = f'subcmd:{cmd.name}.{subcmd.name}'
262
+					self.obj_index[key] = subcmd
263
+					add_text_to_index(subcmd, subcmd.name)
264
+					if subcmd.description:
265
+						add_text_to_index(subcmd, subcmd.description)
266
+		for cog_qname, cog in self.bot.cogs.items():
267
+			if not isinstance(cog, BaseCog):
268
+				continue
269
+			key = f'cog:{cog_qname}'
270
+			self.obj_index[key] = cog
271
+			add_text_to_index(cog, cog.qualified_name)
272
+			if cog.description:
273
+				add_text_to_index(cog, cog.description)
274
+		print(self.obj_index.keys())
275
+		print(self.keyword_index.keys())
276
+
277
+	def object_for_help_symbol(self, symbol: str) -> Optional[Union[Command, Group, BaseCog]]:
278
+		self.__create_help_index()
279
+		return self.obj_index.get(symbol, None)
280
+
199 281
 	@command(name='help')
200 282
 	@guild_only()
201
-	@rename(command_name='command', subcommand_name='subcommand')
202
-	@autocomplete(command_name=command_autocomplete, subcommand_name=subcommand_autocomplete)
203
-	async def help_command(self, interaction: Interaction, command_name: Optional[str] = None, subcommand_name: Optional[str] = None) -> None:
283
+	@autocomplete(topic=topic_autocomplete, subtopic=subtopic_autocomplete)
284
+	async def help_command(self, interaction: Interaction, topic: Optional[str] = None, subtopic: Optional[str] = None) -> None:
204 285
 		"""
205
-		Shows help for using commands and subcommands.
286
+		Shows help for using commands and subcommands and configuring modules.
206 287
 
207
-		`/help` will show a list of top-level commands.
288
+		`/help` will show a list of top-level topics.
208 289
 
209 290
 		`/help /<command_name>` will show help about a specific command or
210 291
 		list a command's subcommands.
@@ -212,55 +293,158 @@ class GeneralCog(BaseCog, name='General'):
212 293
 		`/help /<command_name> <subcommand_name>` will show help about a
213 294
 		specific subcommand.
214 295
 
296
+		`/help <module_name>` will show help about configuring a module.
297
+
298
+		`/help <keywords>` will do a text search for topics.
299
+
215 300
 		Parameters
216 301
 		----------
217 302
 		interaction: Interaction
218
-		command_name: Optional[str]
219
-			Optional name of a command to get specific help for. With or without the leading slash.
220
-		subcommand_name: Optional[str]
221
-			Optional name of a subcommand to get specific help for.
303
+		topic: Optional[str]
304
+			Optional topic to get specific help for. Getting help on a command can optionally start with a leading slash.
305
+		subtopic: Optional[str]
306
+			Optional subtopic to get specific help for.
222 307
 		"""
223
-		print(f'help_command(interaction, {command_name}, {subcommand_name})')
224
-		cmds: list[Command] = self.bot.tree.get_commands()
225
-		if command_name is None:
308
+		print(f'help_command(interaction, {topic}, {subtopic})')
309
+
310
+		# General help
311
+		if topic is None:
226 312
 			await self.__send_general_help(interaction)
227 313
 			return
228 314
 
229
-		if command_name.startswith('/'):
230
-			command_name = command_name[1:]
231
-		cmd = next((c for c in cmds if c.name == command_name), None)
232
-		if cmd is None:
233
-			interaction.response.send_message(
234
-				f'Command `{command_name}` not found!',
235
-				ephemeral=True,
236
-			)
237
-			return
238
-		if subcommand_name is None:
239
-			await self.__send_command_help(interaction, cmd)
315
+		# Specific object reference
316
+		obj = self.object_for_help_symbol(subtopic) if subtopic else self.object_for_help_symbol(topic)
317
+		if obj:
318
+			await self.__send_object_help(interaction, obj)
240 319
 			return
241 320
 
242
-		if not isinstance(cmd, Group):
243
-			await self.__send_command_help(interaction, cmd, addendum=f'{CONFIG["warning_emoji"]} Command does not have subcommands. Showing help for base command.')
321
+		# Text search
322
+		keywords = [
323
+			word
324
+			for word in re.split(r"[^a-zA-Z']+", topic.lower())
325
+			if len(word) > 0 and word not in trivial_words
326
+		]
327
+		matching_objects_set = None
328
+		for keyword in keywords:
329
+			objs = self.keyword_index.get(keyword, None)
330
+			if objs is not None:
331
+				if matching_objects_set is None:
332
+					matching_objects_set = objs
333
+				else:
334
+					matching_objects_set = matching_objects_set & objs
335
+		accessible_objects = [
336
+			obj
337
+			for obj in matching_objects_set or {}
338
+			if ((isinstance(obj, Command) or isinstance(obj, Group)) and can_use_command(obj, interaction.permissions)) or \
339
+			   (isinstance(obj, BaseCog) and can_use_cog(obj, interaction.permissions))
340
+		]
341
+		await self.__send_keyword_help(interaction, accessible_objects)
342
+
343
+	async def __send_object_help(self, interaction: Interaction, obj: Union[Command, Group, BaseCog]) -> None:
344
+		if isinstance(obj, Command):
345
+			if obj.parent:
346
+				await self.__send_subcommand_help(interaction, obj.parent, obj)
347
+			else:
348
+				await self.__send_command_help(interaction, obj)
244 349
 			return
245
-		grp: Group = cmd
246
-		subcmd: Command = next((c for c in grp.commands if c.name == subcommand_name), None)
247
-		if subcmd is None:
248
-			await self.__send_command_help(interaction, cmd, addendum=f'{CONFIG["warning_emoji"]} Command `/{command_name}` does not have a subcommand "{subcommand_name}". Showing help for base command.')
350
+		if isinstance(obj, Group):
351
+			await self.__send_command_help(interaction, obj)
249 352
 			return
250
-		await self.__send_subcommand_help(interaction, grp, subcmd)
251
-		return
353
+		if isinstance(obj, BaseCog):
354
+			await self.__send_cog_help(interaction, obj)
355
+			return
356
+		print(f'No help for object {obj}')
357
+		await interaction.response.send_message(
358
+			f'{CONFIG["failure_emoji"]} Failed to get help info.',
359
+			ephemeral=True,
360
+			delete_after=10,
361
+		)
252 362
 
253 363
 	def get_command_list(self, permissions: Optional[Permissions] = None) -> dict[str, Union[Command, Group]]:
254 364
 		return { cmd.name: cmd for cmd in self.bot.tree.get_commands() if can_use_command(cmd, permissions) }
255 365
 
256 366
 	def get_subcommand_list(self, cmd: Group, permissions: Optional[Permissions] = None) -> dict[str, Command]:
257
-		return { subcmd.name: subcmd for subcmd in cmd.commands if can_use_command(subcmd, permissions) }
367
+		return {
368
+			subcmd.name: subcmd
369
+			for subcmd in cmd.commands
370
+			if can_use_command(subcmd, permissions)
371
+		} if can_use_command(cmd, permissions) else {}
258 372
 
259 373
 	async def __send_general_help(self, interaction: Interaction) -> None:
260 374
 		user_permissions: Permissions = interaction.permissions
261
-		text = f'## :information_source: Commands'
262
-		for cmd_name, cmd in sorted(self.get_command_list(user_permissions).items()):
263
-			text += f'\n- `/{cmd_name}`: {cmd.description}'
375
+		all_commands = sorted(self.get_command_list(user_permissions).items())
376
+		all_cog_tuples = [
377
+			cog_tuple
378
+			for cog_tuple in sorted(self.bot.cogs.items())
379
+			if isinstance(cog_tuple[1], BaseCog) and \
380
+			   can_use_cog(cog_tuple[1], user_permissions) and \
381
+			   (len(cog_tuple[1].settings) > 0)
382
+		]
383
+
384
+		text = f'## :information_source: Help'
385
+		if len(all_commands) + len(all_cog_tuples) == 0:
386
+			text = 'Nothing available for your permissions!'
387
+
388
+		if len(all_commands) > 0:
389
+			text += '\n### Commands'
390
+			text += '\nType `/help /commandname` for more information.'
391
+			for cmd_name, cmd in sorted(self.get_command_list(user_permissions).items()):
392
+				text += f'\n- `/{cmd_name}`: {cmd.description}'
393
+				if isinstance(cmd, Group):
394
+					subcommand_count = len(cmd.commands)
395
+					text += f' ({subcommand_count} subcommands)'
396
+
397
+		if len(all_cog_tuples) > 0:
398
+			text += '\n### Module Configuration'
399
+			for cog_name, cog in all_cog_tuples:
400
+				has_enabled = next((s for s in cog.settings if s.name == 'enabled'), None) is not None
401
+				text += f'\n- **{cog_name}**: {cog.short_description}'
402
+				if has_enabled:
403
+					text += f'\n   - `/enable {cog.config_prefix}` or `/disable {cog.config_prefix}`'
404
+				for setting in cog.settings:
405
+					if setting.name == 'enabled':
406
+						continue
407
+					text += f'\n   - `/get` or `/set {cog.config_prefix}_{setting.name}`'
408
+		await interaction.response.send_message(
409
+			text,
410
+			ephemeral=True,
411
+		)
412
+
413
+	async def __send_keyword_help(self, interaction: Interaction, matching_objects: Optional[list[Union[Command, Group, BaseCog]]]) -> None:
414
+		matching_commands = [
415
+			cmd
416
+			for cmd in matching_objects or []
417
+			if isinstance(cmd, Command) or isinstance(cmd, Group)
418
+		]
419
+		matching_cogs = [
420
+			cog
421
+			for cog in matching_objects or []
422
+			if isinstance(cog, BaseCog)
423
+		]
424
+		if len(matching_commands) + len(matching_cogs) == 0:
425
+			await interaction.response.send_message(
426
+				f'{CONFIG["failure_emoji"]} No available help topics found.',
427
+				ephemeral=True,
428
+				delete_after=10,
429
+			)
430
+			return
431
+		if len(matching_objects) == 1:
432
+			obj = matching_objects[0]
433
+			await self.__send_object_help(interaction, obj)
434
+			return
435
+
436
+		text = '## :information_source: Matching Help Topics'
437
+		if len(matching_commands) > 0:
438
+			text += '\n### Commands'
439
+			for cmd in matching_commands:
440
+				if cmd.parent:
441
+					text += f'\n- `/{cmd.parent.name} {cmd.name}`'
442
+				else:
443
+					text += f'\n- `/{cmd.name}`'
444
+		if len(matching_cogs) > 0:
445
+			text += '\n### Cogs'
446
+			for cog in matching_cogs:
447
+				text += f'\n- {cog.qualified_name}'
264 448
 		await interaction.response.send_message(
265 449
 			text,
266 450
 			ephemeral=True,
@@ -293,3 +477,22 @@ class GeneralCog(BaseCog, name='General'):
293 477
 			for param in params:
294 478
 				text += f'\n- `{param.name}`: {param.description}'
295 479
 		await interaction.response.send_message(text, ephemeral=True)
480
+
481
+	async def __send_cog_help(self, interaction: Interaction, cog: BaseCog) -> None:
482
+		text = f'## :information_source: Module Help\n{cog.qualified_name}'
483
+		if cog.description is not None:
484
+			text += f'\n{cog.description}'
485
+		settings = cog.settings
486
+		if len(settings) > 0:
487
+			text += '\n### Configuration'
488
+			enabled_setting = next((s for s in settings if s.name == 'enabled'), None)
489
+			if enabled_setting is not None:
490
+				text += f'\n- `/enable {cog.config_prefix}` or `/disable {cog.config_prefix}`'
491
+			for setting in sorted(settings, key=lambda s: s.name):
492
+				if setting.name == 'enabled':
493
+					continue
494
+				text += f'\n- `/get` or `/set {cog.config_prefix}_{setting.name}` - {setting.description}'
495
+		await interaction.response.send_message(
496
+			text,
497
+			ephemeral=True,
498
+		)

+ 2
- 3
rocketbot/cogs/joinraidcog.py Parādīt failu

@@ -44,8 +44,8 @@ class JoinRaidCog(BaseCog, name='Join Raids'):
44 44
 				'at when counting recent joins. If joincount or more ' + \
45 45
 				'joins occur within jointime seconds a mod warning is issued.',
46 46
 			usage='<seconds:float>',
47
-			min_value=1.0,
48
-			max_value=900.0)
47
+			min_value=timedelta(seconds=1.0),
48
+			max_value=timedelta(seconds=900.0))
49 49
 
50 50
 	STATE_KEY_RECENT_JOINS = "JoinRaidCog.recent_joins"
51 51
 	STATE_KEY_LAST_RAID = "JoinRaidCog.last_raid"
@@ -54,7 +54,6 @@ class JoinRaidCog(BaseCog, name='Join Raids'):
54 54
 		super().__init__(
55 55
 			bot,
56 56
 			config_prefix='joinraid',
57
-			name='join raid',
58 57
 			short_description='Manages join raid detection and handling.',
59 58
 		)
60 59
 		self.add_setting(JoinRaidCog.SETTING_ENABLED)

+ 1
- 2
rocketbot/cogs/logcog.py Parādīt failu

@@ -48,8 +48,7 @@ class LoggingCog(BaseCog, name='Logging'):
48 48
 		super().__init__(
49 49
 			bot,
50 50
 			config_prefix='logging',
51
-			name='logging',
52
-			short_description='Manages event logging',
51
+			short_description='Manages event logging.',
53 52
 		)
54 53
 		self.add_setting(LoggingCog.SETTING_ENABLED)
55 54
 		self.flush_buffers.start()

+ 0
- 1
rocketbot/cogs/patterncog.py Parādīt failu

@@ -99,7 +99,6 @@ class PatternCog(BaseCog, name='Pattern Matching'):
99 99
 		super().__init__(
100 100
 			bot,
101 101
 			config_prefix='patterns',
102
-			name='patterns',
103 102
 			short_description='Manages message pattern matching.',
104 103
 		)
105 104
 		PatternCog.shared = self

+ 1
- 2
rocketbot/cogs/urlspamcog.py Parādīt failu

@@ -45,7 +45,7 @@ class URLSpamCog(BaseCog, name='URL Spam'):
45 45
 				'new members can say anything. Setting to 0 effectively ' + \
46 46
 				'disables URL spam detection.',
47 47
 			usage='<seconds:int>',
48
-			min_value=0)
48
+			min_value=timedelta(seconds=0))
49 49
 	SETTING_DECEPTIVE_ACTION = CogSetting('deceptiveaction', Literal['nothing', 'modwarn', 'modwarndelete', 'chatwarn', 'chatwarndelete', 'delete', 'kick', 'ban'],
50 50
 			brief='action to take on deceptive link markdown',
51 51
 			description='The action to take on chat messages with links ' + \
@@ -57,7 +57,6 @@ class URLSpamCog(BaseCog, name='URL Spam'):
57 57
 		super().__init__(
58 58
 			bot,
59 59
 			config_prefix='urlspam',
60
-			name='URL spam',
61 60
 			short_description='Manages URL spam detection.',
62 61
 		)
63 62
 		self.add_setting(URLSpamCog.SETTING_ENABLED)

+ 0
- 1
rocketbot/cogs/usernamecog.py Parādīt failu

@@ -57,7 +57,6 @@ class UsernamePatternCog(BaseCog, name='Username Pattern'):
57 57
 		super().__init__(
58 58
 			bot,
59 59
 			config_prefix='username',
60
-			name='username patterns',
61 60
 			short_description='Manages username pattern detection.',
62 61
 		)
63 62
 		self.add_setting(UsernamePatternCog.SETTING_ENABLED)

+ 117
- 62
rocketbot/cogsetting.py Parādīt failu

@@ -5,13 +5,13 @@ from datetime import timedelta
5 5
 from typing import Any, Optional, Type, TypeVar, Literal
6 6
 
7 7
 from discord import Interaction, Permissions
8
-from discord.app_commands import Range, Transform
9
-from discord.app_commands.commands import Command, Group, CommandCallback
8
+from discord.app_commands import Range, Transform, describe
9
+from discord.app_commands.commands import Command, Group, CommandCallback, rename
10 10
 from discord.ext.commands import Bot
11 11
 
12 12
 from config import CONFIG
13 13
 from rocketbot.storage import Storage
14
-from rocketbot.utils import bot_log, TimeDeltaTransformer
14
+from rocketbot.utils import bot_log, TimeDeltaTransformer, MOD_PERMISSIONS
15 15
 
16 16
 # def _fix_command(command: Command) -> None:
17 17
 # 	"""
@@ -75,17 +75,15 @@ class CogSetting:
75 75
 		self.min_value: Optional[Any] = min_value
76 76
 		self.max_value: Optional[Any] = max_value
77 77
 		self.enum_values: Optional[set[Any]] = enum_values
78
-		if self.enum_values or self.min_value is not None or self.max_value is not None:
79
-			self.description += '\n'
80 78
 		if self.enum_values:
81
-			allowed_values = '`' + ('`, `'.join(enum_values)) + '`'
82
-			self.description += f'\nAllowed values: {allowed_values}'
83
-		if self.min_value is not None:
84
-			self.description += f'\nMin value: {self.min_value}'
85
-		if self.max_value is not None:
86
-			self.description += f'\nMax value: {self.max_value}'
87
-		if self.usage is None:
88
-			self.usage = f'<{self.name}>'
79
+			value_list = '`' + ('`, `'.join(self.enum_values)) + '`'
80
+			self.description += f' (Permitted values: {value_list})'
81
+		elif self.min_value is not None and self.max_value is not None:
82
+			self.description += f' (Value must be between `{self.min_value}` and `{self.max_value}`)'
83
+		elif self.min_value is not None:
84
+			self.description += f' (Minimum value: {self.min_value})'
85
+		elif self.max_value is not None:
86
+			self.description += f' (Maximum value: {self.max_value})'
89 87
 
90 88
 	def validate_value(self, new_value: Any) -> None:
91 89
 		"""
@@ -111,17 +109,28 @@ class CogSetting:
111 109
 			self.__get_group.add_command(self.__make_getter_command(cog))
112 110
 			self.__set_group.add_command(self.__make_setter_command(cog))
113 111
 
112
+	def to_stored_value(self, native_value: Any) -> Any:
113
+		if self.datatype is timedelta:
114
+			return native_value.total_seconds()
115
+		return native_value
116
+
117
+	def to_native_value(self, stored_value: Any) -> Any:
118
+		if self.datatype is timedelta:
119
+			return timedelta(seconds=stored_value)
120
+		return stored_value
121
+
114 122
 	def __make_getter_command(self, cog: BaseCog) -> Command:
115 123
 		setting: CogSetting = self
116 124
 		setting_name = setting.name
117 125
 		if cog.config_prefix is not None:
118 126
 			setting_name = f'{cog.config_prefix}_{setting_name}'
119
-		async def getter(self, interaction: Interaction) -> None:
127
+		datatype = self.datatype
128
+		async def getter(cog0, interaction: Interaction) -> None:
120 129
 			print(f"invoking getter for {setting_name}")
121
-			key = f'{self.__class__.__name__}.{setting.name}'
122
-			value = Storage.get_config_value(interaction.guild, key)
130
+			key = f'{cog0.__class__.__name__}.{setting.name}'
131
+			value = setting.to_native_value(Storage.get_config_value(interaction.guild, key))
123 132
 			if value is None:
124
-				value = self.get_cog_default(setting.name)
133
+				value = setting.to_native_value(cog0.get_cog_default(setting.name))
125 134
 				await interaction.response.send_message(
126 135
 					f'{CONFIG["info_emoji"]} `{setting_name}` is using default of `{value}`',
127 136
 					ephemeral=True
@@ -131,15 +140,24 @@ class CogSetting:
131 140
 					f'{CONFIG["info_emoji"]} `{setting_name}` is set to `{value}`',
132 141
 					ephemeral=True
133 142
 				)
134
-		setattr(cog.__class__, f'_cmd_get_{setting.name}', getter)
135
-		getter.__qualname__ = f'{self.__class__.__name__}._cmd_get_{setting.name}'
136
-		getter.__self__ = cog
143
+
144
+		# We have to do some surgery to make the getter function a proper method on the cog
145
+		# that discord.py will recognize and wire up correctly. Same for other accessors below.
146
+		setattr(cog.__class__, f'_cmd_get_{setting.name}', getter)  # add method to cog class
147
+		getter.__qualname__ = f'{self.__class__.__name__}._cmd_get_{setting.name}'  # discord.py checks this to know if it's a method vs function
148
+		getter.__self__ = cog  # discord.py uses this as the self argument
149
+
137 150
 		bot_log(None, cog.__class__, f"Creating /get {setting_name}")
138 151
 		command = Command(
139 152
 			name=setting_name,
140 153
 			description=f'Shows {self.brief}.',
141 154
 			callback=getter,
142 155
 			parent=CogSetting.__get_group,
156
+			extras={
157
+				'cog': cog,
158
+				'setting': setting,
159
+				'long_description': setting.description,
160
+			},
143 161
 		)
144 162
 		return command
145 163
 
@@ -159,7 +177,7 @@ class CogSetting:
159 177
 				)
160 178
 				return
161 179
 			key = f'{self.__class__.__name__}.{setting.name}'
162
-			Storage.set_config_value(interaction.guild, key, new_value)
180
+			Storage.set_config_value(interaction.guild, key, setting.to_stored_value(new_value))
163 181
 			await interaction.response.send_message(
164 182
 				f'{CONFIG["success_emoji"]} `{setting_name}` is now set to `{new_value}`',
165 183
 				ephemeral=True
@@ -172,36 +190,50 @@ class CogSetting:
172 190
 			if self.min_value is not None or self.max_value is not None:
173 191
 				r_min = self.min_value
174 192
 				r_max = self.max_value
175
-				async def setter_range(self, interaction: Interaction, new_value: Range[int, r_min, r_max]) -> None:
176
-					await self.setter_general(interaction, new_value)
193
+				@rename(new_value=self.name)
194
+				@describe(new_value=self.brief)
195
+				async def setter_range(cog0, interaction: Interaction, new_value: Range[int, r_min, r_max]) -> None:
196
+					await setter_general(cog0, interaction, new_value)
177 197
 				setter = setter_range
178 198
 			else:
179
-				async def setter_int(self, interaction: Interaction, new_value: int) -> None:
180
-					await self.setter_general(interaction, new_value)
199
+				@rename(new_value=self.name)
200
+				@describe(new_value=self.brief)
201
+				async def setter_int(cog0, interaction: Interaction, new_value: int) -> None:
202
+					await setter_general(cog0, interaction, new_value)
181 203
 				setter = setter_int
182 204
 		elif self.datatype == float:
183
-			async def setter_float(self, interaction: Interaction, new_value: float) -> None:
184
-				await self.setter_general(interaction, new_value)
205
+			@rename(new_value=self.name)
206
+			@describe(new_value=self.brief)
207
+			async def setter_float(cog0, interaction: Interaction, new_value: float) -> None:
208
+				await setter_general(cog0, interaction, new_value)
185 209
 			setter = setter_float
186 210
 		elif self.datatype == timedelta:
187
-			async def setter_timedelta(self, interaction: Interaction, new_value: Transform[timedelta, TimeDeltaTransformer]) -> None:
188
-				await self.setter_general(interaction, new_value)
211
+			@rename(new_value=self.name)
212
+			@describe(new_value=f'{self.brief} (e.g. 30s, 5m, 1h30s, or 7d)')
213
+			async def setter_timedelta(cog0, interaction: Interaction, new_value: Transform[timedelta, TimeDeltaTransformer]) -> None:
214
+				await setter_general(cog0, interaction, new_value)
189 215
 			setter = setter_timedelta
190 216
 		elif getattr(self.datatype, '__origin__', None) == Literal:
191 217
 			dt = self.datatype
192
-			async def setter_enum(self, interaction: Interaction, new_value: dt) -> None:
193
-				await self.setter_general(interaction, new_value)
218
+			@rename(new_value=self.name)
219
+			@describe(new_value=self.brief)
220
+			async def setter_enum(cog0, interaction: Interaction, new_value: dt) -> None:
221
+				await setter_general(cog0, interaction, new_value)
194 222
 			setter = setter_enum
195 223
 		elif self.datatype == str:
196 224
 			if self.enum_values is not None:
197 225
 				raise ValueError('Type for a setting with enum values should be typing.Literal')
198 226
 			else:
199
-				async def setter_str(self, interaction: Interaction, new_value: str) -> None:
200
-					await self.setter_general(interaction, new_value)
227
+				@rename(new_value=self.name)
228
+				@describe(new_value=self.brief)
229
+				async def setter_str(cog0, interaction: Interaction, new_value: str) -> None:
230
+					await setter_general(cog0, interaction, new_value)
201 231
 				setter = setter_str
202 232
 		elif setting.datatype == bool:
203
-			async def setter_bool(self, interaction: Interaction, new_value: bool) -> None:
204
-				await self.setter_general(interaction, new_value)
233
+			@rename(new_value=self.name)
234
+			@describe(new_value=self.brief)
235
+			async def setter_bool(cog0, interaction: Interaction, new_value: bool) -> None:
236
+				await setter_general(cog0, interaction, new_value)
205 237
 			setter = setter_bool
206 238
 		elif setting.datatype is not None:
207 239
 			raise ValueError(f'Invalid type {self.datatype}')
@@ -214,25 +246,26 @@ class CogSetting:
214 246
 			description=f'Sets {self.brief}.',
215 247
 			callback=setter,
216 248
 			parent=CogSetting.__set_group,
249
+			extras={
250
+				'cog': cog,
251
+				'setting': setting,
252
+				'long_description': setting.description,
253
+			},
217 254
 		)
218
-		# HACK: Passing `cog` in init gets ignored and set to `None` so set after.
219
-		# This ensures the callback is passed the cog as `self` argument.
220
-		# command.cog = cog
221
-		# _fix_command(command)
222 255
 		return command
223 256
 
224 257
 	def __make_enable_command(self, cog: BaseCog) -> Command:
225 258
 		setting: CogSetting = self
226
-		async def enabler(self: BaseCog, interaction: Interaction) -> None:
227
-			print(f"invoking enable for {self.config_prefix}")
228
-			key = f'{self.__class__.__name__}.{setting.name}'
259
+		async def enabler(cog0: BaseCog, interaction: Interaction) -> None:
260
+			print(f"invoking enable for {cog0.config_prefix}")
261
+			key = f'{cog0.__class__.__name__}.{setting.name}'
229 262
 			Storage.set_config_value(interaction.guild, key, True)
230 263
 			await interaction.response.send_message(
231 264
 				f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} enabled.',
232 265
 				ephemeral=True
233 266
 			)
234
-			await self.on_setting_updated(interaction.guild, setting)
235
-			self.log(interaction.guild, f'{interaction.user.name} enabled {self.__class__.__name__}')
267
+			await cog0.on_setting_updated(interaction.guild, setting)
268
+			cog0.log(interaction.guild, f'{interaction.user.name} enabled {cog0.__class__.__name__}')
236 269
 		setattr(cog.__class__, f'_cmd_enable', enabler)
237 270
 		enabler.__qualname__ = f'{cog.__class__.__name__}._cmd_enable'
238 271
 		enabler.__self__ = cog
@@ -240,26 +273,29 @@ class CogSetting:
240 273
 
241 274
 		command = Command(
242 275
 			name=cog.config_prefix,
243
-			description=f'Enables {cog.name} functionality',
276
+			description=f'Enables {cog.qualified_name} functionality.',
244 277
 			callback=enabler,
245 278
 			parent=CogSetting.__enable_group,
279
+			extras={
280
+				'cog': cog,
281
+				'setting': setting,
282
+				'long_description': setting.description,
283
+			},
246 284
 		)
247
-		# command.cog = cog
248
-		# _fix_command(command)
249 285
 		return command
250 286
 
251 287
 	def __make_disable_command(self, cog: BaseCog) -> Command:
252 288
 		setting: CogSetting = self
253
-		async def disabler(self: BaseCog, interaction: Interaction) -> None:
254
-			print(f"invoking disable for {self.config_prefix}")
255
-			key = f'{self.__class__.__name__}.{setting.name}'
289
+		async def disabler(cog0: BaseCog, interaction: Interaction) -> None:
290
+			print(f"invoking disable for {cog0.config_prefix}")
291
+			key = f'{cog0.__class__.__name__}.{setting.name}'
256 292
 			Storage.set_config_value(interaction.guild, key, False)
257 293
 			await interaction.response.send_message(
258 294
 				f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} disabled.',
259 295
 				ephemeral=True
260 296
 			)
261
-			await self.on_setting_updated(interaction.guild, setting)
262
-			self.log(interaction.guild, f'{interaction.user.name} disabled {self.__class__.__name__}')
297
+			await cog0.on_setting_updated(interaction.guild, setting)
298
+			cog0.log(interaction.guild, f'{interaction.user.name} disabled {cog0.__class__.__name__}')
263 299
 		setattr(cog.__class__, f'_cmd_disable', disabler)
264 300
 		disabler.__qualname__ = f'{cog.__class__.__name__}._cmd_disable'
265 301
 		disabler.__self__ = cog
@@ -270,9 +306,12 @@ class CogSetting:
270 306
 			description=f'Disables {cog.config_prefix} functionality',
271 307
 			callback=disabler,
272 308
 			parent=CogSetting.__disable_group,
309
+			extras={
310
+				'cog': cog,
311
+				'setting': setting,
312
+				'long_description': setting.description,
313
+			},
273 314
 		)
274
-		# command.cog = cog
275
-		# _fix_command(command)
276 315
 		return command
277 316
 
278 317
 	__has_set_up_base_commands: bool = False
@@ -303,23 +342,39 @@ class CogSetting:
303 342
 		cls.__has_set_up_base_commands = True
304 343
 		cls.__set_group = Group(
305 344
 			name='set',
306
-			description='Sets a bot configuration value for this guild',
307
-			default_permissions=cls.permissions
345
+			description='Sets a configuration value for this guild.',
346
+			default_permissions=MOD_PERMISSIONS,
347
+			extras={
348
+				'long_description': 'Settings are guild-specific. If no value is set, a default is used. Use `/get` to '
349
+									'see the current value for this guild.',
350
+			},
308 351
 		)
309 352
 		cls.__get_group = Group(
310 353
 			name='get',
311
-			description='Shows a configured bot value for this guild',
312
-			default_permissions=cls.permissions
354
+			description='Shows a configuration value for this guild.',
355
+			default_permissions=MOD_PERMISSIONS,
356
+			extras={
357
+				'long_description': 'Settings are guild-specific. Shows the configured value or default value for a '
358
+									'variable for this guild. Use `/set` to change the value.',
359
+			},
313 360
 		)
314 361
 		cls.__enable_group = Group(
315 362
 			name='enable',
316
-			description='Enables bot functionality for this guild',
317
-			default_permissions=cls.permissions
363
+			description='Enables a module for this guild',
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
+			},
318 369
 		)
319 370
 		cls.__disable_group = Group(
320 371
 			name='disable',
321
-			description='Disables bot functionality for this guild',
322
-			default_permissions=cls.permissions
372
+			description='Disables a module for this guild.',
373
+			default_permissions=MOD_PERMISSIONS,
374
+			extras={
375
+				'long_description': 'Modules are enabled on a per-guild basis and are off by default. Use `/enable` '
376
+					                're-enable a disabled module.',
377
+			},
323 378
 		)
324 379
 		bot.tree.add_command(cls.__set_group)
325 380
 		bot.tree.add_command(cls.__get_group)

Notiek ielāde…
Atcelt
Saglabāt