Answering in time¶
Discord gives a click three seconds. This chapter shows each way to answer, and what to do when the work takes longer.
The clock¶
Discord's documentation asks for a first answer within 3 seconds of the click. Later answers go through a token that comes with the click, separate from your bot's token, and it stays valid for 15 minutes.
- 0 sThe clickThe clock starts.
- 2 sAuto-deferAnswers for a slow handler, if turned on.
- 3 sThe deadlineWith no answer, the click fails.
- 15 minThe token endsCalls through it stop working.
Auto-defer acknowledges the click in the handler's place. An acknowledgement counts as the first answer, so the click
does not fail at 3 seconds, and the real answer can come later. The 2 seconds are the client's auto_defer_delay.
Which message¶
Four calls answer a click, and they differ in which message they touch.
| Call | What it does | AutoDefer for a slow handler |
|---|---|---|
ctx.rerender() |
Redraws the clicked message | UPDATE |
ctx.edit(layout) |
Replaces the clicked message | UPDATE |
ctx.replace(view) |
Turns the clicked message into another view | UPDATE |
ctx.respond(...) |
Sends a new message | THINKING or THINKING_EPHEMERAL |
On a view with no slow handlers, a fast handler needs no auto-defer, because each of these calls is an answer in itself.
AutoDefer.OFF is the default. Under THINKING, a slow rerender() edits the clicked
message and leaves the thinking message with nothing to answer it, so a handler that redraws belongs with UPDATE.
A slow handler¶
This report takes five seconds to build, so refresh and export need an auto-defer, and close needs one so that it
is not stuck behind them.
@risa.register(name="report")
class Report(risa.View):
rows: int = 0
def render(self) -> ui.Layout:
return ui.Container(
ui.TextDisplay(f"Rows in the report: **{self.rows}**"),
ui.Row(
ui.Button(self.refresh, label="Refresh"),
ui.Button(self.export, label="Export"),
ui.Button(self.close, label="Close"),
),
)
In Discord
Rows in the report: 0
refresh redraws its own message. UPDATE acknowledges the click at 2 seconds with no sign the user can see, and
ctx.rerender() edits the message when the report is ready.
@risa.handler(defer=risa.AutoDefer.UPDATE)
async def refresh(self, ctx: risa.ComponentContext) -> None:
self.rows = await build_report()
await ctx.rerender()
After a click on Refresh
Rows in the report: 128
export answers with a new message that only the person who clicked can see. THINKING_EPHEMERAL shows Discord's
"thinking..." message at 2 seconds, and ctx.respond takes its place.
@risa.handler(defer=risa.AutoDefer.THINKING_EPHEMERAL)
async def export(self, ctx: risa.ComponentContext) -> None:
rows = await build_report()
await ctx.respond(f"Exported {rows} rows.", ephemeral=True)
While it works
The answer takes its place
Keep ephemeral=True on the respond: a handler that finishes inside the 2 seconds answers through that call, with no
thinking message before it.
close replaces the message with a layout render() does not draw, so the view ends here. Clicks on one message run one
at a time, so a click on Close while refresh runs waits for it. UPDATE answers that click at 2 seconds, and the
edit follows when its turn comes.
@risa.handler(defer=risa.AutoDefer.UPDATE)
async def close(self, ctx: risa.ComponentContext) -> None:
await ctx.edit(ui.TextDisplay(f"Closed with {self.rows} rows."))
After a click on Close
Closed with 128 rows.
By hand¶
You can also acknowledge a click yourself, as ReportByHand in the full file does.
ctx.defer() does it silently and right away, and
ctx.defer(thinking=True, ephemeral=True) shows the thinking message.
@risa.handler
async def refresh(self, ctx: risa.ComponentContext) -> None:
await ctx.defer()
self.rows = await build_report()
await ctx.rerender()
defer() raises AlreadyRespondedError if the click already has its answer. It also runs only when the handler's turn
comes, so a click waiting behind a slow handler on the same message can miss the 3 seconds. Auto-defer's timer starts
before that wait, so use it on a view with slow handlers.
ctx.acknowledge() takes the same options and does nothing if the
click already has its answer. It suits a handler with one branch that answers and one that does not.
@risa.handler
async def publish(self, ctx: risa.ComponentContext) -> None:
if self.rows == 0:
await ctx.respond("There is nothing to publish.", ephemeral=True)
else:
await send_to_team(self.rows)
await ctx.acknowledge()
Where to set it¶
The handlers above set it for themselves. A view and the client take it too.
| Set it on | With | It applies to |
|---|---|---|
| A handler | @risa.handler(defer=...) |
That handler |
| A view | @risa.register(defer=...) |
Its handlers with no setting of their own |
| The client | client_from_app(auto_defer=...) |
Handlers with neither setting |
The most specific setting wins, so defer=risa.AutoDefer.OFF on a handler turns auto-defer off for it.
A handler that answers nothing¶
| Mode | When the handler returns |
|---|---|
OFF |
risa sends nothing, so the click fails in Discord. On a gateway bot risa logs nothing either |
UPDATE |
risa acknowledges the click silently |
THINKING, THINKING_EPHEMERAL |
Inside the 2 seconds, risa acknowledges the click silently. After them the thinking message is already up and risa sends nothing more, so answer it with ctx.respond |
A handler that changed a field without answering gets a rerender from risa when it returns, in every mode. That saves the change, and it answers the click if the 3 seconds have not run out.
A handler that raises gets no acknowledgement in any mode. risa logs the exception, and the click fails unless the
handler or auto-defer answered first. An error handler can answer it instead, and if it returns
True, risa logs nothing.
Full file
"""A slow handler, and which answer goes with which auto-defer."""
from __future__ import annotations
import asyncio
import logging
import os
import hikari
import risa
from risa import ui
async def build_report() -> int:
await asyncio.sleep(5)
return 128
async def send_to_team(rows: int) -> None:
await asyncio.sleep(0.1)
logging.getLogger("report").info("Sent %d rows to the team.", rows)
@risa.register(name="report")
class Report(risa.View):
rows: int = 0
def render(self) -> ui.Layout:
return ui.Container(
ui.TextDisplay(f"Rows in the report: **{self.rows}**"),
ui.Row(
ui.Button(self.refresh, label="Refresh"),
ui.Button(self.export, label="Export"),
ui.Button(self.close, label="Close"),
),
)
@risa.handler(defer=risa.AutoDefer.UPDATE)
async def refresh(self, ctx: risa.ComponentContext) -> None:
self.rows = await build_report()
await ctx.rerender()
@risa.handler(defer=risa.AutoDefer.THINKING_EPHEMERAL)
async def export(self, ctx: risa.ComponentContext) -> None:
rows = await build_report()
await ctx.respond(f"Exported {rows} rows.", ephemeral=True)
@risa.handler(defer=risa.AutoDefer.UPDATE)
async def close(self, ctx: risa.ComponentContext) -> None:
await ctx.edit(ui.TextDisplay(f"Closed with {self.rows} rows."))
@risa.register(name="report-by-hand")
class ReportByHand(risa.View):
rows: int = 0
def render(self) -> ui.Layout:
return ui.Container(
ui.TextDisplay(f"Rows in the report: **{self.rows}**"),
ui.Row(
ui.Button(self.refresh, label="Refresh"),
ui.Button(self.publish, label="Publish"),
),
)
@risa.handler
async def refresh(self, ctx: risa.ComponentContext) -> None:
await ctx.defer()
self.rows = await build_report()
await ctx.rerender()
@risa.handler
async def publish(self, ctx: risa.ComponentContext) -> None:
if self.rows == 0:
await ctx.respond("There is nothing to publish.", ephemeral=True)
else:
await send_to_team(self.rows)
await ctx.acknowledge()
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(Report)
client.add_view(ReportByHand)
@bot.listen()
async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
if event.content == "!report":
rendered = await client.build(Report())
await rendered.send_to(bot.rest, event.channel_id)
elif event.content == "!byhand":
rendered = await client.build(ReportByHand())
await rendered.send_to(bot.rest, event.channel_id)
bot.run()
if __name__ == "__main__":
main()
When something fails¶
A failure can still get an answer. client.error_handler registers
a function that risa calls when a click fails. This view asks a weather service that is down, and its Next city has a
bug:
FORECASTS = {"Berlin": "Sunny, 18 °C"}
class ForecastUnavailableError(Exception):
"""The weather service did not answer."""
async def fetch_forecast(city: str) -> str:
await asyncio.sleep(0)
raise ForecastUnavailableError(city)
@risa.register(name="forecast")
class Forecast(risa.View):
city: str = "Berlin"
text: risa.Prop[str] = ""
def render(self) -> ui.Layout:
return ui.Container(
ui.TextDisplay(f"**{self.city}**: {self.text or FORECASTS[self.city]}"),
ui.Row(
ui.Button(self.refresh, label="Refresh"),
ui.Button(self.next_city, label="Next city"),
),
)
@risa.handler
async def refresh(self, ctx: risa.ComponentContext) -> None:
self.text = await fetch_forecast(self.city)
await ctx.rerender()
@risa.handler
async def next_city(self, ctx: risa.ComponentContext) -> None:
self.city = "Paris"
await ctx.rerender()
In Discord
Berlin: Sunny, 18 °C
This error handler answers the failure the bot expects:
async def forecast_unavailable(failure: risa.ClickFailure) -> bool:
if not isinstance(failure.error, ForecastUnavailableError):
return False
await failure.ctx.respond(
"The weather service is not answering. Try again in a minute.",
ephemeral=True,
)
return True
After a click on Refresh, while the weather service is down
The weather service is not answering. Try again in a minute.
It gets a ClickFailure: failure.ctx to answer through, failure.error
with the exception, and failure.stage with where it was raised. It returns True once it has dealt with the failure,
and risa logs nothing more. With False it passes the failure on.
stage |
What failed |
|---|---|
HANDLER |
The handler |
LOAD |
load(), so the handler never ran |
ON_OUTDATED, ON_STATE_MISSING |
That hook |
STATE |
Reaching the view's state, before any of your code ran: the store failed or is missing, its lock timed out, or a codec's extra is not installed |
A failure after the handler, such as saving the view when the handler returns, stays a log line.
Several error handlers run highest priority first, and in the order you registered them within one priority, until one
returns True. If none does, risa logs the failure as it would without them. A last one that answers every failure and
returns False tells the person something went wrong and still leaves the failure in your log:
async def something_went_wrong(failure: risa.ClickFailure) -> bool:
await failure.ctx.respond("Something went wrong.", ephemeral=True)
return False
After a click on Next city, which raises a KeyError
def add_error_handlers(client: risa.Client) -> None:
client.error_handler(forecast_unavailable, priority=1)
client.error_handler(something_went_wrong, priority=-1)
@client.error_handler and @client.error_handler(priority=1) work as decorators too.
- The view is gone.
failure.ctxhas no view state, as inon_outdated:respond, aneditwith nothing to click andreplacework, and on a view with fieldsrerender()raisesNoViewError, so a half-changed view is never saved. - Three seconds. An error handler runs without auto-defer, and the three seconds count from the click. A failure that comes late, such as a lock that took ten seconds, leaves it no time unless auto-defer answered first.
- What was sent before.
respondcontinues it, as it would in the handler: after an answer it sends a follow-up, and after a thinking message it replaces that. - Injection. Its parameters after
failureare injected, as a handler's are. - One that raises is logged and counts as
False.
Full file
"""Error handlers: one for a failure the bot expects, and one that answers every other."""
from __future__ import annotations
import asyncio
import os
import hikari
import risa
from risa import ui
FORECASTS = {"Berlin": "Sunny, 18 °C"}
class ForecastUnavailableError(Exception):
"""The weather service did not answer."""
async def fetch_forecast(city: str) -> str:
await asyncio.sleep(0)
raise ForecastUnavailableError(city)
@risa.register(name="forecast")
class Forecast(risa.View):
city: str = "Berlin"
text: risa.Prop[str] = ""
def render(self) -> ui.Layout:
return ui.Container(
ui.TextDisplay(f"**{self.city}**: {self.text or FORECASTS[self.city]}"),
ui.Row(
ui.Button(self.refresh, label="Refresh"),
ui.Button(self.next_city, label="Next city"),
),
)
@risa.handler
async def refresh(self, ctx: risa.ComponentContext) -> None:
self.text = await fetch_forecast(self.city)
await ctx.rerender()
@risa.handler
async def next_city(self, ctx: risa.ComponentContext) -> None:
self.city = "Paris"
await ctx.rerender()
async def forecast_unavailable(failure: risa.ClickFailure) -> bool:
if not isinstance(failure.error, ForecastUnavailableError):
return False
await failure.ctx.respond(
"The weather service is not answering. Try again in a minute.",
ephemeral=True,
)
return True
async def something_went_wrong(failure: risa.ClickFailure) -> bool:
await failure.ctx.respond("Something went wrong.", ephemeral=True)
return False
def add_error_handlers(client: risa.Client) -> None:
client.error_handler(forecast_unavailable, priority=1)
client.error_handler(something_went_wrong, priority=-1)
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(Forecast)
add_error_handlers(client)
@bot.listen()
async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
if event.content == "!forecast":
rendered = await client.build(Forecast())
await rendered.send_to(bot.rest, event.channel_id)
bot.run()
if __name__ == "__main__":
main()