Changing a view safely¶
A message outlives the code that sent it: someone can click last month's poll on the code you released today. This chapter shows which changes break those old buttons, and every way to deal with one that does.
Before you release a change, look it up under What breaks. If it is free, release it. If it breaks old buttons, pick from What you can do, or from Changing a handler if only a handler changed.
Why an old button can break¶
A button's custom_id stays as the old code wrote it until the message's next rerender. These are a poll's ids:
What the buttons hold
question = 'Pizza on Friday?', votes = {'Yes': 3, 'No': 2, 'Maybe': 1}
1aZYDNGOqr!3bMy)AfLb{|mYq_5MFvQ&S!Ht1aZYDNGOqr!3bMy)AfLb{|mYq_5MFvQ%PHz1aZYDNGOqr!3bMy)AfLb{|mYq_5MFvQ)O<{RrWdYellow routes the click to its view and handler. Pink is the key to the state in the store. Lavender is the bound argument.
On a click, risa holds what the id says against the code running now, in this order:
- 1The viewIts
nameandversion. With no class at that version, the click fails in Discord and none of your code runs. - 2The handlerIts
handler_idandversion. With no such method,on_outdatedruns. - 3The argumentsTheir types, then their values. With other types, or an enum value that is gone,
on_outdatedruns. - 4The stateThe fields that wrote it. If they no longer fit,
on_outdatedruns. If a store has lost the record,on_state_runs.missing
If all four pass, the handler runs: today's code on the old state.
What breaks¶
Free changes¶
Old buttons keep working through all of these:
| You change | What to know |
|---|---|
The code of render(), load(), a handler or a hook |
An old message keeps its look until its next rerender |
A button added to render(), or left out |
A button you left out stays on old messages until their next rerender, so keep its handler until then |
| The Python name of the class or its module | risa routes by name= |
| A field added at the end, with a default | It takes room in a message |
| A field's default, or a default added to a field | State that holds the field keeps its value, and state saved before the field existed gets the current default |
A risa.Prop, added, changed or removed |
Props are not saved |
A field from int to hikari.Snowflake |
Both are saved the same way |
A value added to a field's enum.Enum, or to a Literal of the same kinds of values |
risa compares only whether the values are int, str or None |
| An enum member's name, if its value stays | risa saves the value |
ttl |
An old record takes it at its next click, or None at its next save |
| The codec | An old message takes it at its next rerender, as long as risa can still read the old one |
A handler's parameter names, its defer, or an injected parameter added |
See Changing a handler |
Here is the most useful one. This poll gets a note under its question, in a field at the end of the class:
@risa.register(name="poll", version=2, state=risa.InStore(ttl=WEEK))
class Poll(risa.View):
question: str
yes: int = 0
no: int = 0
voters: list[int] = msgspec.field(default_factory=list[int])
note: str = "One vote each."
def render(self) -> ui.Layout:
return ui.Container(
ui.TextDisplay(f"## {self.question}"),
ui.TextDisplay(self.note),
ui.Row(
ui.Button(self.vote_yes, label=f"Yes: {self.yes}"),
ui.Button(self.vote_no, label=f"No: {self.no}"),
),
)
In Discord
Pizza on Friday?
One vote each.
An old poll keeps working. Its next click fills note with the default, rerender() draws it, and the votes are still
there. In a message the new field takes room too: with this note, the poll from
Where state lives has room for ten voters, and one that already holds ten or more
fails on its next vote.
Try it.
Send a poll with the Redis file from Stores and TTL and stop the bot. Start the file below on the same Redis and click Yes on the old poll. It counts your vote and gains the note.
Full file
"""The poll gains a field with a default, and polls already sent keep working."""
from __future__ import annotations
import os
import hikari
import msgspec
import redis.asyncio
import risa
from risa import ui
from risa.store.redis import RedisStore
WEEK = 7 * 24 * 60 * 60
@risa.register(name="poll", version=2, state=risa.InStore(ttl=WEEK))
class Poll(risa.View):
question: str
yes: int = 0
no: int = 0
voters: list[int] = msgspec.field(default_factory=list[int])
note: str = "One vote each."
def render(self) -> ui.Layout:
return ui.Container(
ui.TextDisplay(f"## {self.question}"),
ui.TextDisplay(self.note),
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 ctx.rerender()
@risa.handler
async def vote_no(self, ctx: risa.ComponentContext) -> None:
if await self._first_vote(ctx):
self.no += 1
await ctx.rerender()
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
@classmethod
async def on_state_missing(cls, ctx: risa.ComponentContext) -> None:
await ctx.edit(ui.TextDisplay("This poll has expired."))
def main() -> None:
bot = hikari.GatewayBot(
os.environ["DISCORD_TOKEN"],
intents=hikari.Intents.ALL_UNPRIVILEGED | hikari.Intents.MESSAGE_CONTENT,
)
connection = redis.asyncio.Redis(host="localhost")
client = risa.client_from_app(bot, store=RedisStore(connection))
client.add_view(Poll)
@bot.listen()
async def on_stopped(_: hikari.StoppedEvent) -> None:
await connection.aclose()
@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()
Breaking changes¶
| You change | A click on an old message | risa logs |
|---|---|---|
The view's version or name |
Fails in Discord | DEBUG |
| A field: removed, renamed, reordered or retyped | on_outdated |
ERROR |
| A field added anywhere but the end, or with no default | on_outdated |
ERROR |
A field made a risa.Prop |
on_outdated |
ERROR |
A value removed from a field's enum.Enum or Literal, or changed |
on_outdated, only for messages that hold it |
WARNING |
A field's Literal gains a kind of value, or loses its last value of one |
on_outdated |
ERROR |
| A field's nested struct changes | on_outdated |
ERROR |
InMessage to InStore, or back |
on_outdated |
ERROR |
Another store, or another key_prefix |
on_state_missing |
WARNING, or DEBUG once you override it |
| Less room in a message: more state, or fewer buttons to hold it | The handler runs, and rerender() raises StateOverflowError |
ERROR |
A method renamed or deleted, or its version raised with no method left at the old one |
on_outdated |
WARNING, or DEBUG once you override it |
| A handler's arguments: added, removed or retyped | on_outdated |
ERROR |
| A codec old messages use: its extra removed, or your own no longer registered | Fails in Discord | ERROR for the extra, nothing for your own |
| A bound enum member removed, or its value changed | on_outdated |
WARNING |
| An argument's default removed, from a button that left the argument out | on_outdated |
ERROR |
on_outdated and on_state_missing do nothing until you override them, so without that, a click that reaches them fails
in Discord too.
An enum with a _missing_ method decides itself what a removed value becomes, in a field and in an argument. The handler
then runs with the member it returns, and on_outdated stays out of it.
The ERROR lines on fields and arguments usually mean a forgotten version, and they tell you to raise it. hikari logs
at INFO by default, so you see no line for a raised version until you turn DEBUG on.
What risa cannot see¶
risa compares names and types. If price: int held cents and now holds euros, every check passes, and your handler reads
old cents as euros. An argument that keeps its type and changes its meaning is the same, and so are two enum members that
swap their values. Raise the version yourself when a meaning changes.
In a message or in a store¶
This chapter's rules hold for InMessage and InStore alike, and its poll sits in a store. These are the differences:
InMessage |
InStore |
|
|---|---|---|
| New fields | Take room, and a message that no longer fits fails at its next save | No limit |
| Fewer buttons | Less room, with the same failure | No effect, since each button holds the same key |
| State that is gone | Cannot happen, so on_state_missing never runs |
An expired record, another store or another key_prefix run on_state_missing |
| What a retired message leaves behind | Nothing | Its record, until its ttl runs out, or for good under ttl=None |
| A bound argument | Shares its id with the state | Shares its id with the key |
| Free to change | The codec | ttl |
What you can do¶
Say the poll should take any options, not only yes and no. yes and no become one votes dict, and vote_yes and
vote_no become one handler with an argument. The fields changed, so the view's
version goes up to 3.
@risa.register(name="poll", version=3, state=risa.InStore(ttl=WEEK))
class Poll(risa.View):
question: str
votes: dict[str, int]
voters: list[int] = msgspec.field(default_factory=list[int])
note: str = "One vote each."
def render(self) -> ui.Layout:
buttons = [
ui.Button(
risa.bind(self.vote, option),
label=f"{option}: {count}",
)
for option, count in self.votes.items()
]
return ui.Container(
ui.TextDisplay(f"## {self.question}"),
ui.TextDisplay(self.note),
ui.Row(*buttons),
)
@risa.handler
async def vote(self, ctx: risa.ComponentContext, option: str) -> None:
if ctx.user.id in self.voters:
await ctx.respond("You already voted.", ephemeral=True)
return
self.voters.append(ctx.user.id)
self.votes[option] += 1
await ctx.rerender()
In Discord
Pizza on Friday?
One vote each.
Polls already sent stay at version 2. These are your options for them, and you can choose per old version:
| Option | A click on an old poll | What stays in your code |
|---|---|---|
| Make the change free | Works | The old fields or handlers, next to the new ones |
| Let old buttons fail | Fails in Discord | Nothing |
| Answer them all the same way | Gets one reply | A small class |
| Close the old message | Shows the final result, and the buttons go | The old fields and a method per old button |
| Keep the old version running | Works as before | The whole old class |
| Upgrade the old message | Turns the poll into version 3 | The old fields, a method per old button, the conversion |
Make the change free¶
Many breaking changes have a free form that reaches the same goal:
| Instead of | Do this |
|---|---|
| Renaming or retyping a field | Keep it, add the new one at the end with a default, and read both |
| Renaming a method | Pin its handler_id |
| Changing a handler's arguments | Keep a method at the old version |
For the poll, yes and no would stay behind the two old buttons, and a more: dict[str, int] at the end would hold
the other options. Old polls keep working, and you carry both forms for as long as those polls matter.
Let old buttons fail¶
Raise the version and do nothing else. A click on an old poll reaches no view, so it fails in Discord. on_outdated
on the new class does not run, because that class answers only for version 3. For this poll in a store, that looks like this:
- MondayVersion 2 sends a pollIts buttons route to version 2 and hold the record's key.
- TuesdayYou release version 3New polls route to version 3. The old message stays as it was.
- WednesdaySomeone clicks the old pollThe click fails in Discord, unless a class at version 2 answers it.
- A week after the last click that read itThe record expiresThe message stays, and a later click finds no record.
This suits messages nobody comes back to.
Answer them all the same way¶
A class at the old version needs no fields, no handlers and no state=. Every old button finds no handler there, so
on_outdated answers it. Add the class to the client as well. This one answers the oldest polls, from
version 1 in Where state lives:
@risa.register(name="poll", version=1)
class RetiredPoll(risa.View):
@classmethod
async def on_outdated(cls, ctx: risa.ComponentContext) -> None:
await ctx.respond("This button no longer works.", ephemeral=True)
After a click on a retired button
The old state is out of reach here. To replace the message, so nobody clicks it again, call
ctx.edit(ui.TextDisplay("...")) instead.
Each old version needs a class of its own, and a base class can hold the answer once:
class Retired(risa.View):
@classmethod
async def on_outdated(cls, ctx: risa.ComponentContext) -> None:
await ctx.respond("This button no longer works.", ephemeral=True)
@risa.register(name="poll", version=1)
class PollV1(Retired): ...
@risa.register(name="poll", version=2)
class PollV2(Retired): ...
Close the old message¶
To show what the old poll held, keep a class at the old version. Its fields and its state= have to match the old ones,
or risa cannot read the state.
@risa.register(name="poll", version=2, state=risa.InStore(ttl=WEEK))
class OldPoll(risa.View):
question: str
yes: int = 0
no: int = 0
voters: list[int] = msgspec.field(default_factory=list[int])
note: str = ""
Keep a method for each old button too, with the same name, version and arguments, and let its body say the poll is over.
Such a rejection handler routes like any other and logs nothing, and self holds the old state.
@risa.handler
async def vote_yes(self, ctx: risa.ComponentContext) -> None:
await self._ended(ctx)
@risa.handler
async def vote_no(self, ctx: risa.ComponentContext) -> None:
await self._ended(ctx)
async def _ended(self, ctx: risa.ComponentContext) -> None:
text = f"{self.question} It ended with {self.yes} yes and {self.no} no."
await ctx.edit(ui.TextDisplay(text))
After a click on an old poll
Pizza on Friday? It ended with 3 yes and 2 no.
Add both classes to the client. Each answers for its own version, so an old class in a store needs an on_state_missing
of its own as well.
client.add_view(Poll)
client.add_view(OldPoll)
client.add_view(RetiredPoll)
Full file
"""The poll takes any options, so its version goes up. Old classes answer old polls."""
from __future__ import annotations
import os
import hikari
import msgspec
import redis.asyncio
import risa
from risa import ui
from risa.store.redis import RedisStore
WEEK = 7 * 24 * 60 * 60
@risa.register(name="poll", version=3, state=risa.InStore(ttl=WEEK))
class Poll(risa.View):
question: str
votes: dict[str, int]
voters: list[int] = msgspec.field(default_factory=list[int])
note: str = "One vote each."
def render(self) -> ui.Layout:
buttons = [
ui.Button(
risa.bind(self.vote, option),
label=f"{option}: {count}",
)
for option, count in self.votes.items()
]
return ui.Container(
ui.TextDisplay(f"## {self.question}"),
ui.TextDisplay(self.note),
ui.Row(*buttons),
)
@risa.handler
async def vote(self, ctx: risa.ComponentContext, option: str) -> None:
if ctx.user.id in self.voters:
await ctx.respond("You already voted.", ephemeral=True)
return
self.voters.append(ctx.user.id)
self.votes[option] += 1
await ctx.rerender()
@classmethod
async def on_state_missing(cls, ctx: risa.ComponentContext) -> None:
await ctx.edit(ui.TextDisplay("This poll has expired."))
@risa.register(name="poll", version=2, state=risa.InStore(ttl=WEEK))
class OldPoll(risa.View):
question: str
yes: int = 0
no: int = 0
voters: list[int] = msgspec.field(default_factory=list[int])
note: str = ""
@risa.handler
async def vote_yes(self, ctx: risa.ComponentContext) -> None:
await self._ended(ctx)
@risa.handler
async def vote_no(self, ctx: risa.ComponentContext) -> None:
await self._ended(ctx)
async def _ended(self, ctx: risa.ComponentContext) -> None:
text = f"{self.question} It ended with {self.yes} yes and {self.no} no."
await ctx.edit(ui.TextDisplay(text))
@classmethod
async def on_state_missing(cls, ctx: risa.ComponentContext) -> None:
await ctx.edit(ui.TextDisplay("This poll has expired."))
@risa.register(name="poll", version=1)
class RetiredPoll(risa.View):
@classmethod
async def on_outdated(cls, ctx: risa.ComponentContext) -> None:
await ctx.respond("This button no longer works.", ephemeral=True)
def main() -> None:
bot = hikari.GatewayBot(
os.environ["DISCORD_TOKEN"],
intents=hikari.Intents.ALL_UNPRIVILEGED | hikari.Intents.MESSAGE_CONTENT,
)
connection = redis.asyncio.Redis(host="localhost")
client = risa.client_from_app(bot, store=RedisStore(connection))
client.add_view(Poll)
client.add_view(OldPoll)
client.add_view(RetiredPoll)
@bot.listen()
async def on_stopped(_: hikari.StoppedEvent) -> None:
await connection.aclose()
@bot.listen()
async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
if event.content == "!poll":
poll = Poll(
question="Pizza on Friday?",
votes={"Yes": 0, "No": 0, "Maybe": 0},
)
rendered = await client.build(poll)
await rendered.send_to(bot.rest, event.channel_id)
bot.run()
if __name__ == "__main__":
main()
Keep the old version running¶
Leave the old class as it was, with its render() and handlers, and give it another Python name. Old polls keep working
at version 2, and new polls start at version 3.
@risa.register(name="poll", version=2, state=risa.InStore(ttl=WEEK))
class OldPoll(risa.View):
question: str
yes: int = 0
no: int = 0
voters: list[int] = msgspec.field(default_factory=list[int])
note: str = ""
client.add_view(Poll)
client.add_view(OldPoll)
You now maintain two polls: a bug fix may have to go in both, for as long as the old messages matter.
Full file
"""Version 2 stays as it was, so old polls keep working beside new ones."""
from __future__ import annotations
import os
import hikari
import msgspec
import redis.asyncio
import risa
from risa import ui
from risa.store.redis import RedisStore
WEEK = 7 * 24 * 60 * 60
@risa.register(name="poll", version=3, state=risa.InStore(ttl=WEEK))
class Poll(risa.View):
question: str
votes: dict[str, int]
voters: list[int] = msgspec.field(default_factory=list[int])
note: str = "One vote each."
def render(self) -> ui.Layout:
buttons = [
ui.Button(
risa.bind(self.vote, option),
label=f"{option}: {count}",
)
for option, count in self.votes.items()
]
return ui.Container(
ui.TextDisplay(f"## {self.question}"),
ui.TextDisplay(self.note),
ui.Row(*buttons),
)
@risa.handler
async def vote(self, ctx: risa.ComponentContext, option: str) -> None:
if ctx.user.id in self.voters:
await ctx.respond("You already voted.", ephemeral=True)
return
self.voters.append(ctx.user.id)
self.votes[option] += 1
await ctx.rerender()
@classmethod
async def on_state_missing(cls, ctx: risa.ComponentContext) -> None:
await ctx.edit(ui.TextDisplay("This poll has expired."))
@risa.register(name="poll", version=2, state=risa.InStore(ttl=WEEK))
class OldPoll(risa.View):
question: str
yes: int = 0
no: int = 0
voters: list[int] = msgspec.field(default_factory=list[int])
note: str = ""
def render(self) -> ui.Layout:
return ui.Container(
ui.TextDisplay(f"## {self.question}"),
ui.TextDisplay(self.note),
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 ctx.rerender()
@risa.handler
async def vote_no(self, ctx: risa.ComponentContext) -> None:
if await self._first_vote(ctx):
self.no += 1
await ctx.rerender()
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
@classmethod
async def on_state_missing(cls, ctx: risa.ComponentContext) -> None:
await ctx.edit(ui.TextDisplay("This poll has expired."))
def main() -> None:
bot = hikari.GatewayBot(
os.environ["DISCORD_TOKEN"],
intents=hikari.Intents.ALL_UNPRIVILEGED | hikari.Intents.MESSAGE_CONTENT,
)
connection = redis.asyncio.Redis(host="localhost")
client = risa.client_from_app(bot, store=RedisStore(connection))
client.add_view(Poll)
client.add_view(OldPoll)
@bot.listen()
async def on_stopped(_: hikari.StoppedEvent) -> None:
await connection.aclose()
@bot.listen()
async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
if event.content == "!poll":
poll = Poll(
question="Pizza on Friday?",
votes={"Yes": 0, "No": 0, "Maybe": 0},
)
rendered = await client.build(poll)
await rendered.send_to(bot.rest, event.channel_id)
bot.run()
if __name__ == "__main__":
main()
Upgrade the old message¶
If the old state converts to the new, let the next click do it. These handlers build a version 3 poll from the version 2
state and count the vote. ctx.replace then turns the message
into that poll.
@risa.register(name="poll", version=2, state=risa.InStore(ttl=WEEK))
class OldPoll(risa.View):
question: str
yes: int = 0
no: int = 0
voters: list[int] = msgspec.field(default_factory=list[int])
note: str = ""
@risa.handler
async def vote_yes(self, ctx: risa.ComponentContext) -> None:
await self._upgrade(ctx, "Yes")
@risa.handler
async def vote_no(self, ctx: risa.ComponentContext) -> None:
await self._upgrade(ctx, "No")
async def _upgrade(self, ctx: risa.ComponentContext, option: str) -> None:
poll = Poll(
question=self.question,
votes={"Yes": self.yes, "No": self.no},
voters=self.voters,
note=self.note,
)
if ctx.user.id not in poll.voters:
poll.voters.append(ctx.user.id)
poll.votes[option] += 1
await ctx.replace(poll)
After a click on Yes on an old poll with 3 yes and 2 no
Pizza on Friday?
One vote each.
ctx.editcannot do this. It takes only the buttons of the view that was clicked, and raisesLayoutErrorfor another's.- The old view stops here. Its state is not saved, and
ctx.rerender()andctx.edit()raiseNoViewError. - The new state. In a store,
ctx.replacewrites a new record under a new key, and the old one expires with itsttl. In a message, it raisesStateOverflowErrorif the new state does not fit the new buttons. - Messages nobody clicks. risa keeps no list of the messages it sent, so one that gets no click stays at version 2. It still needs this class when someone clicks it next.
- Two clicks at once. Both start from the same old state, and the second replaces the first with only its own vote.
When the old state cannot convert, close the message instead. A count of votes cannot say who chose what, so a version that records it has nothing to fill it with.
Full file
"""A click on a poll at version 2 turns it into version 3 and counts the vote."""
from __future__ import annotations
import os
import hikari
import msgspec
import redis.asyncio
import risa
from risa import ui
from risa.store.redis import RedisStore
WEEK = 7 * 24 * 60 * 60
@risa.register(name="poll", version=3, state=risa.InStore(ttl=WEEK))
class Poll(risa.View):
question: str
votes: dict[str, int]
voters: list[int] = msgspec.field(default_factory=list[int])
note: str = "One vote each."
def render(self) -> ui.Layout:
buttons = [
ui.Button(
risa.bind(self.vote, option),
label=f"{option}: {count}",
)
for option, count in self.votes.items()
]
return ui.Container(
ui.TextDisplay(f"## {self.question}"),
ui.TextDisplay(self.note),
ui.Row(*buttons),
)
@risa.handler
async def vote(self, ctx: risa.ComponentContext, option: str) -> None:
if ctx.user.id in self.voters:
await ctx.respond("You already voted.", ephemeral=True)
return
self.voters.append(ctx.user.id)
self.votes[option] += 1
await ctx.rerender()
@classmethod
async def on_state_missing(cls, ctx: risa.ComponentContext) -> None:
await ctx.edit(ui.TextDisplay("This poll has expired."))
@risa.register(name="poll", version=2, state=risa.InStore(ttl=WEEK))
class OldPoll(risa.View):
question: str
yes: int = 0
no: int = 0
voters: list[int] = msgspec.field(default_factory=list[int])
note: str = ""
@risa.handler
async def vote_yes(self, ctx: risa.ComponentContext) -> None:
await self._upgrade(ctx, "Yes")
@risa.handler
async def vote_no(self, ctx: risa.ComponentContext) -> None:
await self._upgrade(ctx, "No")
async def _upgrade(self, ctx: risa.ComponentContext, option: str) -> None:
poll = Poll(
question=self.question,
votes={"Yes": self.yes, "No": self.no},
voters=self.voters,
note=self.note,
)
if ctx.user.id not in poll.voters:
poll.voters.append(ctx.user.id)
poll.votes[option] += 1
await ctx.replace(poll)
@classmethod
async def on_state_missing(cls, ctx: risa.ComponentContext) -> None:
await ctx.edit(ui.TextDisplay("This poll has expired."))
def main() -> None:
bot = hikari.GatewayBot(
os.environ["DISCORD_TOKEN"],
intents=hikari.Intents.ALL_UNPRIVILEGED | hikari.Intents.MESSAGE_CONTENT,
)
connection = redis.asyncio.Redis(host="localhost")
client = risa.client_from_app(bot, store=RedisStore(connection))
client.add_view(Poll)
client.add_view(OldPoll)
@bot.listen()
async def on_stopped(_: hikari.StoppedEvent) -> None:
await connection.aclose()
@bot.listen()
async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
if event.content == "!poll":
poll = Poll(
question="Pizza on Friday?",
votes={"Yes": 0, "No": 0, "Maybe": 0},
)
rendered = await client.build(poll)
await rendered.send_to(bot.rest, event.channel_id)
bot.run()
if __name__ == "__main__":
main()
Changing a handler¶
A handler has a route of its own, so you can change one and leave the rest of the view working. A button holds three things about its handler:
| The button holds | Set by |
|---|---|
handler_id |
@risa.handler, the method's name by default |
version |
@risa.handler, 1 by default |
| A fingerprint of the argument types | The parameters after ctx that risa.bind fills |
What stays outside these is free: the body, defer, a parameter's name, an injected parameter, a default added to an
argument, int to hikari.Snowflake, and a member added to a bound enum.
Renaming a method¶
Pin the old name as handler_id, and buttons already sent reach the method under its new name. This method used to be
called vote:
@risa.handler(handler_id="vote")
async def vote_by_name(self, ctx: risa.ComponentContext, option: str) -> None:
await self._count(ctx, option)
With no pin, the route changes with the name, and old buttons go to on_outdated.
Changing its arguments¶
An option's name travels on its button, and in this poll, in a store, it can be 54 ASCII characters long before
client.build raises, and fewer of anything else, since a str takes its UTF-8 bytes. So
the buttons should carry the option's position instead, and option: str becomes position: int. Old buttons still hold
a str, so the new method gets a new version, and the method above stays at the old one.
@risa.handler(version=2)
async def vote(self, ctx: risa.ComponentContext, position: int) -> None:
await self._count(ctx, list(self.votes)[position])
render() binds self.vote, which is version 2:
def render(self) -> ui.Layout:
buttons = [
ui.Button(risa.bind(self.vote, position), label=f"{option}: {count}")
for position, (option, count) in enumerate(self.votes.items())
]
return ui.Container(
ui.TextDisplay(f"## {self.question}"),
ui.TextDisplay(self.note),
ui.Row(*buttons),
)
Both methods route under the handler_id vote, and the button decides which one runs:
| Button | Holds | Reaches |
|---|---|---|
| Already sent | vote, version 1, a str |
vote_by_name |
Drawn by render() |
vote, version 2, an int |
vote |
vote_by_name ends in ctx.rerender(), which draws version 2 buttons, so an old poll moves over at its first click. Two
methods with the same id and the same version raise
DuplicateHandlerError at @risa.register.
If you change the arguments and keep the version, each old button goes to on_outdated with an ERROR line that names
the fix.
Full file
"""A handler's arguments change, and its old version stays for buttons already sent."""
from __future__ import annotations
import os
import hikari
import msgspec
import redis.asyncio
import risa
from risa import ui
from risa.store.redis import RedisStore
WEEK = 7 * 24 * 60 * 60
@risa.register(name="poll", version=3, state=risa.InStore(ttl=WEEK))
class Poll(risa.View):
question: str
votes: dict[str, int]
voters: list[int] = msgspec.field(default_factory=list[int])
note: str = "One vote each."
def render(self) -> ui.Layout:
buttons = [
ui.Button(risa.bind(self.vote, position), label=f"{option}: {count}")
for position, (option, count) in enumerate(self.votes.items())
]
return ui.Container(
ui.TextDisplay(f"## {self.question}"),
ui.TextDisplay(self.note),
ui.Row(*buttons),
)
@risa.handler(handler_id="vote")
async def vote_by_name(self, ctx: risa.ComponentContext, option: str) -> None:
await self._count(ctx, option)
@risa.handler(version=2)
async def vote(self, ctx: risa.ComponentContext, position: int) -> None:
await self._count(ctx, list(self.votes)[position])
async def _count(self, ctx: risa.ComponentContext, option: str) -> None:
if ctx.user.id in self.voters:
await ctx.respond("You already voted.", ephemeral=True)
return
self.voters.append(ctx.user.id)
self.votes[option] += 1
await ctx.rerender()
@classmethod
async def on_state_missing(cls, ctx: risa.ComponentContext) -> None:
await ctx.edit(ui.TextDisplay("This poll has expired."))
def main() -> None:
bot = hikari.GatewayBot(
os.environ["DISCORD_TOKEN"],
intents=hikari.Intents.ALL_UNPRIVILEGED | hikari.Intents.MESSAGE_CONTENT,
)
connection = redis.asyncio.Redis(host="localhost")
client = risa.client_from_app(bot, store=RedisStore(connection))
client.add_view(Poll)
@bot.listen()
async def on_stopped(_: hikari.StoppedEvent) -> None:
await connection.aclose()
@bot.listen()
async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
if event.content == "!poll":
poll = Poll(
question="Pizza on Friday?",
votes={"Yes": 0, "No": 0, "Maybe": 0},
)
rendered = await client.build(poll)
await rendered.send_to(bot.rest, event.channel_id)
bot.run()
if __name__ == "__main__":
main()
Retiring a handler¶
Delete the method once no old button matters. Unlike a raised view version, this reaches the current class, so its
on_outdated answers. To answer with what the view holds, keep the method and let its body say so, as
the closed poll does. Such a method logs nothing.
Sharing code between handlers¶
A handler cannot be called, and self.vote(...) raises
HandlerNotCallableError. Put what they share in a plain method,
as _count above. To run the handler itself, call Poll.vote.func(self, ctx, 0), the plain function. It gets no
injection, so pass what it injects yourself.
Which version to raise¶
| What changed | Raise | What it retires |
|---|---|---|
| One handler's arguments | The handler's version |
The old buttons of that handler |
| The fields, or where the state lives | The view's version |
Every button of every old message |
If both changed, raise the view's. The class you keep at the old version holds the old handlers.
on_outdated¶
View.on_outdated answers a click that reaches a class but cannot run: its
handler has gone, its arguments changed, or the state no longer fits the fields. Data that looks broken, such as an id
that does not parse, stays away from it: the click fails in Discord, and risa logs a line.
- It belongs to the class the button reaches. That class is the one at the button's
nameandversion, which is why a raised viewversionneeds a class at the old one. ctxhas no view.ctx.respondand actx.editwith nothing to click work, andctx.rerender()raisesNoViewError.- Its parameters are injected, as a handler's are. Your type
checker wants
= risa.INJECTEDon them, since the base method takes onlyctx. - Auto-defer does not cover it. Answer within the three seconds Discord gives you.
- By default it does nothing. The click fails in Discord. Overriding it also lowers a
WARNINGabout a missing handler toDEBUG.
on_state_missing follows the same rules, for a record that is gone.