Package fslash

PyPI PyPI - Python Version PyPI - Downloads PyPI - License

discord-ext-fslash

This library registers commands from discord.py command framework as slash commands in discord.py as well by doing a monkey patch.

WARNING
Again, the way this library works is a monkey patch and is not without the possibility of unexpected behavior.

Features

It supports both the cooldown and other decorators of the command framework and the describe decorator of the app command to work.
The converter will automatically replace to the Transformer of slash version.
It can also automatically convert invalid annotations to str and register overnested group commands by accepting subcommands from command arguments.
Even if you have too many commands and reach the maximum number of can be registered slash commands, we have a way to deal with it. (An example is below)
If you are planning to create a bot, we recommend using HybridCommand, which comes standard in discord.py.

Installation

$ pip install discord-ext-fslash

Example

Normal

The following is an example of creating a command called ping that works with both slash and message commands.

from discord.ext.fslash import extend_force_slash

...

bot = extend_force_slash(commands.Bot(command_prefix="fs!", intents=intents))

@bot.command()
async def ping(ctx):
    await ctx.reply("pong")

Split Commands by Categories

Even if there are too many commands in the command framework to register in the slash, it provides a way to implement them all by making only the commands in the slash subcommands of the group command.
The following example creates the group commands server-tool and entertainment and sets up the commands in the command framework as subcommands of those commands.

from discord.ext.fslash import extend_force_slash

...

bot = extend_force_slash(
    commands.Bot(command_prefix="fs!", intents=intents),
    first_groups=(
        discord.app_commands.Group(name="server-tool"),
        discord.app_commands.Group(name="entertainment")
    )
)

@bot.command(description="Ban member", fsparent="server-tool")
@commands.has_guild_permissions(ban_members=True)
@commands.bot_has_guild_permissions(ban_members=True)
@commands.cooldown(1, 10, commands.BucketType.guild)
@discord.app_commands.describe(member="Member to be banned")
async def ban(ctx, *, member: discord.Member):
    # `/server-tool ban member: ...` or `fs!ban ...` to run this command.
    await ctx.typing()
    await member.ban()
    await ctx.reply("pong")

@bot.command(description="Make wow", fsparent="entertainment")
async def wow(ctx):
    # `/entertainment wow` or `fs!wow` to run this command.
    await ctx.reply("wow")

Documentation

There are features to improve compatibility for greater convenience.
See the documentation for details.

Contributing

Issues and PullRequests should be brief in content.
The code should be similar in style to the current code.
Please limit all text to 100 characters per line if possible, except for comments.
For comments, please limit to 200 characters per line if possible.

License

MIT

Expand source code
".. include:: ../../../README.md" # discord-ext-fslash by tasuren

from __future__ import annotations

from typing import Callable, Iterable, Literal, Union, Optional, Any, DefaultDict

from asyncio import gather
import inspect

from collections import defaultdict
from string import octdigits
from re import sub

from discord.ext import commands
from discord import app_commands
import discord

from .types_ import AdjustmentNameMode, ContextMode, BotT
from .context import Context, is_fslash


__all__ = (
    "extend_force_slash", "is_fslash", "Context",
    "groups", "exceptions", "adjustment_command_name"
)
__version__ = "0.2.1"
__author__ = "tasuren"


VALID_COMMAND_NAME_CHARACTERS_WITHOUT_LETTERS = f"{octdigits}-_"
def adjustment_command_name(name: str, mode: AdjustmentNameMode) -> str:
    """Prepares the passed string into a string that can be used as the name of a slash command.

    Parameters
    ----------
    name : str
        Adjustment target.
    mode : AdjustmentNameMode
        It is either a snake case or a kebab case."""
    sandwiched = "_" if mode == AdjustmentNameMode.SNAKE_CASE else "-"
    return "".join(
        char
        for char in sub(
            "(.[A-Z])", lambda x:
                f"{x.group(1)[0]}{sandwiched}"
                f"{x.group(1)[1]}",
            name.lower()
        ).lower()
        if char in VALID_COMMAND_NAME_CHARACTERS_WITHOUT_LETTERS
    )[:32]


_bot = None
_context_kwargs = {}
_ctx_mode = ContextMode.UNOFFICIAL


async def _make_context(
    interaction: discord.Interaction,
    kwargs, command, bot, **other
) -> Context | commands.Context:
    if _ctx_mode == ContextMode.OFFICIAL:
        ctx = await commands.Context.from_interaction(interaction)
        ctx.kwargs = kwargs
        ctx.command = command
        ctx.subcommand_passed = None
        ctx.invoked_subcommand = None
        ctx.command_failed = False
        ctx.invoked_parents = []
        ctx.invoked_with = None
        ctx.__fslash__ = True
        return ctx
    else:
        return Context(interaction, kwargs, command, bot, **other)


# ConverterのアノテーションをTransformerに交換するようにする。
_original_evaluate_annotation = discord.utils.evaluate_annotation
def _new_evaluate_annotation(*args, **kwargs):
    annotation = _original_evaluate_annotation(*args, **kwargs)
    transform = None
    if commands.Converter in getattr(annotation, "__mro__", ()):
        converter = annotation()
        async def transform(_, interaction, value: str):
            return await converter.convert(
                await _make_context(interaction, {}, None, _bot, **_context_kwargs), value
            )
    if inspect.isfunction(annotation):
        # 関数のコンバーターを実行するTransformerを作る。
        converter = annotation
        async def transform(_, __, value):
            return await converter(value) \
                if inspect.iscoroutinefunction(converter) \
                else converter(value)
    if transform is not None:
        annotation = app_commands.Transform[None, type(
            "ConverterTransformer", (app_commands.Transformer,),
            {
                "__fslash_original_annotation__": staticmethod(annotation),
                "transform": classmethod(transform)
            }
        )]
    return annotation
discord.utils.evaluate_annotation = _new_evaluate_annotation


