Преглед изворни кода

Moving help to its own cog

pull/13/head
Rocketsoup пре 2 месеци
родитељ
комит
1b89526523
4 измењених фајлова са 407 додато и 348 уклоњено
  1. 2
    0
      rocketbot/bot.py
  2. 23
    0
      rocketbot/cogs/basecog.py
  3. 1
    348
      rocketbot/cogs/generalcog.py
  4. 381
    0
      rocketbot/cogs/helpcog.py

+ 2
- 0
rocketbot/bot.py Прегледај датотеку

@@ -85,6 +85,7 @@ from rocketbot.cogs.autokickcog import AutoKickCog
85 85
 from rocketbot.cogs.configcog import ConfigCog
86 86
 from rocketbot.cogs.crosspostcog import CrossPostCog
87 87
 from rocketbot.cogs.generalcog import GeneralCog
88
+from rocketbot.cogs.helpcog import HelpCog
88 89
 from rocketbot.cogs.joinraidcog import JoinRaidCog
89 90
 from rocketbot.cogs.logcog import LoggingCog
90 91
 from rocketbot.cogs.patterncog import PatternCog
@@ -102,6 +103,7 @@ async def start_bot():
102 103
 	# Optional
103 104
 	await rocketbot.add_cog(AutoKickCog(rocketbot))
104 105
 	await rocketbot.add_cog(CrossPostCog(rocketbot))
106
+	await rocketbot.add_cog(HelpCog(rocketbot))
105 107
 	await rocketbot.add_cog(JoinRaidCog(rocketbot))
106 108
 	await rocketbot.add_cog(LoggingCog(rocketbot))
107 109
 	await rocketbot.add_cog(PatternCog(rocketbot))

+ 23
- 0
rocketbot/cogs/basecog.py Прегледај датотеку

@@ -68,6 +68,29 @@ class BaseCog(Cog):
68 68
 		except discord.InteractionResponded:
69 69
 			await interaction.followup.send(f"An error occurred: {message}", ephemeral=True)
70 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 self.bot.cogs.values()
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
+		}
93
+
71 94
 	# Config
72 95
 
73 96
 	@classmethod

+ 1
- 348
rocketbot/cogs/generalcog.py Прегледај датотеку

@@ -17,89 +17,6 @@ from rocketbot.cogs.basecog import BaseCog, BotMessage
17 17
 from rocketbot.utils import describe_timedelta, TimeDeltaTransformer, dump_stacktrace, MOD_PERMISSIONS
18 18
 from rocketbot.storage import ConfigKey, Storage
19 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
-
27
-async def command_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
28
-	choices: list[Choice] = []
29
-	try:
30
-		if current.startswith('/'):
31
-			current = current[1:]
32
-		current = current.lower().strip()
33
-		user_permissions = interaction.permissions
34
-		cmds = GeneralCog.shared.get_command_list(user_permissions)
35
-		return [
36
-			Choice(name=f'/{cmdname} command', value=f'cmd:{cmdname}')
37
-			for cmdname in sorted(cmds.keys())
38
-			if len(current) == 0 or current in cmdname
39
-		][:25]
40
-	except BaseException as e:
41
-		dump_stacktrace(e)
42
-	return choices
43
-
44
-async def subcommand_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
45
-	try:
46
-		current = current.lower().strip()
47
-		cmd_name = interaction.namespace['topic']
48
-		cmd = GeneralCog.shared.object_for_help_symbol(cmd_name)
49
-		if not isinstance(cmd, Group):
50
-			return []
51
-		user_permissions = interaction.permissions
52
-		if cmd is None or not isinstance(cmd, Group):
53
-			print(f'No command found named {cmd_name}')
54
-			return []
55
-		grp = cmd
56
-		subcmds = GeneralCog.shared.get_subcommand_list(grp, user_permissions)
57
-		if subcmds is None:
58
-			print(f'Subcommands for {cmd_name} was None')
59
-			return []
60
-		return [
61
-			Choice(name=f'{subcmd_name} subcommand', value=f'subcmd:{cmd_name}.{subcmd_name}')
62
-			for subcmd_name in sorted(subcmds.keys())
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())
79
-		]
80
-	except BaseException as e:
81
-		dump_stacktrace(e)
82
-	return []
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
-
93
-def can_use_command(cmd: Union[Group, Command], user_permissions: Optional[Permissions]) -> bool:
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)
102
-
103 20
 class GeneralCog(BaseCog, name='General'):
