Skip to content

Message Components#

Message components are the interactive buttons and select menus you'll see on some messages sent by bots.

Making a Component Client#

The Component client keeps track of registered components and handles executing them.

This can be created with any of the following class methods:

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 component execution can be found at ComponentClient.alluka.

For the sake of simplicity, the following examples all assume the component client can be accessed through Alluka style dependency injection.

Types of components#

Buttons#

button colours

Message buttons have several different styles, as shown above. Most of these are interactive, meaning that an interaction will be sent to the bot when a user clicks on it. The only non-interactive style is link buttons which simply open the set link in a browser for the user who clicked on it.

A row can have up to 5 buttons in it.

Select Menus#

select menu example

Select menus let users select between 0 to 25 options (dependent on how the bot configured it). These selections are communicated to the bot once the user has finished selecting options via an interaction and there's several different resources they can be selecting:

  • Text menus: lets the bot pre-define up to 25 text options
  • User menus: lets the user pick up to 25 users
  • Role menus: lets the user pick up to 25 roles
  • Channel menus: lets the user pick up to 25 channels
  • Mentionable menus: lets the user pick up to 25 roles and users

Note

As of writing user, role, channel and mentionable menus only let you select entities from the current guild. Only text menus work properly in DM channels.

Each select menu takes up a whole row.

Declaring Components#

When adding sub-components to a select menu, they'll either be appended to the last row or they'll be added to a new row if the new entry wouldn't fit in the last row.

A message can only have up to 5 component rows on it.

There's several different ways to declare components using Yuyo:

Subclassing#

class Column(components.ActionColumnExecutor):
    @components.as_channel_menu
    async def on_channel_menu(self, ctx: components.Context) -> None:
        ctx.selected_channels

    @components.as_role_menu
    async def on_role_menu(self, ctx: components.Context) -> None:
        ctx.selected_roles

    @components.with_option("opt3", "value3")
    @components.with_option("opt2", "value2")
    @components.with_option("opt1", "value1")
    @components.as_text_menu
    async def on_text_menu(self, ctx: components.Context) -> None:
        ctx.selected_texts

    @components.as_user_menu
    async def on_user_menu(self, ctx: components.Context) -> None:
        ctx.selected_users

    @components.as_mentionable_menu
    async def on_mentionable_menu(self, ctx: components.Context) -> None:
        ctx.selected_roles
        ctx.selected_users

