tandemdude / hikari-lightbulb

Simple, elegant and powerful command handler for the Python Discord library, Hikari.
https://hikari-lightbulb.readthedocs.io/en/latest/
MIT License
200 stars 30 forks source link

Slash Command Variable Arguments #163

Closed MaidThatPrograms closed 2 years ago

MaidThatPrograms commented 2 years ago

Summary

A way to specify variable arguments for slash commands.

Why is this needed?

While splitting a string argument and parsing it manually is not too difficult, using a string argument removes some really nice features of slash commands like auto-complete for most types. It also makes it much harder for a user to specify things like users with names that are not one word or have unusual characters.

Alternatively, manually adding the max number of options to a command and treating it like varargs takes a lot of lines of code and makes it much harder to read. Not to mention, actually collecting every option into a list or tuple for iteration is also very hard to read and tedious.

Ideal implementation

With a decorator like @lightbulb.option('users', 'A bunch of users', List[hikari.User]) or @lightbulb.option('users', 'A bunch of users', hikari.User, variable_args=True). Then context.options.users would have a list of the specified users.

Internally, I believe it could function like this. Register the slash command as having 25 optional options of the correct type (or less if other non-variable arguments are given), and collect as many that were input into a list or tuple in the context.

A not very internal workaround I am currently using is as follows for a command foo to have variable numbers of User arguments. It would have to be adjusted when collected arguments to only get the ones of type User.

while len(foo.options) < 25:
    option(f'user{len(foo.options)}', 'Bar?', User, required=False)(foo)

async def foo(context):
    users = filter(None, context.options._options.values())

Checklist

parafoxia commented 2 years ago

The only issue I can envisage personally here is how it would appear on the client. From the internal implementation you've described, it sounds like the command would be flooded with optional args of very similar names (I don't believe two can use the same name).

This honestly just sounds like something Discord would need to implement themselves into slash commands.

MaidThatPrograms commented 2 years ago

So, I did a lot more programming since I posted this and have created a fairly duct-tape, but effective method of setting up simple slash only bots. It doesn't have every feature you could possibly want, but it is good for simple bots at least. I put it into a helper file for loading in a few bots I have. Default command values could probably be implemented without too much work. I'm sure this whole thing could be done much better as well.

Bots can be created like bot = create_bot(default_enabled_guilds=GUILD_NUMBER, cache_settings=CacheSettings(max_messages=10000)) for instance.

Slash commands are registered as follows. Returning an object will respond to the context.

@slasher('Does some random thing!')
async def foo(context, message: ('Say what?', str), *users: ('About whom?', User)):
    content = ''
    for user in users:
        content += f'{user.user.username} is {message}!\n'
    return content
from collections import defaultdict
from getpass import getpass
from inspect import signature
from lightbulb import BotApp, command, CommandErrorEvent, implements, option
from lightbulb.commands import SlashCommand
from os import name

def slasher(description):
    def decorator(function):
        @bot.command
        @command(function.__name__, description, auto_defer=True)
        @implements(SlashCommand)
        async def callback(context):                
            content = await function(context, *filter(None, context.options,_options.values()))
            await context.respond(content)

        parameters = signature(function).parameters
        for name, parameter in parameters.items():
            if parameter.annotation != parameter.empty:
                option(name, *parameter.annotation)(callback)
                if parameter.kind == parameter.VAR_POSITIONAL:
                    for i in range(2, 27 - len(callback.options)):
                        option(f'{name}{i}', *parameter.annotation, required=False)(callback)

    return decorator

def create_bot(**kwargs):
    global bot
    bot = BotApp(getpass(), **kwargs)

    if name == 'posix':
        from uvloop import install
        install()

    @bot.listen(CommandErrorEvent)
    async def on_error(event):
        await event.context.respond(event.exception.__cause__)
        raise event.exception

    return bot

bot = None

It looks like this which can be a little weird, but just pressing enter/tab will automatically select the next user option. bild

parafoxia commented 2 years ago

I'd say this is probably more in the scope of Filament. It's essentially a bunch of utility functions and decorators, and already has a load of hella botched solutions in it, so perhaps could be @tandemdude's next illegal coding job d:

In my personal opinion this is too much of a botchy hackjob for Lightbulb, despite it's uses insofar as features that really should be part of slash commands are concerned.

MaidThatPrograms commented 2 years ago

Well, I have an implementation of BotApp that probably doesn't fit into Lightbulb, but I will link it here for potential solutions. https://github.com/ItsPonks/DiscordBots/blob/main/utils.py