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

Improved single input help search

pull/13/head
Rocketsoup 2 месяцев назад
Родитель
Сommit
d69b755174
2 измененных файлов: 140 добавлений и 55 удалений
  1. 1
    1
      rocketbot/cogs/basecog.py
  2. 139
    54
      rocketbot/cogs/helpcog.py

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

@@ -75,7 +75,7 @@ class BaseCog(Cog):
75 75
 		"""
76 76
 		return [
77 77
 			bcog
78
-			for bcog in self.bot.cogs.values()
78
+			for bcog in sorted(self.bot.cogs.values(), key=lambda c: c.qualified_name)
79 79
 			if isinstance(bcog, BaseCog)
80 80
 		]
81 81
 

+ 139
- 54
rocketbot/cogs/helpcog.py Просмотреть файл

@@ -1,14 +1,31 @@
1
+"""Provides help commands for getting info on using other commands and configuration."""
1 2
 import re
2 3
 from typing import Union, Optional
3 4
 
4 5
 from discord import Interaction, Permissions, AppCommandType
5 6
 from discord.app_commands import Group, Command, autocomplete, guild_only, command, Choice
7
+from discord.ext.commands import cog
6 8
 
7 9
 from config import CONFIG
8 10
 from rocketbot.bot import Rocketbot
9 11
 from rocketbot.cogs.basecog import BaseCog
10 12
 from rocketbot.utils import MOD_PERMISSIONS, dump_stacktrace
11 13
 
14
+HelpTopic = Union[Command, Group, BaseCog]
15
+
16
+def choice_from_obj(obj: HelpTopic, include_full_command: bool = False) -> Choice:
17
+	if isinstance(obj, BaseCog):
18
+		return Choice(name=f'⚙ {obj.qualified_name}', value=f'cog:{obj.qualified_name}')
19
+	if isinstance(obj, Group):
20
+		return Choice(name=f'/{obj.name}', value=f'cmd:{obj.name}')
21
+	if isinstance(obj, Command):
22
+		if obj.parent:
23
+			if include_full_command:
24
+				return Choice(name=f'/{obj.parent.name} {obj.name}', value=f'subcmd:{obj.parent.name}.{obj.name}')
25
+			return Choice(name=f'{obj.name}', value=f'subcmd:{obj.name}')
26
+		return Choice(name=f'/{obj.name}', value=f'cmd:{obj.name}')
27
+	return Choice(name='', value='')
28
+
12 29
 async def command_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
13 30
 	"""Autocomplete handler for top-level command names."""
14 31
 	choices: list[Choice] = []
@@ -31,22 +48,21 @@ async def subcommand_autocomplete(interaction: Interaction, current: str) -> lis
31 48
 	"""Autocomplete handler for subcommand names. Command taken from previous command token."""
32 49
 	try:
33 50
 		current = current.lower().strip()
34
-		cmd_name = interaction.namespace['topic']
51
+		cmd_name = interaction.namespace.get('topic', None)
35 52
 		cmd = HelpCog.shared.object_for_help_symbol(cmd_name)
36
-		if not isinstance(cmd, Group):
53
+		if isinstance(cmd, Command):
54
+			# No subcommands
37 55
 			return []
38 56
 		user_permissions = interaction.permissions
39 57
 		if cmd is None or not isinstance(cmd, Group):
40
-			print(f'No command found named {cmd_name}')
41 58
 			return []
42 59
 		grp = cmd
43 60
 		subcmds = HelpCog.shared.get_subcommand_list(grp, user_permissions)
44 61
 		if subcmds is None:
45
-			print(f'Subcommands for {cmd_name} was None')
46 62
 			return []
47 63
 		return [
48
-			Choice(name=f'{subcmd_name} subcommand', value=f'subcmd:{cmd.name}.{subcmd_name}')
49
-			for subcmd_name in sorted(subcmds.keys())
64
+			choice_from_obj(subcmd)
65
+			for subcmd_name, subcmd in sorted(subcmds.items())
50 66
 			if len(current) == 0 or current in subcmd_name
51 67
 		][:25]
52 68
 	except BaseException as e:
@@ -58,7 +74,7 @@ async def cog_autocomplete(interaction: Interaction, current: str) -> list[Choic
58 74
 	try:
59 75
 		current = current.lower().strip()
60 76
 		return [
61
-			Choice(name=f'⚙ {cog.qualified_name} module', value=f'cog:{cog.qualified_name}')
77
+			choice_from_obj(cog)
62 78
 			for cog in sorted(HelpCog.shared.bot.cogs.values(), key=lambda c: c.qualified_name)
63 79
 			if isinstance(cog, BaseCog) and
64 80
 			   can_use_cog(cog, interaction.permissions) and
@@ -80,6 +96,21 @@ async def subtopic_autocomplete(interaction: Interaction, current: str) -> list[
80 96
 	subcommand_choices = await subcommand_autocomplete(interaction, current)
81 97
 	return subcommand_choices[:25]
82 98
 
99
+async def search_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
100
+	try:
101
+		if len(current) == 0:
102
+			return [
103
+				choice_from_obj(obj, include_full_command=True)
104
+				for obj in HelpCog.shared.all_accessible_objects(interaction.permissions)
105
+			]
106
+		return [
107
+			choice_from_obj(obj, include_full_command=True)
108
+			for obj in HelpCog.shared.objects_for_keywords(current, interaction.permissions)
109
+		]
110
+	except BaseException as e:
111
+		dump_stacktrace(e)
112
+		return []
113
+
83 114
 class HelpCog(BaseCog, name='Help'):
84 115
 	shared: Optional['HelpCog'] = None
85 116
 
@@ -99,8 +130,8 @@ class HelpCog(BaseCog, name='Help'):
99 130
 		"""
