Skip to content

Handlers and arguments

One handler can serve many buttons, and each button can carry what it stands for.

An argument per button

A ticket has a priority, and you want one button for each value, all running one handler. An enum.Enum holds the values.

class Priority(enum.Enum):
    LOW = 1
    NORMAL = 2
    URGENT = 3

risa.bind puts a value on a button, so render() binds one priority to each button it draws.

@risa.register(name="ticket")
class Ticket(risa.View):
    priority: Priority = Priority.NORMAL

    def render(self) -> ui.Layout:
        buttons = [
            ui.Button(
                risa.bind(self.pick, priority),
                label=priority.name.title(),
                disabled=priority is self.priority,
            )
            for priority in Priority
        ]
        return ui.Container(
            ui.TextDisplay(f"Priority: **{self.priority.name.lower()}**"),
            ui.Row(*buttons),
        )

In Discord

Risa BotAPPToday at 12:00

Priority: normal

LowNormalUrgent

The handler receives the value as a parameter after ctx.

@risa.handler
async def pick(self, ctx: risa.ComponentContext, priority: Priority) -> None:
    self.priority = priority
    await ctx.rerender()

Use a field for what changes while the view lives, and an argument for what a button stands for.

Where the argument sits

An argument travels in the button's custom_id, behind the route and any state, so risa's messages call it a wire argument. These are the ticket's real ids:

What the buttons hold

The defaults

