Skip to content

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.

  1. 0 sThe clickThe clock starts.
  2. 2 sAuto-deferAnswers for a slow handler, if turned on.
  3. 3 sThe deadlineWith no answer, the click fails.
  4. 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

Risa BotAPPToday at 12:00

Rows in the report: 0

RefreshExportClose

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

Risa BotAPPToday at 12:00

Rows in the report: 128

RefreshExportClose

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

Risa BotAPPToday at 12:00

Risa Bot is thinking…

Only you can see this·Dismiss message

The answer takes its place

Risa BotAPPToday at 12:00

Exported 128 rows.

Only you can see this·Dismiss message

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

Risa BotAPPToday at 12:00

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

Risa BotAPPToday at 12:00

Berlin: Sunny, 18 °C

RefreshNext city

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

Risa BotAPPToday at 12:00

The weather service is not answering. Try again in a minute.

Only you can see this·Dismiss message

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

Risa BotAPPToday at 12:00

Something went wrong.

Only you can see this·Dismiss message
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.ctx has no view state, as in on_outdated: respond, an edit with nothing to click and replace work, and on a view with fields rerender() raises NoViewError, 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. respond continues 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 failure are 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()