_original_atp = app_commands.transformers.annotation_to_parameter
_original_signature = inspect.signature
def _replace_atp(toggle: bool, _: Optional[dict] = None, riats: bool = False):
    # `annotation_to_parameter`の実行が失敗した際に`str`として扱うようにする関数です。
    # それと、inspectの`signature`もアノテーションがない場合は拡張します。
    if toggle:
        def new_atp(annotation, parameter):
            if riats:
                try:
                    return _original_atp(annotation, parameter)
                except Exception:
                    # 失敗したなら`str`のアノテーションにする。
                    if parameter.kind in (
                        parameter.POSITIONAL_ONLY, parameter.VAR_KEYWORD, parameter.VAR_POSITIONAL
                    ):
                        parameter = parameter.replace(kind=parameter.KEYWORD_ONLY)
                    return _original_atp(str, parameter)
            else:
                return _original_atp(annotation, parameter)
        app_commands.transformers.annotation_to_parameter = new_atp
        app_commands.commands.annotation_to_parameter = new_atp

        def new_signature(*args, **kwargs):
            signature = _original_signature(*args, **kwargs)
            ok = False
            new = []
            for name, parameter in list(signature.parameters.items()):
                if name == "ctx":
                    ok = True
                new.append(parameter)
                if ok and parameter.annotation == parameter.empty:
                    # アノテーションがない場合は`str`を設定して置く。
                    new[-1] = parameter.replace(annotation=str)
            return signature.replace(parameters=new) if ok else signature
        inspect.signature = new_signature
    else:
        app_commands.transformers.annotation_to_parameter = _original_atp
        app_commands.commands.annotation_to_parameter = _original_atp
        inspect.signature = _original_signature


def _get(command, key, default):
    # コマンドオブジェクトの`__original_kwargs__`か`extras`から特定の値を取り出します。
    return command.__original_kwargs__.get(key, default) \
        or command.extras.get(key, default)


# `parse_arguments`で何も実行しないようにする。
_original_parse_arguments = commands.Command._parse_arguments
async def _new_parse_arguments(self, ctx):
    if is_fslash(ctx) and not getattr(ctx, "__fslash_do_original_pa__", False):
        ctx.args = (ctx.command.cog, ctx) if ctx.command.cog else (ctx,)
    else:
        return await _original_parse_arguments(self, ctx)
setattr(commands.Command, "_parse_arguments", _new_parse_arguments)


async def _run_command(bot, interaction, command, content, kwargs={}) -> None:
    # Run command
    ctx = await _make_context(interaction, kwargs, command, bot, **_context_kwargs)
    if content is not None:
        ctx.view = type(ctx.view)(content)
        setattr(ctx, "__fslash_do_original_pa__", True)
    try:
        if await bot.can_run(ctx, call_once=True) and (
            command.parent is None or all(await gather(*(
                parent.can_run(ctx)
                for parent in command.parents
            )))
        ) and await command.can_run(ctx):
            await command.invoke(ctx) # type: ignore
    except commands.CommandError as e:
        await command.dispatch_error(ctx, e)
    else:
        bot.dispatch("command_completion", ctx)


def _apply_describe(command):
    # `describe`等で付けられたデータを`callback`にも適用させる。
    for name, value in filter(
        lambda x: x[0].startswith("__discord_app_commands"),
        command.__dict__.items()
    ):
        setattr(command.callback, name, value)


_original_run_converter = commands.core.run_converters # type: ignore
async def _new_run_converters(ctx, converter, argument, param):
    origin = getattr(converter, "__origin__", None)
    is_choice = False
    if origin is app_commands.Choice and hasattr(
        ctx.command.callback, "__fslash_param_choices__"
    ):
        # ChoiceをLiteralに交換する。
        if choices := ctx.command.callback.__fslash_param_choices__.get(param.name):
            converter = Literal[0]
            setattr(converter, "__args__", tuple(choice.name for choice in choices))
            is_choice = True
    elif hasattr(converter, "__fslash_original_annotation__"):
        # TransformはConverterに置き換える。
        converter = getattr(converter, "__fslash_original_annotation__")
    data = await _original_run_converter(ctx, converter, argument, param)
    if is_choice:
        data = discord.utils.get(choices, name=data)
    return data
commands.core.run_converters = _new_run_converters # type: ignore


def _append_command(
    cog: commands.Cog | None, command: discord.app_commands.Command, parent: bool
):
    if cog is not None:
        if parent:
            if not hasattr(cog, "__fslash_app_parent_commands__"):
                setattr(cog, "__fslash_app_parent_commands__", [])
            getattr(cog, "__fslash_app_parent_commands__").append(command)
        else:
            if not hasattr(cog, "__fslash_app_commands__"):
                setattr(cog, "__fslash_app_commands__", [])
            if not hasattr(cog, "__fslash_app_groups__"):
                setattr(cog, "__fslash_app_groups__", [])
            if isinstance(command, discord.app_commands.Group):
                getattr(cog, "__fslash_app_groups__").append(command)
            else:
                getattr(cog, "__fslash_app_commands__").append(command)