When subclassing ActionColumnExecutor, you can use any of the following class descriptors to add "static" sub-components (which'll be included on every instance and subclass of the column) to it:

class Column(components.ActionColumnExecutor):
    @components.as_interactive_button(hikari.ButtonStyle.DANGER, emoji="👍")
    async def on_button(self, ctx: components.Context) -> None: ...

    link_button = components.link_button("https://example.com", label="label")

Most of these descriptors decorate a callback which'll be called when that specific sub-component is used by a user, with the only exception being link buttons which open a link for the user instead of sending an interaction to the bot.

Warning

If you declare __init__ on an ActionColumnExecutor subclass then you must make sure to first call super().__init__() in it.

column_template = (
    components.column_template()
    .add_static_channel_menu(callback)
    .add_static_text_menu(callback)
    .add_option("opt1", "value1")
    .add_option("opt2", "value2")
    .add_option("opt3", "value3")
    .parent
    .add_static_role_menu(callback)
    .add_static_link_button("https://example.com")
    .add_static_interactive_button(hikari.ButtonStyle.DANGER, callback, label="👍")
)

Alternatively, static sub-components can be added to an ActionColumnExecutor subclass using its chainable add_static_{} class methods.

column_template = components.column_template()

@column_template.with_static_channel_menu
async def on_channel_menu(ctx: components.Context) -> None: ...

@components.with_option("opt3", "value3")
@components.with_option("opt2", "value2")
@components.with_option("opt1", "value1")
@column_template.with_static_text_menu
async def on_text_menu(ctx: components.Context) -> None: ...

@column_template.with_static_role_menu
async def on_role_menu(ctx: components.Context) -> None: ...

@column_template.with_static_interactive_button(hikari.ButtonStyle.DANGER, label="👍")
async def on_button(ctx: components.Context) -> None: ...

Or by using its with_static_{} decorator class methods. The only sub-component type which cannot be added through a decorator call is link buttons.

Note

column_template just provides a shorthand for creating an ActionColumnExecutor subclass and all of these class methods also work on a normal class.

Builder#

column = (
    components.ActionColumnExecutor()
    .add_channel_menu(callback)
    .add_text_menu(callback)
    .add_option("opt1", "value1")
    .add_option("opt2", "value2")
    .add_option("opt3", "value3")
    .parent
    .add_role_menu(callback)
    .add_link_button("https://example.com", label="label")
    .add_interactive_button(hikari.ButtonStyle.DANGER, callback, label="👍")
)

You can also dynamically build a ActionColumnExecutor after initialising it by using its chainable add_{} methods to add sub-components.

column = components.ActionColumnExecutor()

@column.with_channel_menu
async def on_channel_menu(ctx: components.Context) -> None: ...

@components.with_option("opt3", "value3")
@components.with_option("opt2", "value2")
@components.with_option("opt1", "value1")
@column.with_text_menu
async def on_text_menu(ctx: components.Context) -> None: ...

@column.with_role_menu
async def on_role_menu(ctx: components.Context) -> None: ...

@column.with_interactive_button(hikari.ButtonStyle.DANGER, label="👍")
async def on_button(ctx: components.Context) -> None: ...

Or by using its with_{} decorator methods. The only sub-component type which can't be added through a decorator call is link buttons.

Handling Component Interactions#

There's two main ways to handle component interactions with Yuyo:

Stateful#
class ColumnCls(components.ActionColumnExecutor):
    __slots__ = ("state",)

    def __init__(self, state: int) -> None:
        super().__init__()
        self.state = state

    @components.as_interactive_button(hikari.ButtonStyle.DANGER, emoji="👍")
    async def on_button(self, ctx: components.Context) -> None: ...

async def callback(ctx: components.Context, component_client: alluka.Injected[components.Client]) -> None:
    column = ColumnCls(123)
    message = await ctx.respond(components=column.rows)
    component_client.register_executor(column, message=message)

Subclassing ActionColumnExecutor allows you to associate state with a specific message's components through OOP.

When doing this you'll usually be creating an instance of the components column per message.

ComponentClient.register_executor defaults timeout to a 30 second sliding timeout (meaning that the timer resets every use).

Stateless#
class ColumnCls(components.ActionColumnExecutor):
    @components.as_interactive_button(hikari.ButtonStyle.DANGER, emoji="👍")
    async def on_button(self, ctx: components.Context) -> None:
        session_id = uuid.UUID(ctx.id_metadata)

column = ColumnCls()

client = components.ComponentClient()
client.register_executor(column, timeout=None)

...

async def callback(ctx: components.Context, component_client: alluka.Injected[components.Client]) -> None:
    session_id = uuid.uuid4()
    await ctx.respond(components=ColumnCls(id_metadata={"on_button": str(session_id)}).rows)

Alternatively, components can be reused by registering the component to the client on startup with timeout=None and sending the same component's rows per-execution.

Custom IDs have some special handling which allows you to track some metadata for a specific message's components. They are split into two parts as "{match}:{metadata}", where the "match" part is what Yuyo will use to find the executor for a message's components and the "metadata" (ComponentContext.id_metadata) part represents any developer added metadata for that specific instance of the component.

The id_metadata init argument lets you set the metadata for the static components in an action column while initiating it by passing a dict of match IDs/descriptor callback names to the metadata for each specified component.

Custom IDs cannot be longer than 100 characters in total length and the match parts of the custom IDs in an executor have to be globally unique when registering it globally (i.e. without passing message=).

Note

For stateless components like described/above to work properly the match part of custom IDs needs to stay the same between bot restarts.

The as_ descriptors achieve this by generating a constant default ID from the path for the component's callback (which consists of the callback's name and the qualnames of the class and the relevant modules). This does, however, mean that any changes to the function's name or the name of the class/modules it's in will change this generated custom ID leading to it no-longer match any previously declared message components.

However, the add_ and with_ (class)methods generate a random default whenever called and will have to be manually supplied a constant custom ID through the optional custom_id argument. The as_ descriptors also have a custom_id argument which overrides the default path generated ID.

Responding to Components#

class ColumnCls(components.ActionColumnExecutor):
    @components.as_interactive_button(hikari.ButtonStyle.DANGER, emoji="👍")
    async def on_button(self, ctx: components.Context) -> None:
        await ctx.respond(
            "Message content",
            attachments=[hikari.URL("https://img3.gelbooru.com/images/40/5a/405ad89e26a8ec0e96fd09dd1ade334b.jpg")],
        )

ComponentContext.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 component with a response message.

