diff --git a/docs/source/by-examples/100_appendix.md b/docs/source/by-examples/100_appendix.md index fad5ae45..67decfd0 100644 --- a/docs/source/by-examples/100_appendix.md +++ b/docs/source/by-examples/100_appendix.md @@ -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 `. + +--- + +## Scheduled and Repeating Tasks + +TODO diff --git a/examples/basic_bot_example.py b/examples/basic_bot_example.py index 53569a37..838f5318 100644 --- a/examples/basic_bot_example.py +++ b/examples/basic_bot_example.py @@ -18,7 +18,6 @@ # 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. - import hikari import lightbulb @@ -37,7 +36,7 @@ class Ping( ): @lightbulb.invoke async def invoke(self, ctx: lightbulb.Context) -> None: - """Checks that the bot is alive""" + """Checks that the bot is alive.""" await ctx.respond("Pong!") @@ -51,7 +50,7 @@ class Echo( @lightbulb.invoke async def invoke(self, ctx: lightbulb.Context) -> None: - """Repeats the user's input""" + """Repeats the user's input.""" await ctx.respond(self.text) @@ -67,8 +66,9 @@ class Add( @lightbulb.invoke async def invoke(self, ctx: lightbulb.Context) -> None: - """Adds the two given numbers together""" + """Adds the two given numbers together.""" await ctx.respond(f"{self.num1} + {self.num2} = {self.num1 + self.num2}") -bot.run() +if __name__ == "__main__": + bot.run() diff --git a/examples/component_menu_example.py b/examples/component_menu_example.py new file mode 100644 index 00000000..ce7884cc --- /dev/null +++ b/examples/component_menu_example.py @@ -0,0 +1,121 @@ +# -*- 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. +import asyncio + +import hikari + +import lightbulb + +bot = hikari.GatewayBot(token="...") +client = lightbulb.client_from_app(bot) + +bot.subscribe(hikari.StartingEvent, client.start) + +ASSIGNABLE_ROLES: dict[str, int] = { + "Gaming": 000, + "Movies": 123, + "Coding": 456, + "Drawing": 789, +} + + +class ConfirmationMenu(lightbulb.components.Menu): + def __init__(self, member: hikari.Member) -> None: + self.member = member + + self.cancel = self.add_interactive_button(hikari.ButtonStyle.DANGER, self.on_cancel, label="Cancel") + self.confirm = self.add_interactive_button(hikari.ButtonStyle.SUCCESS, self.on_confirm, label="Confirm") + + self.confirmed: bool = False + + async def on_cancel(self, ctx: lightbulb.components.MenuContext) -> None: + if ctx.user.id != self.member.id: + await ctx.respond("You are not permitted to use this menu", ephemeral=True) + return + + await ctx.respond("Cancelled", edit=True, components=[]) + ctx.stop_interacting() + + async def on_confirm(self, ctx: lightbulb.components.MenuContext) -> None: + if ctx.user.id != self.member.id: + await ctx.respond("You are not permitted to use this menu", ephemeral=True) + return + + await ctx.respond("Confirmed", edit=True, components=[]) + self.confirmed = True + ctx.stop_interacting() + + +class RoleSelectorMenu(lightbulb.components.Menu): + def __init__(self, member: hikari.Member) -> None: + self.member = member + self.select = self.add_text_select(list(ASSIGNABLE_ROLES.keys()), self.on_select, placeholder="Select a Role") + + async def on_select(self, ctx: lightbulb.components.MenuContext) -> None: + if ctx.user.id != self.member.id: + await ctx.respond("You are not permitted to use this menu", ephemeral=True) + return + + selected_values = ctx.selected_values_for(self.select) + # We know there will only be one selected value because 'min_values' and 'max_values' + # are both set to 1 for the select component + role_to_add = ASSIGNABLE_ROLES[selected_values[0]] + + # Confirm with the user whether they want to claim the role + confirm_menu = ConfirmationMenu(self.member) + await ctx.respond( + f"Are you sure you want to claim the {selected_values[0]!r} role?", edit=True, components=confirm_menu + ) + try: + # Extend the timeout of this menu to account for the sub-menu + ctx.extend_timeout(30) + await confirm_menu.attach(client, wait=True, timeout=30) + except asyncio.TimeoutError: + await ctx.respond("Timed out", edit=True, components=[]) + + if not confirm_menu.confirmed: + return + + await bot.rest.add_role_to_member(self.member.guild_id, self.member.id, role_to_add) + await ctx.respond(f"Role {selected_values[0]!r} assigned successfully.", edit=True, components=[]) + + +@client.register +class GetRole( + lightbulb.SlashCommand, + name="get-role", + description="Assign yourself a role", + dm_enabled=False, +): + @lightbulb.invoke + async def invoke(self, ctx: lightbulb.Context) -> None: + assert ctx.member is not None + + menu = RoleSelectorMenu(ctx.member) + resp = await ctx.respond("Pick the role you want", components=menu) + try: + await menu.attach(client, wait=True, timeout=30) + except asyncio.TimeoutError: + await ctx.edit_response(resp, "Timed out", components=[]) + + +if __name__ == "__main__": + bot.run() diff --git a/examples/extension_example.py b/examples/extension_example.py index 2ba1fa89..b801013c 100644 --- a/examples/extension_example.py +++ b/examples/extension_example.py @@ -18,7 +18,6 @@ # 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. - import lightbulb loader = lightbulb.Loader() @@ -34,5 +33,5 @@ class Greet( @lightbulb.invoke async def invoke(self, ctx: lightbulb.Context) -> None: - """Greets the specified user""" + """Greets the specified user.""" await ctx.respond(f"Hello, {self.user.mention}!") diff --git a/examples/moderation_example.py b/examples/moderation_example.py index deab7978..00839be1 100644 --- a/examples/moderation_example.py +++ b/examples/moderation_example.py @@ -18,7 +18,6 @@ # 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. - import datetime import hikari @@ -48,7 +47,7 @@ class Ban( @lightbulb.invoke async def invoke(self, ctx: lightbulb.Context, rest: hikari.api.RESTClient) -> None: - """Ban a user from the server with an optional reason""" + """Ban a user from the server with an optional reason.""" if not ctx.guild_id: await ctx.respond("This command can only be used in a guild.") return @@ -73,7 +72,7 @@ class Purge( @lightbulb.invoke async def invoke(self, ctx: lightbulb.Context, rest: hikari.api.RESTClient) -> None: - """Purge a certain amount of messages from a channel""" + """Purge a certain amount of messages from a channel.""" if not ctx.guild_id: await ctx.respond("This command can only be used in a server.") return @@ -94,4 +93,5 @@ async def invoke(self, ctx: lightbulb.Context, rest: hikari.api.RESTClient) -> N await ctx.respond("Could not find any messages younger than 14 days!") -bot.run() +if __name__ == "__main__": + bot.run() diff --git a/fragments/+defer.removal.md b/fragments/+defer.removal.md new file mode 100644 index 00000000..c713d5d1 --- /dev/null +++ b/fragments/+defer.removal.md @@ -0,0 +1 @@ +The `ephemeral` argument for `Context.defer()` is now keyword-only. diff --git a/fragments/438.feature.md b/fragments/438.feature.md new file mode 100644 index 00000000..f78f40e7 --- /dev/null +++ b/fragments/438.feature.md @@ -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. diff --git a/lightbulb/__init__.py b/lightbulb/__init__.py index 524485a5..39fccc1d 100644 --- a/lightbulb/__init__.py +++ b/lightbulb/__init__.py @@ -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.""" +from lightbulb import components from lightbulb import di from lightbulb import exceptions from lightbulb import internal @@ -62,6 +63,7 @@ "boolean", "channel", "client_from_app", + "components", "crontrigger", "di", "exceptions", diff --git a/lightbulb/client.py b/lightbulb/client.py index dfd8de21..9a86422a 100644 --- a/lightbulb/client.py +++ b/lightbulb/client.py @@ -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", @@ -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) @@ -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 @@ -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} @@ -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] @@ -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): @@ -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) @@ -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 @@ -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 diff --git a/lightbulb/commands/utils.py b/lightbulb/commands/utils.py index aebf9d81..8d3d4f5b 100644 --- a/lightbulb/commands/utils.py +++ b/lightbulb/commands/utils.py @@ -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 diff --git a/lightbulb/components/__init__.py b/lightbulb/components/__init__.py new file mode 100644 index 00000000..1addb91d --- /dev/null +++ b/lightbulb/components/__init__.py @@ -0,0 +1,304 @@ +# -*- 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 + + .. 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 + + .. 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 + + .. code-block:: python + + import asyncio + 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 +-------------- + +Creating a Modal +^^^^^^^^^^^^^^^^ + +Modals are handled in a very similar way to components. Instead of subclassing ``Menu``, you will instead +have to subclass :obj:`~lightbulb.components.modals.Modal`. + +.. dropdown:: Example + + .. code-block:: python + + import lightbulb + + class MyModal(lightbulb.components.Modal): + def __init__(self) -> None: + ... + +Adding Components to Modals +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Like menus, you can add components to modals using the relevant methods: + +- :meth:`~lightbulb.components.modals.Modal.add_short_text_input` +- :meth:`~lightbulb.components.modals.Modal.add_paragraph_text_input` + +Just like menus, the modal will lay the added components out into rows automatically. You can use the same methods +``next_row``, ``previous_row``, etc. to further customise how the layout is created. + +When you add a component, the created component object is returned. You should store this within an instance variable +- it will be needed later in order to get the submitted value from the modal context. + +.. important:: + Your modal subclass **must** implement the ``on_submit`` method. This will be called when an interaction for + the modal is received and should perform any logic you require. + +.. dropdown:: Example + + .. code-block:: python + + import lightbulb + + class MyModal(lightbulb.components.Modal): + def __init__(self) -> None: + self.text = self.add_short_text_input("Enter some text") + + async def on_submit(self, ctx: lightbulb.components.ModalContext) -> None: + await ctx.respond(f"submitted: {ctx.value_for(self.text)}") + +Running Modals +^^^^^^^^^^^^^^ + +Sending a modal with a response is similar to using a menu - you should pass the modal instance to the ``components=`` +argument of ``respond_with_modal`` of the context or interaction. + +Like menus, you need the Lightbulb :obj:`~lightbulb.client.Client` instance in order for it to listen for the +relevant interaction. However, unlike menus, when attaching a modal to the client it will **always** wait for the +interaction to be received before continuing. You must also pass a timeout after which an :obj:`asyncio.TimeoutError` +will be raised - if you do not pass a timeout, it will default to 30 seconds. + +When attaching a modal to the client, you must pass the same custom ID you used when sending the modal response, +otherwise Lightbulb will not be able to resolve the correct interaction for the modal submission. + +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 + + .. code-block:: python + + import asyncio + import uuid + import lightbulb + + class MyModal(lightbulb.components.Modal): + def __init__(self) -> None: + self.text = self.add_short_text_input("Enter some text") + + async def on_submit(self, ctx: lightbulb.components.ModalContext) -> None: + await ctx.respond(f"submitted: {ctx.value_for(self.text)}") + + class MyCommand(lightbulb.SlashCommand, name="test", description="test"): + @lightbulb.invoke + async def invoke(self, ctx: lightbulb.Context, client: lightbulb.Client) -> None: + modal = MyModal() + + # Using a uuid as the custom ID for this modal means it is very unlikely that there will + # be any custom ID conflicts - if you used a set value instead then it may pick up a submission + # from a previous or future invocation of this command + await ctx.respond_with_modal("Test Modal", c_id := str(uuid.uuid4()), components=modal) + try: + await modal.attach(client, c_id) + except asyncio.TimeoutError: + await ctx.respond("Modal timed out") + +---- +""" + +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", +] diff --git a/lightbulb/components/base.py b/lightbulb/components/base.py new file mode 100644 index 00000000..98c81d57 --- /dev/null +++ b/lightbulb/components/base.py @@ -0,0 +1,320 @@ +# -*- 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. +from __future__ import annotations + +__all__ = ["BaseComponent", "BuildableComponentContainer", "MessageResponseMixinWithEdit"] + +import abc +import typing as t +from collections.abc import Sequence + +import hikari +from hikari.api import special_endpoints + +from lightbulb import context +from lightbulb.internal import constants + +if t.TYPE_CHECKING: + import typing_extensions as t_ex + +RowT = t.TypeVar("RowT", special_endpoints.MessageActionRowBuilder, special_endpoints.ModalActionRowBuilder) +BaseComponentT = t.TypeVar("BaseComponentT", bound="BaseComponent[t.Any]") + + +class BaseComponent(abc.ABC, t.Generic[RowT]): + """Abstract base class for a component that can be added to an action row builder.""" + + __slots__ = () + + @property + @abc.abstractmethod + def custom_id(self) -> str: + """The custom ID for this component.""" + + @abc.abstractmethod + def add_to_row(self, row: RowT) -> RowT: + """ + Add this component to the given action row builder, and return the updated builder. + + Args: + row: The row to add the component to. + + Returns: + The updated builder. + """ + + +class MessageResponseMixinWithEdit(context.MessageResponseMixin[context.RespondableInteractionT], abc.ABC): + """ + Abstract mixin derived from ``MessageResponseMixin`` that additionally allows creating an initial response of + type :obj:`hikari.interactions.base_interactions.ResponseType.MESSAGE_UPDATE` (and the deferred variant). + """ + + __slots__ = () + + async def defer(self, *, ephemeral: bool = False, edit: bool = False) -> None: + """ + Defer the creation of a response for the interaction that this context represents. + + Args: + ephemeral: Whether to defer ephemerally (message only visible to the user that triggered + the command). + edit: Whether the eventual response should cause an edit instead of creating a new message. + + Returns: + :obj:`None` + """ + async with self._response_lock: + if self._initial_response_sent: + return + + response_type = ( + hikari.ResponseType.DEFERRED_MESSAGE_UPDATE if edit else hikari.ResponseType.DEFERRED_MESSAGE_CREATE + ) + await self._create_initial_response( + response_type, + flags=hikari.MessageFlag.EPHEMERAL if ephemeral else hikari.MessageFlag.NONE, + ) + self._initial_response_sent = True + + async def respond( + self, + content: hikari.UndefinedOr[t.Any] = hikari.UNDEFINED, + *, + ephemeral: bool = False, + edit: bool = False, + flags: int | hikari.MessageFlag | hikari.UndefinedType = hikari.UNDEFINED, + tts: hikari.UndefinedOr[bool] = hikari.UNDEFINED, + attachment: hikari.UndefinedOr[hikari.Resourceish] = hikari.UNDEFINED, + attachments: hikari.UndefinedOr[Sequence[hikari.Resourceish]] = hikari.UNDEFINED, + component: hikari.UndefinedOr[special_endpoints.ComponentBuilder] = hikari.UNDEFINED, + components: hikari.UndefinedOr[Sequence[special_endpoints.ComponentBuilder]] = hikari.UNDEFINED, + embed: hikari.UndefinedOr[hikari.Embed] = hikari.UNDEFINED, + embeds: hikari.UndefinedOr[Sequence[hikari.Embed]] = hikari.UNDEFINED, + mentions_everyone: hikari.UndefinedOr[bool] = hikari.UNDEFINED, + user_mentions: hikari.UndefinedOr[hikari.SnowflakeishSequence[hikari.PartialUser] | bool] = hikari.UNDEFINED, + role_mentions: hikari.UndefinedOr[hikari.SnowflakeishSequence[hikari.PartialRole] | bool] = hikari.UNDEFINED, + ) -> hikari.Snowflakeish: + """ + Create a response to the interaction that this context represents. + + Args: + content: The message contents. + ephemeral: Whether the message should be ephemeral (only visible to the user that triggered the command). + This is just a convenience argument - passing ``flags=hikari.MessageFlag.EPHEMERAL`` will function + the same way. + edit: Whether the response should cause an edit instead of creating a new message. + attachment: The message attachment. + attachments: The message attachments. + component: The builder object of the component to include in this message. + components: The sequence of the component builder objects to include in this message. + embed: The message embed. + embeds: The message embeds. + flags: The message flags this response should have. + tts: Whether the message will be read out by a screen reader using Discord's TTS (text-to-speech) system. + mentions_everyone: Whether the message should parse @everyone/@here mentions. + user_mentions: The user mentions to include in the message. + role_mentions: The role mentions to include in the message. + + Returns: + :obj:`hikari.snowflakes.Snowflakeish`: An identifier for the response. This can then be used to edit, + delete, or fetch the response message using the appropriate methods. + + Note: + This documentation does not contain a full description of the parameters as they would just + be copy-pasted from the hikari documentation. See + :meth:`~hikari.interactions.base_interactions.MessageResponseMixin.create_initial_response` for a more + detailed description. + + Note: + If this is **not** creating an initial response and ``edit`` is :obj:`True`, then this will **always** edit + the initial response, not the most recently created response. + + See Also: + :meth:`~lightbulb.context.MessageResponseMixin.edit_response` + :meth:`~lightbulb.context.MessageResponseMixin.delete_response` + :meth:`~lightbulb.context.MessageResponseMixin.fetch_response` + """ + if ephemeral: + flags = (flags or hikari.MessageFlag.NONE) | hikari.MessageFlag.EPHEMERAL + + async with self._response_lock: + if not self._initial_response_sent: + await self._create_initial_response( + hikari.ResponseType.MESSAGE_UPDATE if edit else hikari.ResponseType.MESSAGE_CREATE, + content, + flags=flags, + tts=tts, + attachment=attachment, + attachments=attachments, + component=component, + components=components, + embed=embed, + embeds=embeds, + mentions_everyone=mentions_everyone, + user_mentions=user_mentions, + role_mentions=role_mentions, + ) + self._initial_response_sent = True + return constants.INITIAL_RESPONSE_IDENTIFIER + else: + if edit: + return ( + await self.edit_response( + constants.INITIAL_RESPONSE_IDENTIFIER, + content, + attachment=attachment, + attachments=attachments, + component=component, + components=components, + embed=embed, + embeds=embeds, + mentions_everyone=mentions_everyone, + user_mentions=user_mentions, + role_mentions=role_mentions, + ) + ).id + # This will automatically cause a response if the initial response was deferred previously. + # I am not sure if this is intentional by discord however so, we may want to look into changing + # this to actually edit the initial response if it was previously deferred. + return ( + await self.interaction.execute( + content, + flags=flags, + tts=tts, + attachment=attachment, + attachments=attachments, + component=component, + components=components, + embed=embed, + embeds=embeds, + mentions_everyone=mentions_everyone, + user_mentions=user_mentions, + role_mentions=role_mentions, + ) + ).id + + +class BuildableComponentContainer(abc.ABC, Sequence[special_endpoints.ComponentBuilder], t.Generic[RowT]): + """ + Abstract base class allowing subclasses to be used as containers for :obj:`~BaseComponent`s, as well + as being passed to the ``components=`` kwarg of respond methods. + + This class does not require ``super().__init__()`` to be called within subclasses. + """ + + __slots__ = ("__current_row", "__rows") + + __current_row: int + __rows: list[list[BaseComponent[RowT]]] + + @property + def _current_row(self) -> int: + try: + return self.__current_row + except AttributeError: + self.__current_row = 0 + return self.__current_row + + @property + def _rows(self) -> list[list[BaseComponent[RowT]]]: + try: + return self.__rows + except AttributeError: + self.__rows = [[] for _ in range(self._max_rows)] + return self.__rows + + @t.overload + def __getitem__(self, item: int) -> special_endpoints.ComponentBuilder: ... + + @t.overload + def __getitem__(self, item: slice) -> Sequence[special_endpoints.ComponentBuilder]: ... + + def __getitem__( + self, item: int | slice + ) -> special_endpoints.ComponentBuilder | Sequence[special_endpoints.ComponentBuilder]: + return self._build().__getitem__(item) + + def __len__(self) -> int: + return sum(1 for row in self._rows if row) + + def _build(self) -> Sequence[special_endpoints.ComponentBuilder]: + built_rows: list[special_endpoints.ComponentBuilder] = [] + for row in self._rows: + if not row: + continue + + bld = self._make_action_row() + for component in row: + bld = component.add_to_row(bld) + built_rows.append(bld) + return built_rows + + def clear_rows(self) -> t_ex.Self: + """Remove all components from this container.""" + self._rows.clear() + return self + + def clear_current_row(self) -> t_ex.Self: + """Remove all components from the current row.""" + self._rows[self._current_row].clear() + return self + + def next_row(self) -> t_ex.Self: + """Move the current row pointer to the next row.""" + if self._current_row + 1 >= self._max_rows: + raise RuntimeError("the maximum number of rows has been reached") + self.__current_row += 1 + return self + + def previous_row(self) -> t_ex.Self: + """Move the current row pointer back to the previous row.""" + self.__current_row = max(0, self.__current_row - 1) + return self + + def add(self, component: BaseComponentT) -> BaseComponentT: + """ + Adds the given component to the container. + + Args: + component: The component to add. + + Returns: + The added component. + """ + if self._current_row_full(): + self.next_row() + + self._rows[self._current_row].append(component) + return component + + @property + @abc.abstractmethod + def _max_rows(self) -> int: + """The maximum number of rows allowed for this container.""" + + @abc.abstractmethod + def _make_action_row(self) -> RowT: + """Create and return an instance of the row type needed for the components to be added to.""" + + @abc.abstractmethod + def _current_row_full(self) -> bool: + """Whether the current row is full. Used during layout to know when to advance to the next row.""" diff --git a/lightbulb/components/menus.py b/lightbulb/components/menus.py new file mode 100644 index 00000000..d4bec260 --- /dev/null +++ b/lightbulb/components/menus.py @@ -0,0 +1,874 @@ +# -*- coding: utf-8 -*- +# +# api_ref_gen::add_autodoc_option::inherited-members +# +# 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. +from __future__ import annotations + +__all__ = [ + "ChannelSelect", + "InteractiveButton", + "LinkButton", + "MentionableSelect", + "Menu", + "MenuContext", + "RoleSelect", + "Select", + "TextSelect", + "UserSelect", +] + +import abc +import asyncio +import typing as t +import uuid +from collections.abc import Sequence + +import async_timeout +import hikari +from hikari.api import special_endpoints +from hikari.impl import special_endpoints as special_endpoints_impl + +from lightbulb.components import base + +if t.TYPE_CHECKING: + from collections.abc import Awaitable + from collections.abc import Callable + + from lightbulb import client as client_ + + ValidSelectOptions: t.TypeAlias = t.Union[Sequence["TextSelectOption"], Sequence[str], Sequence[tuple[str, str]]] + ComponentCallback: t.TypeAlias = Callable[["MenuContext"], Awaitable[None]] + +T = t.TypeVar("T") +MessageComponentT = t.TypeVar("MessageComponentT", bound=base.BaseComponent[special_endpoints.MessageActionRowBuilder]) + +Emojiish: t.TypeAlias = t.Union[hikari.Snowflakeish, str, hikari.Emoji] + + +class InteractiveButton(base.BaseComponent[special_endpoints.MessageActionRowBuilder]): + """Class representing an interactive button.""" + + __slots__ = ("_custom_id", "callback", "disabled", "emoji", "label", "style") + + def __init__( + self, + style: hikari.ButtonStyle, + custom_id: str, + label: hikari.UndefinedOr[str], + emoji: hikari.UndefinedOr[Emojiish], + disabled: bool, + callback: ComponentCallback, + ) -> None: + self.style: hikari.ButtonStyle = style + """The style of the button.""" + + self._custom_id: str = custom_id + + self.label: hikari.UndefinedOr[str] = label + """The label for the button.""" + self.emoji: hikari.UndefinedOr[Emojiish] = emoji + """The emoji for the button.""" + self.disabled: bool = disabled + """Whether the button is disabled.""" + self.callback: ComponentCallback = callback + """The callback method to call when the button is pressed.""" + + @property + def custom_id(self) -> str: + """The custom id of the button.""" + return self._custom_id + + def add_to_row(self, row: special_endpoints.MessageActionRowBuilder) -> special_endpoints.MessageActionRowBuilder: + return row.add_interactive_button( + self.style, # type: ignore[reportArgumentType] + self.custom_id, + emoji=self.emoji, + label=self.label, + is_disabled=self.disabled, + ) + + +class LinkButton(base.BaseComponent[special_endpoints.MessageActionRowBuilder]): + """Dataclass representing a link button.""" + + __slots__ = ("disabled", "emoji", "label", "url") + + def __init__( + self, url: str, label: hikari.UndefinedOr[str], emoji: hikari.UndefinedOr[Emojiish], disabled: bool + ) -> None: + self.url: str = url + """The url the button links to.""" + self.label: hikari.UndefinedOr[str] = label + """The label for the button.""" + self.emoji: hikari.UndefinedOr[Emojiish] = emoji + """The emoji for the button.""" + self.disabled: bool = disabled + """Whether the button is disabled.""" + + @property + def custom_id(self) -> str: + return "__lightbulb_placeholder__" + + def add_to_row(self, row: special_endpoints.MessageActionRowBuilder) -> special_endpoints.MessageActionRowBuilder: + return row.add_link_button( + self.url, + emoji=self.emoji, + label=self.label, + is_disabled=self.disabled, + ) + + +class Select(t.Generic[T], base.BaseComponent[special_endpoints.MessageActionRowBuilder], abc.ABC): + """Dataclass representing a generic select menu.""" + + __slots__ = ("_custom_id", "callback", "disabled", "max_values", "min_values", "placeholder") + + def __init__( + self, + custom_id: str, + placeholder: hikari.UndefinedOr[str], + min_values: int, + max_values: int, + disabled: bool, + callback: ComponentCallback, + ) -> None: + self._custom_id: str = custom_id + + self.placeholder: hikari.UndefinedOr[str] = placeholder + """The placeholder for the select menu.""" + self.min_values: int = min_values + """The minimum number of items that can be selected.""" + self.max_values: int = max_values + """The maximum number of items that can be selected.""" + self.disabled: bool = disabled + """Whether the select menu is disabled.""" + self.callback: ComponentCallback = callback + """The callback method to call when the select menu is submitted.""" + + @property + def custom_id(self) -> str: + """The custom id of the select menu.""" + return self._custom_id + + +class TextSelectOption: + """Class representing an option for a text select menu.""" + + __slots__ = ("default", "description", "emoji", "label", "value") + + def __init__( + self, + label: str, + value: str, + description: hikari.UndefinedOr[str] = hikari.UNDEFINED, + emoji: hikari.UndefinedOr[Emojiish] = hikari.UNDEFINED, + default: bool = False, + ) -> None: + self.label: str = label + """The label for the option.""" + self.value: str = value + """The value of the option.""" + self.description: hikari.UndefinedOr[str] = description + """The description of the option.""" + self.emoji: hikari.UndefinedOr[Emojiish] = emoji + """The emoji for the option.""" + self.default: bool = default + """Whether this option should be set as selected by default.""" + + +class TextSelect(Select[str]): + """Class representing a select menu with text options.""" + + __slots__ = ("options",) + + def __init__( + self, + custom_id: str, + placeholder: hikari.UndefinedOr[str], + min_values: int, + max_values: int, + disabled: bool, + callback: ComponentCallback, + options: ValidSelectOptions, + ) -> None: + super().__init__(custom_id, placeholder, min_values, max_values, disabled, callback) + + self.options: ValidSelectOptions = options + """The options for the select menu.""" + + def add_to_row(self, row: special_endpoints.MessageActionRowBuilder) -> special_endpoints.MessageActionRowBuilder: + normalised_options: list[TextSelectOption] = [] + + for option in self.options: + if isinstance(option, str): + normalised_options.append(TextSelectOption(option, option)) + elif isinstance(option, tuple): + normalised_options.append(TextSelectOption(option[0], option[1])) + else: + normalised_options.append(option) + + bld = row.add_text_menu( + self.custom_id, + placeholder=self.placeholder, + min_values=self.min_values, + max_values=self.max_values, + is_disabled=self.disabled, + ) + for opt in normalised_options: + bld = bld.add_option( + opt.label, opt.value, description=opt.description, emoji=opt.emoji, is_default=opt.default + ) + return bld.parent + + +class UserSelect(Select[hikari.User]): + """Class representing a select menu with user options.""" + + __slots__ = () + + def add_to_row(self, row: special_endpoints.MessageActionRowBuilder) -> special_endpoints.MessageActionRowBuilder: + return row.add_select_menu( + hikari.ComponentType.USER_SELECT_MENU, + self.custom_id, + placeholder=self.placeholder, + min_values=self.min_values, + max_values=self.max_values, + is_disabled=self.disabled, + ) + + +class RoleSelect(Select[hikari.Role]): + """Class representing a select menu with role options.""" + + __slots__ = () + + def add_to_row(self, row: special_endpoints.MessageActionRowBuilder) -> special_endpoints.MessageActionRowBuilder: + return row.add_select_menu( + hikari.ComponentType.ROLE_SELECT_MENU, + self.custom_id, + placeholder=self.placeholder, + min_values=self.min_values, + max_values=self.max_values, + is_disabled=self.disabled, + ) + + +class MentionableSelect(Select[hikari.Unique]): + """Class representing a select menu with snowflake options.""" + + __slots__ = () + + def add_to_row(self, row: special_endpoints.MessageActionRowBuilder) -> special_endpoints.MessageActionRowBuilder: + return row.add_select_menu( + hikari.ComponentType.MENTIONABLE_SELECT_MENU, + self.custom_id, + placeholder=self.placeholder, + min_values=self.min_values, + max_values=self.max_values, + is_disabled=self.disabled, + ) + + +class ChannelSelect(Select[hikari.PartialChannel]): + """Class representing a select menu with channel options.""" + + __slots__ = ("channel_types",) + + def __init__( + self, + custom_id: str, + placeholder: hikari.UndefinedOr[str], + min_values: int, + max_values: int, + disabled: bool, + callback: ComponentCallback, + channel_types: hikari.UndefinedOr[Sequence[hikari.ChannelType]], + ) -> None: + super().__init__(custom_id, placeholder, min_values, max_values, disabled, callback) + + self.channel_types: hikari.UndefinedOr[Sequence[hikari.ChannelType]] = channel_types + """Channel types permitted to be shown as options.""" + + def add_to_row(self, row: special_endpoints.MessageActionRowBuilder) -> special_endpoints.MessageActionRowBuilder: + return row.add_channel_menu( + self.custom_id, + channel_types=self.channel_types or (), + placeholder=self.placeholder, + min_values=self.min_values, + max_values=self.max_values, + is_disabled=self.disabled, + ) + + +class MenuContext(base.MessageResponseMixinWithEdit[hikari.ComponentInteraction]): + """Class representing the context for an invocation of a component that belongs to a menu.""" + + __slots__ = ("_interaction", "_should_re_resolve_custom_ids", "_should_stop_menu", "_timeout", "component", "menu") + + def __init__( + self, + menu: Menu, + interaction: hikari.ComponentInteraction, + component: base.BaseComponent[special_endpoints.MessageActionRowBuilder], + _timeout: async_timeout.Timeout, + ) -> None: + super().__init__() + + self.menu: Menu = menu + """The menu that this context is for.""" + self._interaction: hikari.ComponentInteraction = interaction + self.component: base.BaseComponent[special_endpoints.MessageActionRowBuilder] = component + """The component that triggered the interaction for this context.""" + + self._timeout: async_timeout.Timeout = _timeout + self._should_stop_menu: bool = False + self._should_re_resolve_custom_ids: bool = False + + @property + def interaction(self) -> hikari.ComponentInteraction: + """The interaction that this context is for.""" + return self._interaction + + @property + def guild_id(self) -> hikari.Snowflake | None: + """The ID of the guild that the interaction was created in. :obj:`None` if the interaction occurred in DM.""" + return self.interaction.guild_id + + @property + def channel_id(self) -> hikari.Snowflake: + """The ID of the channel that the interaction was created in.""" + return self.interaction.channel_id + + @property + def user(self) -> hikari.User: + """The user that created the interaction.""" + return self.interaction.user + + @property + def member(self) -> hikari.InteractionMember | None: + """The member that created the interaction, if it was created in a guild.""" + return self.interaction.member + + def stop_interacting(self) -> None: + """Stop receiving interactions for the linked menu.""" + self._should_stop_menu = True + + def extend_timeout(self, length: float) -> None: + """ + Extend the menu's timeout by the given length. + + Args: + length: The number of seconds to extend the timeout for. + + Returns: + :obj:`None` + """ + self._timeout.shift(length) + + def selected_values_for(self, select: Select[T]) -> Sequence[T]: + """ + Get the values the user selected for the given select menu. + + Args: + select: The select menu component to get the selected values for. + + Returns: + The selected values. + """ + if self.interaction.custom_id != select.custom_id: + return () + + if isinstance(select, TextSelect): + # This is **not** unreachable, pyright is just a silly sausage, and I don't want + # to add an overload for all the supported select types :D + return t.cast(Sequence[T], self.interaction.values) + + resolved_data = self.interaction.resolved + if resolved_data is None: + raise RuntimeError("resolved option data is not available") + + resolved: list[T] = [] + for value in self.interaction.values: + sf = hikari.Snowflake(value) + resolved.append( + resolved_data.members.get(sf) + or resolved_data.users.get(sf) + or resolved_data.roles.get(sf) + or resolved_data.channels[sf] # type: ignore[reportArgumentType] + ) + + return resolved + + async def respond_with_modal( + self, + title: str, + custom_id: str, + component: hikari.UndefinedOr[special_endpoints.ComponentBuilder] = hikari.UNDEFINED, + components: hikari.UndefinedOr[Sequence[special_endpoints.ComponentBuilder]] = hikari.UNDEFINED, + ) -> None: + """ + Create a modal response to the interaction that this context represents. + + Args: + title: The title that will show up in the modal. + custom_id: Developer set custom ID used for identifying interactions with this modal. + component: A component builder to send in this modal. + components: A sequence of component builders to send in this modal. + + Returns: + :obj:`None` + + Raises: + :obj:`RuntimeError`: If an initial response has already been sent. + """ + async with self._response_lock: + if self._initial_response_sent: + raise RuntimeError("cannot respond with a modal if an initial response has already been sent") + + await self.interaction.create_modal_response(title, custom_id, component, components) + self._initial_response_sent = True + + async def respond( + self, + content: hikari.UndefinedOr[t.Any] = hikari.UNDEFINED, + *, + ephemeral: bool = False, + edit: bool = False, + rebuild_menu: bool = False, + flags: int | hikari.MessageFlag | hikari.UndefinedType = hikari.UNDEFINED, + tts: hikari.UndefinedOr[bool] = hikari.UNDEFINED, + attachment: hikari.UndefinedOr[hikari.Resourceish] = hikari.UNDEFINED, + attachments: hikari.UndefinedOr[Sequence[hikari.Resourceish]] = hikari.UNDEFINED, + component: hikari.UndefinedOr[special_endpoints.ComponentBuilder] = hikari.UNDEFINED, + components: hikari.UndefinedOr[Sequence[special_endpoints.ComponentBuilder]] = hikari.UNDEFINED, + embed: hikari.UndefinedOr[hikari.Embed] = hikari.UNDEFINED, + embeds: hikari.UndefinedOr[Sequence[hikari.Embed]] = hikari.UNDEFINED, + mentions_everyone: hikari.UndefinedOr[bool] = hikari.UNDEFINED, + user_mentions: hikari.UndefinedOr[hikari.SnowflakeishSequence[hikari.PartialUser] | bool] = hikari.UNDEFINED, + role_mentions: hikari.UndefinedOr[hikari.SnowflakeishSequence[hikari.PartialRole] | bool] = hikari.UNDEFINED, + ) -> hikari.Snowflakeish: + """ + Create a response to the interaction that this context represents. + + Args: + content: The message contents. + ephemeral: Whether the message should be ephemeral (only visible to the user that triggered the command). + This is just a convenience argument - passing `flags=hikari.MessageFlag.EPHEMERAL` will function + the same way. + edit: Whether the response should cause an edit instead of creating a new message. + rebuild_menu: Whether the menu this context is for should be rebuilt and sent with the response. This + is just a convenience argument - passing `components=menu` will function the same way. If you **also** + pass a value to ``components``, that value will be used instead. + attachment: The message attachment. + attachments: The message attachments. + component: The builder object of the component to include in this message. + components: The sequence of the component builder objects to include in this message. + embed: The message embed. + embeds: The message embeds. + flags: The message flags this response should have. + tts: Whether the message will be read out by a screen reader using Discord's TTS (text-to-speech) system. + mentions_everyone: Whether the message should parse @everyone/@here mentions. + user_mentions: The user mentions to include in the message. + role_mentions: The role mentions to include in the message. + + Returns: + :obj:`hikari.snowflakes.Snowflakeish`: An identifier for the response. This can then be used to edit, + delete, or fetch the response message using the appropriate methods. + + Note: + This documentation does not contain a full description of the parameters as they would just + be copy-pasted from the hikari documentation. See + :obj:`~hikari.interactions.base_interactions.MessageResponseMixin.create_initial_response` for a more + detailed description. + + Note: + If this is **not** creating an initial response and ``edit`` is :obj:True`, then this will **always** edit + the initial response, not the most recently created response. + + See Also: + :meth:`~MenuContext.edit_response` + :meth:`~MenuContext.delete_response` + :meth:`~MenuContext.fetch_response` + """ + if rebuild_menu: + components = components if components is not hikari.UNDEFINED else self.menu + + return await super().respond( + content, + ephemeral=ephemeral, + edit=edit, + flags=flags, + tts=tts, + attachment=attachment, + attachments=attachments, + component=component, + components=components, + embed=embed, + embeds=embeds, + mentions_everyone=mentions_everyone, + user_mentions=user_mentions, + role_mentions=role_mentions, + ) + + +class Menu(base.BuildableComponentContainer[special_endpoints.MessageActionRowBuilder]): + """Class representing a component menu.""" + + __slots__ = () + + _MAX_BUTTONS_PER_ROW: t.Final[int] = 5 + + @property + def _max_rows(self) -> int: + return 5 + + def _make_action_row(self) -> special_endpoints.MessageActionRowBuilder: + return special_endpoints_impl.MessageActionRowBuilder() + + def _current_row_full(self) -> bool: + return bool( + len(self._rows[self._current_row]) >= self._MAX_BUTTONS_PER_ROW + or ((r := self._rows[self._current_row]) and isinstance(r[0], Select)) + ) + + def add_interactive_button( + self, + style: hikari.ButtonStyle, + on_press: ComponentCallback, + *, + custom_id: hikari.UndefinedOr[str] = hikari.UNDEFINED, + label: hikari.UndefinedOr[str] = hikari.UNDEFINED, + emoji: hikari.UndefinedOr[hikari.Snowflakeish | str | hikari.Emoji] = hikari.UNDEFINED, + disabled: bool = False, + ) -> InteractiveButton: + """ + Add an interactive button to this menu. + + Args: + style: The style of the button. + on_press: The asynchronous function to call when the button is pressed. + custom_id: The custom ID for the button. Only specify this when you are creating a persistent + menu. If unspecified, one will be generated for you. + label: The label for the button. + emoji: The emoji for the button. + disabled: Whether the button is disabled. + + Returns: + The created button. + + Raises: + :obj:`ValueError`: When neither ``label`` nor ``emoji`` are specified. + """ + if label is hikari.UNDEFINED and emoji is hikari.UNDEFINED: + raise ValueError("at least one of 'label' and 'emoji' must be specified") + + return self.add( + InteractiveButton( + style=style, + custom_id=custom_id or str(uuid.uuid4()), + label=label, + emoji=emoji, + disabled=disabled, + callback=on_press, + ) + ) + + def add_link_button( + self, + url: str, + *, + label: hikari.UndefinedOr[str] = hikari.UNDEFINED, + emoji: hikari.UndefinedOr[hikari.Snowflakeish | str | hikari.Emoji] = hikari.UNDEFINED, + disabled: bool = False, + ) -> LinkButton: + """ + Add a link button to this menu. + + Args: + url: The url the button should link to. + label: The label for the button. + emoji: The emoji for the button. + disabled: Whether the button is disabled. + + Returns: + The created button. + + Raises: + :obj:`ValueError`: When neither ``label`` nor ``emoji`` are specified. + """ + if label is hikari.UNDEFINED and emoji is hikari.UNDEFINED: + raise ValueError("at least one of 'label' and 'emoji' must be specified") + + return self.add(LinkButton(url=url, label=label, emoji=emoji, disabled=disabled)) + + def add_text_select( + self, + options: ValidSelectOptions, + on_select: ComponentCallback, + *, + custom_id: hikari.UndefinedOr[str] = hikari.UNDEFINED, + placeholder: hikari.UndefinedOr[str] = hikari.UNDEFINED, + min_values: int = 1, + max_values: int = 1, + disabled: bool = False, + ) -> TextSelect: + """ + Add a text select menu to this menu. + + Args: + options: The options for the select menu. + on_select: The asynchronous function to call when the select menu is submitted. + custom_id: The custom ID for the select menu. Only specify this when you are creating a persistent + menu. If unspecified, one will be generated for you. + placeholder: The placeholder string for the select menu. + min_values: The minimum number of values that can be selected. + max_values: The maximum number of values that can be selected. + disabled: Whether the select menu is disabled. + + Returns: + The created select menu. + """ + return self.add( + TextSelect( + custom_id=custom_id or str(uuid.uuid4()), + placeholder=placeholder, + min_values=min_values, + max_values=max_values, + disabled=disabled, + callback=on_select, + options=options, + ) + ) + + def add_user_select( + self, + on_select: ComponentCallback, + *, + custom_id: hikari.UndefinedOr[str] = hikari.UNDEFINED, + placeholder: hikari.UndefinedOr[str] = hikari.UNDEFINED, + min_values: int = 1, + max_values: int = 1, + disabled: bool = False, + ) -> UserSelect: + """ + Add a user select menu to this menu. + + Args: + on_select: The asynchronous function to call when the select menu is submitted. + custom_id: The custom ID for the select menu. Only specify this when you are creating a persistent + menu. If unspecified, one will be generated for you. + placeholder: The placeholder string for the select menu. + min_values: The minimum number of values that can be selected. + max_values: The maximum number of values that can be selected. + disabled: Whether the select menu is disabled. + + Returns: + The created select menu. + """ + return self.add( + UserSelect( + custom_id=custom_id or str(uuid.uuid4()), + placeholder=placeholder, + min_values=min_values, + max_values=max_values, + disabled=disabled, + callback=on_select, + ) + ) + + def add_role_select( + self, + on_select: ComponentCallback, + *, + custom_id: hikari.UndefinedOr[str] = hikari.UNDEFINED, + placeholder: hikari.UndefinedOr[str] = hikari.UNDEFINED, + min_values: int = 1, + max_values: int = 1, + disabled: bool = False, + ) -> RoleSelect: + """ + Add a role select menu to this menu. + + Args: + on_select: The asynchronous function to call when the select menu is submitted. + custom_id: The custom ID for the select menu. Only specify this when you are creating a persistent + menu. If unspecified, one will be generated for you. + placeholder: The placeholder string for the select menu. + min_values: The minimum number of values that can be selected. + max_values: The maximum number of values that can be selected. + disabled: Whether the select menu is disabled. + + Returns: + The created select menu. + """ + return self.add( + RoleSelect( + custom_id=custom_id or str(uuid.uuid4()), + placeholder=placeholder, + min_values=min_values, + max_values=max_values, + disabled=disabled, + callback=on_select, + ) + ) + + def add_mentionable_select( + self, + on_select: ComponentCallback, + *, + custom_id: hikari.UndefinedOr[str] = hikari.UNDEFINED, + placeholder: hikari.UndefinedOr[str] = hikari.UNDEFINED, + min_values: int = 1, + max_values: int = 1, + disabled: bool = False, + ) -> MentionableSelect: + """ + Add a 'mentionable object' select menu to this menu. + + Args: + on_select: The asynchronous function to call when the select menu is submitted. + custom_id: The custom ID for the select menu. Only specify this when you are creating a persistent + menu. If unspecified, one will be generated for you. + placeholder: The placeholder string for the select menu. + min_values: The minimum number of values that can be selected. + max_values: The maximum number of values that can be selected. + disabled: Whether the select menu is disabled. + + Returns: + The created select menu. + """ + return self.add( + MentionableSelect( + custom_id=custom_id or str(uuid.uuid4()), + placeholder=placeholder, + min_values=min_values, + max_values=max_values, + disabled=disabled, + callback=on_select, + ) + ) + + def add_channel_select( + self, + on_select: ComponentCallback, + *, + custom_id: hikari.UndefinedOr[str] = hikari.UNDEFINED, + placeholder: hikari.UndefinedOr[str] = hikari.UNDEFINED, + min_values: int = 1, + max_values: int = 1, + disabled: bool = False, + channel_types: hikari.UndefinedOr[Sequence[hikari.ChannelType]] = hikari.UNDEFINED, + ) -> ChannelSelect: + """ + Add a channel select menu to this menu. + + Args: + on_select: The asynchronous function to call when the select menu is submitted. + custom_id: The custom ID for the select menu. Only specify this when you are creating a persistent + menu. If unspecified, one will be generated for you. + placeholder: The placeholder string for the select menu. + min_values: The minimum number of values that can be selected. + max_values: The maximum number of values that can be selected. + disabled: Whether the select menu is disabled. + channel_types: The channel types allowed to be selected. + + Returns: + The created select menu. + """ + return self.add( + ChannelSelect( + custom_id=custom_id or str(uuid.uuid4()), + placeholder=placeholder, + min_values=min_values, + max_values=max_values, + disabled=disabled, + callback=on_select, + channel_types=channel_types, + ) + ) + + async def _run_menu(self, client: client_.Client, timeout: float | None = None) -> None: # noqa: ASYNC109 + all_custom_ids: dict[str, base.BaseComponent[special_endpoints.MessageActionRowBuilder]] = {} + re_resolve_custom_ids: bool = True + + queue: asyncio.Queue[hikari.ComponentInteraction] = asyncio.Queue() + client._menu_queues.add(queue) + try: + stopped: bool = False + async with async_timeout.timeout(timeout) as tm: + # TODO - consider whether individual interactions should be processed in parallel + # instead of waiting for the previous callback to finish before checking the queue again. + # TODO - This could potentially present a race condition where the menu has been stopped but some + # interactions linger that never get responses in the current state. + while not stopped: + if re_resolve_custom_ids: + all_custom_ids = { + c.custom_id: c for row in self._rows for c in row if not isinstance(c, LinkButton) + } + re_resolve_custom_ids = False + + interaction = await queue.get() + if interaction.custom_id not in all_custom_ids: + continue + + component = all_custom_ids[interaction.custom_id] + context = MenuContext(menu=self, interaction=interaction, component=component, _timeout=tm) + + callback: t.Callable[[MenuContext], t.Awaitable[None]] = getattr(component, "callback") + await callback(context) + + stopped = context._should_stop_menu + re_resolve_custom_ids = context._should_re_resolve_custom_ids + finally: + # Unregister queue from client + client._menu_queues.remove(queue) + + async def attach( + self, + client: client_.Client, + *, + wait: bool = False, + timeout: float | None = None, # noqa: ASYNC109 + ) -> asyncio.Task[None]: + """ + Attach this menu to the given client, starting it. You may optionally wait for the menu to finish and/or + provide a timeout, after which an :obj:`asyncio.TimeoutError` will be raised. + + Args: + client: The client to attach the menu to. + wait: Whether to wait for the menu to finish. + timeout: The amount of time in seconds before the menu will time out. + + Returns: + The created task. This allows you to await it later in case you want to perform some logic before + waiting for the menu to finish. + + Raises: + :obj:`asyncio.TimeoutError`: If the timeout is exceeded, and ``wait=True``. If you wait on the returned + task instead, you are expected to check for an exception yourself. + """ + task = client._safe_create_task(self._run_menu(client, timeout)) + if wait: + await task + if (exc := task.exception()) is not None and not isinstance(exc, asyncio.CancelledError): + raise exc + return task diff --git a/lightbulb/components/modals.py b/lightbulb/components/modals.py new file mode 100644 index 00000000..9aae8259 --- /dev/null +++ b/lightbulb/components/modals.py @@ -0,0 +1,294 @@ +# -*- coding: utf-8 -*- +# +# api_ref_gen::add_autodoc_option::inherited-members +# +# 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. +from __future__ import annotations + +__all__ = ["Modal", "ModalContext", "TextInput"] + +import abc +import asyncio +import typing as t +import uuid + +import async_timeout +import hikari +from hikari.api import special_endpoints +from hikari.impl import special_endpoints as special_endpoints_impl + +from lightbulb import context +from lightbulb.components import base + +if t.TYPE_CHECKING: + from lightbulb import client as client_ + +ModalComponentT = t.TypeVar("ModalComponentT", bound=base.BaseComponent[special_endpoints.ModalActionRowBuilder]) + + +class TextInput(base.BaseComponent[special_endpoints.ModalActionRowBuilder]): + """Class representing a text input.""" + + __slots__ = ("_custom_id", "label", "max_length", "min_length", "placeholder", "required", "style", "value") + + def __init__( + self, + custom_id: str, + style: hikari.TextInputStyle, + label: str, + min_length: int, + max_length: int, + required: bool, + value: hikari.UndefinedOr[str], + placeholder: hikari.UndefinedOr[str], + ) -> None: + self._custom_id: str = custom_id + + self.style: hikari.TextInputStyle = style + """The style of the text input.""" + self.label: str = label + """The label for the text input.""" + self.min_length: int = min_length + """The minimum length of the inputted text.""" + self.max_length: int = max_length + """The maximum length of the inputted text.""" + self.required: bool = required + """Whether the text input is required to be filled.""" + self.value: hikari.UndefinedOr[str] = value + """The default value of the text input.""" + self.placeholder: hikari.UndefinedOr[str] = placeholder + """The placeholder value for the text input.""" + + @property + def custom_id(self) -> str: + """The custom id of the text input.""" + return self._custom_id + + def add_to_row(self, row: special_endpoints.ModalActionRowBuilder) -> special_endpoints.ModalActionRowBuilder: + return row.add_text_input( + self.custom_id, + self.label, + style=self.style, + placeholder=self.placeholder, + value=self.value, + required=self.required, + min_length=self.min_length, + max_length=self.max_length, + ) + + +class ModalContext(context.MessageResponseMixin[hikari.ModalInteraction]): + """Class representing the context for a modal interaction.""" + + __slots__ = ("_interaction", "modal") + + def __init__(self, modal: Modal, interaction: hikari.ModalInteraction) -> None: + super().__init__() + + self.modal: Modal = modal + """The modal this context is for.""" + self._interaction: hikari.ModalInteraction = interaction + + @property + def interaction(self) -> hikari.ModalInteraction: + """The interaction this context is for.""" + return self._interaction + + @property + def guild_id(self) -> hikari.Snowflake | None: + """The ID of the guild that the interaction was created in. :obj:`None` if the interaction occurred in DM.""" + return self.interaction.guild_id + + @property + def channel_id(self) -> hikari.Snowflake: + """The ID of the channel that the interaction was created in.""" + return self.interaction.channel_id + + @property + def user(self) -> hikari.User: + """The user that created the interaction.""" + return self.interaction.user + + @property + def member(self) -> hikari.InteractionMember | None: + """The member that created the interaction, if it was created in a guild.""" + return self.interaction.member + + def value_for(self, input: TextInput) -> str | None: + """ + Get the submitted value for the given text input component. + + Args: + input: The text input component to get the value for. + + Returns: + The value submitted for the given text input component, or :obj:`None` if no value was submitted. + """ + for row in self.interaction.components: + for component in row: + if component.custom_id == input.custom_id: + return component.value + return None + + +class Modal(base.BuildableComponentContainer[special_endpoints.ModalActionRowBuilder], abc.ABC): + """Class representing a modal.""" + + __slots__ = () + + @property + def _max_rows(self) -> int: + return 5 + + def _make_action_row(self) -> special_endpoints.ModalActionRowBuilder: + return special_endpoints_impl.ModalActionRowBuilder() + + def _current_row_full(self) -> bool: + # Currently, you are only allowed a single component within each row + # Maybe Discord will change this in the future + return bool(self._rows[self._current_row]) + + def add_short_text_input( + self, + label: str, + *, + custom_id: hikari.UndefinedOr[str] = hikari.UNDEFINED, + min_length: int = 0, + max_length: int = 4000, + required: bool = True, + value: hikari.UndefinedOr[str] = hikari.UNDEFINED, + placeholder: hikari.UndefinedOr[str] = hikari.UNDEFINED, + ) -> TextInput: + """ + Add a short text input component to this modal. + + Args: + label: The label for the text input. + custom_id: The custom ID for the text input. You probably never want to specify this as it should + be unique across all modals that your application creates. If unspecified, one will be generated + for you. + min_length: The minimum length of the inputted text. + max_length: The maximum length of the inputted text. + required: Whether the text input is required to be filled. + value: The default value of the text input. + placeholder: The placeholder value for the text input. + + Returns: + The created text input component. + """ + return self.add( + TextInput( + custom_id=custom_id or str(uuid.uuid4()), + style=hikari.TextInputStyle.SHORT, + label=label, + min_length=min_length, + max_length=max_length, + required=required, + value=value, + placeholder=placeholder, + ) + ) + + def add_paragraph_text_input( + self, + label: str, + *, + custom_id: hikari.UndefinedOr[str] = hikari.UNDEFINED, + min_length: int = 0, + max_length: int = 4000, + required: bool = True, + value: hikari.UndefinedOr[str] = hikari.UNDEFINED, + placeholder: hikari.UndefinedOr[str] = hikari.UNDEFINED, + ) -> TextInput: + """ + Add a paragraph text input component to this modal. + + Args: + label: The label for the text input. + custom_id: The custom ID for the text input. You probably never want to specify this as it should + be unique across all modals that your application creates. If unspecified, one will be generated + for you. + min_length: The minimum length of the inputted text. + max_length: The maximum length of the inputted text. + required: Whether the text input is required to be filled. + value: The default value of the text input. + placeholder: The placeholder value for the text input. + + Returns: + The created text input component. + """ + return self.add( + TextInput( + custom_id=custom_id or str(uuid.uuid4()), + style=hikari.TextInputStyle.PARAGRAPH, + label=label, + min_length=min_length, + max_length=max_length, + required=required, + value=value, + placeholder=placeholder, + ) + ) + + async def attach(self, client: client_.Client, custom_id: str, *, timeout: float = 30) -> None: # noqa: ASYNC109 + """ + Attach this modal to the given client, starting the interaction listener for it. + + Args: + client: The client to attach this modal to. + custom_id: The custom ID used when sending the modal response. + timeout: The number of seconds to wait for the correct modal interaction before timing out. + + Returns: + :obj:`None` + + Raises: + :obj:`asyncio.TimeoutError`: If the timeout is exceeded. + """ + queue: asyncio.Queue[hikari.ModalInteraction] = asyncio.Queue() + client._modal_queues.add(queue) + try: + stopped: bool = False + async with async_timeout.timeout(timeout): + while not stopped: + interaction = await queue.get() + if interaction.custom_id != custom_id: + continue + + context = ModalContext(modal=self, interaction=interaction) + await self.on_submit(context) + + stopped = True + finally: + # Unregister queue from client + client._modal_queues.remove(queue) + + @abc.abstractmethod + async def on_submit(self, ctx: ModalContext) -> None: + """ + The method to call when the modal is submitted. This **must** be overridden by subclasses. + + Args: + ctx: The context for the modal submission. + + Returns: + :obj:`None` + """ diff --git a/lightbulb/context.py b/lightbulb/context.py index b65a98de..23c8a7c8 100644 --- a/lightbulb/context.py +++ b/lightbulb/context.py @@ -1,4 +1,7 @@ # -*- coding: utf-8 -*- +# +# api_ref_gen::add_autodoc_option::inherited-members +# # Copyright (c) 2023-present tandemdude # # Permission is hereby granted, free of charge, to any person obtaining a copy @@ -20,10 +23,10 @@ # SOFTWARE. from __future__ import annotations -__all__ = ["AutocompleteContext", "Context", "RestContext"] +__all__ = ["AutocompleteContext", "Context", "MessageResponseMixin", "RestContext"] +import abc import asyncio -import dataclasses import os import typing as t from collections.abc import Callable @@ -35,11 +38,16 @@ from hikari.api import special_endpoints from hikari.impl import special_endpoints as special_endpoints_impl +from lightbulb.internal import constants + if t.TYPE_CHECKING: from lightbulb import client as client_ from lightbulb import commands T = t.TypeVar("T", int, str, float) +RespondableInteractionT = t.TypeVar( + "RespondableInteractionT", hikari.CommandInteraction, hikari.ComponentInteraction, hikari.ModalInteraction +) AutocompleteResponse: t.TypeAlias = t.Union[ Sequence[special_endpoints.AutocompleteChoiceBuilder], Sequence[T], @@ -47,25 +55,29 @@ Sequence[tuple[str, T]], ] -INITIAL_RESPONSE_IDENTIFIER: t.Final[int] = -1 - -@dataclasses.dataclass(slots=True) class AutocompleteContext(t.Generic[T]): - """Dataclass representing the context for an autocomplete interaction.""" - - client: client_.Client - """The client that created the context.""" + """Class representing the context for an autocomplete interaction.""" - interaction: hikari.AutocompleteInteraction - """The interaction for the autocomplete invocation.""" - options: Sequence[hikari.AutocompleteInteractionOption] - """The options provided with the autocomplete interaction.""" + __slots__ = ("_focused", "client", "command", "interaction", "options") - command: type[commands.CommandBase] - """Command class for the autocomplete invocation.""" + def __init__( + self, + client: client_.Client, + interaction: hikari.AutocompleteInteraction, + options: Sequence[hikari.AutocompleteInteractionOption], + command: type[commands.CommandBase], + ) -> None: + self.client: client_.Client = client + """The client that created the context.""" + self.interaction: hikari.AutocompleteInteraction = interaction + """The interaction for the autocomplete invocation.""" + self.options: Sequence[hikari.AutocompleteInteractionOption] = options + """The options provided with the autocomplete interaction.""" + self.command: type[commands.CommandBase] = command + """Command class for the autocomplete invocation.""" - _focused: hikari.AutocompleteInteractionOption | None = dataclasses.field(init=False, default=None) + self._focused: hikari.AutocompleteInteractionOption | None = None @property def focused(self) -> hikari.AutocompleteInteractionOption: @@ -135,63 +147,40 @@ async def respond(self, choices: AutocompleteResponse[T]) -> None: await self.interaction.create_response(normalised_choices) -@dataclasses.dataclass(slots=True) class RestAutocompleteContext(AutocompleteContext[T]): - _initial_response_callback: Callable[ - [hikari.api.InteractionAutocompleteBuilder], - None, - ] + __slots__ = ("_initial_response_callback",) + + def __init__( + self, + *args: t.Any, + _initial_response_callback: Callable[ + [hikari.api.InteractionAutocompleteBuilder], + None, + ], + **kwargs: t.Any, + ) -> None: + super().__init__(*args, **kwargs) + + self._initial_response_callback = _initial_response_callback async def respond(self, choices: AutocompleteResponse[T]) -> None: normalised_choices = self._normalise_choices(choices) self._initial_response_callback(special_endpoints_impl.InteractionAutocompleteBuilder(normalised_choices)) -@dataclasses.dataclass(slots=True) -class Context: - """Dataclass representing the context for a single command invocation.""" - - client: client_.Client - """The client that created the context.""" - - interaction: hikari.CommandInteraction - """The interaction for the command invocation.""" - options: Sequence[hikari.CommandInteractionOption] - """The options to use for the command invocation.""" +class MessageResponseMixin(abc.ABC, t.Generic[RespondableInteractionT]): + """Abstract mixin for contexts that allow creating responses to interactions.""" - command: commands.CommandBase - """Command instance for the command invocation.""" + __slots__ = ("_initial_response_sent", "_response_lock") - _response_lock: asyncio.Lock = dataclasses.field(init=False, default_factory=asyncio.Lock) - _initial_response_sent: bool = dataclasses.field(init=False, default=False) - - def __post_init__(self) -> None: - self.command._set_context(self) - - @property - def guild_id(self) -> hikari.Snowflake | None: - """The ID of the guild that the command was invoked in. :obj:`None` if the invocation occurred in DM.""" - return self.interaction.guild_id - - @property - def channel_id(self) -> hikari.Snowflake: - """The ID of the channel that the command was invoked in.""" - return self.interaction.channel_id - - @property - def user(self) -> hikari.User: - """The user that invoked the command.""" - return self.interaction.user - - @property - def member(self) -> hikari.InteractionMember | None: - """The member that invoked the command, if it was invoked in a guild.""" - return self.interaction.member + def __init__(self) -> None: + self._response_lock: asyncio.Lock = asyncio.Lock() + self._initial_response_sent: bool = False @property - def command_data(self) -> commands.CommandData: - """The metadata for the invoked command.""" - return self.command._command_data + @abc.abstractmethod + def interaction(self) -> RespondableInteractionT: + """The interaction that this context is for.""" async def edit_response( self, @@ -230,10 +219,10 @@ async def edit_response( Note: This documentation does not contain a full description of the parameters as they would just be copy-pasted from the hikari documentation. See - :obj:`~hikari.interactions.base_interactions.MessageResponseMixin.edit_initial_response` for a more + :meth:`~hikari.interactions.base_interactions.MessageResponseMixin.edit_initial_response` for a more detailed description. """ - if response_id == INITIAL_RESPONSE_IDENTIFIER: + if response_id == constants.INITIAL_RESPONSE_IDENTIFIER: return await self.interaction.edit_initial_response( content, attachment=attachment, @@ -270,7 +259,7 @@ async def delete_response(self, response_id: hikari.Snowflakeish) -> None: Returns: :obj:`None` """ - if response_id == INITIAL_RESPONSE_IDENTIFIER: + if response_id == constants.INITIAL_RESPONSE_IDENTIFIER: return await self.interaction.delete_initial_response() return await self.interaction.delete_message(response_id) @@ -284,13 +273,13 @@ async def fetch_response(self, response_id: hikari.Snowflakeish) -> hikari.Messa Returns: :obj:`~hikari.messages.Message`: The message for the response with the given identifier. """ - if response_id == INITIAL_RESPONSE_IDENTIFIER: + if response_id == constants.INITIAL_RESPONSE_IDENTIFIER: return await self.interaction.fetch_initial_response() return await self.interaction.fetch_message(response_id) async def _create_initial_response( self, - response_type: t.Literal[hikari.ResponseType.MESSAGE_CREATE, hikari.ResponseType.DEFERRED_MESSAGE_CREATE], + response_type: hikari.ResponseType, content: hikari.UndefinedOr[t.Any] = hikari.UNDEFINED, *, flags: int | hikari.MessageFlag | hikari.UndefinedType = hikari.UNDEFINED, @@ -306,7 +295,7 @@ async def _create_initial_response( role_mentions: hikari.UndefinedOr[hikari.SnowflakeishSequence[hikari.PartialRole] | bool] = hikari.UNDEFINED, ) -> hikari.Snowflakeish: await self.interaction.create_initial_response( - response_type, + response_type, # type: ignore[reportArgumentType] content, flags=flags, tts=tts, @@ -320,9 +309,9 @@ async def _create_initial_response( user_mentions=user_mentions, role_mentions=role_mentions, ) - return INITIAL_RESPONSE_IDENTIFIER + return constants.INITIAL_RESPONSE_IDENTIFIER - async def defer(self, ephemeral: bool = False) -> None: + async def defer(self, *, ephemeral: bool = False) -> None: """ Defer the creation of a response for the interaction that this context represents. @@ -343,32 +332,6 @@ async def defer(self, ephemeral: bool = False) -> None: ) self._initial_response_sent = True - async def respond_with_modal( - self, - title: str, - custom_id: str, - component: hikari.UndefinedOr[special_endpoints.ComponentBuilder] = hikari.UNDEFINED, - components: hikari.UndefinedOr[Sequence[special_endpoints.ComponentBuilder]] = hikari.UNDEFINED, - ) -> None: - """ - Create a modal response to the interaction that this context represents. - - Args: - title: The title that will show up in the modal. - custom_id: Developer set custom ID used for identifying interactions with this modal. - component: A component builder to send in this modal. - components: A sequence of component builders to send in this modal. - - Returns: - :obj:`None` - """ - async with self._response_lock: - if self._initial_response_sent: - return - - await self.interaction.create_modal_response(title, custom_id, component, components) - self._initial_response_sent = True - async def respond( self, content: hikari.UndefinedOr[t.Any] = hikari.UNDEFINED, @@ -392,7 +355,7 @@ async def respond( Args: content: The message contents. ephemeral: Whether the message should be ephemeral (only visible to the user that triggered the command). - This is just a convenience argument - passing `flags=hikari.MessageFlag.EPHEMERAL` will function + This is just a convenience argument - passing ``flags=hikari.MessageFlag.EPHEMERAL`` will function the same way. attachment: The message attachment. attachments: The message attachments. @@ -413,13 +376,13 @@ async def respond( Note: This documentation does not contain a full description of the parameters as they would just be copy-pasted from the hikari documentation. See - :obj:`~hikari.interactions.base_interactions.MessageResponseMixin.create_initial_response` for a more + :meth:`~hikari.interactions.base_interactions.MessageResponseMixin.create_initial_response` for a more detailed description. See Also: - :meth:`~Context.edit_response` - :meth:`~Context.delete_response` - :meth:`~Context.fetch_response` + :meth:`~MessageResponseMixin.edit_response` + :meth:`~MessageResponseMixin.delete_response` + :meth:`~MessageResponseMixin.fetch_response` """ if ephemeral: flags = (flags or hikari.MessageFlag.NONE) | hikari.MessageFlag.EPHEMERAL @@ -442,7 +405,7 @@ async def respond( role_mentions=role_mentions, ) self._initial_response_sent = True - return INITIAL_RESPONSE_IDENTIFIER + return constants.INITIAL_RESPONSE_IDENTIFIER else: # This will automatically cause a response if the initial response was deferred previously. # I am not sure if this is intentional by discord however so, we may want to look into changing @@ -465,12 +428,105 @@ async def respond( ).id -@dataclasses.dataclass(slots=True) +class Context(MessageResponseMixin[hikari.CommandInteraction]): + """Class representing the context for a single command invocation.""" + + __slots__ = ("_interaction", "client", "command", "options") + + def __init__( + self, + client: client_.Client, + interaction: hikari.CommandInteraction, + options: Sequence[hikari.CommandInteractionOption], + command: commands.CommandBase, + ) -> None: + super().__init__() + + self.client: client_.Client = client + """The client that created the context.""" + self._interaction: hikari.CommandInteraction = interaction + self.options: Sequence[hikari.CommandInteractionOption] = options + """The options to use for the command invocation.""" + self.command: commands.CommandBase = command + """Command instance for the command invocation.""" + + self.command._set_context(self) + + @property + def interaction(self) -> hikari.CommandInteraction: + """The interaction for the command invocation.""" + return self._interaction + + @property + def guild_id(self) -> hikari.Snowflake | None: + """The ID of the guild that the command was invoked in. :obj:`None` if the invocation occurred in DM.""" + return self.interaction.guild_id + + @property + def channel_id(self) -> hikari.Snowflake: + """The ID of the channel that the command was invoked in.""" + return self.interaction.channel_id + + @property + def user(self) -> hikari.User: + """The user that invoked the command.""" + return self.interaction.user + + @property + def member(self) -> hikari.InteractionMember | None: + """The member that invoked the command, if it was invoked in a guild.""" + return self.interaction.member + + @property + def command_data(self) -> commands.CommandData: + """The metadata for the invoked command.""" + return self.command._command_data + + async def respond_with_modal( + self, + title: str, + custom_id: str, + component: hikari.UndefinedOr[special_endpoints.ComponentBuilder] = hikari.UNDEFINED, + components: hikari.UndefinedOr[Sequence[special_endpoints.ComponentBuilder]] = hikari.UNDEFINED, + ) -> None: + """ + Create a modal response to the interaction that this context represents. + + Args: + title: The title that will show up in the modal. + custom_id: Developer set custom ID used for identifying interactions with this modal. + component: A component builder to send in this modal. + components: A sequence of component builders to send in this modal. + + Returns: + :obj:`None` + + Raises: + :obj:`RuntimeError`: If an initial response has already been sent. + """ + async with self._response_lock: + if self._initial_response_sent: + raise RuntimeError("cannot respond with a modal if an initial response has already been sent") + + await self.interaction.create_modal_response(title, custom_id, component, components) + self._initial_response_sent = True + + class RestContext(Context): - _initial_response_callback: Callable[ - [hikari.api.InteractionResponseBuilder], - None, - ] + __slots__ = ("_initial_response_callback",) + + def __init__( + self, + *args: t.Any, + _initial_response_callback: Callable[ + [hikari.api.InteractionResponseBuilder], + None, + ], + **kwargs: t.Any, + ) -> None: + super().__init__(*args, **kwargs) + + self._initial_response_callback = _initial_response_callback async def respond_with_modal( self, @@ -487,7 +543,7 @@ async def respond_with_modal( async with self._response_lock: if self._initial_response_sent: - return + raise RuntimeError("cannot respond with a modal if an initial response has already been sent") self._initial_response_callback( special_endpoints_impl.InteractionModalBuilder(title, custom_id, list(components)) @@ -549,4 +605,4 @@ async def _create_initial_response( raise TypeError("unexpected response_type passed") self._initial_response_callback(bld) - return INITIAL_RESPONSE_IDENTIFIER + return constants.INITIAL_RESPONSE_IDENTIFIER diff --git a/lightbulb/internal/constants.py b/lightbulb/internal/constants.py index c82607ed..e991c15e 100644 --- a/lightbulb/internal/constants.py +++ b/lightbulb/internal/constants.py @@ -20,7 +20,10 @@ # SOFTWARE. __all__ = ["COMMAND_INVOKE_METHOD_MARKER", "GLOBAL_COMMAND_KEY"] -COMMAND_INVOKE_METHOD_MARKER = "__lb_command_invoke_method__" +import typing as t + +COMMAND_INVOKE_METHOD_MARKER: t.Final[str] = "__lb_command_invoke_method__" """Name of the marker attribute set to mark a method as the invocation method for a command.""" -GLOBAL_COMMAND_KEY = 0 +GLOBAL_COMMAND_KEY: t.Final[int] = 0 """The value used to store global commands on the client instance.""" +INITIAL_RESPONSE_IDENTIFIER: t.Final[int] = -1 diff --git a/noxfile.py b/noxfile.py index 4116bdfc..8ec88779 100644 --- a/noxfile.py +++ b/noxfile.py @@ -20,11 +20,14 @@ # SOFTWARE. import os +import typing as t +from collections.abc import Callable import nox from nox import options SCRIPT_PATHS = [ + os.path.join(".", "examples"), os.path.join(".", "lightbulb"), os.path.join(".", "scripts"), os.path.join(".", "tests"), @@ -35,33 +38,43 @@ options.sessions = ["format_fix", "typecheck", "slotscheck", "test"] -@nox.session() +def nox_session(**kwargs: t.Any) -> Callable[[Callable[[nox.Session], None]], Callable[[nox.Session], None]]: + kwargs.setdefault("venv_backend", "uv|virtualenv") + kwargs.setdefault("reuse_venv", True) + + def inner(func: Callable[[nox.Session], None]) -> Callable[[nox.Session], None]: + return nox.session(**kwargs)(func) + + return inner + + +@nox_session() def format_fix(session: nox.Session) -> None: session.install(".[localization,crontrigger,dev.format]") session.run("python", "-m", "ruff", "format", *SCRIPT_PATHS) session.run("python", "-m", "ruff", "check", "--fix", *SCRIPT_PATHS) -@nox.session() +@nox_session() def format_check(session: nox.Session) -> None: session.install(".[localization,crontrigger,dev.format]") session.run("python", "-m", "ruff", "format", *SCRIPT_PATHS, "--check") session.run("python", "-m", "ruff", "check", "--output-format", "github", *SCRIPT_PATHS) -@nox.session() +@nox_session() def typecheck(session: nox.Session) -> None: session.install(".[localization,crontrigger,dev.typecheck]") session.run("python", "-m", "pyright") -@nox.session() +@nox_session() def slotscheck(session: nox.Session) -> None: session.install(".[localization,crontrigger,dev.slotscheck]") session.run("python", "-m", "slotscheck", "-m", "lightbulb") -@nox.session() +@nox_session() def test(session: nox.Session) -> None: session.install(".[localization,crontrigger,dev.test]") @@ -73,7 +86,7 @@ def test(session: nox.Session) -> None: session.run(*args) -@nox.session(reuse_venv=True) +@nox_session() def sphinx(session: nox.Session) -> None: session.install(".[localization,crontrigger,dev.docs]") session.run("python", "./scripts/docs/api_reference_generator.py") diff --git a/pyproject.toml b/pyproject.toml index 6a6de1f6..0e6b3011 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed", ] -dependencies = ["hikari>=2.0.0.dev126, <3", "networkx>=3.3, <4"] +dependencies = ["hikari>=2.0.0.dev126, <3", "networkx>=3.3, <4", "async-timeout>=4, <5"] dynamic = ["version", "description"] [project.urls] @@ -53,9 +53,10 @@ dev = ["nox==2024.4.15"] "dev.format" = ["ruff>=0.5.5, <1"] "dev.typecheck" = [ "pyright>=1.1.373, <2", + "typing-extensions>=4.12.2, <5", "types-networkx>=3.2.1.20240703, <4", "types-polib>=1.2.0.20240327, <2", - "types-croniter>=3.0.0.20240727, <4" + "types-croniter>=3.0.0.20240727, <4", ] "dev.slotscheck" = ["slotscheck>=0.19.0, <1"] "dev.test" = ["pytest>=8.3.2, <9", "pytest-asyncio>=0.23.8, <0.24", "pytest-cov>=5.0.0, <6"] @@ -158,14 +159,12 @@ require-superclass = true require-subclass = true exclude-classes = """ ( - ^lightbulb\\.commands\\.commands:CommandMeta$ | - ^lightbulb\\.context:RestAutocompleteContext$ | - ^lightbulb\\.context:RestContext$ + ^lightbulb\\.commands\\.commands:CommandMeta$ ) """ [tool.pyright] -include = ["lightbulb"] +include = ["lightbulb", "examples"] exclude = ["docs", ".github"] pythonVersion = "3.10" typeCheckingMode = "strict" diff --git a/scripts/docs/api_reference_generator.py b/scripts/docs/api_reference_generator.py index d00044d3..507eaf9c 100644 --- a/scripts/docs/api_reference_generator.py +++ b/scripts/docs/api_reference_generator.py @@ -18,14 +18,23 @@ # 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. +import collections import importlib import inspect import os import pathlib +import re import typing as t API_REFERENCES_DIRECTORY = "docs", "source", "api-references" +# Regex to match special modifiers that can change the output when writing reST +# +# Implemented modifiers: +# - api_ref_gen::ignore - ignores the current file, does not output a reST file +# - api_ref_gen::add_autodoc_option::