groups = []
"List containing group commands scheduled to be registered with a slash."
exceptions: DefaultDict[str, dict[Any, Exception]] = defaultdict(dict)
"This dictionary is used to include errors when something failed but did not output an error."
__patched = False
def extend_force_slash(
    bot: BotT, *,
    check: Optional[Callable[[Union[commands.Command, commands.Group]], bool]] = None,
    adjustment_name: Optional[AdjustmentNameMode] = None,
    replace_invalid_annotation_to_str: bool = False,
    default_description: str = "...",
    first_groups: Optional[Iterable[app_commands.Group]] = None,
    context_mode: ContextMode = ContextMode.OFFICIAL,
    context_kwargs: Optional[dict] = None
) -> BotT:
    """This class forces commands in the command framework bot to be registered even if they are slash commands.

    Parameters
    ----------
    bot : discord.ext.commands.bot.Bot
        Target Bot.
    check : Callable[[Union[commands.Command, commands.Group]], bool], optional
        Function used to check if a command should be added.  
        This is useful if you do not want some commands to be registered as slashes.
    adjustment_name : types_.AdjustmentNameMode, optional
        Whether the name should be Snake Case or Kebab Case and the number of characters should be automatically converted to 32 or less.  
        If you have many commands with names that cannot be used as slash command names, this is useful because it will automatically convert them all to usable names.
    replace_invalid_annotation_to_str : bool, default False
        Whether invalid annotations as slashes are automatically set to `str`.  
        The default is `False`, but if you have a lot of commands and are not confident that all the annotations are correct and do not have the energy to fix the wrong ones, you can use this.  
        When automatically exchanged for `str`, the error is written to `fslash.exceptions["annotation"]`.  
        If you think something is wrong, check here.
    default_description : str, default "..."
        This is the string to put in place when an empty description is encountered.
    first_groups : Iterable[discord.app_commands.Group], optional
        This is a list of group commands to be registered first.  
        If you have reached the maximum number of slash commands that can be registered, you can register more commands by registering the already registered commands as subcommands of the group command in this list.  
        How to do it is described in the Notes of this function.
    context_mode : types_.ContextMode, default types_.ContextMode.OFFICIAL
        How to make `ctx`.
    context_kwargs : dict, optional
        Keyword arguments to be passed to the arguments after `typing_mode` of `fslash.context.Context`.  
        Detail is here: `Context`

    Warnings
    --------
    This function performs a monkey patch so that the command framework command is registered as a slash at runtime.  
    It also temporarily replaces the command framework command object and the `signature` of the standard library `inspect` with another one.  
    We will try to avoid interfering with other libraries as much as possible, but we can't guarantee that we won't, so please understand that and use it accordingly.  
    And you can only call this function once.

    Notes
    -----
    One may wonder if decorators such as `app_commands.describe` can be used, but of course they can.  
    Also, the command framework checks work.  
    The converter is automatically replaced by `app_commands.Transformer`.  
    Cooldown also works.  
    However, the decorator must be placed below `command`.  
    If you have a lot of nested commands like group command of group command of group command... you can't register them in the slash as they should be.  
    If such a command is encountered, it will take the subcommands after the unregistrable subcommand as arguments.  
    Example: `/group level1 level2 content: level3 level4 ...`

    The number of slash commands registered may exceed the maximum number of slash commands registered as commands in the command framework.  
    In that case, use the `first_groups` argument.  
    The command in the list of group commands passed to this argument can be set as the parent command of the command framework command.  
    To do this, simply enter the name of the parent command as `fsparent` in the command framework command argument or `extras` argument.  
    Example:  
    ```python
    bot = extend_force_slash(
        commands.Bot(command_prefix="t!", intents=intents),
        first_groups=(
            discord.app_commands.Group(
                name="server-tool", description="Some commands are useful for server operation."
            ),
        )
    )


    @bot.command(fsparent="server-tool") # or `extras={"fsparent": "server-tool"}`
    async def normal(ctx):
        "This command can be called by run `/server-tool normal`."
    ```

    You can also specify a guild.  
    Just pass a value for the `guild` argument, similar to `guild` in `CommandTree.command`.  
    (Or you can pass `guild` as a key to `extras`).  
    Group commands can also be passed in the same way as `app_commands.Group`. (`guild_ids`)  
    ```python
    @bot.command(guild=GUILD_ID)
    async def test(ctx):
        ...
    ```

    You can change which methods return interaction responses and how `Context.typing` behaves by passing a value to `Context` with the `context_kwargs` argument.
    Also, `discord.app_commands.Choice` is replaced by `Literal` in the command framework commands.  
    But the value of the argument at runtime is the value of `Choice`."""
    global _bot, groups, exceptions, _context_kwargs, _ctx_mode
    _ctx_mode = context_mode
    _context_kwargs.update(context_kwargs or {})
    _bot = bot
    if first_groups is not None:
        for g in first_groups:
            groups.append(g)

    global __patched
    assert not __patched, "This can only be called once."
    __patched = True
    if check is None: check = lambda _: True

    # コマンドが作られた際にそのコマンドを呼び出すコマンドをtreeに登録する。
    original_command_init = commands.Command.__init__
    def command_new_init(command: commands.Command, func, /, **kwargs):
        if not (cog_mode := kwargs.pop("__cog_mode__", False)):
            original_command_init(command, func, **kwargs)
        cog = kwargs.pop("__cog__", None)

        # コグに実装されているコマンドの場合は、コグが追加された後にスラッシュとして登録する。
        # 理由は内部でコピーを行うためここが(多分)二回呼ばれてしまうためで、それを対策しようとするととてもめんどくさいことになってしまうから。
        if command.callback.__code__.co_varnames[0] == "self" and not cog_mode:
            if not isinstance(command, commands.Group):
                _apply_describe(command)
            return

        # もしNestしすぎたグループコマンドのコマンドの場合はパスする。この`__fslash_*_*__`は下で作られます。
        if command.parent is not None and getattr(
            command.parent, "__fslash_max_parent__", False
        ):
            setattr(command, "__fslash_max_parent__", True)
            return

        # コマンドを実装するかのチェックをする。
        if not check(command): return

        _replace_atp(
            True, exceptions["replace_invalid_annotation_to_str"],
            replace_invalid_annotation_to_str
        )

        # もし親のグループが指定されているのならそれを探し出す。
        parent = None
        fsparent = _get(command, "fsparent", None)
        for group in groups:
            if fsparent == group.name:
                parent = group
                break
        else:
            assert fsparent is None, f"A group command that has not yet been registered as a parent command in `{command}` has been specified."
        # もしコマンドフレームワークのグループコマンドのサブコマンドの場合は、親コマンドのスラッシュのグループコマンドを、スラッシュでも親コマンドとする。
        if parent is None and command.parent is not None:
            parent = getattr(command.parent, "__fslash__", None)
            if parent is None:
                return _replace_atp(False, None, replace_invalid_annotation_to_str)
        # choiceのデータをコマンドフレームワークのコマンド実行時にLiteralに交換するので取って置く。
        if hasattr(command.callback, "__discord_app_commands_param_choices__"):
            setattr(
                command._callback, "__fslash_param_choices__",
                getattr(
                    command.callback, "__discord_app_commands_param_choices__", None
                ).copy() # type: ignore
            )
        # スラッシュコマンドを作る。
        name = command.name if adjustment_name is None \
            else adjustment_command_name(command.name, adjustment_name)
        if getattr(parent, "__fslash_max_parent__", False):
            return _replace_atp(False, None, replace_invalid_annotation_to_str)
        is_group = isinstance(command, commands.Group)
        try:
            assert parent is None or len(parent._children) < 24
            if is_group:
                for index, group in enumerate(groups):
                    if group.name == name:
                        del groups[index]
                        break
                groups.append(group := app_commands.Group(
                    name=name,
                    description=command.description or default_description,
                    parent=parent, guild_ids=_get(command, "guild_ids", None)
                ))
                setattr(command, "__fslash__", group)
                if parent is None:
                    _append_command(cog, group, False)
                elif fsparent is not None:
                    _append_command(cog, group, True)
            else:
                _apply_describe(command)
                # スラッシュコマンドを作る。
                app_command: app_commands.Command = (bot.tree.command if parent is None else parent.command)(
                    name=name, description=command.description or default_description,
                    **(dict(
                        guild=_get(command, "guild", discord.utils.MISSING), guilds=_get(
                            command, "guilds", discord.utils.MISSING
                        )
                    ) if parent is None else {})
                )(command.callback) # type: ignore
                setattr(command, "__fslash__", app_command)

                # 実行される関数を用意する。
                async def inner_function(interaction: discord.Interaction, **kwargs): # type: ignore
                    await _run_command(bot, interaction, command, None, kwargs)
                setattr(app_command, "_callback", inner_function)

                if parent is None:
                    _append_command(cog, app_command, False)
                elif fsparent is not None:
                    _append_command(cog, app_command, True)
        except (ValueError, AssertionError) as e:
            # もしNestしすぎたグループコマンドがある場合は、コマンドの文を受け取るコマンドを代わりに作る。
            assert isinstance(parent, app_commands.Group)
            @parent.command(
                name=name, description=command.description or default_description
            )
            async def alternative_for_nested(
                interaction: discord.Interaction, content: str
            ):
                await _run_command(bot, interaction, command, content)
            setattr(command, "__fslash_max_parent__", True)
            if isinstance(e, AssertionError):
                setattr(parent, "__fslash_max_parent__", True)

        _replace_atp(False, None, replace_invalid_annotation_to_str)
    setattr(commands.Command, "__init__", command_new_init)

    # コグ追加時に、コグに実装されているコマンドをスラッシュで登録する。
    original_inject = commands.Cog._inject
    def new_inject(self: commands.Cog, *args, **kwargs):
        for command in self.__cog_commands__:
            command_new_init(command, command.callback, __cog_mode__=True, __cog__=self)
        return original_inject(self, *args, **kwargs)
    commands.Cog._inject = new_inject

    # コグ削除時に、コグに実装されているコマンドが削除されるようにする。
    original_remove_cog = commands.bot.BotBase.remove_cog
    async def new_remove_cog(self: commands.bot.BotBase, name: str, /, *args, **kwargs):
        if name in self.cogs:
            if hasattr(self.cogs[name], "__fslash_app_commands__"):
                setattr(
                    self.cogs[name], "__cog_app_commands__",
                    getattr(self.cogs[name], "__fslash_app_commands__")
                )
            if hasattr(self.cogs[name], "__fslash_app_groups__"):
                setattr(
                    self.cogs[name], "__cog_app_commands_group__",
                    getattr(self.cogs[name], "__fslash_app_groups__")
                )
            # fsparentで親コマンドが指定されているコマンドを削除する。
            if hasattr(self.cogs[name], "__fslash_app_parent_commands__"):
                for command in getattr(self.cogs[name], "__fslash_app_parent_commands__"):
                    command.parent.remove_command(command.name)
        return await original_remove_cog(self, name, *args, **kwargs)
    commands.bot.BotBase.remove_cog = new_remove_cog

    # コマンドが削除された時はスラッシュコマンドも削除する。
    def command_new_del(command: commands.Command):
        slash: Optional[app_commands.Command] = getattr(command, "__fslash__", None);
        if slash is not None:
            if slash.parent is None:
                bot.tree.remove_command(slash) # type: ignore
            else:
                slash.parent.remove_command(slash) # type: ignore
    setattr(commands.Command, "__del__", command_new_del)

    @bot.listen("on_ready")
    async def _add_groups():
        # `groups`にあるものを追加する。
        global groups
        for group in groups:
            if not getattr(group, "__synced__", False) \
                    and group.parent is None:
                bot.tree.add_command(group)
                setattr(group, "__synced__", True)

    # `sync`が実行された際に`_add_groups`を実行する様にする。
    original_sync = app_commands.CommandTree.sync
    async def new_sync(self, *, guild=None):
        if bot.is_ready():
            await _add_groups()
        return await original_sync(self, guild=guild)
    app_commands.CommandTree.sync = new_sync

    return bot