100 131
 		if getattr(self, 'obj_index', None) is not None:
101 132
 			return
102
-		self.obj_index: dict[str, Union[Command, Group, BaseCog]] = {}
103
-		self.keyword_index: dict[str, set[Union[Command, Group, BaseCog]]] = {}
133
+		self.obj_index: dict[str, HelpTopic] = {}
134
+		self.keyword_index: dict[str, set[HelpTopic]] = {}
104 135
 
105 136
 		def add_text_to_index(obj, text: str):
106 137
 			words = [
@@ -113,9 +144,7 @@ class HelpCog(BaseCog, name='Help'):
113 144
 				matches.add(obj)
114 145
 				self.keyword_index[word] = matches
115 146
 
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)
147
+		cmds = self.all_commands()
119 148
 		for cmd in cmds:
120 149
 			key = f'cmd:{cmd.name}'
121 150
 			self.obj_index[key] = cmd
@@ -126,6 +155,7 @@ class HelpCog(BaseCog, name='Help'):
126 155
 				for subcmd in cmd.commands:
127 156
 					key = f'subcmd:{cmd.name}.{subcmd.name}'
128 157
 					self.obj_index[key] = subcmd
158
+					add_text_to_index(subcmd, cmd.name)
129 159
 					add_text_to_index(subcmd, subcmd.name)
130 160
 					if subcmd.description:
131 161
 						add_text_to_index(subcmd, subcmd.description)
@@ -138,14 +168,99 @@ class HelpCog(BaseCog, name='Help'):
138 168
 			if cog.description:
139 169
 				add_text_to_index(cog, cog.description)
140 170
 
141
-	def object_for_help_symbol(self, symbol: str) -> Optional[Union[Command, Group, BaseCog]]:
171
+	def object_for_help_symbol(self, symbol: str) -> Optional[HelpTopic]:
142 172
 		self.__create_help_index()
143 173
 		return self.obj_index.get(symbol, None)
144 174
 
