Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(components): initial implementation of a component menu and handler #438

Merged
merged 12 commits into from
Aug 13, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/source/by-examples/100_appendix.md
Original file line number Diff line number Diff line change
@@ -1 +1,15 @@
# Appendix

---

## Components and Modals

Lightbulb includes a component handler (and modal handler) that you can use to make processing component and modal
interactions easier than it otherwise would be using raw Hikari code. For a usage guide you should see the
documentation for the {obj}`components subpackage <lightbulb.components>`.

---

## Scheduled and Repeating Tasks

TODO
1 change: 1 addition & 0 deletions fragments/+defer.removal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The `ephemeral` argument for `Context.defer()` is now keyword-only.
2 changes: 2 additions & 0 deletions fragments/438.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add a component and modal handler implementation. You can use these to more easily handle the creation and execution
of message components, as well as submitted modal forms.
4 changes: 3 additions & 1 deletion lightbulb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""A simple-to-use command handler for Hikari."""
"""A simple, elegant and powerful command handler for Hikari."""
tandemdude marked this conversation as resolved.
Show resolved Hide resolved

from lightbulb import components
from lightbulb import di
from lightbulb import exceptions
from lightbulb import internal
Expand Down Expand Up @@ -62,6 +63,7 @@
"boolean",
"channel",
"client_from_app",
"components",
"crontrigger",
"di",
"exceptions",
Expand Down
55 changes: 50 additions & 5 deletions lightbulb/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,15 @@ class Client(abc.ABC):

__slots__ = (
"_application",
"_asyncio_tasks",
"_command_invocation_mapping",
"_created_commands",
"_di",
"_error_handlers",
"_extensions",
"_localization",
"_menu_queues",
"_modal_queues",
"_owner_ids",
"_registered_commands",
"_started",
Expand Down Expand Up @@ -168,7 +171,12 @@ def __init__(
self._error_handlers: dict[int, list[lb_types.ErrorHandler]] = {}
self._application: hikari.Application | None = None
self._extensions: set[str] = set()

self._tasks: set[tasks.Task] = set()
self._menu_queues: set[asyncio.Queue[hikari.ComponentInteraction]] = set()
self._modal_queues: set[asyncio.Queue[hikari.ModalInteraction]] = set()

self._asyncio_tasks: set[asyncio.Task[t.Any]] = set()

self.di.registry_for(di_.Contexts.DEFAULT).register_value(hikari.api.RESTClient, self.rest)
self.di.registry_for(di_.Contexts.DEFAULT).register_value(Client, self)
Expand Down Expand Up @@ -196,6 +204,12 @@ def created_commands(self) -> Mapping[hikari.Snowflakeish, Collection[hikari.Par
"""
return self._created_commands

def _safe_create_task(self, coro: Coroutine[None, None, T]) -> asyncio.Task[T]:
task = asyncio.create_task(coro)
self._asyncio_tasks.add(task)
task.add_done_callback(lambda tsk: self._asyncio_tasks.remove(tsk))
return task

async def start(self, *_: t.Any) -> None:
"""
Starts the client. Ensures that commands are registered properly with the client, and that
Expand Down Expand Up @@ -792,11 +806,11 @@ async def sync_application_commands(self) -> None:
subcommand_option = await subcommand.to_command_option(
self.default_locale, self.localization_provider
)
all_commands[(builder.name, subcommand_or_subgroup_option.name, subcommand_option.name)] = (
all_commands[(builder.name, subcommand_or_subgroup_option.name, subcommand_option.name)] = ( # noqa: RUF031
subcommand
)
else:
all_commands[(builder.name, subcommand_or_subgroup_option.name)] = subcommand_or_subgroup
all_commands[(builder.name, subcommand_or_subgroup_option.name)] = subcommand_or_subgroup # noqa: RUF031
else:
all_commands = {(builder.name,): command}

Expand Down Expand Up @@ -883,7 +897,7 @@ def build_autocomplete_context(
Returns:
:obj:`~lightbulb.context.AutocompleteContext`: The built context.
"""
return context_.AutocompleteContext(self, interaction, options, command_cls)
return context_.AutocompleteContext(client=self, interaction=interaction, options=options, command=command_cls)

