Your first view¶
Build a counter: one message with one button, where each click raises the number. It runs on your own bot and keeps counting after a restart.
Before you start¶
- Install hikari-risa.
- Create an application in Discord's Developer Portal and copy the token from its Bot page.
- Invite the bot to a server you can test in, with the scopes
botandapplications.commandsand the permission to send messages. - Put the token in the environment variable
DISCORD_TOKEN:
export DISCORD_TOKEN="your-bot-token"
In Windows PowerShell, that is $env:DISCORD_TOKEN = "your-bot-token".
The view¶
A view is a class. Its fields are its state, render() draws the message from them, and its handlers answer the clicks.
@risa.register(name="counter")
class Counter(risa.View):
count: int = 0
def render(self) -> ui.Layout:
return ui.Container(
ui.TextDisplay(f"Clicks: **{self.count}**"),
ui.Row(ui.Button(self.click, label="+1")),
)
@risa.handler
async def click(self, ctx: risa.ComponentContext) -> None:
self.count += 1
await ctx.rerender()
In Discord
Clicks: 0
After a click
Clicks: 1
Part by part:
@risa.register(name="counter")tells risa about the class. A click finds its way back to the view by this name, so keep the name once messages are out. Renaming a view retires the buttons already sent.count: int = 0is the state. risa keeps every annotated field from click to click, and the default is where a new counter starts.render()returns the message's layout, drawn from the fields.ui.Containeris a box like an embed,ui.TextDisplayis text in Discord's markdown, andui.Rowholds buttons. Selects, sections, galleries shows everything else you can draw.ui.Button(self.click, label="+1")draws a button, andself.clickis the handler its clicks go to.@risa.handlermakesclicka handler. When it runs, the view already holds the state from the click, andctxis the context of that click.await ctx.rerender()answers the click: it draws the message again from the changed fields and saves them. Discord fails a click that gets no answer within three seconds.
Send it¶
A view reaches Discord in two steps. Once at startup, you make a client and give it the class with add_view, so that
clicks find it. Then, for each message, client.build(Counter()) renders a new view and you send the result.
The two ways below differ in what makes the bot post. Pick one.
/counter posts the counter. Take this one if your bot gets its slash commands from
lightbulb. You install it yourself, with uv add hikari-lightbulb or
pip install hikari-lightbulb. The metadata of hikari-lightbulb 3.2.6 declares Python below 3.15.
risa_client = risa.client_from_lightbulb(lightbulb_client)
risa_client.add_view(Counter)
@lightbulb_client.register()
class CounterCommand(
lightbulb.SlashCommand,
name="counter",
description="Post the counter.",
):
@lightbulb.invoke
async def invoke(self, ctx: lightbulb.Context) -> None:
await ctx.respond(**await risa_client.build(Counter()))
risa.client_from_lightbulb makes a client that shares
lightbulb's bot and its dependency manager.
build returns a mapping, and ** spreads it into ctx.respond.
lightbulb registers /counter with Discord when the bot starts.
Full file
"""The counter, sent from a lightbulb slash command."""
from __future__ import annotations
import os
import hikari
import lightbulb
import risa
from risa import ui
@risa.register(name="counter")
class Counter(risa.View):
count: int = 0
def render(self) -> ui.Layout:
return ui.Container(
ui.TextDisplay(f"Clicks: **{self.count}**"),
ui.Row(ui.Button(self.click, label="+1")),
)
@risa.handler
async def click(self, ctx: risa.ComponentContext) -> None:
self.count += 1
await ctx.rerender()
def main() -> None:
bot = hikari.GatewayBot(os.environ["DISCORD_TOKEN"])
lightbulb_client = lightbulb.client_from_app(bot)
bot.subscribe(hikari.StartingEvent, lightbulb_client.start)
risa_client = risa.client_from_lightbulb(lightbulb_client)
risa_client.add_view(Counter)
@lightbulb_client.register()
class CounterCommand(
lightbulb.SlashCommand,
name="counter",
description="Post the counter.",
):
@lightbulb.invoke
async def invoke(self, ctx: lightbulb.Context) -> None:
await ctx.respond(**await risa_client.build(Counter()))
bot.run()
if __name__ == "__main__":
main()
Writing !counter in a server channel posts the counter. This is plain hikari, with no other package.
Reading what people write takes a privileged intent. Turn on Message Content Intent on the Bot page of the Developer Portal, or Discord closes the connection when the bot starts.
client = risa.client_from_app(bot)
client.add_view(Counter)
@bot.listen()
async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
if event.content == "!counter":
rendered = await client.build(Counter())
await rendered.send_to(bot.rest, event.channel_id)
risa.client_from_app makes a client for the bot. send_to sends
the built view as a new message, here to the channel the command came from.
Full file
"""The counter, sent from a gateway event."""
from __future__ import annotations
import os
import hikari
import risa
from risa import ui
@risa.register(name="counter")
class Counter(risa.View):
count: int = 0
def render(self) -> ui.Layout:
return ui.Container(
ui.TextDisplay(f"Clicks: **{self.count}**"),
ui.Row(ui.Button(self.click, label="+1")),
)
@risa.handler
async def click(self, ctx: risa.ComponentContext) -> None:
self.count += 1
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(Counter)
@bot.listen()
async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
if event.content == "!counter":
rendered = await client.build(Counter())
await rendered.send_to(bot.rest, event.channel_id)
bot.run()
if __name__ == "__main__":
main()
A REST bot works the same way: risa.client_from_app takes it too, and
takes over its component listener.
Run it¶
Save the full file of your tab as bot.py and start it:
uv run bot.py
If you installed with pip, that is python bot.py. Post the counter the way your tab does, then click +1. The
number in the message goes up.
Restart it¶
Stop the bot with Ctrl+C, start it again, and click +1 on the same message. The count carries on.
The count is not kept in your bot. When risa draws the button, it writes the count into the button's hidden id, which
Discord stores with the message and sends back with every click. On a click, risa reads the count out of the message,
rebuilds the view and runs your handler, and ctx.rerender() writes the new count onto the button. A restart loses
nothing, and the new process only needs add_view, which your code calls at every start.
A click that lands while the bot is down fails in Discord, which shows that the interaction failed. The count stays where it was, and the next click after the restart works.
The count is not secret.
Anyone who can see the message can read what the button holds, so keep secrets somewhere else.
The message also has limited room: about 60 bytes on one button, and about 70 more for each further one, with the
default codec. A view that needs more can move to a store with risa.InStore. Its
handlers stay the same, but the move means raising its version, which retires the buttons already sent.
A click changes the id of the button that holds the count, and Discord refuses a click on an id the message no longer has. If two people click at nearly the same moment, the second can fail in Discord and click again. In a store the ids hold a key instead of the count, so clicks leave them as they are, which suits a view many people click at once.
In miru.
A view stops answering after a restart unless you make it persistent. Coming from miru has the whole comparison.
Try it.
Add a button that subtracts one: a second @risa.handler with self.count -= 1, and a second ui.Button in the
row. Restart the bot and click +1 on the old message. It keeps its count and gains the new button.
Where to read more¶
By example goes through the rest, one chapter at a time. The API reference has every class and parameter.