import re from typing import Optional, TypedDict from discord import Interaction, Guild, Message, TextChannel from discord.app_commands import Group from discord.ext.commands import Cog 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, update_paged_content, paginate from rocketbot.utils import MOD_PERMISSIONS, blockquote_markdown, indent_markdown _CURRENT_DATA_VERSION = 1 class BangCommand(TypedDict): content: str mod_only: bool version: int class BangCommandCog(BaseCog, name='Bang Commands'): SETTING_COMMANDS = CogSetting( name='commands', datatype=dict[str, BangCommand], default_value={}, ) 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 messages starting with an exclamation ' '(bang) that will make the bot respond with simple informational replies. ' 'Useful for posting answers to frequently asked questions, reminding users ' 'of rules, and similar.' ) def _get_commands(self, guild: Guild) -> dict[str, BangCommand]: return self.get_guild_setting(guild, BangCommandCog.SETTING_COMMANDS) def _set_commands(self, guild: Guild, commands: dict[str, BangCommand]) -> None: self.set_guild_setting(guild, BangCommandCog.SETTING_COMMANDS, commands) bang = Group( name='bangcommand', description='Provides custom informational chat !commands.', guild_only=True, default_permissions=MOD_PERMISSIONS, ) @bang.command() async def define(self, interaction: Interaction, name: str, definition: str, 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 """ if not BangCommandCog._is_valid_name(name): self.log(interaction.guild, f'{interaction.user.name} used command /bangcommand define {name} {definition}') await interaction.response.send_message( f'{CONFIG["failure_emoji"]} Invalid command name. Names must consist of lowercase letters, underscores, and hyphens (no spaces).', ephemeral=True, ) return name = BangCommandCog._normalize_name(name) cmds = self._get_commands(interaction.guild) cmds[name] = { 'content': definition, 'mod_only': mod_only, 'version': _CURRENT_DATA_VERSION, } self._set_commands(interaction.guild, cmds) await interaction.response.send_message( f'{CONFIG["success_emoji"]} Command `!{name}` has been defined.\n\n{blockquote_markdown(definition)}', ephemeral=True, ) @bang.command() async def undefine(self, interaction: Interaction, name: str) -> None: """ Removes a bang command. Parameters ---------- interaction: Interaction name: string name of the previously defined command """ name = BangCommandCog._normalize_name(name) cmds = self._get_commands(interaction.guild) if name not in cmds: await interaction.response.send_message( f'{CONFIG["failure_emoji"]} Command `!{name}` does not exist.', ephemeral=True, ) return del cmds[name] self._set_commands(interaction.guild, cmds) await interaction.response.send_message( f'{CONFIG["success_emoji"]} Command `!{name}` removed.', ephemeral=True, ) @bang.command() async def list(self, interaction: Interaction) -> None: """ Lists all defined bang commands. Parameters ---------- interaction: Interaction """ cmds = self._get_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, ) 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) @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 if content is None or not content.startswith('!') or not BangCommandCog._is_valid_name(content): return name = BangCommandCog._normalize_name(content) cmds = self._get_commands(message.guild) cmd = cmds.get(name, None) 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: if name is None: return False return re.match(r'^!?([a-z]+)([_-][a-z]+)*$', name) is not None