Modals#
Modals allow bots to prompt a user for more information as the initial response for a slash command, context menu or message component interaction.
Modals take the shape of dialogue boxes which show up on top of everything for the user who triggered the relevant interaction (as shown above).
Making a Modal client#
The Modal client keeps track of registered modals and handles executing them.
This can be created with any of the following class methods:
- ModalClient.from_gateway_bot: Create a modal client from a Hikari gateway bot (i.e. hikari.GatewayBot).
- ModalClient.from_rest_bot: Create a modal client from a Hikari REST bot (i.e. hikari.RESTBot or yuyo.asgi.AsgiBot).
-
ModalClient.from_tanjun: Create a modal client from a Tanjun Client.
This method will make the modal client use Tanjun's Alluka client for dependency injection, essentially mirroring the dependencies registered for Tanjun's DI while also registering ModalClient as a type dependency.
Client state can be managed through dependency injection. This is implemented using Alluka and more information about it can be found in Alluka's usage guide. The Alluka client used for modal execution can be found at ModalClient.alluka.
For the sake of simplicity, the following examples all assume the modal client can be accessed through Alluka style dependency injection.
Declaring Modals#
The only field type supported for modals right now are text inputs.
A modal can have up to 5 text inputs in it and there are two different flavours of text inputs: the default "sentence" style which only lets users input a single line of text and the "paragraph" style which allows multiple lines of text.
There's several different ways to declare modals using Yuyo:
Subclasses#
When working with modal classes you'll be adding "static" fields which are included on every instance of the modal class. There's a couple of ways to declare these:
class Modal(modals.Modal):
async def callback(
self,
ctx: modals.Context,
field: str = modals.text_input("label", min_length=5, max_length=50, default="John Doe"),
other_field: typing.Optional[str] = modals.text_input(
"other label", style=hikari.TextInputStyle.PARAGRAPH, default=None
),
) -> None:
await ctx.respond("hi")
Subclassing Modal lets you create a unique modal template. Modal
subclasses will never inherit fields. The modal's execution callback must always be called callback
when subclassing.
You can define the fields that'll appear on all instances of a modal template by setting field descriptors as argument defaults for the modal's callback (as shown above).
The following descriptors are supported:
Warning
If you declare __init__
on a Modal subclass then you must make sure to first call super().__init__()
in it.
@modals.with_static_text_input("label", parameter="field", default=None)
class Modal(modals.Modal):
async def callback(self, ctx: modals.Context, field: typing.Optional[str], other_field: str) -> None:
ctx.interaction.components
Modal.add_static_text_input("other label", parameter="other_field")
You can also define the template's fields by manually calling the add_static_{}
class methods either directly or through one of the provided with_static_{}
decorator functions. Note that decorators are executed from the bottom upwards and this will be reflected in the order of these fields.
When using this approach the field's value/default will only be passed to the callback if you explicitly pass the relevant argument's name as parameter=
to the add (class) method.
@modals.with_static_text_input("label", parameter="field", default=None)
@modals.as_modal_template
async def modal_template(ctx: modals.Context, field: str, other_field: str = modals.text_input("label")) -> None:
await ctx.respond("hi")
as_modal_template provides a short hand for creating a Modal subclass from a callback.
Instances#
@modals.with_text_input("other label", parameter="other")
@modals.with_text_input("label", parameter="field")
@modals.as_modal
async def modal(ctx: modals.Context, field: str, other_field: typing.Optional[str]) -> None: ...
async def callback(ctx: modals.Context, field: str, other_field: typing.Optional[str]) -> None: ...
modal = (
modals.modal(callback)
.add_text_input("label", parameter="field")
.add_text_input("other label", parameter="other_field", default=None)
)
as_modal and modal both provide ways to create instances of modals from a callback.
These only support the signature field descriptors and modal dataclasses when parse_signature=True
is explicitly passed.
Options Dataclass#
class ModalOptions(modals.ModalOptions):
field: str = modals.text_input("label", min_length=5, max_length=500)
other_field: typing.Optional[str] = modals.text_input(
"other label", default=None, style=hikari.TextInputStyle.PARAGRAPH
)
@modals.as_modal(parse_signature=True)
async def modal(ctx: modals.Context, options: ModalOptions) -> None:
options.field
Another aspect of signature parsing is ModalOptions. This is a dataclass of modal fields which supports declaring said fields by using the same descriptors listed earlier as class variables.
To use this dataclass with a modal you then have to use it as type-hint for one of the modal callback's arguments.
This supports inheriting fields from other modal options dataclasses (including mixed inheritance) but does not support slotting nor custom __init__
s.
Handling Modal Interactions#
There's two main ways to handle modal interactions with Yuyo:
Stateful#
class Modal(modals.Modal):
__slots__ = ("state",)
def __init__(self, state: str) -> None:
super().__init__()
self.state = state
async def callback(self, ctx: modals.Context, field: str = modals.text_input("field")) -> None:
await ctx.respond(self.state)
async def command_callback(ctx: tanjun.abc.AppCommandContext, modal_client: alluka.Injected[modals.Client]) -> None:
modal = Modal("state")
custom_id = str(ctx.interaction.id)
modal_client.register_modal(custom_id, modal)
await ctx.create_modal_response("Title", custom_id, components=modal.rows)
Subclassing Modal let you associate state with a specific modal execution through OOP.
When doing this you'll usually be creating an instance of the modal per interaction and associating this with a specific modal execution by using the parent interaction's custom ID as the modal's custom ID (as shown above).
ModalClient.register_modal defaults timeout
to a 2 minute one use timeout.
Stateless#
# parse_signature defaults to False for as_modal and modal (unlike as_modal_template).
@modals.as_modal(parse_signature=True)
async def modal(ctx: modals.Context, field: str = modals.text_input("field")) -> None:
session_id = uuid.UUID(ctx.id_metadata)
MODAL_ID = "MODAL_ID"
client = modals.ModalClient()
client.register_modal(MODAL_ID, modal, timeout=None)
...
async def command_callback(
ctx: tanjun.abc.AppCommandContext, modal_client: alluka.Injected[modals.ModalClient]
) -> None:
session_id = uuid.uuid4()
await ctx.create_modal_response("Title", f"{MODAL_ID}:{session_id}", components=modal.rows)
Alternatively, modals can be reused by using a global custom ID and registering the modal to the client on startup with timeout=None
and sending the same modal's rows per-execution.
Custom IDs have some special handling which allows you to track some metadata for specific modal executions. They are split into two parts as "{match}:{metadata}"
, where the "match" part is what Yuyo will use to find the executor for a modal call and the "metadata" (ModalContext.id_metadata) part represents any developer added metadata for that instance of the modal.
If should be noted that Custom IDs can never be longer than 100 characters in total length.
Responding to Modals#
@modals.as_modal(parse_signature=True)
async def modal(ctx: modals.Context) -> None:
await ctx.respond(
"Message content",
attachments=[hikari.URL("https://img3.gelbooru.com/images/81/f2/81f26993b71525683a3267b16ecd0ea9.jpg")],
)
ModalContext.respond is used to respond to an interaction with a new message, this has a similar signature to Hikari's message respond method but will only be guaranteed to return a hikari.Message object when ensure_result=True
is passed.
Alternatively, yuyo.InteractionError can be raised to end the execution of a modal with a response message.
Note
You cannot create another modal prompt in response to a modal interaction.
Ephemeral responses#
@modals.as_modal(parse_signature=True)
async def modal(ctx: modals.Context) -> None:
await ctx.create_initial_response("Initiating Mower", ephemeral=True)
await ctx.create_followup("Meowing finished", ephemeral=True)
Ephemeral responses mark the response message as private (so that only the author can see it) and temporary. A response can be marked as ephemeral by passing ephemeral=True
to either ModalContext.create_initial_response (when initially responding to the interaction) or ModalContext.create_followup (for followup responses).
Deferrals#
Interactions need an initial response within 3 seconds but, if you can't give a response within 3 seconds, you can defer the first response using ModalContext.defer.
A deferral should then be finished by editing in the initial response using either ModalContext.edit_initial_response or ModalContext.respond and if you want a response to be ephemeral then you'll have to pass ephemeral=True
when deferring.
Updating the source message#
@modals.as_modal()
async def modal(ctx: modals.Context) -> None:
await ctx.create_initial_response("content", response_type=hikari.ResponseType.MESSAGE_UPDATE, attachments=[])
When a modal is triggered by a button which is attached to a message you can also use the initial response to edit said message. To do this you need to pass response_type=hikari.ResponseType.MESSAGE_UPDATE
while calling ModalContext.create_initial_response. After doing this any further calls to ModalContext.delete_initial_response and ModalContext.edit_initial_response will target the source message as well.
You cannot change the ephemeral state of the source message.
You need to pass response_type=hikari.ResponseType.DEFERRED_MESSAGE_UPDATE
When deferring with the intent to update the source message.