175
+	def all_commands(self) -> list[Union[Command, Group]]:
176
+		# PyCharm not interpreting conditional return type correctly.
177
+		# noinspection PyTypeChecker
178
+		cmds: list[Union[Command, Group]] = self.bot.tree.get_commands(type=AppCommandType.chat_input)
179
+		return sorted(cmds, key=lambda cmd: cmd.name)
180
+
181
+	def all_accessible_commands(self, permissions: Optional[Permissions]) -> list[Union[Command, Group]]:
182
+		return [
183
+			cmd
184
+			for cmd in self.all_commands()
185
+			if can_use_command(cmd, permissions)
186
+		]
187
+
188
+	def all_accessible_subcommands(self, permissions: Optional[Permissions]) -> list[Command]:
189
+		cmds = self.all_accessible_commands(permissions)
190
+		subcmds: list[Command] = []
191
+		for cmd in cmds:
192
+			if isinstance(cmd, Group):
193
+				for subcmd in sorted(cmd.commands, key=lambda cmd: cmd.name):
194
+					if can_use_command(subcmd, permissions):
195
+						subcmds.append(subcmd)
196
+		return subcmds
197
+
198
+	def all_accessible_cogs(self, permissions: Optional[Permissions]) -> list[BaseCog]:
199
+		return [
200
+			cog
201
+			for cog in self.basecogs
202
+			if can_use_cog(cog, permissions)
203
+		]
204
+
205
+	def all_accessible_objects(self, permissions: Optional[Permissions], *,
206
+							   include_cogs: bool = True,
207
+							   include_commands: bool = True,
208
+							   include_subcommands: bool = True) -> list[HelpTopic]:
209
+		objs = []
210
+		if include_cogs:
211
+			objs += self.all_accessible_cogs(permissions)
212
+		if include_commands:
213
+			objs += self.all_accessible_commands(permissions)
214
+		if include_subcommands:
215
+			objs += self.all_accessible_subcommands(permissions)
216
+		return objs
217
+
218
+	def objects_for_keywords(self, search: str, permissions: Optional[Permissions]) -> list[HelpTopic]:
219
+		self.__create_help_index()
220
+
221
+		# Break into words (or word fragments)
222
+		words: list[str] = [
223
+			word.lower()
224
+			for word in re.split(r"[^a-zA-Z']+", search)
225
+			# if len(word) > 1 and word not in trivial_words
226
+		]
227
+
228
+		# FIXME: This is a super weird way of doing this. Converting word fragments
229
+		# to known indexed keywords, then collecting those associated results. Should
230
+		# just keep corpuses of searchable, normalized text for each topic and do a
231
+		# direct `in` test.
232
+		matching_objects_set = None
233
+		for word in words:
234
+			word_matches = set()
235
+			for k in self.keyword_index.keys():
236
+				if word in k:
237
+					objs = self.keyword_index.get(k, None)
238
+					if objs is not None:
239
+						word_matches.update(objs)
240
+			if matching_objects_set is None:
241
+				matching_objects_set = word_matches
242
+			else:
243
+				matching_objects_set = matching_objects_set & word_matches
244
+
245
+		# Filter by accessibility
246
+		accessible_objects = [
247
+			obj
248
+			for obj in matching_objects_set or {}
249
+			if ((isinstance(obj, Command) or isinstance(obj, Group)) and can_use_command(obj, permissions)) or \
250
+			   (isinstance(obj, BaseCog) and can_use_cog(obj, permissions))
251
+		]
252
+
253
+		# Sort and return
254
+		return sorted(accessible_objects, key=lambda obj: (
255
+			isinstance(obj, Command),
256
+			isinstance(obj, BaseCog),
257
+			obj.qualified_name if isinstance(obj, BaseCog) else obj.name
258
+		))
259
+
145 260
 	@command(name='help')
146 261
 	@guild_only()
147
-	@autocomplete(topic=topic_autocomplete, subtopic=subtopic_autocomplete)
148
-	async def help_command(self, interaction: Interaction, topic: Optional[str] = None, subtopic: Optional[str] = None) -> None:
262
+	@autocomplete(search=search_autocomplete)
263
+	async def help_command(self, interaction: Interaction, search: Optional[str]) -> None:
149 264
 		"""
150 265
 		Shows help for using commands and subcommands and configuring modules.
151 266
 
@@ -164,47 +279,20 @@ class HelpCog(BaseCog, name='Help'):
164 279
 		Parameters
165 280
 		----------
166 281
 		interaction: Interaction
167
-		topic: Optional[str]
168
-			optional command, module, or keywords to get specific help for
169
-		subtopic: Optional[str]
170
-			optional subcommand to get specific help for
282
+		search: Optional[str]
283
+			search terms
171 284
 		"""