Sub-modules

fslash.context
fslash.types_

Global variables

var exceptions : DefaultDict[str, dict[Any, Exception]]

This dictionary is used to include errors when something failed but did not output an error.

var groups

List containing group commands scheduled to be registered with a slash.

Functions

def adjustment_command_name(name: str, mode: AdjustmentNameMode) ‑> str

Prepares the passed string into a string that can be used as the name of a slash command.

Parameters

name : str
Adjustment target.
mode : AdjustmentNameMode
It is either a snake case or a kebab case.
Expand source code
def adjustment_command_name(name: str, mode: AdjustmentNameMode) -> str:
    """Prepares the passed string into a string that can be used as the name of a slash command.

    Parameters
    ----------
    name : str
        Adjustment target.
    mode : AdjustmentNameMode
        It is either a snake case or a kebab case."""
    sandwiched = "_" if mode == AdjustmentNameMode.SNAKE_CASE else "-"
    return "".join(
        char
        for char in sub(
            "(.[A-Z])", lambda x:
                f"{x.group(1)[0]}{sandwiched}"
                f"{x.group(1)[1]}",
            name.lower()
        ).lower()
        if char in VALID_COMMAND_NAME_CHARACTERS_WITHOUT_LETTERS
    )[:32]
def extend_force_slash(bot: BotT, *, check: Optional[Callable[[Union[commands.Command, commands.Group]], bool]] = None, adjustment_name: Optional[AdjustmentNameMode] = None, replace_invalid_annotation_to_str: bool = False, default_description: str = '...', first_groups: Optional[Iterable[app_commands.Group]] = None, context_mode: ContextMode = ContextMode.OFFICIAL, context_kwargs: Optional[dict] = None) ‑> ~BotT

