Where state lives¶
A view's state sits in its message until the message runs out of room. This chapter shows what you can do then, and how to choose between the message and a store.
What you can do¶
Smallest step first. The last column counts for the poll below, which fits twelve voters as it is:
| Step | What you write | Room for voters |
|---|---|---|
| Read it fresh | Mark the field risa.Prop[T] and fill it in load() from your database |
The field takes none |
| Another codec | state=risa.InMessage(codec=risa.codecs.BASE32768), with the radixly extra |
33 voters, or 30 with URO14 |
| A store | state=risa.InStore(ttl=...), as Stores and TTL shows |
No size limit |
A codec change leaves messages already sent working, since each id names its codec. A prop or a move to a store retires them: a click on one fails in Discord, unless a class at the old version answers it.
A poll that outgrows its message¶
This poll counts the votes and keeps the id of everyone who voted, so that nobody votes twice.
@risa.register(name="poll")
class Poll(risa.View):
question: str
yes: int = 0
no: int = 0
voters: list[int] = msgspec.field(default_factory=list[int])
Yes and No each ask this method first, and it adds the voter's id to the list.
async def _first_vote(self, ctx: risa.ComponentContext) -> bool:
if ctx.user.id in self.voters:
await ctx.respond("You already voted.", ephemeral=True)
return False
self.voters.append(ctx.user.id)
return True
A second vote from the same person
The limit¶
Two buttons hold 140 bytes, framing included, and each voter takes 9 of them. With the question and the counts, twelve voters fit:
12 voters
Pizza on Friday?
The thirteenth does not. client.build raises
StateOverflowError, and the error lists what each field takes:
13 voters: what client.build raises
view 'poll' needs 147 bytes of durable state, but its 2 interactive components carry only 140 with codec 'base85':
voters 118 bytes
question 17 bytes
yes 1 byte
no 1 byte
framing 10 bytes
Make room by marking per-dispatch fields risa.Prop[...], adding components, shortening fields, using a denser codec (state=risa.InMessage(codec=risa.codecs.BASE32768) with hikari-risa[radixly]), or moving the view to state=risa.InStore(...).A running poll gets this error from ctx.rerender() in the thirteenth voter's click. Nothing has been written yet, so
the message keeps twelve votes. If the handler lets the error through, risa logs it and the click fails in Discord. This
poll catches it and answers instead:
async def _save(self, ctx: risa.ComponentContext) -> None:
try:
await ctx.rerender()
except risa.StateOverflowError:
await ctx.respond("This poll is full.", ephemeral=True)
The thirteenth voter's click
self still holds the thirteenth vote. When the handler returns, risa tries to save it once more, finds that it still
does not fit, and leaves it at a DEBUG line, since the handler has dealt with it. A handler that goes on to change the
poll after the except saves that change together with the thirteenth vote, which does not fit either, so it takes the
vote back first.
At twelve voters the state fills both buttons:
What the buttons hold
Twelve voters
1a*_|V|0b=!{Z2*dGR!{%{0F<y$X?l8LAa8CUMsjInVR=6X1)R?k1sIFTy+8ra5(OBG$-Q3i&k_X~i^;ux-_H^S7>mihoX*b@1sD1a*_|V|0SU#xi^;vaz0VQ_7>mih+N{qK1sIFTz4@EZ5(OBG$-Wqh&k_X~i^;w>dCw9B7>miiR%p)>1sIFTzI9j65(OBG$-b0EYellow routes the click to its view and handler. Pink is the state.
The voters are not secret.
render() shows only the totals, but the voters' ids travel in the pink part. Anyone who can see the message can
read who voted.
Each vote also changes these ids. When tested, Discord refused a click on an old id, so of two people voting at the same moment, the second can get a failed click and has to click again.
Another codec¶
A codec decides how risa writes a view's state into the ids of its buttons. Discord allows 100 characters per id, however
many bytes each character takes, so a codec with more characters to choose from fits more into each one. The default,
BASE85, uses 85 ASCII characters and writes 6.4 bits with each.
BASE32768 chooses from 32,768 characters and writes 15. When tested, ids of 100
such characters came back from Discord byte for byte.
| Codec | Bits per character | One button holds | The poll fits | A bound str, in ASCII characters |
|---|---|---|---|---|
BASE85 |
6.4 | 70 bytes | 12 voters | 68 |
BASE32768 |
15 | 165 bytes | 33 voters | 159 |
URO14 |
14 | 152 bytes | 30 voters | 147 |
The bytes include 9 of framing per view, so the default codec leaves about 60 for your state on one button, and about 70 for each further one. The last column is for a button with no state, as in What can be an argument.
To switch, install the radixly extra:
uv add "hikari-risa[radixly]"
pip install "hikari-risa[radixly]"
Then name the codec on the view with risa.InMessage. Nothing
else in the view changes.
@risa.register(name="poll", state=risa.InMessage(codec=risa.codecs.BASE32768))
class Poll(risa.View):
question: str
yes: int = 0
no: int = 0
voters: list[int] = msgspec.field(default_factory=list[int])
Compare the ids with the ones above: the same twelve voters now fit on the first button, and the second has no state at all.
What the buttons hold
The same twelve voters in BASE32768
1w*_|V|0b=!m峠䣛優ҠҤ礡䜒ꄚ困䈛橨趆熃ᨤꔎᣜ跩ᄦ㞹䈔Ү抨⠱⩉蔏抓袠矨蒭鱗旾㡅⬅飏姹鍑㙨袏㦻戝跩ᄦ㞹䈝篆抨⠱⩉蕜赓袠矨蒭黂㯞㡅⬅飏浏䌱㙨袏㦼糈跩ᄦ㞹䈧儞抨⠱⩉薪㟿1w*_|V|0SU#!Yellow routes the click to its view and handler. Pink is the state.
You don't raise version, and messages already sent keep working. Each id names its codec, so risa reads an old message in
the codec it was written in, and writes it in the new one at its next rerender. A poll that was full gets its room with the
next vote.
BASE32768 holds the most, so take it unless you have a reason not to. URO14 holds a little less and writes only
characters from one CJK block. InMessage(codec=...) also takes a Codec of your own.
Keep these in mind:
- There is still a limit. The 34th voter raises
StateOverflowError, where a store has no limit. - The state stays readable. The ids look scrambled, but anyone can decode them, so a codec is no encryption.
- Each vote still changes the ids, so two people voting at the same moment can still get a failed click.
- The extra has to stay installed. Without
radixly,@risa.registerraisesCodecUnavailableErroron a view that names the codec. A click on a message written in it logs an error that names the extra, and the click fails. - A codec of your own has to stay registered. Once no view names it, call
risa.codecs.registeron it in every process that answers clicks. Otherwise risa takes its ids for another bot's and ignores them, so the click fails with no line in your log. - Going back can overflow. A message that holds more than the old codec fits fails at its next rerender with
StateOverflowError.
A codec is only for the message. InStore has no codec, since its buttons hold a short key.
Message or store¶
The message, risa.InMessage, is the default. It needs no infrastructure, and the state lives as long as the message does. Move to a store
when the view needs one of these:
| The view needs | A store gives it |
|---|---|
| Room | No size limit |
| A lifetime | A ttl, where state in a message never expires |
| Data not everyone should read | Buttons that hold a random key, with the state out of sight |
| Many people clicking at once | Ids that change only with a bound argument, so a vote leaves them as they are |
Where the bot runs counts too, as Several processes shows.
Full file
"""A poll that records who voted, in the message, until the message runs out of room."""
from __future__ import annotations
import os
import hikari
import msgspec
import risa
from risa import ui
@risa.register(name="poll")
class Poll(risa.View):
question: str
yes: int = 0
no: int = 0
voters: list[int] = msgspec.field(default_factory=list[int])
def render(self) -> ui.Layout:
return ui.Container(
ui.TextDisplay(f"## {self.question}"),
ui.Row(
ui.Button(self.vote_yes, label=f"Yes: {self.yes}"),
ui.Button(self.vote_no, label=f"No: {self.no}"),
),
)
@risa.handler
async def vote_yes(self, ctx: risa.ComponentContext) -> None:
if await self._first_vote(ctx):
self.yes += 1
await self._save(ctx)
@risa.handler
async def vote_no(self, ctx: risa.ComponentContext) -> None:
if await self._first_vote(ctx):
self.no += 1
await self._save(ctx)
async def _first_vote(self, ctx: risa.ComponentContext) -> bool:
if ctx.user.id in self.voters:
await ctx.respond("You already voted.", ephemeral=True)
return False
self.voters.append(ctx.user.id)
return True
async def _save(self, ctx: risa.ComponentContext) -> None:
try:
await ctx.rerender()
except risa.StateOverflowError:
await ctx.respond("This poll is full.", 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(Poll)
@bot.listen()
async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
if event.content == "!poll":
rendered = await client.build(Poll(question="Pizza on Friday?"))
await rendered.send_to(bot.rest, event.channel_id)
bot.run()
if __name__ == "__main__":
main()