Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

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

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

Filter by extension

Filter by extension


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

---

## Components and Modals

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

---

## Scheduled and Repeating Tasks

TODO
10 changes: 5 additions & 5 deletions examples/basic_bot_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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!")


Expand All @@ -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)


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

async def handle_rest_application_command_interaction(
self, interaction: hikari.CommandInteraction
Expand Down
1 change: 0 additions & 1 deletion lightbulb/commands/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
# api_reference_gen::ignore
# Copyright (c) 2023-present tandemdude
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
Expand Down
Loading