| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352 |
- import re
- from typing import Optional, TypedDict
-
- from discord import Guild, Interaction, Message, SelectOption, TextChannel, TextStyle
- from discord.app_commands import Choice, Group, autocomplete
- from discord.ext.commands import Cog
- from discord.ui import Label, Modal, Select, TextInput
-
- from config import CONFIG
- from rocketbot.bot import Rocketbot
- from rocketbot.cogs.basecog import BaseCog
- from rocketbot.cogsetting import CogSetting
- from rocketbot.ui.pagedcontent import PAGE_BREAK, paginate, update_paged_content
- from rocketbot.utils import (
- MOD_PERMISSIONS,
- blockquote_markdown,
- dump_stacktrace,
- indent_markdown,
- )
-
- _CURRENT_DATA_VERSION = 1
- _MAX_CONTENT_LENGTH = 2000
-
- class BangCommand(TypedDict):
- content: str
- mod_only: bool
- version: int
-
- async def command_autocomplete(interaction: Interaction, text: str) -> list[Choice[str]]:
- cmds = BangCommandCog.shared.get_saved_commands(interaction.guild)
- return [
- Choice(name=f'!{name}', value=name)
- for name, cmd in sorted(cmds.items())
- if len(text) == 0 or text.lower() in name
- ]
-
- class BangCommandCog(BaseCog, name='Bang Commands'):
- SETTING_COMMANDS = CogSetting(
- name='commands',
- datatype=dict[str, BangCommand],
- default_value={},
- )
-
- shared: Optional['BangCommandCog'] = None
-
- def __init__(self, bot: Rocketbot):
- super().__init__(
- bot,
- config_prefix='bangcommand',
- short_description='Provides custom informational chat !commands.',
- long_description='Bang commands are simple one-word chat messages starting with an exclamation '
- '(a "bang") that will make the bot respond with simple informational replies. '
- 'This functionality is similar to Twitch bots. Useful for posting answers to '
- 'frequently asked questions, reminding users of rules, and similar canned responses. '
- 'Commands can be individually made mod-only or usable by anyone.'
- )
- BangCommandCog.shared = self
-
- def get_saved_commands(self, guild: Guild) -> dict[str, BangCommand]:
- return self.get_guild_setting(guild, BangCommandCog.SETTING_COMMANDS)
-
- def get_saved_command(self, guild: Guild, name: str) -> Optional[BangCommand]:
- cmds = self.get_saved_commands(guild)
- name = BangCommandCog._normalize_name(name)
- return cmds.get(name, None)
-
- def set_saved_commands(self, guild: Guild, commands: dict[str, BangCommand]) -> None:
- self.set_guild_setting(guild, BangCommandCog.SETTING_COMMANDS, commands)
-
- bang = Group(
- name='command',
- description='Provides custom informational chat !commands.',
- guild_only=True,
- default_permissions=MOD_PERMISSIONS,
- )
-
- @bang.command(
- name='define',
- extras={
- 'long_description': 'Simple one-line content can be specified in the command. '
- 'For multi-line content, run the command without content '
- 'specified to use the editor popup.'
- }
- )
- @autocomplete(name=command_autocomplete)
- async def define_command(self, interaction: Interaction, name: str, definition: Optional[str] = None, mod_only: bool = False) -> None:
- """
- Defines or redefines a bang command.
-
- Parameters
- ----------
- interaction: Interaction
- name: string
- name of the command (lowercase a-z, underscores, and hyphens)
- definition: string
- content of the command
- mod_only: bool
- whether the command will only be recognized when a mod uses it
- """
- self.log(interaction.guild, f'{interaction.user.name} used command /bangcommand define {name} {definition} {mod_only}')
- name = BangCommandCog._normalize_name(name)
- if definition is None:
- cmd = self.get_saved_command(interaction.guild, name)
- await interaction.response.send_modal(
- _EditModal(
- name,
- content=cmd['content'] if cmd else None,
- mod_only=cmd['mod_only'] if cmd else None,
- exists=cmd is not None,
- )
- )
- return
- try:
- self.define(interaction.guild, name, definition, mod_only)
- await interaction.response.send_message(
- f'{CONFIG["success_emoji"]} Command `!{name}` has been defined.\n\n{blockquote_markdown(definition)}',
- ephemeral=True,
- )
- except ValueError as e:
- await interaction.response.send_message(
- f'{CONFIG["failure_emoji"]} {e}',
- ephemeral=True,
- )
- return
-
- @bang.command(
- name='undefine'
- )
- @autocomplete(name=command_autocomplete)
- async def undefine_command(self, interaction: Interaction, name: str) -> None:
- """
- Removes a bang command.
-
- Parameters
- ----------
- interaction: Interaction
- name: string
- name of the previously defined command
- """
- try:
- self.undefine(interaction.guild, name)
- await interaction.response.send_message(
- f'{CONFIG["success_emoji"]} Command `!{name}` removed.',
- ephemeral=True,
- )
- except ValueError as e:
- await interaction.response.send_message(
- f'{CONFIG["failure_emoji"]} {e}',
- ephemeral=True,
- )
-
- @bang.command(
- name='list'
- )
- async def list_command(self, interaction: Interaction) -> None:
- """
- Lists all defined bang commands.
-
- Parameters
- ----------
- interaction: Interaction
- """
- cmds = self.get_saved_commands(interaction.guild)
- if cmds is None or len(cmds) == 0:
- await interaction.response.send_message(
- f'{CONFIG["info_emoji"]} No commands defined.',
- ephemeral=True,
- delete_after=15,
- )
- return
- text = '## Commands'
- for name, cmd in sorted(cmds.items()):
- text += PAGE_BREAK + f'\n- `!{name}`'
- if cmd['mod_only']:
- text += ' - **mod only**'
- text += f'\n{indent_markdown(cmd["content"])}'
- pages = paginate(text)
- await update_paged_content(interaction, None, 0, pages)
-
- @bang.command(
- name='invoke',
- extras={
- 'long_description': 'Useful when you do not want the command to show up in chat.',
- }
- )
- @autocomplete(name=command_autocomplete)
- async def invoke_command(self, interaction: Interaction, name: str) -> None:
- """
- Invokes a bang command without typing it in chat.
-
- Parameters
- ----------
- interaction: Interaction
- name: string
- the bang command name
- """
- cmd = self.get_saved_command(interaction.guild, name)
- if cmd is None:
- await interaction.response.send_message(
- f'{CONFIG["failure_emoji"]} Command `!{name}` does not exist.',
- ephemeral=True,
- )
- return
- resp = await interaction.response.defer(ephemeral=True, thinking=False)
- await interaction.channel.send(
- cmd['content']
- )
- if resp.resource:
- await resp.resource.delete()
-
- def define(self, guild: Guild, name: str, content: str, mod_only: bool, check_exists: bool = False) -> None:
- if not BangCommandCog._is_valid_name(name):
- raise ValueError('Invalid command name. Must consist of lowercase letters, underscores, and hyphens (no spaces).')
- name = BangCommandCog._normalize_name(name)
- if len(content) < 1 or len(content) > 2000:
- raise ValueError(f'Content must be between 1 and {_MAX_CONTENT_LENGTH} characters.')
- cmds = self.get_saved_commands(guild)
- if check_exists:
- if cmds.get(name, None) is not None:
- raise ValueError(f'Command with name "{name}" already exists.')
- cmds[name] = {
- 'content': content,
- 'mod_only': mod_only,
- 'version': _CURRENT_DATA_VERSION,
- }
- self.set_saved_commands(guild, cmds)
-
- def undefine(self, guild: Guild, name: str) -> None:
- name = BangCommandCog._normalize_name(name)
- cmds = self.get_saved_commands(guild)
- if cmds.get(name, None) is None:
- raise ValueError(f'Command with name "{name}" does not exist.')
- del cmds[name]
- self.set_saved_commands(guild, cmds)
-
- @Cog.listener()
- async def on_message(self, message: Message) -> None:
- if message.guild is None or message.channel is None or not isinstance(message.channel, TextChannel):
- return
- content = message.content
- name = BangCommandCog._name_from_command_message(content)
- if name is None:
- return
- cmd = self.get_saved_command(message.guild, name)
- if cmd is None:
- return
- if cmd['mod_only'] and not message.author.guild_permissions.ban_members:
- return
- text = cmd["content"]
- # text = f'{text}\n\n-# {message.author.name} used `!{name}`'
- await message.channel.send(
- text,
- )
-
- @staticmethod
- def _normalize_name(name: str) -> str:
- name = name.lower().strip()
- if name.startswith('!'):
- name = name[1:]
- return name
-
- @staticmethod
- def _is_valid_name(name: Optional[str]) -> bool:
- return name is not None and re.match(r'^!?([a-z]+)([_-][a-z]+)*$', name) is not None
-
- @staticmethod
- def _name_from_command_message(name: Optional[str]) -> Optional[str]:
- if name is None:
- return None
- match = re.match(r'^!?((?:[a-z]+)(?:[_-][a-z]+)*)\b.*$', name)
- return BangCommandCog._normalize_name(match.group(1)) if match else None
-
- class _EditModal(Modal, title='Edit Command'):
- name_label = Label(
- text='Command name',
- description='What gets typed in chat to trigger the command. Must be a-z, underscores, and hyphens (no spaces).',
- component=TextInput(
- style=TextStyle.short, # one line
- placeholder='!command_name',
- min_length=1,
- max_length=100,
- )
- )
- content_label = Label(
- text='Content',
- description='The text the bot will respond with when someone uses the command. Can contain markdown.',
- component=TextInput(
- style=TextStyle.paragraph,
- placeholder='Lorem ipsum dolor...',
- min_length=1,
- max_length=2000,
- )
- )
- mod_only_label = Label(
- text='Mod only?',
- description='Whether mods are the only users who can invoke this command.',
- component=Select(
- options=[
- SelectOption(label='No', value='False',
- description='Anyone can invoke this command.'),
- SelectOption(label='Yes', value='True',
- description='Only mods can invoke this command.'),
- ],
- )
- )
-
- def __init__(self, name: Optional[str] = None, content: Optional[str] = None, mod_only: Optional[bool] = None, exists: bool = False):
- super().__init__()
- self.exists = exists
- # noinspection PyTypeChecker
- name_input: TextInput = self.name_label.component
- # noinspection PyTypeChecker
- content_input: TextInput = self.content_label.component
- # noinspection PyTypeChecker
- mod_only_input: Select = self.mod_only_label.component
- name_input.default = name
- content_input.default = content
- resolved_mod_only = mod_only if mod_only is not None else False
- mod_only_input.options[0].default = not resolved_mod_only
- mod_only_input.options[1].default = resolved_mod_only
-
- async def on_submit(self, interaction: Interaction) -> None:
- # noinspection PyTypeChecker
- name_input: TextInput = self.name_label.component
- # noinspection PyTypeChecker
- content_input: TextInput = self.content_label.component
- # noinspection PyTypeChecker
- mod_only_input: Select = self.mod_only_label.component
- name = name_input.value
- content = content_input.value
- mod_only = mod_only_input.values[0] == 'True'
- try:
- BangCommandCog.shared.define(interaction.guild, name, content, mod_only, not self.exists)
- await interaction.response.send_message(
- f'{CONFIG["success_emoji"]} Command `!{name}` has been defined.\n\n{blockquote_markdown(content)}',
- ephemeral=True,
- )
- except ValueError as e:
- await interaction.response.send_message(
- f'{CONFIG["failure_emoji"]} {e}',
- ephemeral=True,
- )
-
- async def on_error(self, interaction: Interaction, error: Exception) -> None:
- dump_stacktrace(error)
- try:
- await interaction.response.send_message(
- f'{CONFIG["failure_emoji"]} Save failed',
- ephemeral=True,
- )
- except BaseException:
- pass
|