This class forces commands in the command framework bot to be registered even if they are slash commands.

Parameters

bot : discord.ext.commands.bot.Bot
Target Bot.
check : Callable[[Union[commands.Command, commands.Group]], bool], optional
Function used to check if a command should be added.
This is useful if you do not want some commands to be registered as slashes.
adjustment_name : AdjustmentNameMode, optional
Whether the name should be Snake Case or Kebab Case and the number of characters should be automatically converted to 32 or less.
If you have many commands with names that cannot be used as slash command names, this is useful because it will automatically convert them all to usable names.
replace_invalid_annotation_to_str : bool, default False
Whether invalid annotations as slashes are automatically set to str.
The default is False, but if you have a lot of commands and are not confident that all the annotations are correct and do not have the energy to fix the wrong ones, you can use this.
When automatically exchanged for str, the error is written to fslash.exceptions["annotation"].
If you think something is wrong, check here.
default_description : str, default "..."
This is the string to put in place when an empty description is encountered.
first_groups : Iterable[discord.app_commands.Group], optional
This is a list of group commands to be registered first.
If you have reached the maximum number of slash commands that can be registered, you can register more commands by registering the already registered commands as subcommands of the group command in this list.
How to do it is described in the Notes of this function.
context_mode : ContextMode, default ContextMode.OFFICIAL
How to make ctx.
context_kwargs : dict, optional
Keyword arguments to be passed to the arguments after typing_mode of Context.
Detail is here: Context

Warnings

This function performs a monkey patch so that the command framework command is registered as a slash at runtime.
It also temporarily replaces the command framework command object and the signature of the standard library inspect with another one.
We will try to avoid interfering with other libraries as much as possible, but we can't guarantee that we won't, so please understand that and use it accordingly.
And you can only call this function once.

Notes

One may wonder if decorators such as app_commands.describe can be used, but of course they can.
Also, the command framework checks work.
The converter is automatically replaced by app_commands.Transformer.
Cooldown also works.
However, the decorator must be placed below command.
If you have a lot of nested commands like group command of group command of group command… you can't register them in the slash as they should be.
If such a command is encountered, it will take the subcommands after the unregistrable subcommand as arguments.
Example: /group level1 level2 content: level3 level4 ...

The number of slash commands registered may exceed the maximum number of slash commands registered as commands in the command framework.
In that case, use the first_groups argument.
The command in the list of group commands passed to this argument can be set as the parent command of the command framework command.
To do this, simply enter the name of the parent command as fsparent in the command framework command argument or extras argument.
Example:

bot = extend_force_slash(
    commands.Bot(command_prefix="t!", intents=intents),
    first_groups=(
        discord.app_commands.Group(
            name="server-tool", description="Some commands are useful for server operation."
        ),
    )
)


@bot.command(fsparent="server-tool") # or `extras={"fsparent": "server-tool"}`
async def normal(ctx):
    "This command can be called by run `/server-tool normal`."

You can also specify a guild.
Just pass a value for the guild argument, similar to guild in CommandTree.command.
(Or you can pass guild as a key to extras).
Group commands can also be passed in the same way as app_commands.Group. (guild_ids)

@bot.command(guild=GUILD_ID)
async def test(ctx):
    ...

You can change which methods return interaction responses and how Context.typing() behaves by passing a value to Context with the context_kwargs argument. Also, discord.app_commands.Choice is replaced by Literal in the command framework commands.
But the value of the argument at runtime is the value of Choice.