async def _execute_autocomplete_context(
self, context: context_.AutocompleteContext[t.Any], autocomplete_provider: options_.AutocompleteProvider[t.Any]
Expand Down Expand Up @@ -992,11 +1006,29 @@ async def handle_application_command_interaction(self, interaction: hikari.Comma
LOGGER.debug("invoking command - %r", command._command_data.qualified_name)
await self._execute_command_context(context)

async def handle_component_interaction(self, interaction: hikari.ComponentInteraction) -> None:
if not self._started:
LOGGER.debug("ignoring component interaction received before the client was started")
return

await asyncio.gather(*(q.put(interaction) for q in self._menu_queues))

async def handle_modal_interaction(self, interaction: hikari.ModalInteraction) -> None:
if not self._started:
LOGGER.debug("ignoring modal interaction received before the client was started")
return

await asyncio.gather(*(q.put(interaction) for q in self._modal_queues))

async def handle_interaction_create(self, interaction: hikari.PartialInteraction) -> None:
if isinstance(interaction, hikari.AutocompleteInteraction):
await self.handle_autocomplete_interaction(interaction)
elif isinstance(interaction, hikari.CommandInteraction):
await self.handle_application_command_interaction(interaction)
elif isinstance(interaction, hikari.ComponentInteraction):
await self.handle_component_interaction(interaction)
elif isinstance(interaction, hikari.ModalInteraction):
await self.handle_modal_interaction(interaction)


class GatewayEnabledClient(Client):
Expand Down Expand Up @@ -1085,6 +1117,7 @@ def __init__(self, app: RestClientAppT, *args: t.Any, **kwargs: t.Any) -> None:

app.interaction_server.set_listener(hikari.AutocompleteInteraction, self.handle_rest_autocomplete_interaction)
app.interaction_server.set_listener(hikari.CommandInteraction, self.handle_rest_application_command_interaction)
# TODO - make RESTBot compatible with component and modal handler

if isinstance(app, hikari.RESTBot):
self.di.registry_for(di_.Contexts.DEFAULT).register_value(hikari.RESTBot, app)
Expand All @@ -1101,7 +1134,13 @@ def build_rest_autocomplete_context(
command_cls: type[commands.CommandBase],
response_callback: Callable[[hikari.api.InteractionResponseBuilder], None],
) -> context_.AutocompleteContext[t.Any]:
return context_.RestAutocompleteContext(self, interaction, options, command_cls, response_callback)
return context_.RestAutocompleteContext(
client=self,
interaction=interaction,
options=options,
command=command_cls,
_initial_response_callback=response_callback,
)

async def handle_rest_autocomplete_interaction(
self, interaction: hikari.AutocompleteInteraction
Expand Down Expand Up @@ -1163,7 +1202,13 @@ def build_rest_command_context(
command_cls: type[commands.CommandBase],
response_callback: Callable[[hikari.api.InteractionResponseBuilder], None],
) -> context_.Context:
return context_.RestContext(self, interaction, options, command_cls(), response_callback)
return context_.RestContext(
client=self,
interaction=interaction,
options=options,
command=command_cls(),
_initial_response_callback=response_callback,
)

async def handle_rest_application_command_interaction(
self, interaction: hikari.CommandInteraction
Expand Down
1 change: 0 additions & 1 deletion lightbulb/commands/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
# api_reference_gen::ignore
# Copyright (c) 2023-present tandemdude
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
Expand Down
217 changes: 217 additions & 0 deletions lightbulb/components/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2023-present tandemdude
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""
This package contains a framework for creating your own component and modal handlers, without having to go
through the common issues when trying to do it using raw Hikari.

----

Component Handling
------------------

Creating a Menu
^^^^^^^^^^^^^^^

Creating your own component handler is as easy as creating a subclass of the :obj:`~lightbulb.components.menus.Menu`
class.

.. dropdown:: Example

Creating a menu.

.. code-block:: python

import lightbulb

class MyMenu(lightbulb.components.Menu):
def __init__(self) -> None:
...

A single menu class encapsulates the components and state that will be used when handling interactions for any
of the attached components.

Adding Components to Menus
^^^^^^^^^^^^^^^^^^^^^^^^^^

You can add components to a menu using any of the appropriate methods:

- :meth:`~lightbulb.components.menus.Menu.add_interactive_button`
- :meth:`~lightbulb.components.menus.Menu.add_link_button`
- :meth:`~lightbulb.components.menus.Menu.add_text_select`
- :meth:`~lightbulb.components.menus.Menu.add_user_select`
- :meth:`~lightbulb.components.menus.Menu.add_role_select`
- :meth:`~lightbulb.components.menus.Menu.add_mentionable_select`
- :meth:`~lightbulb.components.menus.Menu.add_channel_select`

The menu will lay out the added components into rows automatically. If you wish to customise the layout, you
can use the methods :meth:`~lightbulb.components.base.BuildableComponentContainer.next_row` and
:meth:`~lightbulb.components.base.BuildableComponentContainer.previous_row` to move between rows while adding
components. If a row becomes full (either through having five buttons, or one select), then the menu will
**always** move to the next row if you add another component to it.

When adding a component to a menu, the methods return an object representing the created component. It is recommended
that you store this component within an instance variable so that you can modify it later if you wish to update
the menu's appearance.

.. dropdown:: Example

Adding a component to a menu.

.. code-block:: python

import lightbulb

class MyMenu(lightbulb.components.Menu):
def __init__(self) -> None:
self.btn = self.add_interactive_button(
hikari.ButtonStyle.PRIMARY,
self.on_button_press,
label="Test Button",
)

async def on_button_press(self, ctx: lightbulb.components.MenuContext) -> None:
await ctx.respond("Button pressed!")

Running Menus
^^^^^^^^^^^^^

To send a menu with a message, you can pass the menu instance to the ``components`` argument of the method you
are using (i.e. ``Context.respond``, ``RESTClient.create_message``) - it will be automatically built and sent
with the message.

Menus require the Lightbulb :obj:`~lightbulb.client.Client` in order to listen for the appropriate interactions. You
can run a menu by calling the :meth:`~lightbulb.components.menus.Menu.attach` method. When calling this method,
you can optionally choose to wait until the menu completes before continuing, and pass a timeout after which
time an :obj:`asyncio.TimeoutError` will be raised.

If you do not pass ``wait=True`` to the ``attach()`` method, then it is recommended that you pass your own known
custom IDs when you are adding components to the menu - otherwise they will be randomly generated and the menu will
probably not work as you intended.

To get your ``Client`` instance within a command, you can use dependency injection as seen in the following example.
Check the "Dependencies" guide within the by-example section of the documentation for more details about dependency
injection.

.. dropdown:: Example

Attaching the menu to a client instance within a command.

.. code-block:: python

import lightbulb

class MyMenu(lightbulb.components.Menu):
def __init__(self) -> None:
self.btn = self.add_interactive_button(
hikari.ButtonStyle.PRIMARY,
self.on_button_press,
label="Test Button",
)

async def on_button_press(self, ctx: lightbulb.components.MenuContext) -> None:
# Edit the message containing the buttons with the new content, and
# remove all the attached components.
await ctx.respond("Button pressed!", edit=True, components=[])
# Stop listening for additional interactions for this menu
ctx.stop_interacting()

class MyCommand(lightbulb.SlashCommand, name="test, description="test"):
@lightbulb.invoke
async def invoke(self, ctx: lightbulb.Context, client: lightbulb.Client) -> None:
menu = MyMenu()
resp = await ctx.respond("Menu testing", components=menu)

# Run the menu, and catch a timeout if one occurs
try:
await menu.attach(client, wait=True, timeout=30)
except asyncio.TimeoutError:
await ctx.edit_respond(resp, "Timed out!", components=[])

.. warning::
You should **always** pass a timeout, unless you wish the menu to be persistent. If you do not set a timeout,
then the number of active menus will grow forever, along with the memory usage of your program.

.. warning::
There are no checks added to menus by default to ensure that only one user can interact with any menu. If you
wish to restrict a menu to only a single user (or add other checks) you should pass any state to the menu
constructor and run your check at the top of each component callback.

.. important::
It is recommended that you create a new instance of your menu every time you send it for the first time - otherwise
multiple invocations could potentially interact with each other in unexpected ways.

Once you have sent your menu, and it is processing interactions, you can safely modify the menu from within your
component callbacks in any way - change attributes of the components, add components, remove components, etc. If,
within a component callback, you wish to resend the menu with a response (after changing anything) - you can pass
``rebuild_menu=True``, or ``components=self`` to the context respond call .

A Note on Select Components
^^^^^^^^^^^^^^^^^^^^^^^^^^^

When adding a select menu to a component menu you **must** store it as an instance variable. If you do not do this
then getting the selected values for it will not be typed correctly.

You can get the selected values for a select menu using the
:meth:`~lightbulb.components.menus.MenuContext.selected_values_for` method.

.. dropdown:: Example

.. code-block:: python

import lightbulb

class MyMenu(lightbulb.components.Menu):
def __init__(self) -> None:
self.select = self.add_text_select(["foo", "bar", "baz"], self.on_select)

async def on_select(self, ctx: lightbulb.components.MenuContext) -> None:
await ctx.respond(f"Selected: {ctx.selected_values_for(self.select)}")

----

Modal Handling
--------------

bar

----
"""

from lightbulb.components.base import *
from lightbulb.components.menus import *
from lightbulb.components.modals import *

__all__ = [
"BaseComponent",
"ChannelSelect",
"InteractiveButton",
"LinkButton",
"MentionableSelect",
"Menu",
"MenuContext",
"Modal",
"ModalContext",
"RoleSelect",
"Select",
"TextInput",
"TextSelect",
"UserSelect",
]
Loading