172
-		print(f'help_command(interaction, {topic}, {subtopic})')
173
-
174
-		# General help
175
-		if topic is None:
285
+		if search is None:
176 286
 			await self.__send_general_help(interaction)
177 287
 			return
178
-
179
-		# Specific object reference
180
-		obj = self.object_for_help_symbol(subtopic) if subtopic else self.object_for_help_symbol(topic)
288
+		obj = self.object_for_help_symbol(search)
181 289
 		if obj:
182 290
 			await self.__send_object_help(interaction, obj)
183 291
 			return
292
+		matches = self.objects_for_keywords(search, interaction.permissions)
293
+		await self.__send_keyword_help(interaction, matches)
184 294
 
185
-		# Text search
186
-		keywords = [
187
-			word
188
-			for word in re.split(r"[^a-zA-Z']+", topic.lower())
189
-			if len(word) > 0 and word not in trivial_words
190
-		]
191
-		matching_objects_set = None
192
-		for keyword in keywords:
193
-			objs = self.keyword_index.get(keyword, None)
194
-			if objs is not None:
195
-				if matching_objects_set is None:
196
-					matching_objects_set = objs
197
-				else:
198
-					matching_objects_set = matching_objects_set & objs
199
-		accessible_objects = [
200
-			obj
201
-			for obj in matching_objects_set or {}
202
-			if ((isinstance(obj, Command) or isinstance(obj, Group)) and can_use_command(obj, interaction.permissions)) or \
203
-			   (isinstance(obj, BaseCog) and can_use_cog(obj, interaction.permissions))
204
-		]
205
-		await self.__send_keyword_help(interaction, accessible_objects)
206
-
207
-	async def __send_object_help(self, interaction: Interaction, obj: Union[Command, Group, BaseCog]) -> None:
295
+	async def __send_object_help(self, interaction: Interaction, obj: HelpTopic) -> None:
208 296
 		if isinstance(obj, Command):
209 297
 			if obj.parent:
210 298
 				await self.__send_subcommand_help(interaction, obj.parent, obj)
@@ -217,7 +305,7 @@ class HelpCog(BaseCog, name='Help'):
217 305
 		if isinstance(obj, BaseCog):
218 306
 			await self.__send_cog_help(interaction, obj)
219 307
 			return
220
-		print(f'No help for object {obj}')
308
+		self.log(interaction.guild, f'No help for object {obj}')
221 309
 		await interaction.response.send_message(
222 310
 			f'{CONFIG["failure_emoji"]} Failed to get help info.',
223 311
 			ephemeral=True,
@@ -273,7 +361,7 @@ class HelpCog(BaseCog, name='Help'):
273 361
 			ephemeral=True,
274 362
 		)
275 363
 
276
-	async def __send_keyword_help(self, interaction: Interaction, matching_objects: Optional[list[Union[Command, Group, BaseCog]]]) -> None:
364
+	async def __send_keyword_help(self, interaction: Interaction, matching_objects: Optional[list[HelpTopic]]) -> None:
277 365
 		matching_commands = [
278 366
 			cmd
279 367
 			for cmd in matching_objects or []
@@ -314,8 +402,6 @@ class HelpCog(BaseCog, name='Help'):
314 402
 		)
315 403
 
316 404
 	async def __send_command_help(self, interaction: Interaction, command_or_group: Union[Command, Group], addendum: Optional[str] = None) -> None:
317
-		if isinstance(command_or_group, Command):
318
-			print(f"Doc:\n{command_or_group.callback.__doc__}")
319 405
 		text = ''
320 406
 		if addendum is not None:
321 407
 			text += addendum + '\n\n'
@@ -375,7 +461,6 @@ class HelpCog(BaseCog, name='Help'):
375 461
 				if setting.name == 'enabled':
376 462
 					continue
377 463
 				text += f'\n- `/get` or `/set {cog.config_prefix}_{setting.name}` - {setting.brief}'
378
-		print(text)
379 464
 		await interaction.response.send_message(
380 465
 			text,
381 466
 			ephemeral=True,

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