Expand source code
def extend_force_slash(
    bot: BotT, *,
    check: Optional[Callable[[Union[commands.Command, commands.Group]], bool]] = None,
    adjustment_name: Optional[AdjustmentNameMode] = None,
    replace_invalid_annotation_to_str: bool = False,
    default_description: str = "...",
    first_groups: Optional[Iterable[app_commands.Group]] = None,
    context_mode: ContextMode = ContextMode.OFFICIAL,
    context_kwargs: Optional[dict] = None
) -> BotT:
    """This class forces commands in the command framework bot to be registered even if they are slash commands.

    Parameters
    ----------
    bot : discord.ext.commands.bot.Bot
        Target Bot.
    check : Callable[[Union[commands.Command, commands.Group]], bool], optional
        Function used to check if a command should be added.  
        This is useful if you do not want some commands to be registered as slashes.
    adjustment_name : types_.AdjustmentNameMode, optional
        Whether the name should be Snake Case or Kebab Case and the number of characters should be automatically converted to 32 or less.  
        If you have many commands with names that cannot be used as slash command names, this is useful because it will automatically convert them all to usable names.
    replace_invalid_annotation_to_str : bool, default False
        Whether invalid annotations as slashes are automatically set to `str`.  
        The default is `False`, but if you have a lot of commands and are not confident that all the annotations are correct and do not have the energy to fix the wrong ones, you can use this.  
        When automatically exchanged for `str`, the error is written to `fslash.exceptions["annotation"]`.  
        If you think something is wrong, check here.
    default_description : str, default "..."
        This is the string to put in place when an empty description is encountered.
    first_groups : Iterable[discord.app_commands.Group], optional
        This is a list of group commands to be registered first.  
        If you have reached the maximum number of slash commands that can be registered, you can register more commands by registering the already registered commands as subcommands of the group command in this list.  
        How to do it is described in the Notes of this function.
    context_mode : types_.ContextMode, default types_.ContextMode.OFFICIAL
        How to make `ctx`.
    context_kwargs : dict, optional
        Keyword arguments to be passed to the arguments after `typing_mode` of `fslash.context.Context`.  
        Detail is here: `Context`

    Warnings
    --------
    This function performs a monkey patch so that the command framework command is registered as a slash at runtime.  
    It also temporarily replaces the command framework command object and the `signature` of the standard library `inspect` with another one.  
    We will try to avoid interfering with other libraries as much as possible, but we can't guarantee that we won't, so please understand that and use it accordingly.  
    And you can only call this function once.

    Notes
    -----
    One may wonder if decorators such as `app_commands.describe` can be used, but of course they can.  
    Also, the command framework checks work.  
    The converter is automatically replaced by `app_commands.Transformer`.  
    Cooldown also works.  
    However, the decorator must be placed below `command`.  
    If you have a lot of nested commands like group command of group command of group command... you can't register them in the slash as they should be.  
    If such a command is encountered, it will take the subcommands after the unregistrable subcommand as arguments.  
    Example: `/group level1 level2 content: level3 level4 ...`

    The number of slash commands registered may exceed the maximum number of slash commands registered as commands in the command framework.  
    In that case, use the `first_groups` argument.  
    The command in the list of group commands passed to this argument can be set as the parent command of the command framework command.  
    To do this, simply enter the name of the parent command as `fsparent` in the command framework command argument or `extras` argument.  
    Example:  
    ```python
    bot = extend_force_slash(
        commands.Bot(command_prefix="t!", intents=intents),
        first_groups=(
            discord.app_commands.Group(
                name="server-tool", description="Some commands are useful for server operation."
            ),
        )
    )


    @bot.command(fsparent="server-tool") # or `extras={"fsparent": "server-tool"}`
    async def normal(ctx):
        "This command can be called by run `/server-tool normal`."
    ```

    You can also specify a guild.  
    Just pass a value for the `guild` argument, similar to `guild` in `CommandTree.command`.  
    (Or you can pass `guild` as a key to `extras`).  
    Group commands can also be passed in the same way as `app_commands.Group`. (`guild_ids`)  
    ```python
    @bot.command(guild=GUILD_ID)
    async def test(ctx):
        ...
    ```

    You can change which methods return interaction responses and how `Context.typing` behaves by passing a value to `Context` with the `context_kwargs` argument.
    Also, `discord.app_commands.Choice` is replaced by `Literal` in the command framework commands.  
    But the value of the argument at runtime is the value of `Choice`."""
    global _bot, groups, exceptions, _context_kwargs, _ctx_mode
    _ctx_mode = context_mode
    _context_kwargs.update(context_kwargs or {})
    _bot = bot
    if first_groups is not None:
        for g in first_groups:
            groups.append(g)

    global __patched
    assert not __patched, "This can only be called once."
    __patched = True
    if check is None: check = lambda _: True

    # コマンドが作られた際にそのコマンドを呼び出すコマンドをtreeに登録する。
    original_command_init = commands.Command.__init__
    def command_new_init(command: commands.Command, func, /, **kwargs):
        if not (cog_mode := kwargs.pop("__cog_mode__", False)):
            original_command_init(command, func, **kwargs)
        cog = kwargs.pop("__cog__", None)

        # コグに実装されているコマンドの場合は、コグが追加された後にスラッシュとして登録する。
        # 理由は内部でコピーを行うためここが(多分)二回呼ばれてしまうためで、それを対策しようとするととてもめんどくさいことになってしまうから。
        if command.callback.__code__.co_varnames[0] == "self" and not cog_mode:
            if not isinstance(command, commands.Group):
                _apply_describe(command)
            return

        # もしNestしすぎたグループコマンドのコマンドの場合はパスする。この`__fslash_*_*__`は下で作られます。
        if command.parent is not None and getattr(
            command.parent, "__fslash_max_parent__", False
        ):
            setattr(command, "__fslash_max_parent__", True)
            return

        # コマンドを実装するかのチェックをする。
        if not check(command): return

        _replace_atp(
            True, exceptions["replace_invalid_annotation_to_str"],
            replace_invalid_annotation_to_str
        )

        # もし親のグループが指定されているのならそれを探し出す。
        parent = None
        fsparent = _get(command, "fsparent", None)
        for group in groups:
            if fsparent == group.name:
                parent = group
                break
        else:
            assert fsparent is None, f"A group command that has not yet been registered as a parent command in `{command}` has been specified."
        # もしコマンドフレームワークのグループコマンドのサブコマンドの場合は、親コマンドのスラッシュのグループコマンドを、スラッシュでも親コマンドとする。
        if parent is None and command.parent is not None:
            parent = getattr(command.parent, "__fslash__", None)
            if parent is None:
                return _replace_atp(False, None, replace_invalid_annotation_to_str)
        # choiceのデータをコマンドフレームワークのコマンド実行時にLiteralに交換するので取って置く。
        if hasattr(command.callback, "__discord_app_commands_param_choices__"):
            setattr(
                command._callback, "__fslash_param_choices__",
                getattr(
                    command.callback, "__discord_app_commands_param_choices__", None
                ).copy() # type: ignore
            )
        # スラッシュコマンドを作る。
        name = command.name if adjustment_name is None \
            else adjustment_command_name(command.name, adjustment_name)
        if getattr(parent, "__fslash_max_parent__", False):
            return _replace_atp(False, None, replace_invalid_annotation_to_str)
        is_group = isinstance(command, commands.Group)
        try:
            assert parent is None or len(parent._children) < 24
            if is_group:
                for index, group in enumerate(groups):
                    if group.name == name:
                        del groups[index]
                        break
                groups.append(group := app_commands.Group(
                    name=name,
                    description=command.description or default_description,
                    parent=parent, guild_ids=_get(command, "guild_ids", None)
                ))
                setattr(command, "__fslash__", group)
                if parent is None:
                    _append_command(cog, group, False)
                elif fsparent is not None:
                    _append_command(cog, group, True)
            else:
                _apply_describe(command)
                # スラッシュコマンドを作る。
                app_command: app_commands.Command = (bot.tree.command if parent is None else parent.command)(
                    name=name, description=command.description or default_description,
                    **(dict(
                        guild=_get(command, "guild", discord.utils.MISSING), guilds=_get(
                            command, "guilds", discord.utils.MISSING
                        )
                    ) if parent is None else {})
                )(command.callback) # type: ignore
                setattr(command, "__fslash__", app_command)

                # 実行される関数を用意する。
                async def inner_function(interaction: discord.Interaction, **kwargs): # type: ignore
                    await _run_command(bot, interaction, command, None, kwargs)
                setattr(app_command, "_callback", inner_function)

                if parent is None:
                    _append_command(cog, app_command, False)
                elif fsparent is not None:
                    _append_command(cog, app_command, True)
        except (ValueError, AssertionError) as e:
            # もしNestしすぎたグループコマンドがある場合は、コマンドの文を受け取るコマンドを代わりに作る。
            assert isinstance(parent, app_commands.Group)
            @parent.command(
                name=name, description=command.description or default_description
            )
            async def alternative_for_nested(
                interaction: discord.Interaction, content: str
            ):
                await _run_command(bot, interaction, command, content)
            setattr(command, "__fslash_max_parent__", True)
            if isinstance(e, AssertionError):
                setattr(parent, "__fslash_max_parent__", True)

        _replace_atp(False, None, replace_invalid_annotation_to_str)
    setattr(commands.Command, "__init__", command_new_init)

    # コグ追加時に、コグに実装されているコマンドをスラッシュで登録する。
    original_inject = commands.Cog._inject
    def new_inject(self: commands.Cog, *args, **kwargs):
        for command in self.__cog_commands__:
            command_new_init(command, command.callback, __cog_mode__=True, __cog__=self)
        return original_inject(self, *args, **kwargs)
    commands.Cog._inject = new_inject

    # コグ削除時に、コグに実装されているコマンドが削除されるようにする。
    original_remove_cog = commands.bot.BotBase.remove_cog
    async def new_remove_cog(self: commands.bot.BotBase, name: str, /, *args, **kwargs):
        if name in self.cogs:
            if hasattr(self.cogs[name], "__fslash_app_commands__"):
                setattr(
                    self.cogs[name], "__cog_app_commands__",
                    getattr(self.cogs[name], "__fslash_app_commands__")
                )
            if hasattr(self.cogs[name], "__fslash_app_groups__"):
                setattr(
                    self.cogs[name], "__cog_app_commands_group__",
                    getattr(self.cogs[name], "__fslash_app_groups__")
                )
            # fsparentで親コマンドが指定されているコマンドを削除する。
            if hasattr(self.cogs[name], "__fslash_app_parent_commands__"):
                for command in getattr(self.cogs[name], "__fslash_app_parent_commands__"):
                    command.parent.remove_command(command.name)
        return await original_remove_cog(self, name, *args, **kwargs)
    commands.bot.BotBase.remove_cog = new_remove_cog

    # コマンドが削除された時はスラッシュコマンドも削除する。
    def command_new_del(command: commands.Command):
        slash: Optional[app_commands.Command] = getattr(command, "__fslash__", None);
        if slash is not None:
            if slash.parent is None:
                bot.tree.remove_command(slash) # type: ignore
            else:
                slash.parent.remove_command(slash) # type: ignore
    setattr(commands.Command, "__del__", command_new_del)

    @bot.listen("on_ready")
    async def _add_groups():
        # `groups`にあるものを追加する。
        global groups
        for group in groups:
            if not getattr(group, "__synced__", False) \
                    and group.parent is None:
                bot.tree.add_command(group)
                setattr(group, "__synced__", True)

    # `sync`が実行された際に`_add_groups`を実行する様にする。
    original_sync = app_commands.CommandTree.sync
    async def new_sync(self, *, guild=None):
        if bot.is_ready():
            await _add_groups()
        return await original_sync(self, guild=guild)
    app_commands.CommandTree.sync = new_sync

    return bot