Low1aDehXDjA<!0Z2$`>Z+id$0FeR+X$0R
Normal1aDehXDjA<#!+X$0s
Urgent1aDehXDjA<$!+X$0{

Yellow routes the click to its view and handler. Pink is the state. Lavender is the bound argument.

What can be an argument

Arguments come right after ctx and are positional. They are annotated bool, int, str, hikari.Snowflake or an enum.Enum of int or str values. Every parameter after them without a default is filled in by dependency injection through linkd, as Dependency injection shows.

An argument takes room on its button, out of what the button can hold of the state. With the default codec, a button with no state holds a str of up to 68 ASCII characters, and a longer one makes client.build raise. Another codec fits more.

Full file
"""One handler behind several buttons, each carrying its own argument."""

from __future__ import annotations

import enum
import os

import hikari

import risa
from risa import ui


class Priority(enum.Enum):
    LOW = 1
    NORMAL = 2
    URGENT = 3


@risa.register(name="ticket")
class Ticket(risa.View):
    priority: Priority = Priority.NORMAL

    def render(self) -> ui.Layout:
        buttons = [
            ui.Button(
                risa.bind(self.pick, priority),
                label=priority.name.title(),
                disabled=priority is self.priority,
            )
            for priority in Priority
        ]
        return ui.Container(
            ui.TextDisplay(f"Priority: **{self.priority.name.lower()}**"),
            ui.Row(*buttons),
        )

    @risa.handler
    async def pick(self, ctx: risa.ComponentContext, priority: Priority) -> None:
        self.priority = priority
        await ctx.rerender()


def main() -> None:
    bot = hikari.GatewayBot(
        os.environ["DISCORD_TOKEN"],
        intents=hikari.Intents.ALL_UNPRIVILEGED | hikari.Intents.MESSAGE_CONTENT,
    )
    client = risa.client_from_app(bot)
    client.add_view(Ticket)

    @bot.listen()
    async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
        if event.content == "!ticket":
            rendered = await client.build(Ticket())
            await rendered.send_to(bot.rest, event.channel_id)

    bot.run()


if __name__ == "__main__":
    main()

hikari's enums

hikari's enums are not enum.Enum.

Annotate a parameter hikari.ChannelType and risa counts it as something to inject, so it is no argument at all, and a value bound to it fails when client.build runs.

@risa.register(name="channel-kind-wrong")
class ChannelKindWrong(risa.View):
    def render(self) -> ui.Layout:
        voice = hikari.ChannelType.GUILD_VOICE
        return ui.Container(
            ui.TextDisplay("Which kind of channel?"),
            ui.Row(ui.Button(risa.bind(self.pick, voice), label="Voice")),
        )

    @risa.handler
    async def pick(
        self,
        ctx: risa.ComponentContext,
        kind: hikari.ChannelType,
    ) -> None:
        await ctx.respond(f"You picked {kind.name}.", ephemeral=True)

What client.build raises

handler 'ChannelKindWrong.pick': received 1 wire argument, but the handler declares 0

To carry one on the wire, bind it as an int.

@risa.register(name="channel-kind")
class ChannelKind(risa.View):
    def render(self) -> ui.Layout:
        text = hikari.ChannelType.GUILD_TEXT
        voice = hikari.ChannelType.GUILD_VOICE
        return ui.Container(
            ui.TextDisplay("Which kind of channel?"),
            ui.Row(
                ui.Button(risa.bind(self.pick, int(text)), label="Text"),
                ui.Button(risa.bind(self.pick, int(voice)), label="Voice"),
            ),
        )

In Discord

Risa BotAPPToday at 12:00

Which kind of channel?

TextVoice

Annotate the parameter int, and rebuild the enum in the handler with hikari.ChannelType(kind).

@risa.handler
async def pick(self, ctx: risa.ComponentContext, kind: int) -> None:
    picked = hikari.ChannelType(kind)
    await ctx.respond(f"You picked {picked.name}.", ephemeral=True)
Full file
"""A hikari enum as an argument: a view that fails to build, and the fix."""

from __future__ import annotations

import os

import hikari

import risa
from risa import ui


@risa.register(name="channel-kind-wrong")
class ChannelKindWrong(risa.View):
    def render(self) -> ui.Layout:
        voice = hikari.ChannelType.GUILD_VOICE
        return ui.Container(
            ui.TextDisplay("Which kind of channel?"),
            ui.Row(ui.Button(risa.bind(self.pick, voice), label="Voice")),
        )

    @risa.handler
    async def pick(
        self,
        ctx: risa.ComponentContext,
        kind: hikari.ChannelType,
    ) -> None:
        await ctx.respond(f"You picked {kind.name}.", ephemeral=True)


@risa.register(name="channel-kind")
class ChannelKind(risa.View):
    def render(self) -> ui.Layout:
        text = hikari.ChannelType.GUILD_TEXT
        voice = hikari.ChannelType.GUILD_VOICE
        return ui.Container(
            ui.TextDisplay("Which kind of channel?"),
            ui.Row(
                ui.Button(risa.bind(self.pick, int(text)), label="Text"),
                ui.Button(risa.bind(self.pick, int(voice)), label="Voice"),
            ),
        )

    @risa.handler
    async def pick(self, ctx: risa.ComponentContext, kind: int) -> None:
        picked = hikari.ChannelType(kind)
        await ctx.respond(f"You picked {picked.name}.", ephemeral=True)


def main() -> None:
    bot = hikari.GatewayBot(
        os.environ["DISCORD_TOKEN"],
        intents=hikari.Intents.ALL_UNPRIVILEGED | hikari.Intents.MESSAGE_CONTENT,
    )
    client = risa.client_from_app(bot)
    client.add_view(ChannelKind)

    @bot.listen()
    async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
        if event.content == "!channelkind":
            rendered = await client.build(ChannelKind())
            await rendered.send_to(bot.rest, event.channel_id)

    bot.run()


if __name__ == "__main__":
    main()

The options of @risa.handler

Used bare, @risa.handler takes the defaults. With options, it takes keywords, as in @risa.handler(handler_id="pick", version=2).

Option What it does
handler_id The id the handler routes under, the method's name by default
version Bump it when the handler's arguments change
defer The handler's AutoDefer, overriding the view's

Changing a handler shows what each change does to buttons already sent, how handler_id keeps them working through a rename, and how a method at the old version answers them.