Stores and TTL¶
A store gives the poll room for every voter and a lifetime. This chapter moves the poll into one, first in memory and then in Redis.
Moving the poll¶
risa.InStore on @risa.register moves the view, and it takes
ttl, in seconds. The handlers stay as they were. Polls already sent cannot follow, so version goes up, as
Changing a view safely explains.
@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])
The thirteen voters that raised an error now fit:
13 voters, in a store
Pizza on Friday?
The buttons hold a random key to one record in the store, so the voters' ids stay out of the message, and a vote leaves the buttons' ids as they are:
What the buttons hold
question = 'Pizza on Friday?', yes = 0, no = 0, voters = []
1aKxPkh7b=!3b60si2qqVv8J#m}_51aKxPkh7SU!3b60si2qqVv8J#m}_5question = 'Pizza on Friday?', yes = 1, no = 0, voters = [1298471056230400001]
1aKxPkh7b=!3b60si2qqVv8J#m}_51aKxPkh7SU!3b60si2qqVv8J#m}_5Yellow routes the click to its view and handler. Pink is the key to the state in the store.
MemoryStore, for trying things out¶
A client gets its store from store=. risa.MemoryStore needs
nothing installed.
client = risa.client_from_app(bot, store=risa.MemoryStore())
client.add_view(Poll)
add_view warns you about it:
In your log at startup
view poll keeps its state in store 'default', which is a MemoryStore: the state lives in this process only, so it is lost on every restart, and on a REST bot with more than one process most clicks will not find it - use it for development onlyA bot with several stores passes stores={"votes": store}, and the view names one with
risa.InStore(ttl=WEEK, store="votes").
add_view raises ViewDeclarationError at startup if the client
has no store for the view. A client made with
use_global=True skips add_view and finds out late: client.build raises, and a click on a poll already sent fails,
with an ERROR in your log.
ttl¶
ttl is how many seconds a record lives untouched. Every click restarts the clock, even one that changes nothing, so
only a poll nobody clicks for a week expires. ttl=None keeps a record forever, including one that a deleted message, a
view you ended or a failed send leaves behind.
The message stays as it is when the time runs out, and the next click runs
on_state_missing.
@classmethod
async def on_state_missing(cls, ctx: risa.ComponentContext) -> None:
await ctx.edit(ui.TextDisplay("This poll has expired."))
A click after the record expired
This poll has expired.
Without this method the click fails in Discord, and risa logs a warning. ctx has no view here, so ctx.respond and a
ctx.edit with nothing to click work, and ctx.rerender() raises NoViewError, as a
ctx.edit with a button does.
Full file
"""The poll in a store, a `MemoryStore` for trying things out."""
from __future__ import annotations
import os
import hikari
import msgspec
import risa
from risa import ui
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])
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 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,
)
client = risa.client_from_app(bot, store=risa.MemoryStore())
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()
Redis and PostgreSQL¶
These two keep the state through a restart, and all processes of a bot can share them. Each lives in a module of its own and needs its extra.
| Store | Extra | It takes |
|---|---|---|
risa.store.redis.RedisStore |
redis |
A redis.asyncio.Redis |
risa.store.postgres.PostgresStore |
postgres |
An asyncpg pool |
The connection stays yours, so the bot closes it when it stops. An asyncpg pool opens inside the running event loop, so
make the pool and the client in a hikari.StartingEvent listener.
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()
Try it.
Vote, stop the bot and start it again. A second vote from you gets "You already voted.", because the record is still there.
Run Redis with maxmemory-policy noeviction. Any other policy may evict a record before it expires, which ends that
poll, and RedisStore warns about those on first use, if the server lets it read the setting. Turn on AOF as well (appendonly yes, appendfsync everysec),
because RDB snapshots alone lose recent votes in a crash.
PostgreSQL needs no cleanup job: PostgresStore creates its tables on first use and deletes expired rows itself.
Full file
"""The poll in Redis, where it survives a restart. Needs `hikari-risa[redis]`."""
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])
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 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()