Experimental Discord bot written in Python
Du kannst nicht mehr als 25 Themen auswählen Themen müssen mit entweder einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

generalcog.py 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. """
  2. Cog for handling most ungrouped commands and basic behaviors.
  3. """
  4. import re
  5. from datetime import datetime, timedelta, timezone
  6. from typing import Optional, Union
  7. from discord import Interaction, Message, User, Permissions, AppCommandType
  8. from discord.app_commands import Command, Group, command, default_permissions, guild_only, Transform, Choice, \
  9. autocomplete
  10. from discord.errors import DiscordException
  11. from discord.ext.commands import Cog
  12. from config import CONFIG
  13. from rocketbot.bot import Rocketbot
  14. from rocketbot.cogs.basecog import BaseCog, BotMessage
  15. from rocketbot.utils import describe_timedelta, TimeDeltaTransformer, dump_stacktrace, MOD_PERMISSIONS
  16. from rocketbot.storage import ConfigKey, Storage
  17. trivial_words = {
  18. 'a', 'an', 'and', 'are', "aren't", 'as', 'by', 'can', 'for', 'have', 'if', 'in',
  19. 'is', 'it', 'its', "it's", 'not', 'of', 'on', 'or', 'than', 'that', 'the', 'then',
  20. 'there', 'them', 'they', "they're", 'this', 'to', 'when', 'with',
  21. }
  22. async def command_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
  23. choices: list[Choice] = []
  24. try:
  25. if current.startswith('/'):
  26. current = current[1:]
  27. current = current.lower().strip()
  28. user_permissions = interaction.permissions
  29. cmds = GeneralCog.shared.get_command_list(user_permissions)
  30. return [
  31. Choice(name=f'/{cmdname} command', value=f'cmd:{cmdname}')
  32. for cmdname in sorted(cmds.keys())
  33. if len(current) == 0 or current in cmdname
  34. ][:25]
  35. except BaseException as e:
  36. dump_stacktrace(e)
  37. return choices
  38. async def subcommand_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
  39. try:
  40. current = current.lower().strip()
  41. cmd_name = interaction.namespace['topic']
  42. cmd = GeneralCog.shared.object_for_help_symbol(cmd_name)
  43. if not isinstance(cmd, Group):
  44. return []
  45. user_permissions = interaction.permissions
  46. if cmd is None or not isinstance(cmd, Group):
  47. print(f'No command found named {cmd_name}')
  48. return []
  49. grp = cmd
  50. subcmds = GeneralCog.shared.get_subcommand_list(grp, user_permissions)
  51. if subcmds is None:
  52. print(f'Subcommands for {cmd_name} was None')
  53. return []
  54. return [
  55. Choice(name=f'{subcmd_name} subcommand', value=f'subcmd:{cmd_name}.{subcmd_name}')
  56. for subcmd_name in sorted(subcmds.keys())
  57. if len(current) == 0 or current in subcmd_name
  58. ][:25]
  59. except BaseException as e:
  60. dump_stacktrace(e)
  61. return []
  62. async def cog_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
  63. try:
  64. current = current.lower().strip()
  65. return [
  66. Choice(name=f'⚙ {cog.qualified_name} module', value=f'cog:{cog.qualified_name}')
  67. for cog in sorted(GeneralCog.shared.bot.cogs.values(), key=lambda c: c.qualified_name)
  68. if isinstance(cog, BaseCog) and
  69. can_use_cog(cog, interaction.permissions) and
  70. (len(cog.get_commands()) > 0 or len(cog.settings) > 0) and \
  71. (len(current) == 0 or current in cog.qualified_name.lower())
  72. ]
  73. except BaseException as e:
  74. dump_stacktrace(e)
  75. return []
  76. async def topic_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
  77. command_choices = await command_autocomplete(interaction, current)
  78. cog_choices = await cog_autocomplete(interaction, current)
  79. return (command_choices + cog_choices)[:25]
  80. async def subtopic_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
  81. subcommand_choices = await subcommand_autocomplete(interaction, current)
  82. return subcommand_choices[:25]
  83. def can_use_command(cmd: Union[Group, Command], user_permissions: Optional[Permissions]) -> bool:
  84. if user_permissions is None:
  85. return False
  86. if cmd.parent and not can_use_command(cmd.parent, user_permissions):
  87. return False
  88. return cmd.default_permissions is None or cmd.default_permissions.is_subset(user_permissions)
  89. def can_use_cog(cog: BaseCog, user_permissions: Optional[Permissions]) -> bool:
  90. return user_permissions is not None and MOD_PERMISSIONS.is_subset(user_permissions)
  91. class GeneralCog(BaseCog, name='General'):
  92. """
  93. Cog for handling high-level bot functionality and commands. Should be the
  94. first cog added to the bot.
  95. """
  96. shared: Optional['GeneralCog'] = None
  97. def __init__(self, bot: Rocketbot):
  98. super().__init__(
  99. bot,
  100. config_prefix=None,
  101. short_description='',
  102. )
  103. self.is_connected = False
  104. self.is_first_connect = True
  105. self.last_disconnect_time: Optional[datetime] = None
  106. self.noteworthy_disconnect_duration = timedelta(seconds=5)
  107. GeneralCog.shared = self
  108. @Cog.listener()
  109. async def on_connect(self):
  110. """Event handler"""
  111. if self.is_first_connect:
  112. self.log(None, 'Connected')
  113. self.is_first_connect = False
  114. else:
  115. disconnect_duration = datetime.now(
  116. timezone.utc) - self.last_disconnect_time if self.last_disconnect_time else None
  117. if disconnect_duration is not None and disconnect_duration > self.noteworthy_disconnect_duration:
  118. self.log(None, f'Reconnected after {disconnect_duration.total_seconds()} seconds')
  119. self.is_connected = True
  120. @Cog.listener()
  121. async def on_disconnect(self):
  122. """Event handler"""
  123. self.last_disconnect_time = datetime.now(timezone.utc)
  124. # self.log(None, 'Disconnected')
  125. @Cog.listener()
  126. async def on_resumed(self):
  127. """Event handler"""
  128. disconnect_duration = datetime.now(timezone.utc) - self.last_disconnect_time if self.last_disconnect_time else None
  129. if disconnect_duration is not None and disconnect_duration > self.noteworthy_disconnect_duration:
  130. self.log(None, f'Session resumed after {disconnect_duration.total_seconds()} seconds')
  131. @command(
  132. description='Posts a test warning',
  133. extras={
  134. 'long_description': 'Tests whether a warning channel is configured for this ' + \
  135. 'guild by posting a test warning. If a mod mention is ' + \
  136. 'configured, that user/role will be tagged in the test warning.',
  137. },
  138. )
  139. @guild_only()
  140. @default_permissions(ban_members=True)
  141. async def test_warn(self, interaction: Interaction):
  142. """Command handler"""
  143. if Storage.get_config_value(interaction.guild, ConfigKey.WARNING_CHANNEL_ID) is None:
  144. await interaction.response.send_message(
  145. f'{CONFIG["warning_emoji"]} No warning channel set!',
  146. ephemeral=True,
  147. )
  148. else:
  149. bm = BotMessage(
  150. interaction.guild,
  151. f'Test warning message (requested by {interaction.user.name})',
  152. type=BotMessage.TYPE_MOD_WARNING)
  153. await self.post_message(bm)
  154. await interaction.response.send_message(
  155. 'Warning issued',
  156. ephemeral=True,
  157. )
  158. @command(
  159. description='Simple test reply',
  160. extras={
  161. 'long_description': 'Replies to the command message. Useful to ensure the ' + \
  162. 'bot is working properly.',
  163. },
  164. )
  165. async def hello(self, interaction: Interaction):
  166. """Command handler"""
  167. await interaction.response.send_message(
  168. f'Hey, {interaction.user.name}!',
  169. ephemeral=True,
  170. )
  171. @command(
  172. description='Shuts down the bot',
  173. extras={
  174. 'long_description': 'Causes the bot script to terminate. Only usable by a ' + \
  175. 'user with server admin permissions.',
  176. },
  177. )
  178. @guild_only()
  179. @default_permissions(administrator=True)
  180. async def shutdown(self, interaction: Interaction):
  181. """Command handler"""
  182. await interaction.response.send_message('👋', ephemeral=True)
  183. await self.bot.close()
  184. @command(
  185. description='Mass deletes messages',
  186. extras={
  187. 'long_description': 'Deletes recent messages by the given user. The user ' +
  188. 'can be either an @ mention or a numeric user ID. The age is ' +
  189. 'a duration, such as "30s", "5m", "1h30m". Only the most ' +
  190. 'recent 100 messages in each channel are searched.',
  191. 'usage': '<user:id|mention> <age:timespan>',
  192. },
  193. )
  194. @guild_only()
  195. @default_permissions(manage_messages=True)
  196. async def delete_messages(self, interaction: Interaction, user: User, age: Transform[timedelta, TimeDeltaTransformer]) -> None:
  197. """Command handler"""
  198. member_id = user.id
  199. cutoff: datetime = datetime.now(timezone.utc) - age
  200. def predicate(message: Message) -> bool:
  201. return str(message.author.id) == member_id and message.created_at >= cutoff
  202. deleted_messages = []
  203. for channel in interaction.guild.text_channels:
  204. try:
  205. deleted_messages += await channel.purge(limit=100, check=predicate)
  206. except DiscordException:
  207. # XXX: Sloppily glossing over access errors instead of checking access
  208. pass
  209. await interaction.response.send_message(
  210. f'{CONFIG["success_emoji"]} Deleted {len(deleted_messages)} ' + \
  211. f'messages by {user.mention}> from the past {describe_timedelta(age)}.',
  212. ephemeral=True,
  213. )
  214. def __create_help_index(self) -> None:
  215. if getattr(self, 'obj_index', None) is not None:
  216. return
  217. self.obj_index: dict[str, Union[Command, Group, BaseCog]] = {}
  218. self.keyword_index: dict[str, set[Union[Command, Group, BaseCog]]] = {}
  219. def add_text_to_index(obj, text: str):
  220. words = [
  221. word
  222. for word in re.split(r"[^a-zA-Z']+", text.lower())
  223. if len(word) > 1 and word not in trivial_words
  224. ]
  225. for word in words:
  226. matches = self.keyword_index.get(word, set())
  227. matches.add(obj)
  228. self.keyword_index[word] = matches
  229. for cmd in self.bot.tree.get_commands(type=AppCommandType.chat_input):
  230. key = f'cmd:{cmd.name}'
  231. self.obj_index[key] = cmd
  232. add_text_to_index(cmd, cmd.name)
  233. if cmd.description:
  234. add_text_to_index(cmd, cmd.description)
  235. if isinstance(cmd, Group):
  236. for subcmd in cmd.commands:
  237. key = f'subcmd:{cmd.name}.{subcmd.name}'
  238. self.obj_index[key] = subcmd
  239. add_text_to_index(subcmd, subcmd.name)
  240. if subcmd.description:
  241. add_text_to_index(subcmd, subcmd.description)
  242. for cog_qname, cog in self.bot.cogs.items():
  243. if not isinstance(cog, BaseCog):
  244. continue
  245. key = f'cog:{cog_qname}'
  246. self.obj_index[key] = cog
  247. add_text_to_index(cog, cog.qualified_name)
  248. if cog.description:
  249. add_text_to_index(cog, cog.description)
  250. print(self.obj_index.keys())
  251. print(self.keyword_index.keys())
  252. def object_for_help_symbol(self, symbol: str) -> Optional[Union[Command, Group, BaseCog]]:
  253. self.__create_help_index()
  254. return self.obj_index.get(symbol, None)
  255. @command(name='help')
  256. @guild_only()
  257. @autocomplete(topic=topic_autocomplete, subtopic=subtopic_autocomplete)
  258. async def help_command(self, interaction: Interaction, topic: Optional[str] = None, subtopic: Optional[str] = None) -> None:
  259. """
  260. Shows help for using commands and subcommands and configuring modules.
  261. `/help` will show a list of top-level topics.
  262. `/help /<command_name>` will show help about a specific command or
  263. list a command's subcommands.
  264. `/help /<command_name> <subcommand_name>` will show help about a
  265. specific subcommand.
  266. `/help <module_name>` will show help about configuring a module.
  267. `/help <keywords>` will do a text search for topics.
  268. Parameters
  269. ----------
  270. interaction: Interaction
  271. topic: Optional[str]
  272. Optional topic to get specific help for. Getting help on a command can optionally start with a leading slash.
  273. subtopic: Optional[str]
  274. Optional subtopic to get specific help for.
  275. """
  276. print(f'help_command(interaction, {topic}, {subtopic})')
  277. # General help
  278. if topic is None:
  279. await self.__send_general_help(interaction)
  280. return
  281. # Specific object reference
  282. obj = self.object_for_help_symbol(subtopic) if subtopic else self.object_for_help_symbol(topic)
  283. if obj:
  284. await self.__send_object_help(interaction, obj)
  285. return
  286. # Text search
  287. keywords = [
  288. word
  289. for word in re.split(r"[^a-zA-Z']+", topic.lower())
  290. if len(word) > 0 and word not in trivial_words
  291. ]
  292. matching_objects_set = None
  293. for keyword in keywords:
  294. objs = self.keyword_index.get(keyword, None)
  295. if objs is not None:
  296. if matching_objects_set is None:
  297. matching_objects_set = objs
  298. else:
  299. matching_objects_set = matching_objects_set & objs
  300. accessible_objects = [
  301. obj
  302. for obj in matching_objects_set or {}
  303. if ((isinstance(obj, Command) or isinstance(obj, Group)) and can_use_command(obj, interaction.permissions)) or \
  304. (isinstance(obj, BaseCog) and can_use_cog(obj, interaction.permissions))
  305. ]
  306. await self.__send_keyword_help(interaction, accessible_objects)
  307. async def __send_object_help(self, interaction: Interaction, obj: Union[Command, Group, BaseCog]) -> None:
  308. if isinstance(obj, Command):
  309. if obj.parent:
  310. await self.__send_subcommand_help(interaction, obj.parent, obj)
  311. else:
  312. await self.__send_command_help(interaction, obj)
  313. return
  314. if isinstance(obj, Group):
  315. await self.__send_command_help(interaction, obj)
  316. return
  317. if isinstance(obj, BaseCog):
  318. await self.__send_cog_help(interaction, obj)
  319. return
  320. print(f'No help for object {obj}')
  321. await interaction.response.send_message(
  322. f'{CONFIG["failure_emoji"]} Failed to get help info.',
  323. ephemeral=True,
  324. delete_after=10,
  325. )
  326. def get_command_list(self, permissions: Optional[Permissions] = None) -> dict[str, Union[Command, Group]]:
  327. return { cmd.name: cmd for cmd in self.bot.tree.get_commands() if can_use_command(cmd, permissions) }
  328. def get_subcommand_list(self, cmd: Group, permissions: Optional[Permissions] = None) -> dict[str, Command]:
  329. return {
  330. subcmd.name: subcmd
  331. for subcmd in cmd.commands
  332. if can_use_command(subcmd, permissions)
  333. } if can_use_command(cmd, permissions) else {}
  334. async def __send_general_help(self, interaction: Interaction) -> None:
  335. user_permissions: Permissions = interaction.permissions
  336. all_commands = sorted(self.get_command_list(user_permissions).items())
  337. all_cog_tuples = [
  338. cog_tuple
  339. for cog_tuple in sorted(self.bot.cogs.items())
  340. if isinstance(cog_tuple[1], BaseCog) and \
  341. can_use_cog(cog_tuple[1], user_permissions) and \
  342. (len(cog_tuple[1].settings) > 0)
  343. ]
  344. text = f'## :information_source: Help'
  345. if len(all_commands) + len(all_cog_tuples) == 0:
  346. text = 'Nothing available for your permissions!'
  347. if len(all_commands) > 0:
  348. text += '\n### Commands'
  349. text += '\nType `/help /commandname` for more information.'
  350. for cmd_name, cmd in sorted(self.get_command_list(user_permissions).items()):
  351. text += f'\n- `/{cmd_name}`: {cmd.description}'
  352. if isinstance(cmd, Group):
  353. subcommand_count = len(cmd.commands)
  354. text += f' ({subcommand_count} subcommands)'
  355. if len(all_cog_tuples) > 0:
  356. text += '\n### Module Configuration'
  357. for cog_name, cog in all_cog_tuples:
  358. has_enabled = next((s for s in cog.settings if s.name == 'enabled'), None) is not None
  359. text += f'\n- **{cog_name}**: {cog.short_description}'
  360. if has_enabled:
  361. text += f'\n - `/enable {cog.config_prefix}` or `/disable {cog.config_prefix}`'
  362. for setting in cog.settings:
  363. if setting.name == 'enabled':
  364. continue
  365. text += f'\n - `/get` or `/set {cog.config_prefix}_{setting.name}`'
  366. await interaction.response.send_message(
  367. text,
  368. ephemeral=True,
  369. )
  370. async def __send_keyword_help(self, interaction: Interaction, matching_objects: Optional[list[Union[Command, Group, BaseCog]]]) -> None:
  371. matching_commands = [
  372. cmd
  373. for cmd in matching_objects or []
  374. if isinstance(cmd, Command) or isinstance(cmd, Group)
  375. ]
  376. matching_cogs = [
  377. cog
  378. for cog in matching_objects or []
  379. if isinstance(cog, BaseCog)
  380. ]
  381. if len(matching_commands) + len(matching_cogs) == 0:
  382. await interaction.response.send_message(
  383. f'{CONFIG["failure_emoji"]} No available help topics found.',
  384. ephemeral=True,
  385. delete_after=10,
  386. )
  387. return
  388. if len(matching_objects) == 1:
  389. obj = matching_objects[0]
  390. await self.__send_object_help(interaction, obj)
  391. return
  392. text = '## :information_source: Matching Help Topics'
  393. if len(matching_commands) > 0:
  394. text += '\n### Commands'
  395. for cmd in matching_commands:
  396. if cmd.parent:
  397. text += f'\n- `/{cmd.parent.name} {cmd.name}`'
  398. else:
  399. text += f'\n- `/{cmd.name}`'
  400. if len(matching_cogs) > 0:
  401. text += '\n### Cogs'
  402. for cog in matching_cogs:
  403. text += f'\n- {cog.qualified_name}'
  404. await interaction.response.send_message(
  405. text,
  406. ephemeral=True,
  407. )
  408. async def __send_command_help(self, interaction: Interaction, command_or_group: Union[Command, Group], addendum: Optional[str] = None) -> None:
  409. text = ''
  410. if addendum is not None:
  411. text += addendum + '\n\n'
  412. text += f'## :information_source: Command Help\n`/{command_or_group.name}`\n\n{command_or_group.description}'
  413. if isinstance(command_or_group, Group):
  414. subcmds: dict[str, Command] = self.get_subcommand_list(command_or_group, permissions=interaction.permissions)
  415. if len(subcmds) > 0:
  416. text += '\n\n### Subcommands:'
  417. for subcmd_name, subcmd in sorted(subcmds.items()):
  418. text += f'\n- `{subcmd_name}`: {subcmd.description}'
  419. else:
  420. params = command_or_group.parameters
  421. if len(params) > 0:
  422. text += '\n\n### Parameters:'
  423. for param in params:
  424. text += f'\n- `{param.name}`: {param.description}'
  425. await interaction.response.send_message(text, ephemeral=True)
  426. async def __send_subcommand_help(self, interaction: Interaction, group: Group, subcommand: Command) -> None:
  427. text = f'## :information_source: Subcommand Help\n`/{group.name} {subcommand.name}`\n\n{subcommand.description}\n\n{subcommand.description}'
  428. params = subcommand.parameters
  429. if len(params) > 0:
  430. text += '\n\n### Parameters:'
  431. for param in params:
  432. text += f'\n- `{param.name}`: {param.description}'
  433. await interaction.response.send_message(text, ephemeral=True)
  434. async def __send_cog_help(self, interaction: Interaction, cog: BaseCog) -> None:
  435. text = f'## :information_source: Module Help\n{cog.qualified_name}'
  436. if cog.description is not None:
  437. text += f'\n{cog.description}'
  438. settings = cog.settings
  439. if len(settings) > 0:
  440. text += '\n### Configuration'
  441. enabled_setting = next((s for s in settings if s.name == 'enabled'), None)
  442. if enabled_setting is not None:
  443. text += f'\n- `/enable {cog.config_prefix}` or `/disable {cog.config_prefix}`'
  444. for setting in sorted(settings, key=lambda s: s.name):
  445. if setting.name == 'enabled':
  446. continue
  447. text += f'\n- `/get` or `/set {cog.config_prefix}_{setting.name}` - {setting.description}'
  448. await interaction.response.send_message(
  449. text,
  450. ephemeral=True,
  451. )