104 21
 	"""
105 22
 	Cog for handling high-level bot functionality and commands. Should be the
@@ -175,7 +92,7 @@ class GeneralCog(BaseCog, name='General'):
175 92
 			)
176 93
 
177 94
 	@command(
178
-		description='Simple test reply',
95
+		description='Greets the user',
179 96
 		extras={
180 97
 			'long_description': 'Replies to the command message. Useful to ensure the ' + \
181 98
 				'bot is working properly.',
@@ -232,267 +149,3 @@ class GeneralCog(BaseCog, name='General'):
232 149
 			f'messages by {user.mention}> from the past {describe_timedelta(age)}.',
233 150
 			ephemeral=True,
234 151
 		)
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
-
281
-	@command(name='help')
282
-	@guild_only()
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:
285
-		"""
286
-		Shows help for using commands and subcommands and configuring modules.
287
-
288
-		`/help` will show a list of top-level topics.
289
-
290
-		`/help /<command_name>` will show help about a specific command or
291
-		list a command's subcommands.
292
-
293
-		`/help /<command_name> <subcommand_name>` will show help about a
294
-		specific subcommand.
295
-
296
-		`/help <module_name>` will show help about configuring a module.
297
-
298
-		`/help <keywords>` will do a text search for topics.
299
-
300
-		Parameters
301
-		----------
302
-		interaction: Interaction
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.
307
-		"""
308
-		print(f'help_command(interaction, {topic}, {subtopic})')
309
-
310
-		# General help
311
-		if topic is None:
312
-			await self.__send_general_help(interaction)
313
-			return
314
-
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)
319
-			return
320
-
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)
349
-			return
350
-		if isinstance(obj, Group):
351
-			await self.__send_command_help(interaction, obj)
352
-			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
-		)
362
-
363
-	def get_command_list(self, permissions: Optional[Permissions] = None) -> dict[str, Union[Command, Group]]:
364
-		return { cmd.name: cmd for cmd in self.bot.tree.get_commands() if can_use_command(cmd, permissions) }
365
-
366
-	def get_subcommand_list(self, cmd: Group, permissions: Optional[Permissions] = None) -> dict[str, Command]:
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 {}
372
-
373
-	async def __send_general_help(self, interaction: Interaction) -> None:
374
-		user_permissions: Permissions = interaction.permissions
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}'
448
-		await interaction.response.send_message(
449
-			text,
450
-			ephemeral=True,
451
-		)
452
-
453
-	async def __send_command_help(self, interaction: Interaction, command_or_group: Union[Command, Group], addendum: Optional[str] = None) -> None:
454
-		text = ''
455
-		if addendum is not None:
456
-			text += addendum + '\n\n'
457
-		text += f'## :information_source: Command Help\n`/{command_or_group.name}`\n\n{command_or_group.description}'
458
-		if isinstance(command_or_group, Group):
459
-			subcmds: dict[str, Command] = self.get_subcommand_list(command_or_group, permissions=interaction.permissions)
460
-			if len(subcmds) > 0:
461
-				text += '\n\n### Subcommands:'
462
-				for subcmd_name, subcmd in sorted(subcmds.items()):
463
-					text += f'\n- `{subcmd_name}`: {subcmd.description}'
464
-		else:
465
-			params = command_or_group.parameters
466
-			if len(params) > 0:
467
-				text += '\n\n### Parameters:'
468
-				for param in params:
469
-					text += f'\n- `{param.name}`: {param.description}'
470
-		await interaction.response.send_message(text, ephemeral=True)
471
-
472
-	async def __send_subcommand_help(self, interaction: Interaction, group: Group, subcommand: Command) -> None:
473
-		text = f'## :information_source: Subcommand Help\n`/{group.name} {subcommand.name}`\n\n{subcommand.description}\n\n{subcommand.description}'
474
-		params = subcommand.parameters
475
-		if len(params) > 0:
476
-			text += '\n\n### Parameters:'
477
-			for param in params:
478
-				text += f'\n- `{param.name}`: {param.description}'
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
-		)

+ 381
- 0
rocketbot/cogs/helpcog.py Прегледај датотеку

@@ -0,0 +1,381 @@
1
+import re
2
+from typing import Union, Optional
3
+
4
+from discord import Interaction, Permissions, AppCommandType
5
+from discord.app_commands import Group, Command, autocomplete, guild_only, command, Choice
6
+
7
+from config import CONFIG
8
+from rocketbot.bot import Rocketbot
9
+from rocketbot.cogs.basecog import BaseCog
10
+from rocketbot.utils import MOD_PERMISSIONS, dump_stacktrace
11
+
12
+async def command_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
13
+	"""Autocomplete handler for top-level command names."""
14
+	choices: list[Choice] = []
15
+	try:
16
+		if current.startswith('/'):
17
+			current = current[1:]
18
+		current = current.lower().strip()
19
+		user_permissions = interaction.permissions
20
+		cmds = HelpCog.shared.get_command_list(user_permissions)
21
+		return [
22
+			Choice(name=f'/{cmdname} command', value=f'cmd:{cmdname}')
23
+			for cmdname in sorted(cmds.keys())
24
+			if len(current) == 0 or current in cmdname
25
+		][:25]
26
+	except BaseException as e:
27
+		dump_stacktrace(e)
28
+	return choices
29
+
30
+async def subcommand_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
31
+	"""Autocomplete handler for subcommand names. Command taken from previous command token."""
32
+	try:
33
+		current = current.lower().strip()
34
+		cmd_name = interaction.namespace['topic']
35
+		cmd = HelpCog.shared.object_for_help_symbol(cmd_name)
36
+		if not isinstance(cmd, Group):
37
+			return []
38
+		user_permissions = interaction.permissions
39
+		if cmd is None or not isinstance(cmd, Group):
40
+			print(f'No command found named {cmd_name}')
41
+			return []
42
+		grp = cmd
43
+		subcmds = HelpCog.shared.get_subcommand_list(grp, user_permissions)
44
+		if subcmds is None:
45
+			print(f'Subcommands for {cmd_name} was None')
46
+			return []
47
+		return [
48
+			Choice(name=f'{subcmd_name} subcommand', value=f'subcmd:{cmd_name}.{subcmd_name}')
49
+			for subcmd_name in sorted(subcmds.keys())
50
+			if len(current) == 0 or current in subcmd_name
51
+		][:25]
52
+	except BaseException as e:
53
+		dump_stacktrace(e)
54
+	return []
55
+
56
+async def cog_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
57
+	"""Autocomplete handler for cog names."""
58
+	try:
59
+		current = current.lower().strip()
60
+		return [
61
+			Choice(name=f'⚙ {cog.qualified_name} module', value=f'cog:{cog.qualified_name}')
62
+			for cog in sorted(HelpCog.shared.bot.cogs.values(), key=lambda c: c.qualified_name)
63
+			if isinstance(cog, BaseCog) and
64
+			   can_use_cog(cog, interaction.permissions) and
65
+			   (len(cog.get_commands()) > 0 or len(cog.settings) > 0) and \
66
+			   (len(current) == 0 or current in cog.qualified_name.lower())
67
+		]
68
+	except BaseException as e:
69
+		dump_stacktrace(e)
70
+	return []
71
+
72
+async def topic_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
73
+	"""Autocomplete handler that combines slash commands and cog names."""
74
+	command_choices = await command_autocomplete(interaction, current)
75
+	cog_choices = await cog_autocomplete(interaction, current)
76
+	return (command_choices + cog_choices)[:25]
77
+
78
+async def subtopic_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
79
+	"""Autocomplete handler for subtopic names. Currently just handles subcommands."""
80
+	subcommand_choices = await subcommand_autocomplete(interaction, current)
81
+	return subcommand_choices[:25]
82
+
83
+class HelpCog(BaseCog, name='Help'):
84
+	shared: Optional['HelpCog'] = None
85
+
86
+	def __init__(self, bot: Rocketbot):
87
+		super().__init__(
88
+			bot,
89
+			config_prefix='help',
90
+			short_description='Provides help on using commands and modules.'
91
+		)
92
+		HelpCog.shared = self
93
+
94
+	def __create_help_index(self) -> None:
95
+		"""
96
+		Populates self.obj_index and self.keyword_index. Bails if already
97
+		populated. Intended to be run on demand so all cogs and commands have
98
+		had time to get set up and synced.
99
+		"""
100
+		if getattr(self, 'obj_index', None) is not None:
101
+			return
102
+		self.obj_index: dict[str, Union[Command, Group, BaseCog]] = {}
103
+		self.keyword_index: dict[str, set[Union[Command, Group, BaseCog]]] = {}
104
+
105
+		def add_text_to_index(obj, text: str):
106
+			words = [
107
+				word
108
+				for word in re.split(r"[^a-zA-Z']+", text.lower())
109
+				if len(word) > 1 and word not in trivial_words
110
+			]
111
+			for word in words:
112
+				matches = self.keyword_index.get(word, set())
113
+				matches.add(obj)
114
+				self.keyword_index[word] = matches
115
+
116
+		# PyCharm not interpreting conditional return type correctly.
117
+		# noinspection PyTypeChecker
118
+		cmds: list[Union[Command, Group]] = self.bot.tree.get_commands(type=AppCommandType.chat_input)
119
+		for cmd in cmds:
120
+			key = f'cmd:{cmd.name}'
121
+			self.obj_index[key] = cmd
122
+			add_text_to_index(cmd, cmd.name)
123
+			if cmd.description:
124
+				add_text_to_index(cmd, cmd.description)
125
+			if isinstance(cmd, Group):
126
+				for subcmd in cmd.commands:
127
+					key = f'subcmd:{cmd.name}.{subcmd.name}'
128
+					self.obj_index[key] = subcmd
129
+					add_text_to_index(subcmd, subcmd.name)
130
+					if subcmd.description:
131
+						add_text_to_index(subcmd, subcmd.description)
132
+		for cog_qname, cog in self.bot.cogs.items():
133
+			if not isinstance(cog, BaseCog):
134
+				continue
135
+			key = f'cog:{cog_qname}'
136
+			self.obj_index[key] = cog
137
+			add_text_to_index(cog, cog.qualified_name)
138
+			if cog.description:
139
+				add_text_to_index(cog, cog.description)
140
+		print(self.obj_index.keys())
141
+		print(self.keyword_index.keys())
142
+
143
+	def object_for_help_symbol(self, symbol: str) -> Optional[Union[Command, Group, BaseCog]]:
144
+		self.__create_help_index()
145
+		return self.obj_index.get(symbol, None)
146
+
147
+	@command(name='help')
148
+	@guild_only()
149
+	@autocomplete(topic=topic_autocomplete, subtopic=subtopic_autocomplete)
150
+	async def help_command(self, interaction: Interaction, topic: Optional[str] = None, subtopic: Optional[str] = None) -> None:
151
+		"""
152
+		Shows help for using commands and subcommands and configuring modules.
153
+
154
+		`/help` will show a list of top-level topics.
155
+
156
+		`/help /<command_name>` will show help about a specific command or
157
+		list a command's subcommands.
158
+
159
+		`/help /<command_name> <subcommand_name>` will show help about a
160
+		specific subcommand.
161
+
162
+		`/help <module_name>` will show help about configuring a module.
163
+
164
+		`/help <keywords>` will do a text search for topics.
165
+
166
+		Parameters
167
+		----------
168
+		interaction: Interaction
169
+		topic: Optional[str]
170
+			Optional topic to get specific help for. Getting help on a command can optionally start with a leading slash.
171
+		subtopic: Optional[str]
172
+			Optional subtopic to get specific help for.
173
+		"""
174
+		print(f'help_command(interaction, {topic}, {subtopic})')
175
+
176
+		# General help
177
+		if topic is None:
178
+			await self.__send_general_help(interaction)
179
+			return
180
+
181
+		# Specific object reference
182
+		obj = self.object_for_help_symbol(subtopic) if subtopic else self.object_for_help_symbol(topic)
183
+		if obj:
184
+			await self.__send_object_help(interaction, obj)
185
+			return
186
+
187
+		# Text search
188
+		keywords = [
189
+			word
190
+			for word in re.split(r"[^a-zA-Z']+", topic.lower())
191
+			if len(word) > 0 and word not in trivial_words
192
+		]
193
+		matching_objects_set = None
194
+		for keyword in keywords:
195
+			objs = self.keyword_index.get(keyword, None)
196
+			if objs is not None:
197
+				if matching_objects_set is None:
198
+					matching_objects_set = objs
199
+				else:
200
+					matching_objects_set = matching_objects_set & objs
201
+		accessible_objects = [
202
+			obj
203
+			for obj in matching_objects_set or {}
204
+			if ((isinstance(obj, Command) or isinstance(obj, Group)) and can_use_command(obj, interaction.permissions)) or \
205
+			   (isinstance(obj, BaseCog) and can_use_cog(obj, interaction.permissions))
206
+		]
207
+		await self.__send_keyword_help(interaction, accessible_objects)
208
+
209
+	async def __send_object_help(self, interaction: Interaction, obj: Union[Command, Group, BaseCog]) -> None:
210
+		if isinstance(obj, Command):
211
+			if obj.parent:
212
+				await self.__send_subcommand_help(interaction, obj.parent, obj)
213
+			else:
214
+				await self.__send_command_help(interaction, obj)
215
+			return
216
+		if isinstance(obj, Group):
217
+			await self.__send_command_help(interaction, obj)
218
+			return
219
+		if isinstance(obj, BaseCog):
220
+			await self.__send_cog_help(interaction, obj)
221
+			return
222
+		print(f'No help for object {obj}')
223
+		await interaction.response.send_message(
224
+			f'{CONFIG["failure_emoji"]} Failed to get help info.',
225
+			ephemeral=True,
226
+			delete_after=10,
227
+		)
228
+
229
+	def get_command_list(self, permissions: Optional[Permissions] = None) -> dict[str, Union[Command, Group]]:
230
+		return { cmd.name: cmd for cmd in self.bot.tree.get_commands() if can_use_command(cmd, permissions) }
231
+
232
+	def get_subcommand_list(self, cmd: Group, permissions: Optional[Permissions] = None) -> dict[str, Command]:
233
+		return {
234
+			subcmd.name: subcmd
235
+			for subcmd in cmd.commands
236
+			if can_use_command(subcmd, permissions)
237
+		} if can_use_command(cmd, permissions) else {}
238
+
239
+	async def __send_general_help(self, interaction: Interaction) -> None:
240
+		user_permissions: Permissions = interaction.permissions
241
+		all_commands = sorted(self.get_command_list(user_permissions).items())
242
+		all_cog_tuples: list[tuple[str, BaseCog]] = [
243
+			cog_tuple
244
+			for cog_tuple in sorted(self.basecog_map.items())
245
+			if can_use_cog(cog_tuple[1], user_permissions) and \
246
+			   (len(cog_tuple[1].settings) > 0)
247
+		]
248
+
249
+		text = f'## :information_source: Help'
250
+		if len(all_commands) + len(all_cog_tuples) == 0:
251
+			text = 'Nothing available for your permissions!'
252
+
253
+		if len(all_commands) > 0:
254
+			text += '\n### Commands'
255
+			text += '\nType `/help /commandname` for more information.'
256
+			for cmd_name, cmd in sorted(self.get_command_list(user_permissions).items()):
257
+				text += f'\n- `/{cmd_name}`: {cmd.description}'
258
+				if isinstance(cmd, Group):
259
+					subcommand_count = len(cmd.commands)
260
+					text += f' ({subcommand_count} subcommands)'
261
+
262
+		if len(all_cog_tuples) > 0:
263
+			text += '\n### Module Configuration'
264
+			for cog_name, cog in all_cog_tuples:
265
+				has_enabled = next((s for s in cog.settings if s.name == 'enabled'), None) is not None
266
+				text += f'\n- **{cog_name}**: {cog.short_description}'
267
+				if has_enabled:
268
+					text += f'\n   - `/enable {cog.config_prefix}` or `/disable {cog.config_prefix}`'
269
+				for setting in cog.settings:
270
+					if setting.name == 'enabled':
271
+						continue
272
+					text += f'\n   - `/get` or `/set {cog.config_prefix}_{setting.name}`'
273
+		await interaction.response.send_message(
274
+			text,
275
+			ephemeral=True,
276
+		)
277
+
278
+	async def __send_keyword_help(self, interaction: Interaction, matching_objects: Optional[list[Union[Command, Group, BaseCog]]]) -> None:
279
+		matching_commands = [
280
+			cmd
281
+			for cmd in matching_objects or []
282
+			if isinstance(cmd, Command) or isinstance(cmd, Group)
283
+		]
284
+		matching_cogs = [
285
+			cog
286
+			for cog in matching_objects or []
287
+			if isinstance(cog, BaseCog)
288
+		]
289
+		if len(matching_commands) + len(matching_cogs) == 0:
290
+			await interaction.response.send_message(
291
+				f'{CONFIG["failure_emoji"]} No available help topics found.',
292
+				ephemeral=True,
293
+				delete_after=10,
294
+			)
295
+			return
296
+		if len(matching_objects) == 1:
297
+			obj = matching_objects[0]
298
+			await self.__send_object_help(interaction, obj)
299
+			return
300
+
301
+		text = '## :information_source: Matching Help Topics'
302
+		if len(matching_commands) > 0:
303
+			text += '\n### Commands'
304
+			for cmd in matching_commands:
305
+				if cmd.parent:
306
+					text += f'\n- `/{cmd.parent.name} {cmd.name}`'
307
+				else:
308
+					text += f'\n- `/{cmd.name}`'
309
+		if len(matching_cogs) > 0:
310
+			text += '\n### Cogs'
311
+			for cog in matching_cogs:
312
+				text += f'\n- {cog.qualified_name}'
313
+		await interaction.response.send_message(
314
+			text,
315
+			ephemeral=True,
316
+		)
317
+
318
+	async def __send_command_help(self, interaction: Interaction, command_or_group: Union[Command, Group], addendum: Optional[str] = None) -> None:
319
+		text = ''
320
+		if addendum is not None:
321
+			text += addendum + '\n\n'
322
+		text += f'## :information_source: Command Help\n`/{command_or_group.name}`\n\n{command_or_group.description}'
323
+		if isinstance(command_or_group, Group):
324
+			subcmds: dict[str, Command] = self.get_subcommand_list(command_or_group, permissions=interaction.permissions)
325
+			if len(subcmds) > 0:
326
+				text += '\n\n### Subcommands:'
327
+				for subcmd_name, subcmd in sorted(subcmds.items()):
328
+					text += f'\n- `{subcmd_name}`: {subcmd.description}'
329
+		else:
330
+			params = command_or_group.parameters
331
+			if len(params) > 0:
332
+				text += '\n\n### Parameters:'
333
+				for param in params:
334
+					text += f'\n- `{param.name}`: {param.description}'
335
+		await interaction.response.send_message(text, ephemeral=True)
336
+
337
+	async def __send_subcommand_help(self, interaction: Interaction, group: Group, subcommand: Command) -> None:
338
+		text = f'## :information_source: Subcommand Help\n`/{group.name} {subcommand.name}`\n\n{subcommand.description}\n\n{subcommand.description}'
339
+		params = subcommand.parameters
340
+		if len(params) > 0:
341
+			text += '\n\n### Parameters:'
342
+			for param in params:
343
+				text += f'\n- `{param.name}`: {param.description}'
344
+		await interaction.response.send_message(text, ephemeral=True)
345
+
346
+	async def __send_cog_help(self, interaction: Interaction, cog: BaseCog) -> None:
347
+		text = f'## :information_source: Module Help\n{cog.qualified_name}'
348
+		if cog.description is not None:
349
+			text += f'\n{cog.description}'
350
+		settings = cog.settings
351
+		if len(settings) > 0:
352
+			text += '\n### Configuration'
353
+			enabled_setting = next((s for s in settings if s.name == 'enabled'), None)
354
+			if enabled_setting is not None:
355
+				text += f'\n- `/enable {cog.config_prefix}` or `/disable {cog.config_prefix}`'
356
+			for setting in sorted(settings, key=lambda s: s.name):
357
+				if setting.name == 'enabled':
358
+					continue
359
+				text += f'\n- `/get` or `/set {cog.config_prefix}_{setting.name}` - {setting.description}'
360
+		await interaction.response.send_message(
361
+			text,
362
+			ephemeral=True,
363
+		)
364
+
365
+# Exclusions from keyword indexing
366
+trivial_words = {
367
+	'a', 'an', 'and', 'are', "aren't", 'as', 'by', 'can', 'for', 'have', 'if', 'in',
368
+	'is', 'it', 'its', "it's", 'not', 'of', 'on', 'or', 'than', 'that', 'the', 'then',
369
+	'there', 'them', 'they', "they're", 'this', 'to', 'when', 'with',
370
+}
371
+
372
+def can_use_command(cmd: Union[Group, Command], user_permissions: Optional[Permissions]) -> bool:
373
+	if user_permissions is None:
374
+		return False
375
+	if cmd.parent and not can_use_command(cmd.parent, user_permissions):
376
+		return False
377
+	return cmd.default_permissions is None or cmd.default_permissions.is_subset(user_permissions)
378
+
379
+def can_use_cog(cog: BaseCog, user_permissions: Optional[Permissions]) -> bool:
380
+	# "Using" a cog for now means configuring it, and only mods can configure cogs.
381
+	return user_permissions is not None and MOD_PERMISSIONS.is_subset(user_permissions)

Loading…
Откажи
Сачувај