Experimental Discord bot written in Python
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

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