Ephemeral responses#
class ColumnCls(components.ActionColumnExecutor):
    @components.as_interactive_button(hikari.ButtonStyle.DANGER, emoji="👍")
    async def on_button(self, ctx: components.Context) -> None:
        await ctx.create_initial_response("Starting cat", ephemeral=True)
        await ctx.create_followup("The cat rules us all now", 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 ComponentContext.create_initial_response (when initially responding to the interaction with a message response) or ComponentContext.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 ComponentContext.defer.

A deferral should then be finished by editing in the initial response using either ComponentContext.edit_initial_response or ComponentContext.respond and if you want a response to be an ephemeral message create then you'll have to pass ephemeral=True when deferring.

Updating the source message#
class ColumnCls(components.ActionColumnExecutor):
    @components.as_interactive_button(hikari.ButtonStyle.DANGER, emoji="👍")
    async def on_button(self, ctx: components.Context) -> None:
        await ctx.create_initial_response(response_type=hikari.ResponseType.MESSAGE_UPDATE, attachments=[])

You can also use the initial response to edit the message the component being used is on. To do this you need to pass response_type=hikari.ResponseType.MESSAGE_UPDATE while calling ComponentContext.create_initial_response. After doing this any further calls to ComponentContext.delete_initial_response and ComponentContext.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.

You can also create a Modal prompt as the initial response to a component interaction.

For more information on how to handle modals see the Modals usage guide, where ComponentContext.create_modal_response should be used to create the initial prompt.

Other Executors#

Pagination#

Yuyo provides standard component implementations for handling paginating message responses. These all function by adding buttons to the messages which are used to move between the response pages.

Runtime pagination#

components.ComponentPaginator is a paginator implementation for creating transient paginators which are linked to specific responses/messages.

async def command(ctx: components.Context, component_client: alluka.Injected[components.Client]) -> None:
    pages = [pagination.Page("Page 1"), pagination.Page("Page 2"), pagination.Page("Page 3")]
    paginator = components.Paginator(iter(pages))

    message = await ctx.respond(components=paginator.rows, ensure_result=True)
    component_client.register_executor(paginator, message=message)

This paginator takes iterators/generators of yuyo.pagination.Pages and will only push the iterator forwards as the user interacts with the paginator. This allows for lazily generating responses.

Because of this you must use iter before passing a list of pre-built data to its init.

pages = (pagination.Page(content) async for content in _async_iterator())
paginator = components.Paginator(pages)

This also supports asynchronous iterators/generators, allowing for functionality like fetching data as the user scrolls through it.

paginator = (
    components.Paginator(pages, triggers=[])
    .add_first_button()
    .add_previous_button()
    .add_stop_button()
    .add_next_button()
    .add_last_button()
)

The paginator only enables 3 buttons by default: step backwards, stop and step forwards. To enable the other 2 buttons or even just customise these buttons (i.e. set a specific custom_id or emoji/label) you should pass triggers=[] to ComponentPaginator.__init__ to disable the default triggers then use the provided builder methods as shown above.

You can also add your own buttons to this alongside the pagination buttons using the methods provided by ActionColumnExecutor.

Static pagination#

components.StaticPaginator is a static paginator implementation. For this you register paginator pages on bot startup and then use the chosen ID to add associated paginator components to messages/responses.

component_client = components.Client.from_gateway_bot(bot)
modal_client = modals.Client.from_gateway_bot(bot)
PAGINATOR_ID = "PAGINATOR_1"

(
    components.StaticPaginatorIndex()
    .set_paginator(PAGINATOR_ID, [pagination.Page("Page 1"), pagination.Page("Page 2"), pagination.Page("Page 3")])
    .add_to_clients(component_client, modal_client)
)

...

async def callback(
    ctx: components.Context, paginator_index: alluka.Injected[components.StaticPaginatorIndex]
) -> None:
    paginator = paginator_index.get_paginator(PAGINATOR_ID)
    await ctx.respond(**paginator.pages[0].to_kwargs(), components=paginator.make_components(0).rows)

Buttons can be added or modified by extending components.StaticPaginator. include_buttons=False will need to be passed to components.StaticPaginator.__init__ if you want to override the default buttons using the relevant set_ methods.

components.StaticPaginatorIndex().set_paginator(PAGINATOR_ID, pages, content_hash="v0.1.0")

content_hash can be passed to StaticPaginatorIndex.set_paginator to indicate the version of the state being stored by the bot for a paginator ID. Messages linked to old versions will be left in a no-op state when this is set.