|
|
@@ -1,6 +1,7 @@
|
|
1
|
1
|
"""Provides help commands for getting info on using other commands and configuration."""
|
|
2
|
2
|
import re
|
|
3
|
|
-from typing import Union, Optional
|
|
|
3
|
+import time
|
|
|
4
|
+from typing import Union, Optional, TypedDict
|
|
4
|
5
|
|
|
5
|
6
|
from discord import Interaction, Permissions, AppCommandType, ButtonStyle, InteractionResponse
|
|
6
|
7
|
from discord.app_commands import Group, Command, autocomplete, guild_only, command, Choice
|
|
|
@@ -12,6 +13,10 @@ from rocketbot.cogs.basecog import BaseCog
|
|
12
|
13
|
from rocketbot.utils import MOD_PERMISSIONS, dump_stacktrace
|
|
13
|
14
|
|
|
14
|
15
|
HelpTopic = Union[Command, Group, BaseCog]
|
|
|
16
|
+class HelpMeta(TypedDict):
|
|
|
17
|
+ id: str
|
|
|
18
|
+ text: str
|
|
|
19
|
+ topic: HelpTopic
|
|
15
|
20
|
|
|
16
|
21
|
# Potential place to break text neatly in large help content
|
|
17
|
22
|
PAGE_BREAK = '\f'
|
|
|
@@ -39,7 +44,7 @@ async def search_autocomplete(interaction: Interaction, current: str) -> list[Ch
|
|
39
|
44
|
return [
|
|
40
|
45
|
choice_from_topic(topic, include_full_command=True)
|
|
41
|
46
|
for topic in HelpCog.shared.topics_for_keywords(current, interaction.permissions)
|
|
42
|
|
- ]
|
|
|
47
|
+ ][:25]
|
|
43
|
48
|
except BaseException as e:
|
|
44
|
49
|
dump_stacktrace(e)
|
|
45
|
50
|
return []
|
|
|
@@ -57,53 +62,60 @@ class HelpCog(BaseCog, name='Help'):
|
|
57
|
62
|
|
|
58
|
63
|
def __create_help_index(self) -> None:
|
|
59
|
64
|
"""
|
|
60
|
|
- Populates self.topic_index and self.keyword_index. Bails if already
|
|
|
65
|
+ Populates self.id_to_topic and self.keyword_index. Bails if already
|
|
61
|
66
|
populated. Intended to be run on demand so all cogs and commands have
|
|
62
|
67
|
had time to get set up and synced.
|
|
63
|
68
|
"""
|
|
64
|
|
- if getattr(self, 'topic_index', None) is not None:
|
|
|
69
|
+ if getattr(self, 'id_to_topic', None) is not None:
|
|
65
|
70
|
return
|
|
66
|
|
- self.topic_index: dict[str, HelpTopic] = {}
|
|
67
|
|
- self.keyword_index: dict[str, set[HelpTopic]] = {}
|
|
|
71
|
+ self.id_to_topic: dict[str, HelpTopic] = {}
|
|
|
72
|
+ self.topics: list[HelpMeta] = []
|
|
68
|
73
|
|
|
69
|
|
- def add_text_to_index(topic: HelpTopic, text: str):
|
|
70
|
|
- words = [
|
|
|
74
|
+ def process_text(t: str) -> str:
|
|
|
75
|
+ return ' '.join([
|
|
71
|
76
|
word
|
|
72
|
|
- for word in re.split(r"[^a-zA-Z']+", text.lower())
|
|
73
|
|
- if len(word) > 1 and word not in trivial_words
|
|
74
|
|
- ]
|
|
75
|
|
- for word in words:
|
|
76
|
|
- matches = self.keyword_index.get(word, set())
|
|
77
|
|
- matches.add(topic)
|
|
78
|
|
- self.keyword_index[word] = matches
|
|
|
77
|
+ for word in re.split(r"[^a-z']+", t.lower())
|
|
|
78
|
+ if word not in trivial_words
|
|
|
79
|
+ ]).strip()
|
|
79
|
80
|
|
|
80
|
81
|
cmds = self.all_commands()
|
|
81
|
82
|
for cmd in cmds:
|
|
82
|
|
- self.topic_index[f'cmd:{cmd.name}'] = cmd
|
|
83
|
|
- self.topic_index[f'/{cmd.name}'] = cmd
|
|
84
|
|
- add_text_to_index(cmd, cmd.name)
|
|
|
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
|
|
85
|
87
|
if cmd.description:
|
|
86
|
|
- add_text_to_index(cmd, 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 })
|
|
87
|
92
|
if isinstance(cmd, Group):
|
|
88
|
93
|
for subcmd in cmd.commands:
|
|
89
|
|
- self.topic_index[f'subcmd:{cmd.name}.{subcmd.name}'] = subcmd
|
|
90
|
|
- self.topic_index[f'/{cmd.name} {subcmd.name}'] = subcmd
|
|
91
|
|
- add_text_to_index(subcmd, cmd.name)
|
|
92
|
|
- add_text_to_index(subcmd, subcmd.name)
|
|
|
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}'
|
|
93
|
99
|
if subcmd.description:
|
|
94
|
|
- add_text_to_index(subcmd, 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 })
|
|
95
|
104
|
for cog_qname, cog in self.bot.cogs.items():
|
|
96
|
105
|
if not isinstance(cog, BaseCog):
|
|
97
|
106
|
continue
|
|
98
|
107
|
key = f'cog:{cog_qname}'
|
|
99
|
|
- self.topic_index[key] = cog
|
|
100
|
|
- add_text_to_index(cog, cog.qualified_name)
|
|
101
|
|
- if cog.description:
|
|
102
|
|
- add_text_to_index(cog, cog.description)
|
|
|
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 })
|
|
103
|
115
|
|
|
104
|
116
|
def topic_for_help_symbol(self, symbol: str) -> Optional[HelpTopic]:
|
|
105
|
117
|
self.__create_help_index()
|
|
106
|
|
- return self.topic_index.get(symbol, None)
|
|
|
118
|
+ return self.id_to_topic.get(symbol, None)
|
|
107
|
119
|
|
|
108
|
120
|
def all_commands(self) -> list[Union[Command, Group]]:
|
|
109
|
121
|
# PyCharm not interpreting conditional return type correctly.
|
|
|
@@ -149,46 +161,41 @@ class HelpCog(BaseCog, name='Help'):
|
|
149
|
161
|
return topics
|
|
150
|
162
|
|
|
151
|
163
|
def topics_for_keywords(self, search: str, permissions: Optional[Permissions]) -> list[HelpTopic]:
|
|
|
164
|
+ start_time = time.perf_counter()
|
|
152
|
165
|
self.__create_help_index()
|
|
153
|
166
|
|
|
154
|
167
|
# Break into words (or word fragments)
|
|
155
|
168
|
words: list[str] = [
|
|
156
|
|
- word.lower()
|
|
157
|
|
- for word in re.split(r"[^a-zA-Z']+", search)
|
|
158
|
|
- # if len(word) > 1 and word not in trivial_words
|
|
|
169
|
+ word
|
|
|
170
|
+ for word in re.split(r"[^a-z']+", search.lower())
|
|
159
|
171
|
]
|
|
160
|
172
|
|
|
161
|
|
- # FIXME: This is a super weird way of doing this. Converting word fragments
|
|
162
|
|
- # to known indexed keywords, then collecting those associated results. Should
|
|
163
|
|
- # just keep corpuses of searchable, normalized text for each topic and do a
|
|
164
|
|
- # direct `in` test.
|
|
165
|
|
- matching_topics_set = None
|
|
166
|
|
- for word in words:
|
|
167
|
|
- word_matches = set()
|
|
168
|
|
- for k in self.keyword_index.keys():
|
|
169
|
|
- if word in k:
|
|
170
|
|
- topics = self.keyword_index.get(k, None)
|
|
171
|
|
- if topics is not None:
|
|
172
|
|
- word_matches.update(topics)
|
|
173
|
|
- if matching_topics_set is None:
|
|
174
|
|
- matching_topics_set = word_matches
|
|
175
|
|
- else:
|
|
176
|
|
- matching_topics_set = matching_topics_set & word_matches
|
|
|
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) ]
|
|
177
|
180
|
|
|
178
|
181
|
# Filter by accessibility
|
|
179
|
182
|
accessible_topics = [
|
|
180
|
183
|
topic
|
|
181
|
|
- for topic in matching_topics_set or {}
|
|
|
184
|
+ for topic in matching_topics
|
|
182
|
185
|
if ((isinstance(topic, Command) or isinstance(topic, Group)) and can_use_command(topic, permissions)) or \
|
|
183
|
186
|
(isinstance(topic, BaseCog) and can_use_cog(topic, permissions))
|
|
184
|
187
|
]
|
|
185
|
188
|
|
|
186
|
189
|
# Sort and return
|
|
187
|
|
- return sorted(accessible_topics, key=lambda topic: (
|
|
|
190
|
+ result = sorted(accessible_topics, key=lambda topic: (
|
|
188
|
191
|
isinstance(topic, Command),
|
|
189
|
192
|
isinstance(topic, BaseCog),
|
|
190
|
193
|
topic.qualified_name if isinstance(topic, BaseCog) else topic.name
|
|
191
|
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
|
|
192
|
199
|
|
|
193
|
200
|
@command(
|
|
194
|
201
|
name='help',
|