def is_fslash(context: Union[Context, commands.Context]) ‑> bool

Checks if the specified Context is defined by fslash.

Parameters

context
 
Expand source code
def is_fslash(context: Union[Context, commands.Context]) -> bool:
    """Checks if the specified Context is defined by fslash.

    Parameters
    ----------
    context"""
    return getattr(context, "__fslash__", False)

Classes

class Context (interaction: discord.Interaction, kwargs: dict[str, Any], command: Optional[Union[commands.Command, commands.Group]] = None, bot: Optional[BotT] = None, interaction_response_mode: InteractionResponseMode = InteractionResponseMode.REPLY, typing_mode: TypingMode = TypingMode.DEFER_THINKING)

Context that is passed to the command framework commands at slash execution time instead.

Warnings

This is different from the usual discord.ext.commands.Context.
We have tried to make it as similar to the original Context as possible, but some of the commands in the command framework may behave unexpectedly.
It is recommended to check just in case.

Note

The following are attributes that are guaranteed to work.

  • bot
  • guild
  • author
  • channel
  • cog
  • me
  • voice_client
  • send
  • reply
  • typing
  • history
  • pins
  • fetch_message
  • add_reaction (Do nothing)
  • remove_reaction (Do nothing)

send_help is currently not implemented.

Parameters

interaction : discord.Interaction
 
kwargs : dict[str, Any]
 
