Write a bot for Aurival. Declare commands, call run(), and the SDK holds the socket.
from aurival import Bot, Context
bot = Bot()
@bot.command("ping", "Check that the bot is alive")
async def ping(ctx: Context):
await ctx.reply("pong")
bot.run()
Python 3.10+.
A bot can declare up to 50 commands, the SDK refuses to connect past that.
Getting started
Create a virtual environment.
python3 -m venv .venv
Activate it.
. .venv/bin/activate
The venv matters on current Debian, Ubuntu and Fedora, whose system Python refuses
pip install outside a virtual environment (PEP 668).
Install the SDK.
pip install aurival
Scaffold a starter bot.
aurival init
aurival init pairs this machine and writes the bot.py above for you. Run python bot.py
when it's done.
First run
There is nothing to configure. The first run() pairs this machine:
aurival: pairing code K7QP-2M4X
aurival: fingerprint 018R-6WAC
Compare the fingerprint in the app, then approve. Waiting...
Compare the fingerprint in the app before you approve — it is what makes a stolen code
useless. The key lands in ./.aurival/machine.json (mode 0600, in a 0700 directory,
with a .gitignore beside it so it can never be committed). Every later run just starts.
The key deploys with the project, like a .env. Each bot has its own. A leaked key is one
machine you revoke; a leaked token would be the bot.
What a handler gets
@bot.command("say")
async def say(ctx: Context) -> None:
ctx.command # "say"
ctx.arguments # the raw rest of the line, unparsed, possibly ""
ctx.chat # Chat(id, type, name, member_count)
ctx.sender # User(id, handle, name) — always set
ctx.message.text # "/say hi" — the invoking message, verbatim
await ctx.reply("…")
ctx.sender and ctx.message are plain User and Message, never None — command.invoked
always carries both, so a type that admitted None would only make you narrow something that
is always there. ctx.message is the invoking message in full: .id, .text, .sent_at,
.sender (the person who wrote it, or None on a message that is only a reference) and
.reply_to, the id of the message it quoted, or None. ctx.reply() quotes it, so an answer
never floats free in a busy chat — there is no flag, and on an event that carries no message
it sends a plain message instead.
Handlers run concurrently, and an event is acked only after its handler returns — so a
crash mid-handler redelivers rather than loses. Do not block inside a handler. A
synchronous call (requests.get, time.sleep, a busy loop) starves the heartbeat on the
same event loop, and the server reaps a socket that has gone quiet for three intervals.
Use await, or hand the work to a thread.
An exception in a handler is logged with its traceback, the bot stays up, and the event is still acked:
from aurival import AnyContext, Event
@bot.on_error
async def on_error(error: BaseException | Event, ctx: AnyContext | None) -> None:
... # ctx is None for anything that did not come from a handler
The hook also sees the two things that are not a handler's fault: a problem frame from the
server (your socket stays open), and a backlog.overflowed event telling you how many events
you missed while you were away and where delivery resumed. Neither reaches a command handler,
which is why error is BaseException | Event and not just an exception.
Upgrading from 0.2.x
ctx.sender is a User again on a command. Where 0.2.x made you write
if ctx.sender is not None: before ctx.sender.handle, 0.3.0 wants ctx.sender.handle on
its own — the narrowing existed only because one class covered every event, and that is what
went away.
ctx.user, ctx.actor and ctx.emoji are gone from Context. They live on the class for
the event that carries them, so ctx: Context on a member.joined handler becomes
ctx: MemberContext, and the if ctx.user is not None: guard it needed goes with it. An
unannotated ctx keeps working unchanged — it registers and runs as it always did, you
simply get nothing checked and nothing completed — see
Events beyond commands.
Context.from_event no longer builds a context for anything but command.invoked, and
context_for is internal. If you were calling Context.from_event(event, http=...) to make
a context by hand, there is no supported replacement — the SDK builds the context, because
picking the class for an event type is exactly the decision the release moved off you.
Events beyond commands
@bot.command is for command.invoked. Everything else — membership changes, your bot
being added or removed, a reaction landing on one of its messages — goes through
@bot.on(event_type), a decorator or a direct call, either registering another handler for
that type:
from aurival import BotContext, EventContext, MemberContext, ReactionContext
@bot.on("member.joined")
async def welcome(ctx: MemberContext) -> None:
await ctx.send(ctx.chat, f"welcome, {ctx.user.name}!")
@bot.on("member.left")
async def farewell(ctx: MemberContext) -> None:
await ctx.send(ctx.chat, f"{ctx.user.name} has left")
@bot.on("bot.added")
async def added(ctx: BotContext) -> None:
await ctx.reply(f"thanks for the invite, {ctx.actor.name}")
@bot.on("bot.removed")
async def removed(ctx: BotContext) -> None:
...
@bot.on("reaction.added")
async def liked(ctx: ReactionContext) -> None:
await ctx.react(ctx.message, ctx.emoji) # no reaction.removed exists; un-reacting is silent
@bot.on("some.future.type")
async def future(ctx: EventContext) -> None:
if ctx.user is not None:
await ctx.reply(ctx.user.handle)
def sync_registration(bot: Bot) -> None:
async def joined(ctx: MemberContext) -> None:
...
bot.on("member.joined", joined) # the non-decorator form
One class per event family, and each class declares only the fields its own event carries:
| event | context class | fields it adds |
|---|---|---|
command.invoked |
Context |
command, arguments, sender, message |
member.joined / member.left |
MemberContext |
user |
bot.added / bot.removed |
BotContext |
actor |
reaction.added |
ReactionContext |
sender, message (id only), emoji |
| anything else | EventContext |
sender, user, actor, message, emoji, every one optional |
chat, event and all six actions come from BaseContext, so they are on every context
whatever the event. The split is there so your editor can tell you what ctx has: typing
bot.on(" offers the five names the SDK knows, a handler's ctx annotation is checked
against the family you registered for — decorator or direct call — and autocomplete on
ctx. lists what this event actually carries and nothing that would always be empty. You
never read a doc to find out which fields are real on which event, and a handler annotated
for the wrong family is a red line before you run it.
Annotating is optional — a bare ctx registers and runs exactly as it always did, it is
just Any, so nothing completes it and nothing catches a field the event never carries.
EventContext is the forward-compatibility door: a type this SDK does not know yet still
registers and still builds a context, never an exception, because a bot has to keep running
against a server that has shipped an eighth type. It is populated opportunistically from
whatever the payload recognizably carries under the familiar keys, which is why every field
on it is optional — "ignored, not fatal" means you get what's there, not nothing.
ctx.chat.member_count is the chat's live participant count, bots included: an int on
every event the server sends today, and None only on a frame that omitted the key, because
defaulting it to 0 would claim a count we were never given.
Actions
Six more things a handler can do, beyond ctx.reply(). Every send returns the Message the
server stored, so the thing you just posted is the thing you edit or delete next — you never
have to fish an id back out:
@bot.command("work")
async def work(ctx: Context) -> None:
sent = await ctx.reply("working…")
async with ctx.typing():
... # is_typing:true on enter, is_typing:false on exit, even on exception
await ctx.edit(sent, "done")
await ctx.react(ctx.message, "👍")
await ctx.unreact(ctx.message, "👍")
await ctx.delete(sent)
page = await ctx.members() # defaults to ctx.chat
while page.has_more: # never inferred from a short page or a cursor alone
page = await ctx.members(cursor=page.next_cursor) # one page at a time
edit and delete take a Message or a bare id, and only the bot's own messages — anyone
else's answers MessageNotYours. react and unreact work on any message in a chat the bot
is in and are idempotent, so reacting twice leaves one reaction rather than toggling it off.
The sender on a message you sent is id-only, with handle and name empty, because the
stored entity carries a bare usr_… id and the SDK will not invent the rest.
The typing indicator is also automatic: a command handler still running 300 ms after it
started shows the chat "is thinking", and the indicator clears when the handler returns,
including on an exception. A handler that replies inside those 300 ms sends nothing, so a
fast bot never flickers. Bot(auto_typing=False) turns it off if you would rather drive
ctx.typing() yourself, and async with ctx.typing(): is also how you get the indicator up
from the first instant, or outside a command.
ctx.send(chat, text, mentions=...) posts to any chat, not only the one that triggered the
handler, and mentions a user by writing @ + their handle into text yourself — mention()
builds that token for you:
from aurival import MemberContext, mention
@bot.on("member.joined")
async def greet(ctx: MemberContext) -> None:
who = mention(ctx.user)
await ctx.send(ctx.chat, f"hey {who}, welcome!", mentions=[who])
mentions also takes a bare User or {"user": "usr_…"} directly — mention() exists for
the token, not because the other forms are wrong. Every entry in mentions needs its
@handle token actually present in text, or the server rejects the request.
Shadowed commands
Every run() syncs your command list, and the server answers with the chats where another
bot already holds one of your names. Yours never fires there. The SDK logs one warning
line per shadowed chat, through the standard aurival logger:
WARNING:aurival:command 'ping' is shadowed in chat chat_01j… by another bot's command with the same name, and will never fire there
Rename the command, or get the other bot out of that chat. Nothing else in the SDK reacts to it — a shadowed command in one chat is still live in every other.
Environment
| variable | what it does |
|---|---|
AURIVAL_API |
point at another host. Must be https:// unless it is loopback. The SDK prints the host it is using, so a redirect is visible. |
AURIVAL_KEY_PATH |
put machine.json somewhere else. No .gitignore is written beside an override — that is your directory, not ours. |
Errors
One class per error type, one subclass per code the SDK acts on, and code and doc_url
on every instance:
from aurival import RateLimitError, KeyRevoked
try:
await ctx.reply("…")
except RateLimitError as exc:
print(exc.code, exc.retry_after, exc.doc_url)
A rate_limited reply is retried for you, up to five attempts, as long as retry_after is
15 seconds or less. Past that the error is raised at once instead of slept through: a handler
that sleeps for minutes holds its event unacknowledged for the whole wait, and the server
redelivers behind it.
KeyRevoked, SessionSuperseded and BotSuspended end the process on purpose — each one
means something a reconnect cannot fix. Everything else the SDK handles for you:
token expiry, deploys, network faults, redelivery.
Dependencies
Two, and each is here for a reason:
| package | version | why |
|---|---|---|
aiohttp |
3.14.3 |
HTTP and the websocket, one library doing both. Two libraries where one would do is a dependency we would be choosing. |
cryptography |
50.0.1 |
Ed25519 signing. PyCA-maintained with prebuilt wheels and first-class Ed25519 — hand-rolling it would be a security review we do not want to own. |
Development only: pytest 9.1.1, pytest-asyncio 1.4.0, ruff 0.16.6, build 1.6.0, setuptools 84.0.0.
Every version was resolved from PyPI at build time (2026-09-05) and pinned. None came from memory.
Tests
pytest tests
The end-to-end test runs a real bot against the real Go service through
backend-go/cmd/bot-api-testbed, which needs a throwaway Postgres:
MIGRATE_TEST_DATABASE_URL='postgres://postgres@127.0.0.1:5432/postgres?sslmode=disable' \
pytest tests
Without that variable the end-to-end test announces that it skipped. It never passes quietly — a suite reporting green when it never reached the service is worse than no suite.
License
Apache-2.0, see LICENSE. The Aurival name, wordmark, and mascot are trademarks of Nullspire LLC and are not covered by the license, see NOTICE.
Release files for aurival 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| aurival-0.3.0.tar.gz | 119.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| aurival-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 172.1 kB
Release files / aurival-0.3.0.tar.gz
| Download URL | aurival-0.3.0.tar.gz |
|---|---|
| Size | 119.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d11b8ac1f1c50a64c65057d163da14c7a15a7251b00277d376fe8f78c8bc9a79
|
|
BLAKE2b-256 checksum How to use checksums |
fae17195f5c1233ee035cee56abbef25772016cfe0f4b7baa509414cc05153a9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 17, 2026.
Transparency logRelease files / aurival-0.3.0-py3-none-any.whl
| Download URL | aurival-0.3.0-py3-none-any.whl |
|---|---|
| Size | 52.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
22ff6e99a954d0e71640d1361d6331be17fae769ba2ec526a59ac89e133b24c3
|
|
BLAKE2b-256 checksum How to use checksums |
49a335174ffe030d792b6a0e36232ac12a866b9bdf2180c1c624774b0f683855
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 17, 2026.
Transparency log