command : Union[discord.ext.commands.Command, discord.ext.commands.Group], optional
 
bot : discord.ext.commands.Bot, optional
 
interaction_response_mode : InteractionResponseMode, default types._InteractionResponseMode.REPLY
Which method is used to reply to the interaction response.
typing_mode : TypingMode, default TypingMode.DEFER_THINKING
Sets the behavior when Context.typing() and Context.typing() is executed.
You can use defer in the interaction response instead.
The Context.reply() can still be used afterwards.
Expand source code
class Context(Generic[BotT]):
    """Context that is passed to the command framework commands at slash execution time instead.

    Warnings
    --------
    This is different from the usual `discord.ext.commands.Context`.  
    We have tried to make it as similar to the original Context as possible, but some of the commands in the command framework may behave unexpectedly.  
    It is recommended to check just in case.

    Note
    ----
    The following are attributes that are guaranteed to work.

    * bot
    * guild
    * author
    * channel
    * cog
    * me
    * voice_client
    * send
    * reply
    * typing
    * history
    * pins
    * fetch_message
    * add_reaction (Do nothing)
    * remove_reaction (Do nothing)

    `send_help` is currently not implemented.

    Parameters
    ----------
    interaction : discord.Interaction
    kwargs : dict[str, Any]
    command : Union[discord.ext.commands.Command, discord.ext.commands.Group], optional
    bot : discord.ext.commands.Bot, optional
    interaction_response_mode : types_.InteractionResponseMode, default types._InteractionResponseMode.REPLY
        Which method is used to reply to the interaction response.
    typing_mode : types_.TypingMode, default types_.TypingMode.DEFER_THINKING
        Sets the behavior when `Context.typing` and `Context.typing` is executed.  
        You can use `defer` in the interaction response instead.  
        The `Context.reply` can still be used afterwards."""

    __fslash__ = True

    def __init__(
        self, interaction: discord.Interaction, kwargs: dict[str, Any],
        command: Optional[Union[commands.Command, commands.Group]] = None,
        bot: Optional[BotT] = None,
        interaction_response_mode: InteractionResponseMode = InteractionResponseMode.REPLY,
        typing_mode: TypingMode = TypingMode.DEFER_THINKING
    ):
        self.bot, self.interaction, self._state = bot, interaction, bot._connection

        self.message = interaction.message or self
        self.mentions = []
        self.guild = interaction.guild
        self.author = interaction.user
        self.channel = interaction.channel or interaction.user
        self.fetch_message = self.channel.fetch_message # type: ignore
        self.history = self.channel.history # type: ignore
        self.pins = self.channel.pins # type: ignore
        if self.guild is None:
            self.me, self.voice_client = None, None
        else:
            self.me = self.guild.me
            self.voice_client = self.guild.voice_client

        self.valid = True
        self.prefix = "/"
        self.clean_prefix = "/"
        self.cog = None if command is None else command.cog
        self.command_failed = False
        self.subcommand_passed = None
        self.invoked_subcommand = None

        self.edited_at: Optional[datetime] = None
        self.created_at = interaction.created_at

        self.command, self.app_command = command, getattr(command, "__fslash__", None)
        self.args, self.kwargs = (), kwargs

        if self.command is None:
            self.reinvoke, self.invoke = None, None
        else:
            self.reinvoke = self.command.reinvoke

        self.view = StringView("")
        self.invoked_parents: list[Any] = []
        self.invoked_with = None
        self.edit = self._reply
        self.attachments = []

        self.typing_mode = typing_mode
        self.interaction_response_mode = interaction_response_mode
        self._sended_defer = False
        self._emojis = ""

    async def invoke(self, command, *args, **kwargs):
        return await command(self, *args, **kwargs)

    async def _reply(self, content, kwargs):
        if self._sended_defer:
            if content is not None:
                kwargs["content"] = content
            await self.interaction.edit_original_response(**kwargs)
            return self
        else:
            return await self.interaction.response.send_message(
                content, **kwargs
            )

    async def reply(self, content: Optional[str] = None, **kwargs):
        if self.interaction_response_mode in (
            InteractionResponseMode.REPLY, InteractionResponseMode.SEND_AND_REPLY
        ):
            return await self._reply(content, kwargs)

    async def send(self, content: Optional[str] = None, **kwargs):
        if self.interaction_response_mode in (
            InteractionResponseMode.SEND, InteractionResponseMode.SEND_AND_REPLY
        ):
            await self._reply(content, kwargs)
            return self
        else:
            return await self.channel.send(content, **kwargs) # type: ignore

    def typing(self) -> NewTyping:
        return NewTyping(self) # type: ignore

    async def add_reaction(self, _): ...

    async def remove_reaction(self, _, __): ...

Ancestors

  • typing.Generic

Methods

async def add_reaction(self, _)
Expand source code
async def add_reaction(self, _): ...
async def invoke(self, command, *args, **kwargs)
Expand source code
async def invoke(self, command, *args, **kwargs):
    return await command(self, *args, **kwargs)
async def remove_reaction(self, _, __)
Expand source code
async def remove_reaction(self, _, __): ...
async def reply(self, content: Optional[str] = None, **kwargs)
Expand source code
async def reply(self, content: Optional[str] = None, **kwargs):
    if self.interaction_response_mode in (
        InteractionResponseMode.REPLY, InteractionResponseMode.SEND_AND_REPLY
    ):
        return await self._reply(content, kwargs)
async def send(self, content: Optional[str] = None, **kwargs)
Expand source code
async def send(self, content: Optional[str] = None, **kwargs):
    if self.interaction_response_mode in (
        InteractionResponseMode.SEND, InteractionResponseMode.SEND_AND_REPLY
    ):
        await self._reply(content, kwargs)
        return self
    else:
        return await self.channel.send(content, **kwargs) # type: ignore
def typing(self) ‑> NewTyping
Expand source code
def typing(self) -> NewTyping:
    return NewTyping(self) # type: ignore