Bot and SyncBot¶
Bot runs async def handlers; SyncBot runs plain functions in a pool of
threads. They take the same options and have the same methods: on SyncBot, call them
without await. The decorators that register handlers (on_message, command, …)
come from Router, documented in Routers and filters.
The guides show them at work: Messages, Events, filters and middleware, Long polling, Webhooks, OpenPGP step by step.
Bot¶
- class Bot(
- token=None,
- *,
- api_url=None,
- pgp_private_key=None,
- pgp_passphrase=None,
- pgp_previous_keys=(),
- storage=None,
- concurrency=8,
- publish_key=False,
- require_encryption=False,
- verify_signatures='auto',
- pin_keys=False,
- cache_ttl=60.0,
- auto_answer_callbacks=True,
- retry=None,
- rate_limit=True,
- timeout=None,
- allow_insecure_http=False,
- max_handler_runs=5,
- shutdown_timeout=30.0,
- transport=None,
- name=None,
A PGP Chat bot whose handlers are
async deffunctions.Give it a token, register handlers, and call
run: the bot long-polls the server, runs the handler that matches each update, and stops cleanly on Ctrl+C orSIGTERM. The updates of one chat are handled one at a time, in order; different chats at the same time. In an end-to-end encrypted chat the bot opens incoming messages and seals what it sends on its own, so handlers read and write plain text. For plaindefhandlers, useSyncBot: it takes the same options and has the same methods.A bot is a
Router: the decorators that register handlers (Router.on_message,Router.command,Router.on_callback_query,Router.on_error, …) andRouter.include_routercome from there. Its own methods cover the whole Bot API, with the OpenPGP part done for you, andapiis the low-level client underneath. Besides the errors each method lists, every method that calls the server can raise the ones in Errors every method can raise.It is also an async context manager:
async with Bot() as bot:callsstart, andstopat the end, so a script can use the bot’s methods without polling.Example
An echo bot; the token comes from
PGPCHAT_BOT_TOKEN:from pgpcbot import Bot bot = Bot() @bot.on_message() async def echo(message): await message.reply(message.text or "...") bot.run()
Sets up the bot. Nothing is sent to the server until it starts.
The format of the token is checked here, and the private key is loaded and unlocked here, so a malformed token or a wrong passphrase fails at once, not at the first update. Every option except
tokenis keyword-only, and a value given here wins over the environment.- Parameters:
token (str | Secret | Callable[[], str] | None) – The bot’s token,
pgpbot_<id>.<43 characters>: a string, aSecret, or a function that returns one (called once, here).NonereadsPGPCHAT_BOT_TOKEN, or the file named byPGPCHAT_BOT_TOKEN_FILE.api_url (str | None) – The API server, like
https://api.pgpchat.app; a trailing/bot/v1is removed.NonereadsPGPCHAT_API, and useshttps://api.pgpchat.appwhen it is not set.pgp_private_key (BotKey | str | bytes | PathLike[str] | None) – The bot’s private key, for end-to-end encrypted chats: a
BotKey, the path of a key file, or the armored key as text or bytes.NonereadsPGPCHAT_BOT_KEY(the armored key or a path) or the file named byPGPCHAT_BOT_KEY_FILE; with neither, the bot has no key.pgp_passphrase (str | Secret | Callable[[], str] | None) – The passphrase that unlocks the private key: a string, a
Secret, or a function that returns one.NonereadsPGPCHAT_BOT_KEY_PASSPHRASE(orPGPCHAT_BOT_KEY_PASSPHRASE_FILE). Not used with aBotKey, which is unlocked already.pgp_previous_keys (Sequence[BotKey]) – Older keys, still tried to decrypt messages after a rotation. Keep the old key here for at least 72 hours after
rotate_key.storage (Storage | str | PathLike[str] | None) – Where updates wait to be handled, with the failed updates and the pinned keys: a
Storage, or a path, which meansSQLiteStorage(path).Nonemeans a newMemoryStorage, which forgets everything when the process ends.concurrency (int) – How many chats are handled at the same time, at least 1. The updates of one chat are always handled one at a time, in order. On a
SyncBotit is also the number of handler threads.publish_key (bool) –
Truepublishes the key at start-up when the server has a different one, or none. Publishing is one-way: see the warning below.require_encryption (bool) –
Truenever sends anything in clear, anywhere: a send into a readable chat raisesEncryptionRequiredError.verify_signatures (str) – How the signatures of encrypted messages are checked.
"auto"checks them and reports the result in the message’sverification."require"refuses a message without a valid signature: aSignatureErrorgoes to the error handlers instead."off"does not check, and does not fetch the members’ keys.pin_keys (bool) –
Trueremembers the first key fingerprint seen for each person and refuses a different one later, withKeyChangedError: sends to that person fail, and their messages go to the error handlers (whenverify_signaturesis not"off").trust_keyaccepts a new key. The pins are kept in the storage: use aSQLiteStorageto keep them across restarts.cache_ttl (float) – How long, in seconds, chat details, chat modes and members’ keys are cached. The readable mode of a group or channel is trusted for 10 seconds at most.
auto_answer_callbacks (bool) –
Trueanswers a button press with nothing after the handler ends, if the handler did not answer it, so the button stops spinning.retry (RetryPolicy | None) – How often a failed call is tried again.
Nonemeans the defaults ofRetryPolicy.rate_limit (bool | RateLimiter) –
Truepaces the calls with aRateLimiterthat takes the server’s numbers fromGET /meat start-up.Falseturns pacing off. ARateLimiterof your own is used as it is, with its own numbers.timeout (float | httpx2.Timeout | None) – How long one HTTP request may take: seconds, or an
httpx2.Timeout.Nonemeans 30 seconds, and 10 to connect. Long polls get extra time on their own.allow_insecure_http (bool) –
Trueallows anhttp://server that is notlocalhost. Only for a test server on your own network: see the warning below.max_handler_runs (int) – An update whose handlers were started this many times and never finished (the process crashed or was stopped half way) goes to the failed list instead of running again.
shutdown_timeout (float) – At stop, how many seconds the running handlers get to finish. Those still running are then cancelled, and their updates run again at the next start. A
SyncBotcan’t cancel a thread: there, a late handler goes on until it returns (seeSyncBot.run).transport (Any) – Advanced: an httpx2 transport for the bot’s HTTP clients, such as a proxy setup or a fake server in tests.
name (str | None) – The name of the bot’s root router;
Nonemeans"bot".
- Raises:
ConfigurationError – No token was given or found, the token or
api_urlis not acceptable, an option has a wrong value, orpgp_private_keyis neither aBotKey, armored text nor a file that can be read.InvalidKeyError – The private key is not a readable OpenPGP key, or it is a public key.
PassphraseError – The private key is locked, and the passphrase is missing or wrong.
- key¶
The bot’s private key, or
Nonewhen it has none.rotate_keyreplaces it.- Type:
BotKey | None
- auto_answer_callbacks¶
Whether a button press the handler did not answer is answered after it.
- Type:
- max_handler_runs¶
How many unfinished runs an update gets before it goes to the failed list.
- Type:
Example
An encrypted bot whose updates wait in a SQLite file.
SyncBottakes the same options:import os from pgpcbot import Bot bot = Bot( pgp_private_key="bot-key.asc", pgp_passphrase=os.environ["BOT_KEY_PASSPHRASE"], storage="bot.db", )
Note
repr(bot)shows the token masked (pgpbot_412.****abcd), and a bot can’t be pickled: it holds the token, and maybe a private key.Warning
Publishing a key is one-way: the bot’s private chats become end-to-end encrypted, and only its owner can undo that, from the console in the app.
allow_insecure_http=Truelets the token travel in clear, readable by anyone on the way to the server.- property api: AsyncClient¶
The low-level
AsyncClientunderneath the bot.It has one method per operation of the Bot API, and shares the bot’s connections and rate limiter. It sends what you give it as it is: in an encrypted chat, nothing is sealed or opened for you.
- run(
- *,
- poll_timeout=50,
- limit=100,
- drop_webhook=False,
- skip_pending=False,
- configure_logging=True,
Long-polls the server until Ctrl+C or
SIGTERM, then stops cleanly.Blocks until the bot has stopped. First the bot starts: it reads
GET /me(waiting for the server if it is down), checks its key, opens the storage and runs theon_startuphooks. Then it asks the server for updates and runs their handlers until it is told to stop: Ctrl+C,SIGTERM, Ctrl+Break on Windows, orrequest_stop. Then it stops asstopdoes. Network failures, server errors and a paused bot are waited out; see Long polling.- Parameters:
poll_timeout (int) – How long each poll may wait on the server for something to arrive, 0 to 50 seconds.
limit (int) – How many updates one poll may bring, 1 to 100.
drop_webhook (bool) –
Trueremoves a webhook that is set, and polls. Without it, a set webhook stops the bot withPollingError.skip_pending (bool) –
Truethrows away, without handling them, the updates that waited on the server while the bot was off. See the warning below.configure_logging (bool) –
Truesets up logging atINFOlevel, if your program has not set up logging itself.
- Raises:
AuthenticationError – The token is wrong or was revoked.
PollingError – A webhook is set and
drop_webhookisFalse, or one was set while the bot was polling.PollingConflictError – Another process polls this bot with the same
SQLiteStoragefile, or the storage is in use by another running bot in this process.ConfigurationError – A handler, middleware, error handler or hook is a plain function: a
Botneedsasync def.RuntimeError – It was called where an event loop is running already (inside an
async deffunction, in a Jupyter cell):run()makes its own loop. Nothing is sent. Usestart_pollingthere.
Example
Take over from a webhook, and skip what queued while the bot was off:
bot.run(drop_webhook=True, skip_pending=True)
Warning
skip_pending=Trueconfirms the waiting updates to the server, which deletes them for good. Updates already in aSQLiteStoragestill run.
- async start_polling(*, poll_timeout=50, limit=100, drop_webhook=False, skip_pending=False)¶
Long-polls the server inside your own event loop, until the bot is stopped.
It does what
rundoes, without making an event loop or setting up logging. Start it as a task. It returns once the bot has stopped: afterstoporrequest_stop, onSIGTERM(in the main thread), or when the task is cancelled.- Parameters:
poll_timeout (int) – How long each poll may wait on the server for something to arrive, 0 to 50 seconds.
limit (int) – How many updates one poll may bring, 1 to 100.
drop_webhook (bool) –
Trueremoves a webhook that is set, and polls. Without it, a set webhook stops the bot withPollingError.skip_pending (bool) –
Truethrows away, without handling them, the updates that waited on the server while the bot was off. The server deletes them for good.
- Raises:
AuthenticationError – The token is wrong or was revoked.
PollingError – A webhook is set and
drop_webhookisFalse, or one was set while the bot was polling.PollingConflictError – Another process polls this bot with the same
SQLiteStoragefile, or the storage is in use by another running bot in this process.ConfigurationError – A handler, middleware, error handler or hook is a plain function: a
Botneedsasync def.
Example
Poll in the background while the program does other work:
async def main(): polling = asyncio.create_task(bot.start_polling()) await other_work() await bot.stop() # lets the running handlers finish await polling
- async start()¶
Starts the bot without polling: for a webhook, or to use its methods from a script.
It reads
GET /me(waiting for the server if it is down), checks the bot’s key (and publishes it withpublish_key=True), opens the storage, runs theon_startuphooks and, ifcreate_webhook_appwas given aurl, registers the webhook. Handlers then run for the updates the webhook app receives. Stop the bot withstop;async with bot:calls both.- Raises:
AuthenticationError – The token is wrong or was revoked.
ConfigurationError – A handler, middleware, error handler or hook is a plain function: a
Botneedsasync def.InvalidKeyError –
publish_key=Trueand the key is a v6 key, which phones can’t use.StorageError – The storage can’t be opened.
BadWebhookError – The server refused the webhook
url.
Example
Send from a script, into an encrypted chat too:
async def main(): async with Bot(pgp_private_key="bot-key.asc", pgp_passphrase=passphrase) as bot: await bot.send_message(7781, "Nightly job done") asyncio.run(main())
- async stop()¶
Stops the bot, and returns once it has stopped.
It stops taking updates (a poll in progress is dropped, a webhook app answers
503), waits up toshutdown_timeoutseconds for the running handlers and cancels the ones still running (their updates run again at the next start), runs theon_shutdownhooks, and closes the storage and the connections. On a bot that is not running, it only closes the connections.Called from code that runs for an update (a handler, a middleware, an error handler), it only asks for the stop, as
request_stopdoes, and returns at once: waiting for the running handlers would mean waiting for itself.
- async process_update(update)¶
Runs one update through decryption, filters and handlers, now.
Meant for tests, and for updates that come from somewhere else. The update skips the queue: nothing is stored, nothing is confirmed to the server, and a repeat is not recognised. The bot does not need to be started, but this still calls the API: for
GET /meifmeis not known yet, for what the handlers send, and for the automatic answer to a button press.- Parameters:
update (Update | Mapping[str, Any]) – An
Update, or one in the JSON form the server sends (a dict withupdateIdandkind).- Raises:
Exception – What a handler raised, or what opening an encrypted message raised, when no error handler took it.
Example
In a test, with the bot on a fake transport or a test server:
await bot.process_update({ "updateId": 1, "kind": "message", "message": { "id": 10, "chat": {"id": 55, "kind": "private"}, "from": {"id": 55, "username": "anna"}, "date": 1791462000000, "kind": "user", "text": "/start", }, })
- async get_me(*, refresh=False)¶
Reads the bot’s own details (
GET /me).The copy in
meis returned without asking the server, unless there is none yet orrefreshisTrue.GET /meworks while the owner has paused the bot, so it is the way to find out why nothing else does.
- async get_chat(chat, *, refresh=False)¶
Reads a chat the bot is in (
GET /chats/{id}).The answer is cached for
cache_ttlseconds;refresh=Trueasks the server again.- Parameters:
- Returns:
The
Chat, with its kind, its mode (openorprotected) and, in a group or channel, the bot’s own seat.- Raises:
ChatNotFoundError – There is no such chat, or the bot is not in it.
- Return type:
- async recipients(chat)¶
Lists the checked keys a message into this end-to-end encrypted chat is sealed to.
The keys come from
GET /chats/{id}/keys, cached forcache_ttlseconds, and each one is checked: its fingerprint must match the one the server gives, it must not be revoked or expired, it must have a live encryption subkey, and it must be a v4 key (phones can’t open what is sealed to a v6 key). In a group, a member whose key fails a check is left out with a warning in the log, as the app does; in a private chat, it raisesEncryptionError. The bot’s own key is not in the list:sealadds it.- Parameters:
chat (int | ChatRef) – The chat’s ID, or a chat object such as a
Chat, aChatReformessage.chat.- Returns:
The
PublicKeyof every member the message can be sealed to.- Raises:
EncryptionError – Nobody in the chat has a usable key, or the key of the person in a private chat can’t be used.
KeyChangedError – With
pin_keys=True, a member’s key is not the one pinned for them.ChatIsOpenError – The chat is readable, not end-to-end encrypted.
NoUserKeyError – The person in a private chat has no published key.
- Return type:
- async seal(chat, text)¶
Encrypts
textfor everyone in an end-to-end encrypted chat, signed by the bot.Nothing is sent. The text is sealed to the keys
recipientsgives and to the bot’s own key, and signed with the bot’s key: whatsend_messagedoes before sending, with the same checks. Unlikesend_message, the text is not checked against the server’s limits.- Parameters:
- Returns:
The ASCII-armored OpenPGP message, to send as
armored.- Raises:
EncryptionError – The bot has no private key, or the message can’t be sealed to the chat’s keys.
KeyChangedError – With
pin_keys=True, a member’s key is not the one pinned for them.ChatIsOpenError – The chat is readable, not end-to-end encrypted.
- Return type:
Example
What
send_messagedoes, by hand:armored = await bot.seal(chat_id, "draft") await bot.api.send_message(chat_id, armored=armored)
- async send_message(
- chat,
- text,
- *,
- reply_to=None,
- markup=None,
- silent=False,
- encrypt=None,
- send_id=None,
Sends a text message, sealed and signed when the chat is end-to-end encrypted.
Before sending, the bot finds out the chat’s mode, and asks the server when it does not know it yet. Into a readable chat the text goes as it is. Into an encrypted chat it is sealed to every member with a usable key and to the bot, and signed with the bot’s key (see
recipients). If it can’t be sealed, nothing is sent: the bot never falls back to plain text.The mode can change between the check and the send. If the chat became encrypted, the server refuses the text, and the bot seals it and sends it again with the same
send_id; a warning is logged, because the text did reach the server in the refused request. If the chat became readable, the server refuses the sealed message, and it is not sent again in clear: this raises, and the next send follows the new mode.- Parameters:
chat (int | ChatRef) – The chat’s ID, or a chat object such as a
Chat, aChatReformessage.chat.text (str) – The text: 1 to
text_max(4096) characters, counted in UTF-16 units as JavaScript does.CR LFandCRline ends becomeLF; other control characters, except tabs, are refused.reply_to (int | MessageBase | None) – The message to quote: its ID, or the message itself. The server ignores a wrong ID.
markup (InlineKeyboard | Mapping[str, Any] | None) – Buttons under the message: an
InlineKeyboard, or the same in the JSON form of the API. Never encrypted: see the warning below.silent (bool) –
Trueposts without a notification. It only matters in channels.encrypt (bool | None) –
Nonefollows the chat.Truerefuses to send in clear (EncryptionRequiredErrorin a readable chat);Falserefuses to send sealed (ValidationErrorin an encrypted chat).send_id (str | UUID | None) – A UUID that makes the send safe to repeat: the server writes one message per
send_id, and answers a repeat with the message it already wrote. One is made for you; pass your own, saved with your job, to make your own retries safe too.
- Returns:
The
SentMessageas the server stored it, which can be edited or deleted.- Raises:
ValidationError – The text, the buttons or an ID breaks a rule, or
encrypt=Falsein an encrypted chat. Nothing was sent.EncryptionRequiredError – The chat is readable, and
encrypt=Trueorrequire_encryption=True. Nothing was sent.EncryptionError – The chat is encrypted and the message can’t be sealed: the bot has no private key, nobody has a usable key, or the key of the person in a private chat can’t be used. Nothing was sent.
KeyChangedError – With
pin_keys=True, a member’s key is not the one pinned for them. Nothing was sent.WrongBodyKindError – The chat became readable since the bot last looked. The sealed message was refused, and is not sent again in clear.
ChatIsOpenError – The same, noticed while fetching the members’ keys.
ForbiddenError – The bot may not write in this chat.
BlockedByUserError – The person blocked the bot.
UserGoneError – The person closed their account.
ChatNotFoundError – There is no such chat, or the bot is not in it.
SendIdConflictError – The
send_idwas used already, in another chat.
- Return type:
Examples
Into any chat the bot is in:
await bot.send_message(7781, "Your report is ready")
With buttons, and a
send_idsaved with the job, so that a job run twice still sends once:keyboard = InlineKeyboard.build( [Button.callback("Monday", "day:mon"), Button.callback("Friday", "day:fri")], ) await bot.send_message( group_id, "Vote for the next meetup", markup=keyboard, send_id=job.send_id )
Warning
Buttons are never encrypted: their text and their callback data travel in clear, even in an encrypted chat. Don’t put personal data in them.
See also
Message.replyandMessage.answer, which send into the chat a message came from.
- async edit_message(chat, message, text=None, *, markup=KEEP, encrypt=None)¶
Edits one of the bot’s messages: new text, new buttons, or both.
The bot can edit only its own ordinary messages, at any age, and people see the edit live. New text is sealed like a send when the chat is end-to-end encrypted, and a change of the chat’s mode is handled as in
send_message.- Parameters:
chat (int | ChatRef) – The chat’s ID, or a chat object such as a
Chat, aChatReformessage.chat.message (int | SentMessageBase) – The bot’s message: its ID, or the
SentMessagea send returned.text (str | None) – The new text, with the same rules as in
send_message.Nonekeeps the text.markup (InlineKeyboard | Mapping[str, Any] | None) – The buttons.
KEEP, the default, keeps them;Noneremoves them; a keyboard replaces them. Never encrypted.encrypt (bool | None) –
Nonefollows the chat.Truerefuses to send the text in clear;Falserefuses to send it sealed.
- Returns:
The edited message, as the server stored it.
- Raises:
ValidationError – There is nothing to change (no
text, andmarkupleft atKEEP), or the text, the buttons or an ID breaks a rule.MessageNotFoundError – There is no such message in this chat.
ForbiddenError – The message is not one of the bot’s own ordinary messages.
EncryptionRequiredError – The chat is readable, and
encrypt=Trueorrequire_encryption=True.EncryptionError – The chat is encrypted and the new text can’t be sealed.
WrongBodyKindError – The chat became readable since the bot last looked. The sealed text was refused, and is not sent again in clear.
ChatIsOpenError – The same, noticed while fetching the members’ keys.
- Return type:
Example
A message that counts:
sent = await bot.send_message(chat_id, "Counting...") for n in range(1, 4): await asyncio.sleep(1) await bot.edit_message(chat_id, sent, f"Counting... {n}")
- async delete_message(chat, message)¶
Deletes a message for everyone, for good.
The bot can delete its own messages anywhere, and other people’s in a group or channel where it has the “delete messages” right. Secret messages and service notices can’t be deleted.
- Parameters:
chat (int | ChatRef) – The chat’s ID, or a chat object such as a
Chat, aChatReformessage.chat.message (int | MessageBase | SentMessageBase) – The message: its ID, a received
Message, or aSentMessage.
- Returns:
True. When an attempt timed out and the retry finds the message gone, the first attempt worked, and the result isTruetoo.- Raises:
MessageNotFoundError – There is no such message, or it is gone already.
ForbiddenError – The bot may not delete this message.
ChatNotFoundError – There is no such chat, or the bot is not in it.
- Return type:
- async answer_callback(callback_id, text=None, *, alert=False, url=None)¶
Answers a button press, which stops its spinner.
Each press can be answered once, within 60 seconds. The answer can also show a line of text, a box, or a link. In a handler,
CallbackQuery.answerdoes the same. The bot remembers the answer, so it does not send its automatic empty one after the handler (seeauto_answer_callbacks).- Parameters:
callback_id (str) – The ID of the press,
query.id.text (str | None) – Up to 200 characters on one line, shown over the chat for a moment.
Noneshows nothing.alert (bool) –
Trueshowstextin a box the person has to close.url (str | None) – An
httporhttpsaddress, up to 1024 characters, that the phone offers to open.
- Returns:
True. A retry after a timeout that finds the press answered already counts as done.- Raises:
CallbackExpiredError – More than 60 seconds have passed since the press.
CallbackAnsweredError – The press was answered already.
CallbackNotFoundError – There is no such press.
- Return type:
Warning
Answers are not encrypted, even in an encrypted chat.
- async send_typing(chat, typing=True)¶
Shows “typing…” in a chat for a few seconds.
The indicator goes away by itself, or when the bot’s message arrives. The server shows at most one every 3 seconds per chat; a call in between does nothing and returns
False. It is not tried again after a network error: a late indicator is useless.- Parameters:
- Returns:
Trueif the indicator was shown,Falseif one went out to this chat less than 3 seconds ago.- Raises:
ForbiddenError – The bot may not write in this chat.
ChatNotFoundError – There is no such chat, or the bot is not in it.
- Return type:
- async get_chat_keys(chat)¶
Reads the public keys of an end-to-end encrypted chat, as the server lists them.
They come straight from
GET /chats/{id}/keys: not cached, and not checked.recipientsgives the checked keys a message is sealed to. The bot’s own key is not in the list, and neither are bots in privacy mode, as in the app.- Parameters:
chat (int | ChatRef) – The chat’s ID, or a chat object such as a
Chat, aChatReformessage.chat.- Returns:
The
ChatKeys, with oneUserKeyper member who has a key and, in a group, the member count.- Raises:
ChatIsOpenError – The chat is readable, not end-to-end encrypted.
NoUserKeyError – The person in a private chat has no published key.
ChatNotFoundError – There is no such chat, or the bot is not in it.
- Return type:
- async get_chat_members(chat)¶
Reads the member list of a group or channel, as a member in the bot’s seat sees it.
Where the member list is hidden, a bot that is not an admin sees the admins only. Phone numbers are never included.
- Parameters:
chat (int | ChatRef) – The group or channel: its ID, or a chat object such as a
Chat, aChatReformessage.chat.- Returns:
The
ChatMembers, with the members the bot may see and how many members there are in all.- Raises:
BadRequestError – The chat is a private chat.
ChatNotFoundError – There is no such chat, or the bot is not in it.
- Return type:
- async get_chat_member(chat, user_id)¶
Reads one member’s seat in a group or channel.
- Parameters:
- Returns:
The
Member, with the person, their status and their admin rights.- Raises:
MemberNotFoundError – The person is not a member, or is hidden from the bot.
BadRequestError – The chat is a private chat.
ChatNotFoundError – There is no such chat, or the bot is not in it.
- Return type:
- async leave_chat(chat)¶
Leaves a group or channel; its members are told.
The bot also forgets what it had cached about the chat.
- Parameters:
chat (int | ChatRef) – The group or channel: its ID, or a chat object such as a
Chat, aChatReformessage.chat.- Returns:
True. A retry after a timeout that finds the bot gone already counts as done.- Raises:
BadRequestError – The chat is a private chat, which can’t be left.
ChatNotFoundError – There is no such chat, or the bot is not in it.
- Return type:
- async resolve(username)¶
Turns a public handle into an ID (
GET /resolve/{username}).For the handle somebody writes at the bot, when no update has handed that ID over. People, groups and channels share one pool of names, so a handle belongs to exactly one of the three:
Resolved.kindsays which andResolved.idis filled in either way. A leading@and the case do not matter.Nothing is unlocked by knowing the number. Sending still needs the bot to be in the chat, and
Bot.get_user_keystill needs a chat in common.- Parameters:
username (str) – The handle, with or without the
@.- Returns:
A
Resolved– the ID, the handle it goes by, and either the person (ChatPeer) or the chat (ChatCard).- Raises:
ValidationError – Not a handle: 3-32 characters, letters, digits and underscore.
UsernameNotFoundError – Nobody and no chat answers to it. Also the answer for a group with no public name and for an account that has been closed.
FloodWaitError – More than 60 lookups this minute, where a handle nobody holds counts as five.
- Return type:
Example
Answer “chi e @anna”:
@bot.on_text() async def who(message: Message) -> None: found = await bot.resolve(message.text.split("@")[-1]) await message.reply(f"{found.mention} ha id {found.id}")
- async get_user_key(user_id)¶
Reads one person’s public key (
GET /users/{id}/key).The key is not checked here:
PublicKey.from_user_keyreads it and checks its fingerprint.- Parameters:
user_id (int) – The person’s user ID. The bot must share a chat with them.
- Returns:
The
UserKey, with the user ID, the fingerprint and the armored public key.- Raises:
UserNotFoundError – There is no such person, or the bot shares no chat with them.
NoUserKeyError – The person never published a key.
- Return type:
- async get_commands(scope=None)¶
Reads the bot’s command menus: every scope, or one.
- Parameters:
scope (str | None) –
Nonefor every scope, or one of"default","private","groups","groupAdmins"and"chat:<id>".- Returns:
With no
scope, a list ofCommandScope, one per scope with its commands. With ascope, the list ofCommandof that scope.- Return type:
- async set_commands(commands, scope=None)¶
Replaces the whole command menu of one scope.
The app shows the menu when someone types
/. The owner can change the same lists in the app: whoever writes last wins.sync_commandsbuilds the list from the bot’s own handlers.- Parameters:
commands (Iterable[Command | Mapping[str, Any] | tuple[str, str]]) – Up to 100 commands:
Commandobjects, dicts withcommandanddescription, or(name, description)pairs. A name has 1 to 32 characters ofa-z,0-9and_(a leading/is dropped, and capitals are made lowercase); a description has 1 to 256 characters. When a name comes twice, the first one is kept.scope (str | None) – Which menu:
"default"(also whenNone),"private","groups","groupAdmins"or"chat:<id>".
- Returns:
The
CommandScopeas the server saved it.- Raises:
ChatNotFoundError – A
chat:<id>scope names a chat the bot is not in.- Return type:
Example
A menu for every chat, and one for a single group:
await bot.set_commands([("start", "Say hello"), ("help", "What I can do")]) await bot.set_commands([("rules", "Show the rules")], scope="chat:7781")
- async delete_commands(scope=None)¶
Empties the command menu of one scope.
An empty scope does not hide the wider ones: the app shows the next scope that has commands,
"default"last.- Parameters:
scope (str | None) – Which menu:
"default"(also whenNone),"private","groups","groupAdmins"or"chat:<id>".- Returns:
The
CommandScope, now with no commands.- Return type:
- async sync_commands(scope=None)¶
Publishes the commands registered with a description as the menu of one scope.
Every
Router.commandwith adescription, on the bot and on its included routers, goes into the list; when a name comes twice, the first description wins. It replaces the whole list of that scope, asset_commandsdoes. Anon_startuphook is a good place to call it.- Parameters:
scope (str | None) – Which menu:
"default"(also whenNone),"private","groups","groupAdmins"or"chat:<id>".- Returns:
The
CommandScopeas the server saved it.- Raises:
ConfigurationError – No command was registered with a description.
- Return type:
Example
Publish the menu at every start:
@bot.command("weather", description="Forecast for a town") async def weather(message, command): ... @bot.on_startup async def ready(bot): await bot.sync_commands()
- async get_webhook()¶
Reads the webhook, if one is set, and how its deliveries are going.
Works while the owner has paused the bot.
- Returns:
The
WebhookInfo, with the address, whether it has a secret, how many updates are waiting, and the failures and last error of the deliveries.- Return type:
- async set_webhook(url, secret=None)¶
Sets a webhook: from now on the server POSTs every update to
url.Setting it ends any poll in progress, and the server refuses polls while it is set. You rarely need this:
create_webhook_appwith aurlregisters the webhook every time the bot starts.- Parameters:
url (str) – The address:
https, up to 1024 characters, with no user name, password or#fragment, pointing to a public IP address.secret (str | None) – 1 to 256 characters of
A-Z a-z 0-9 _ -, sent back in theX-PGPChat-Bot-Api-Secret-Tokenheader of every delivery. Always set one: without it, anyone who finds the address can send fake updates.
- Returns:
The
WebhookInfoof the new webhook.- Raises:
BadWebhookError – The server refused the address, for example because it points to a private IP address.
- Return type:
- async delete_webhook()¶
Removes the webhook, so the bot can poll again.
The updates that queued meanwhile are not lost: the next poll gets them. Works while the owner has paused the bot.
- Returns:
The
WebhookInfo, now with nourl;pendingcounts the waiting updates.- Return type:
- async publish_key(key=None)¶
Publishes the bot’s public key, which makes its private chats end-to-end encrypted.
It sends the public part of the key (
PUT /key) with a fingerprint computed from the key itself, so the two always match: a wrong one would make phones seal to a key the bot does not have. A v6 key is refused before anything is sent, because phones can’t use it. Afterwardsmeis read again and shows the new mode. Publishing does not change the key the bot uses: to switch to a new key, userotate_key.- Parameters:
key (BotKey | None) – The key to publish.
Nonepublishes the bot’s own key.- Returns:
The
PublishedKey, with the fingerprint and the bot’s new mode.- Raises:
ConfigurationError – There is no key to publish.
InvalidKeyError – The key is a v6 key.
- Return type:
Warning
Publishing is one-way: there is no API to remove the key. Only the bot’s owner can make the bot readable again, from the console in the app.
- async rotate_key(new_key)¶
Publishes
new_keyand makes it the bot’s key.From then on, phones seal to the new key. Messages sealed to the old one may still be on their way, or waiting on the server, for up to 72 hours: the old key stays loaded, and is still tried for decryption as long as this process runs. When you restart the bot, pass the old key in
pgp_previous_keysfor at least 72 hours.- Parameters:
new_key (BotKey) – The new private key.
BotKey.generatemakes one of the kind phones can use.- Returns:
The
PublishedKey, with the new fingerprint.- Raises:
InvalidKeyError –
new_keyis a v6 key, which phones can’t use.- Return type:
Example
Make a new key, save it, and switch to it:
new_key = BotKey.generate("Weather", username="weatherbot", passphrase=passphrase) new_key.save("bot-key-2026-11.asc", passphrase=None) await bot.rotate_key(new_key)
- async revoke_token()¶
Burns the bot’s token, for good.
From then on every call with this token fails, the bot’s own included, until the owner makes a new token in the console; the owner is told at once. Use it when the token has leaked. It works while the owner has paused the bot.
- Returns:
True. A retry after a timeout that finds the token burnt already counts as done.- Return type:
Warning
There is no undo: every call made with the token fails from then on, with
AuthenticationError, and a polling bot stops.
- async download_media(media, *, max_bytes=MEDIA_MAX_BYTES)¶
Downloads the file behind a
Media, decrypted when it came from an encrypted chat.The file comes from the
/media/<id>address of the API server; the token is not sent, because that address does not use it. An attachment of an encrypted chat is decrypted with the bot’s key. In a private chat with the bot this always works. In an encrypted group the phones seal attachments only to the chat’s members, so it works only when the bot’s privacy mode is off; otherwise it raisesNotARecipientError.- Parameters:
- Returns:
The file’s bytes, decrypted when needed.
- Raises:
NotARecipientError – The file was not sealed to any of the bot’s keys.
DecryptionError – The bot has no private key, or the file can’t be decrypted: it is damaged, it opens to more than
max_bytes, or it is compressed. Phones never compress attachments, and a compressed file could open to many gigabytes.ValidationError – The file is bigger than
max_bytes, or the address is not a media address of this server.NotFoundError – The server has no such file.
- Return type:
Example
Save every attachment of a message:
@bot.on_message(f.has_media) async def save(message): for item in message.media: data = await bot.download_media(item) Path(f"{item.id}.bin").write_bytes(data)
Note
The message that carried the attachment had its signature checked, but the file itself is not checked against a signature.
- async trust_key(user_id, fingerprint)¶
Accepts
fingerprintas the key ofuser_idfrom now on.With
pin_keys=Truethe bot refuses a person’s key when it is not the one pinned for them, withKeyChangedError. Once you have checked the new key with the person, out of band, call this to pin it: sends to them work again, and their new messages reach the handlers. Their messages that failed meanwhile wait in the failed list, andretry_failedruns them again. The pin is saved in the storage.- Parameters:
- Raises:
ValidationError –
fingerprintis not 40 or 64 hex digits.
Example
After checking the fingerprint with the person:
await bot.trust_key(user_id, "3F1A9C0D52E7B8A46C110D93E2F47A559B10C6D2")
Note
The bot opens a
SQLiteStoragewhen it starts and closes it when it stops: with one, call this while the bot runs (from a handler, from a hook, or afterstart).
- async failed_updates(limit=100)¶
Lists the updates whose handlers failed, oldest first.
An update fails when a handler raises and no error handler takes the error. It is confirmed to the server like any other, so it does not block the queue, and kept in the failed list with the error. For an update from an encrypted chat, a handler’s own exception is kept as its type and the place it was raised, without its message, which could quote the decrypted text. The list keeps the newest ones only: 1000 with a
MemoryStorage, 10 000 per bot with aSQLiteStorage(theirmax_failedoption).- Parameters:
limit (int) – The most updates to return.
- Returns:
The failed updates, each a
FailedUpdatewithupdate_id,payload(the update as the server sent it),errorandfailed_at.- Return type:
Example
Look at what failed:
for item in await bot.failed_updates(): print(item.update_id, item.failed_at, item.error)
Note
The bot opens a
SQLiteStoragewhen it starts and closes it when it stops: with one, call this while the bot runs (from a handler, from a hook, or afterstart).
- async retry_failed(update_ids=None)¶
Puts failed updates back in the queue, to run through the handlers again.
They run as if they had just arrived: at once when the bot is running, otherwise at its next start. A common pattern is to fix the bug, restart the bot, and call this.
- Parameters:
update_ids (Sequence[int] | None) – The IDs of the updates to retry.
Noneretries all of them.- Returns:
How many updates were put back.
- Return type:
Note
The bot opens a
SQLiteStoragewhen it starts and closes it when it stops: with one, call this while the bot runs (from a handler, from a hook, or afterstart).
- async discard_failed(update_ids=None)¶
Forgets failed updates, for good.
- Parameters:
update_ids (Sequence[int] | None) – The IDs of the updates to forget.
Noneforgets all of them.- Returns:
How many updates were forgotten.
- Return type:
Note
The bot opens a
SQLiteStoragewhen it starts and closes it when it stops: with one, call this while the bot runs (from a handler, from a hook, or afterstart).
- create_webhook_app(
- secret,
- *,
- url=None,
- path=None,
- max_body=1024 * 1024,
- ack_timeout=8.0,
- allow_no_secret=False,
Makes the ASGI application the server POSTs updates to.
The app checks the secret of every delivery, stores the update and runs the handlers. Serve it with any ASGI server (
uvicorn bot:app): there it starts and stops the bot itself. Or add it as a route of a FastAPI or Starlette application, and passlifespan=bot.lifespanto that application (seelifespan). The bot keeps the last app made here, and registers itsurlwhen it starts.When the server gets its
200depends on the storage: with aSQLiteStorage, as soon as the update is written to it; with aMemoryStorage, only once the handlers are done, so the bot handles one update at a time. Use aSQLiteStoragefor webhooks.- Parameters:
secret (str | None) – The secret the server sends with every delivery, in the
X-PGPChat-Bot-Api-Secret-Tokenheader: 1 to 256 characters ofA-Z a-z 0-9 _ -.generate_secretmakes a good one. A delivery with a missing or wrong secret gets401.url (str | None) – Your public
httpsaddress. When given, the webhook and its secret are registered with the server every time the bot starts.path (str | None) – Answer only on this path, and
404elsewhere. Leave it out when another application routes to this one.max_body (int) – The largest body accepted, in bytes (1 MiB by default); a bigger one gets
413.ack_timeout (float) – With a
MemoryStorage, how many seconds to wait for the handlers before answering503, so that the server delivers the update again later. Under 10, the server’s own limit.allow_no_secret (bool) –
Trueallowssecret=None. Anyone who finds the address could then send fake updates.
- Returns:
The
WebhookApp.- Raises:
ConfigurationError –
secretisNonewithoutallow_no_secret=True, orack_timeoutis not between 0 and 10 seconds (both excluded).ValidationError –
secrethas characters the server does not accept, orurlis not an absolutehttporhttpsaddress of up to 1024 characters, or it carries a user name, a password or a#fragment.
- Return type:
Example
A webhook bot, served with
uvicorn bot:app:bot = Bot(storage="bot.db") app = bot.create_webhook_app( secret=os.environ["WEBHOOK_SECRET"], url="https://bot.example.org/pgpchat", )
Note
Inside FastAPI or Starlette, add it with
app.add_route(...), notapp.mount(...): a mount answers the address without the trailing slash with a redirect, which the server counts as a failed delivery, and the queue stops.
- lifespan(app=None)¶
Starts the bot with a FastAPI or Starlette application, and stops it at shutdown.
Pass it as
FastAPI(lifespan=bot.lifespan)orStarlette(lifespan=bot.lifespan)when the app made bycreate_webhook_appis a route of that application: inside another application, the webhook app gets no start and stop events of its own. On entry the bot starts asBot.startdoes, and registers the webhook if the app has aurl; on exit it stops asBot.stopdoes. Without it, the bot starts at the first delivery and logs a warning.- Parameters:
app (Any) – The application, passed in by the framework. Not used.
- Yields:
Nothing; the bot runs until the application shuts down.
- Return type:
AsyncIterator[None]
Examples
With FastAPI:
webhook = bot.create_webhook_app( secret=os.environ["WEBHOOK_SECRET"], url="https://example.org/pgpchat" ) app = FastAPI(lifespan=bot.lifespan) app.add_route("/pgpchat", webhook, methods=["POST"])
An application with a lifespan of its own enters this one inside it:
@asynccontextmanager async def lifespan(app): async with bot.lifespan(app): await database.connect() yield await database.disconnect()
- property me: BotInfo | None¶
The bot’s own details, as
GET /melast returned them.Noneuntil they are first read: when the bot starts, or byget_me. The bot reads them again when it needs to (after publishing a key, for example), andget_mewithrefresh=Truedoes it when you ask.
- on_shutdown(func)¶
Registers
functo run when the bot stops, while it can still send.Use it as a decorator.
funcgets the bot as its only argument. It runs once the running handlers have finished, or onceshutdown_timeouthas run out, and before the storage and the connections close, so it can still call the API. Hooks run in the order they were registered; an error in one is logged, not raised, and the next one still runs. The hooks don’t run when the bot did not start, or when a startup hook failed.- Parameters:
func (Callable[[...], Any]) – The hook: an
async deffunction on aBot, a plain function on aSyncBot.- Returns:
funcitself, unchanged.- Raises:
TypeError –
funcis not callable.- Return type:
Example
Close what a startup hook opened:
@bot.on_shutdown async def closing(bot): await database.disconnect()
- on_startup(func)¶
Registers
functo run when the bot starts, once it is connected.Use it as a decorator.
funcgets the bot as its only argument. It runs afterGET /me(someis set and API calls work) and once the storage is open, before the bot takes new updates: the place to publish the command menu or to open a database pool. Hooks run in the order they were registered. If one raises, the bot does not start, and the error is raised where the bot was started (run,Bot.start, …).- Parameters:
func (Callable[[...], Any]) – The hook: an
async deffunction on aBot, a plain function on aSyncBot. The other kind makes the bot refuse to start, with aConfigurationError.- Returns:
funcitself, unchanged.- Raises:
TypeError –
funcis not callable.- Return type:
Example
Publish the command menu as soon as the bot is up:
@bot.on_startup async def ready(bot): await bot.sync_commands()
On a
SyncBotthe hook is a plain function that callsbot.sync_commands().Note
With a
SQLiteStorage, the updates left pending by the last run are queued just before the hooks run, so they can start while a hook is still running.
- request_stop()¶
Asks a running bot to stop, and returns at once.
Safe to call from a handler, from another thread or from a signal handler.
run(orBot.start_polling) then stops the way it does on Ctrl+C: no new updates, the running handlers getshutdown_timeoutseconds to finish, then everything closes. A webhook app answers503from then on, so the server keeps the updates and delivers them again later; stopping the web server is up to you. Does nothing when the bot is not running.Example
A command that stops the bot (on a
SyncBot, the same withoutasyncandawait):@bot.command("shutdown", filters=(f.user(ADMIN_ID),)) async def shutdown(message): await message.reply("Bye") bot.request_stop()
SyncBot¶
- class SyncBot(
- token=None,
- *,
- api_url=None,
- pgp_private_key=None,
- pgp_passphrase=None,
- pgp_previous_keys=(),
- storage=None,
- concurrency=8,
- publish_key=False,
- require_encryption=False,
- verify_signatures='auto',
- pin_keys=False,
- cache_ttl=60.0,
- auto_answer_callbacks=True,
- retry=None,
- rate_limit=True,
- timeout=None,
- allow_insecure_http=False,
- max_handler_runs=5,
- shutdown_timeout=30.0,
- transport=None,
- name=None,
A PGP Chat bot whose handlers are plain functions, for code that does not use asyncio.
Give it a token, register handlers, and call
run: the bot long-polls the server, runs the handler that matches each update, and stops cleanly on Ctrl+C orSIGTERM. Handlers run in a pool ofconcurrencythreads (8 by default), namedpgpcbot-handler_0,pgpcbot-handler_1and so on. The updates of one chat are handled one at a time, in order; different chats at the same time, so code that handlers share must be thread-safe. In an end-to-end encrypted chat the bot opens incoming messages and seals what it sends on its own, so handlers read and write plain text. Forasync defhandlers, useBot: it takes the same options and has the same methods.A bot is a
Router: the decorators that register handlers (Router.on_message,Router.command,Router.on_callback_query,Router.on_error, …) andRouter.include_routercome from there. Handlers, filters, middlewares, error handlers and hooks are plain functions: anasync defone makes the bot refuse to start, with aConfigurationErrorthat names it. The bot’s own methods cover the whole Bot API, with the OpenPGP part done for you. They are the methods ofBot, called withoutawait, and each one blocks until it is done.apiis the low-level client underneath. Besides the errors each method lists, every method that calls the server can raise the ones in Errors every method can raise.Unlike
Bot, it has nostartorstart_pollingand is not a context manager: its methods also work on a bot that is not running, so a script can make one and send. ASQLiteStorage, though, is open only whilerunruns. Outside it, whatever uses the storage raisesStorageError:failed_updates,trust_key, or a send into an encrypted chat withpin_keys=True.runandprocess_updatemake an event loop of their own, so they can’t be called inside a running one: in async code, useBot.Examples
An echo bot; the token comes from
PGPCHAT_BOT_TOKEN:from pgpcbot import SyncBot bot = SyncBot() @bot.on_message() def echo(message): message.reply(message.text or "...") bot.run()
A script that only sends, into an encrypted chat too:
bot = SyncBot(pgp_private_key="bot-key.asc", pgp_passphrase=passphrase) bot.send_message(7781, "Nightly job done")
Sets up the bot. Nothing is sent to the server until it starts.
The format of the token is checked here, and the private key is loaded and unlocked here, so a malformed token or a wrong passphrase fails at once, not at the first update. Every option except
tokenis keyword-only, and a value given here wins over the environment.- Parameters:
token (str | Secret | Callable[[], str] | None) – The bot’s token,
pgpbot_<id>.<43 characters>: a string, aSecret, or a function that returns one (called once, here).NonereadsPGPCHAT_BOT_TOKEN, or the file named byPGPCHAT_BOT_TOKEN_FILE.api_url (str | None) – The API server, like
https://api.pgpchat.app; a trailing/bot/v1is removed.NonereadsPGPCHAT_API, and useshttps://api.pgpchat.appwhen it is not set.pgp_private_key (BotKey | str | bytes | PathLike[str] | None) – The bot’s private key, for end-to-end encrypted chats: a
BotKey, the path of a key file, or the armored key as text or bytes.NonereadsPGPCHAT_BOT_KEY(the armored key or a path) or the file named byPGPCHAT_BOT_KEY_FILE; with neither, the bot has no key.pgp_passphrase (str | Secret | Callable[[], str] | None) – The passphrase that unlocks the private key: a string, a
Secret, or a function that returns one.NonereadsPGPCHAT_BOT_KEY_PASSPHRASE(orPGPCHAT_BOT_KEY_PASSPHRASE_FILE). Not used with aBotKey, which is unlocked already.pgp_previous_keys (Sequence[BotKey]) – Older keys, still tried to decrypt messages after a rotation. Keep the old key here for at least 72 hours after
rotate_key.storage (Storage | str | PathLike[str] | None) – Where updates wait to be handled, with the failed updates and the pinned keys: a
Storage, or a path, which meansSQLiteStorage(path).Nonemeans a newMemoryStorage, which forgets everything when the process ends.concurrency (int) – How many chats are handled at the same time, at least 1. The updates of one chat are always handled one at a time, in order. On a
SyncBotit is also the number of handler threads.publish_key (bool) –
Truepublishes the key at start-up when the server has a different one, or none. Publishing is one-way: see the warning below.require_encryption (bool) –
Truenever sends anything in clear, anywhere: a send into a readable chat raisesEncryptionRequiredError.verify_signatures (str) – How the signatures of encrypted messages are checked.
"auto"checks them and reports the result in the message’sverification."require"refuses a message without a valid signature: aSignatureErrorgoes to the error handlers instead."off"does not check, and does not fetch the members’ keys.pin_keys (bool) –
Trueremembers the first key fingerprint seen for each person and refuses a different one later, withKeyChangedError: sends to that person fail, and their messages go to the error handlers (whenverify_signaturesis not"off").trust_keyaccepts a new key. The pins are kept in the storage: use aSQLiteStorageto keep them across restarts.cache_ttl (float) – How long, in seconds, chat details, chat modes and members’ keys are cached. The readable mode of a group or channel is trusted for 10 seconds at most.
auto_answer_callbacks (bool) –
Trueanswers a button press with nothing after the handler ends, if the handler did not answer it, so the button stops spinning.retry (RetryPolicy | None) – How often a failed call is tried again.
Nonemeans the defaults ofRetryPolicy.rate_limit (bool | RateLimiter) –
Truepaces the calls with aRateLimiterthat takes the server’s numbers fromGET /meat start-up.Falseturns pacing off. ARateLimiterof your own is used as it is, with its own numbers.timeout (float | httpx2.Timeout | None) – How long one HTTP request may take: seconds, or an
httpx2.Timeout.Nonemeans 30 seconds, and 10 to connect. Long polls get extra time on their own.allow_insecure_http (bool) –
Trueallows anhttp://server that is notlocalhost. Only for a test server on your own network: see the warning below.max_handler_runs (int) – An update whose handlers were started this many times and never finished (the process crashed or was stopped half way) goes to the failed list instead of running again.
shutdown_timeout (float) – At stop, how many seconds the running handlers get to finish. Those still running are then cancelled, and their updates run again at the next start. A
SyncBotcan’t cancel a thread: there, a late handler goes on until it returns (seeSyncBot.run).transport (Any) – Advanced: an httpx2 transport for the bot’s HTTP clients, such as a proxy setup or a fake server in tests.
name (str | None) – The name of the bot’s root router;
Nonemeans"bot".
- Raises:
ConfigurationError – No token was given or found, the token or
api_urlis not acceptable, an option has a wrong value, orpgp_private_keyis neither aBotKey, armored text nor a file that can be read.InvalidKeyError – The private key is not a readable OpenPGP key, or it is a public key.
PassphraseError – The private key is locked, and the passphrase is missing or wrong.
- key¶
The bot’s private key, or
Nonewhen it has none.rotate_keyreplaces it.- Type:
BotKey | None
- auto_answer_callbacks¶
Whether a button press the handler did not answer is answered after it.
- Type:
- max_handler_runs¶
How many unfinished runs an update gets before it goes to the failed list.
- Type:
Example
An encrypted bot whose updates wait in a SQLite file.
SyncBottakes the same options:import os from pgpcbot import Bot bot = Bot( pgp_private_key="bot-key.asc", pgp_passphrase=os.environ["BOT_KEY_PASSPHRASE"], storage="bot.db", )
Note
repr(bot)shows the token masked (pgpbot_412.****abcd), and a bot can’t be pickled: it holds the token, and maybe a private key.Warning
Publishing a key is one-way: the bot’s private chats become end-to-end encrypted, and only its owner can undo that, from the console in the app.
allow_insecure_http=Truelets the token travel in clear, readable by anyone on the way to the server.- property api: Client¶
The low-level
Clientthat the bot’s methods call.It has one method per operation of the Bot API, and shares the bot’s rate limiter. It sends what you give it as it is: in an encrypted chat, nothing is sealed or opened for you. The bot makes it when it is first needed and closes it when
runreturns. Leave closing it to the bot: a closedClientcan’t be used again, so the bot’s own calls would fail withConfigurationError.
- stop()¶
Asks the running bot to stop, and returns at once.
It does what
request_stopdoes, and is just as safe to call from a handler, from another thread or from a signal handler.runthen stops cleanly: no new updates, the running handlers get up toshutdown_timeoutseconds to finish, theon_shutdownhooks run, the storage and the connections close, andrunreturns. Does nothing when the bot is not running.Unlike
Bot.stop, it does not wait for all that: called from a handler, waiting would mean waiting for itself.
- run(
- *,
- poll_timeout=50,
- limit=100,
- drop_webhook=False,
- skip_pending=False,
- configure_logging=True,
Long-polls the server until Ctrl+C or
SIGTERM, then stops cleanly.Blocks until the bot has stopped. First the bot starts: it reads
GET /me(waiting for the server if it is down), checks its key, opens the storage and runs theon_startuphooks. Then it asks the server for updates and runs their handlers in the handler threads, until it is told to stop: Ctrl+C,SIGTERM, Ctrl+Break on Windows,stoporrequest_stop. Then it stops taking updates, gives the running handlers up toshutdown_timeoutseconds to finish, runs theon_shutdownhooks, and closes the storage and the connections. Network failures, server errors and a paused bot are waited out; see Long polling.- Parameters:
poll_timeout (int) – How long each poll may wait on the server for something to arrive, 0 to 50 seconds.
limit (int) – How many updates one poll may bring, 1 to 100.
drop_webhook (bool) –
Trueremoves a webhook that is set, and polls. Without it, a set webhook stops the bot withPollingError.skip_pending (bool) –
Truethrows away, without handling them, the updates that waited on the server while the bot was off. See the warning below.configure_logging (bool) –
Truesets up logging atINFOlevel, if your program has not set up logging itself.
- Raises:
AuthenticationError – The token is wrong or was revoked.
PollingError – A webhook is set and
drop_webhookisFalse, or one was set while the bot was polling.PollingConflictError – Another process polls this bot with the same
SQLiteStoragefile, or the storage is in use by another running bot in this process.ConfigurationError – A handler, filter, middleware, error handler or hook is an
async deffunction: aSyncBotneeds plain functions.RuntimeError – It was called where an event loop is running already (inside an
async deffunction, in a Jupyter cell):run()makes its own loop. Nothing is sent. In async code, useBotandawait bot.start_polling().
Example
Take over from a webhook, and skip what queued while the bot was off:
bot.run(drop_webhook=True, skip_pending=True)
Note
A handler runs in a thread, and a thread can’t be stopped from outside. A handler still running after
shutdown_timeoutgoes on until it returns, while the bot shuts down without it, and the process waits for it before it exits. Its update was not finished, so it runs again at the next start.Warning
skip_pending=Trueconfirms the waiting updates to the server, which deletes them for good. Updates already in aSQLiteStoragestill run.
- process_update(update)¶
Runs one update through decryption, filters and handlers, now.
Meant for tests, and for updates that come from somewhere else. The update skips the queue: nothing is stored, nothing is confirmed to the server, and a repeat is not recognised. The bot does not need to be running, but this still calls the API: for
GET /meifmeis not known yet, for what the handlers send, and for the automatic answer to a button press. It blocks until the handlers are done; they run in the handler threads, as underrun.- Parameters:
update (Update | Mapping[str, Any]) – An
Update, or one in the JSON form the server sends (a dict withupdateIdandkind).- Raises:
Exception – What a handler raised, or what opening an encrypted message raised, when no error handler took it.
RuntimeError – It was called where an event loop is running already, in an
async deftest for example: likerun, it makes its own loop.
Example
In a test, with the bot on a fake transport or a test server:
bot.process_update({ "updateId": 1, "kind": "message", "message": { "id": 10, "chat": {"id": 55, "kind": "private"}, "from": {"id": 55, "username": "anna"}, "date": 1791462000000, "kind": "user", "text": "/start", }, })
- get_me(*, refresh=False)¶
Reads the bot’s own details (
GET /me).The copy in
meis returned without asking the server, unless there is none yet orrefreshisTrue.GET /meworks while the owner has paused the bot, so it is the way to find out why nothing else does.
- get_chat(chat, *, refresh=False)¶
Reads a chat the bot is in (
GET /chats/{id}).The answer is cached for
cache_ttlseconds;refresh=Trueasks the server again.- Parameters:
- Returns:
The
Chat, with its kind, its mode (openorprotected) and, in a group or channel, the bot’s own seat.- Raises:
ChatNotFoundError – There is no such chat, or the bot is not in it.
- Return type:
- recipients(chat)¶
Lists the checked keys a message into this end-to-end encrypted chat is sealed to.
The keys come from
GET /chats/{id}/keys, cached forcache_ttlseconds, and each one is checked: its fingerprint must match the one the server gives, it must not be revoked or expired, it must have a live encryption subkey, and it must be a v4 key (phones can’t open what is sealed to a v6 key). In a group, a member whose key fails a check is left out with a warning in the log, as the app does; in a private chat, it raisesEncryptionError. The bot’s own key is not in the list:sealadds it.- Parameters:
chat (int | ChatRef) – The chat’s ID, or a chat object such as a
Chat, aChatReformessage.chat.- Returns:
The
PublicKeyof every member the message can be sealed to.- Raises:
EncryptionError – Nobody in the chat has a usable key, or the key of the person in a private chat can’t be used.
KeyChangedError – With
pin_keys=True, a member’s key is not the one pinned for them.ChatIsOpenError – The chat is readable, not end-to-end encrypted.
NoUserKeyError – The person in a private chat has no published key.
- Return type:
- seal(chat, text)¶
Encrypts
textfor everyone in an end-to-end encrypted chat, signed by the bot.Nothing is sent. The text is sealed to the keys
recipientsgives and to the bot’s own key, and signed with the bot’s key: whatsend_messagedoes before sending, with the same checks. Unlikesend_message, the text is not checked against the server’s limits.- Parameters:
- Returns:
The ASCII-armored OpenPGP message, to send as
armored.- Raises:
EncryptionError – The bot has no private key, or the message can’t be sealed to the chat’s keys.
KeyChangedError – With
pin_keys=True, a member’s key is not the one pinned for them.ChatIsOpenError – The chat is readable, not end-to-end encrypted.
- Return type:
Example
What
send_messagedoes, by hand:armored = bot.seal(chat_id, "draft") bot.api.send_message(chat_id, armored=armored)
- send_message(
- chat,
- text,
- *,
- reply_to=None,
- markup=None,
- silent=False,
- encrypt=None,
- send_id=None,
Sends a text message, sealed and signed when the chat is end-to-end encrypted.
Before sending, the bot finds out the chat’s mode, and asks the server when it does not know it yet. Into a readable chat the text goes as it is. Into an encrypted chat it is sealed to every member with a usable key and to the bot, and signed with the bot’s key (see
recipients). If it can’t be sealed, nothing is sent: the bot never falls back to plain text.The mode can change between the check and the send. If the chat became encrypted, the server refuses the text, and the bot seals it and sends it again with the same
send_id; a warning is logged, because the text did reach the server in the refused request. If the chat became readable, the server refuses the sealed message, and it is not sent again in clear: this raises, and the next send follows the new mode.- Parameters:
chat (int | ChatRef) – The chat’s ID, or a chat object such as a
Chat, aChatReformessage.chat.text (str) – The text: 1 to
text_max(4096) characters, counted in UTF-16 units as JavaScript does.CR LFandCRline ends becomeLF; other control characters, except tabs, are refused.reply_to (int | MessageBase | None) – The message to quote: its ID, or the message itself. The server ignores a wrong ID.
markup (InlineKeyboard | Mapping[str, Any] | None) – Buttons under the message: an
InlineKeyboard, or the same in the JSON form of the API. Never encrypted: see the warning below.silent (bool) –
Trueposts without a notification. It only matters in channels.encrypt (bool | None) –
Nonefollows the chat.Truerefuses to send in clear (EncryptionRequiredErrorin a readable chat);Falserefuses to send sealed (ValidationErrorin an encrypted chat).send_id (str | UUID | None) – A UUID that makes the send safe to repeat: the server writes one message per
send_id, and answers a repeat with the message it already wrote. One is made for you; pass your own, saved with your job, to make your own retries safe too.
- Returns:
The
SyncSentMessageas the server stored it, which can be edited or deleted.- Raises:
ValidationError – The text, the buttons or an ID breaks a rule, or
encrypt=Falsein an encrypted chat. Nothing was sent.EncryptionRequiredError – The chat is readable, and
encrypt=Trueorrequire_encryption=True. Nothing was sent.EncryptionError – The chat is encrypted and the message can’t be sealed: the bot has no private key, nobody has a usable key, or the key of the person in a private chat can’t be used. Nothing was sent.
KeyChangedError – With
pin_keys=True, a member’s key is not the one pinned for them. Nothing was sent.WrongBodyKindError – The chat became readable since the bot last looked. The sealed message was refused, and is not sent again in clear.
ChatIsOpenError – The same, noticed while fetching the members’ keys.
ForbiddenError – The bot may not write in this chat.
BlockedByUserError – The person blocked the bot.
UserGoneError – The person closed their account.
ChatNotFoundError – There is no such chat, or the bot is not in it.
SendIdConflictError – The
send_idwas used already, in another chat.
- Return type:
Examples
Into any chat the bot is in:
bot.send_message(7781, "Your report is ready")
With buttons, and a
send_idsaved with the job, so that a job run twice still sends once:keyboard = InlineKeyboard.build( [Button.callback("Monday", "day:mon"), Button.callback("Friday", "day:fri")], ) bot.send_message( group_id, "Vote for the next meetup", markup=keyboard, send_id=job.send_id )
Warning
Buttons are never encrypted: their text and their callback data travel in clear, even in an encrypted chat. Don’t put personal data in them.
See also
SyncMessage.replyandSyncMessage.answer, which send into the chat a message came from.
- edit_message(chat, message, text=None, *, markup=KEEP, encrypt=None)¶
Edits one of the bot’s messages: new text, new buttons, or both.
The bot can edit only its own ordinary messages, at any age, and people see the edit live. New text is sealed like a send when the chat is end-to-end encrypted, and a change of the chat’s mode is handled as in
send_message.- Parameters:
chat (int | ChatRef) – The chat’s ID, or a chat object such as a
Chat, aChatReformessage.chat.message (int | SentMessageBase) – The bot’s message: its ID, or the
SyncSentMessagea send returned.text (str | None) – The new text, with the same rules as in
send_message.Nonekeeps the text.markup (InlineKeyboard | Mapping[str, Any] | None) – The buttons.
KEEP, the default, keeps them;Noneremoves them; a keyboard replaces them. Never encrypted.encrypt (bool | None) –
Nonefollows the chat.Truerefuses to send the text in clear;Falserefuses to send it sealed.
- Returns:
The edited message, as the server stored it.
- Raises:
ValidationError – There is nothing to change (no
text, andmarkupleft atKEEP), or the text, the buttons or an ID breaks a rule.MessageNotFoundError – There is no such message in this chat.
ForbiddenError – The message is not one of the bot’s own ordinary messages.
EncryptionRequiredError – The chat is readable, and
encrypt=Trueorrequire_encryption=True.EncryptionError – The chat is encrypted and the new text can’t be sealed.
WrongBodyKindError – The chat became readable since the bot last looked. The sealed text was refused, and is not sent again in clear.
ChatIsOpenError – The same, noticed while fetching the members’ keys.
- Return type:
Example
A message that counts:
sent = bot.send_message(chat_id, "Counting...") for n in range(1, 4): time.sleep(1) bot.edit_message(chat_id, sent, f"Counting... {n}")
- delete_message(chat, message)¶
Deletes a message for everyone, for good.
The bot can delete its own messages anywhere, and other people’s in a group or channel where it has the “delete messages” right. Secret messages and service notices can’t be deleted.
- Parameters:
chat (int | ChatRef) – The chat’s ID, or a chat object such as a
Chat, aChatReformessage.chat.message (int | MessageBase | SentMessageBase) – The message: its ID, a received
SyncMessage, or aSyncSentMessage.
- Returns:
True. When an attempt timed out and the retry finds the message gone, the first attempt worked, and the result isTruetoo.- Raises:
MessageNotFoundError – There is no such message, or it is gone already.
ForbiddenError – The bot may not delete this message.
ChatNotFoundError – There is no such chat, or the bot is not in it.
- Return type:
- answer_callback(callback_id, text=None, *, alert=False, url=None)¶
Answers a button press, which stops its spinner.
Each press can be answered once, within 60 seconds. The answer can also show a line of text, a box, or a link. In a handler,
SyncCallbackQuery.answerdoes the same. The bot remembers the answer, so it does not send its automatic empty one after the handler (seeauto_answer_callbacks).- Parameters:
callback_id (str) – The ID of the press,
query.id.text (str | None) – Up to 200 characters on one line, shown over the chat for a moment.
Noneshows nothing.alert (bool) –
Trueshowstextin a box the person has to close.url (str | None) – An
httporhttpsaddress, up to 1024 characters, that the phone offers to open.
- Returns:
True. A retry after a timeout that finds the press answered already counts as done.- Raises:
CallbackExpiredError – More than 60 seconds have passed since the press.
CallbackAnsweredError – The press was answered already.
CallbackNotFoundError – There is no such press.
- Return type:
Warning
Answers are not encrypted, even in an encrypted chat.
- send_typing(chat, typing=True)¶
Shows “typing…” in a chat for a few seconds.
The indicator goes away by itself, or when the bot’s message arrives. The server shows at most one every 3 seconds per chat; a call in between does nothing and returns
False. It is not tried again after a network error: a late indicator is useless.- Parameters:
- Returns:
Trueif the indicator was shown,Falseif one went out to this chat less than 3 seconds ago.- Raises:
ForbiddenError – The bot may not write in this chat.
ChatNotFoundError – There is no such chat, or the bot is not in it.
- Return type:
- get_chat_keys(chat)¶
Reads the public keys of an end-to-end encrypted chat, as the server lists them.
They come straight from
GET /chats/{id}/keys: not cached, and not checked.recipientsgives the checked keys a message is sealed to. The bot’s own key is not in the list, and neither are bots in privacy mode, as in the app.- Parameters:
chat (int | ChatRef) – The chat’s ID, or a chat object such as a
Chat, aChatReformessage.chat.- Returns:
The
ChatKeys, with oneUserKeyper member who has a key and, in a group, the member count.- Raises:
ChatIsOpenError – The chat is readable, not end-to-end encrypted.
NoUserKeyError – The person in a private chat has no published key.
ChatNotFoundError – There is no such chat, or the bot is not in it.
- Return type:
- get_chat_members(chat)¶
Reads the member list of a group or channel, as a member in the bot’s seat sees it.
Where the member list is hidden, a bot that is not an admin sees the admins only. Phone numbers are never included.
- Parameters:
chat (int | ChatRef) – The group or channel: its ID, or a chat object such as a
Chat, aChatReformessage.chat.- Returns:
The
ChatMembers, with the members the bot may see and how many members there are in all.- Raises:
BadRequestError – The chat is a private chat.
ChatNotFoundError – There is no such chat, or the bot is not in it.
- Return type:
- get_chat_member(chat, user_id)¶
Reads one member’s seat in a group or channel.
- Parameters:
- Returns:
The
Member, with the person, their status and their admin rights.- Raises:
MemberNotFoundError – The person is not a member, or is hidden from the bot.
BadRequestError – The chat is a private chat.
ChatNotFoundError – There is no such chat, or the bot is not in it.
- Return type:
- leave_chat(chat)¶
Leaves a group or channel; its members are told.
The bot also forgets what it had cached about the chat.
- Parameters:
chat (int | ChatRef) – The group or channel: its ID, or a chat object such as a
Chat, aChatReformessage.chat.- Returns:
True. A retry after a timeout that finds the bot gone already counts as done.- Raises:
BadRequestError – The chat is a private chat, which can’t be left.
ChatNotFoundError – There is no such chat, or the bot is not in it.
- Return type:
- resolve(username)¶
Turns a public handle into an ID (
GET /resolve/{username}).For the handle somebody writes at the bot, when no update has handed that ID over. People, groups and channels share one pool of names, so a handle belongs to exactly one of the three:
Resolved.kindsays which andResolved.idis filled in either way. A leading@and the case do not matter.Nothing is unlocked by knowing the number. Sending still needs the bot to be in the chat, and
SyncBot.get_user_keystill needs a chat in common.- Parameters:
username (str) – The handle, with or without the
@.- Returns:
A
Resolved– the ID, the handle it goes by, and either the person (ChatPeer) or the chat (ChatCard).- Raises:
ValidationError – Not a handle: 3-32 characters, letters, digits and underscore.
UsernameNotFoundError – Nobody and no chat answers to it. Also the answer for a group with no public name and for an account that has been closed.
FloodWaitError – More than 60 lookups this minute, where a handle nobody holds counts as five.
- Return type:
Example
Answer “chi e @anna”:
@bot.on_text() def who(message: Message) -> None: found = bot.resolve(message.text.split("@")[-1]) message.reply(f"{found.mention} ha id {found.id}")
- get_user_key(user_id)¶
Reads one person’s public key (
GET /users/{id}/key).The key is not checked here:
PublicKey.from_user_keyreads it and checks its fingerprint.- Parameters:
user_id (int) – The person’s user ID. The bot must share a chat with them.
- Returns:
The
UserKey, with the user ID, the fingerprint and the armored public key.- Raises:
UserNotFoundError – There is no such person, or the bot shares no chat with them.
NoUserKeyError – The person never published a key.
- Return type:
- get_commands(scope=None)¶
Reads the bot’s command menus: every scope, or one.
- Parameters:
scope (str | None) –
Nonefor every scope, or one of"default","private","groups","groupAdmins"and"chat:<id>".- Returns:
With no
scope, a list ofCommandScope, one per scope with its commands. With ascope, the list ofCommandof that scope.- Return type:
- set_commands(commands, scope=None)¶
Replaces the whole command menu of one scope.
The app shows the menu when someone types
/. The owner can change the same lists in the app: whoever writes last wins.sync_commandsbuilds the list from the bot’s own handlers.- Parameters:
commands (Iterable[Command | Mapping[str, Any] | tuple[str, str]]) – Up to 100 commands:
Commandobjects, dicts withcommandanddescription, or(name, description)pairs. A name has 1 to 32 characters ofa-z,0-9and_(a leading/is dropped, and capitals are made lowercase); a description has 1 to 256 characters. When a name comes twice, the first one is kept.scope (str | None) – Which menu:
"default"(also whenNone),"private","groups","groupAdmins"or"chat:<id>".
- Returns:
The
CommandScopeas the server saved it.- Raises:
ChatNotFoundError – A
chat:<id>scope names a chat the bot is not in.- Return type:
Example
A menu for every chat, and one for a single group:
bot.set_commands([("start", "Say hello"), ("help", "What I can do")]) bot.set_commands([("rules", "Show the rules")], scope="chat:7781")
- delete_commands(scope=None)¶
Empties the command menu of one scope.
An empty scope does not hide the wider ones: the app shows the next scope that has commands,
"default"last.- Parameters:
scope (str | None) – Which menu:
"default"(also whenNone),"private","groups","groupAdmins"or"chat:<id>".- Returns:
The
CommandScope, now with no commands.- Return type:
- sync_commands(scope=None)¶
Publishes the commands registered with a description as the menu of one scope.
Every
Router.commandwith adescription, on the bot and on its included routers, goes into the list; when a name comes twice, the first description wins. It replaces the whole list of that scope, asset_commandsdoes. Anon_startuphook is a good place to call it.- Parameters:
scope (str | None) – Which menu:
"default"(also whenNone),"private","groups","groupAdmins"or"chat:<id>".- Returns:
The
CommandScopeas the server saved it.- Raises:
ConfigurationError – No command was registered with a description.
- Return type:
Example
Publish the menu at every start:
@bot.command("weather", description="Forecast for a town") def weather(message, command): ... @bot.on_startup def ready(bot): bot.sync_commands()
- get_webhook()¶
Reads the webhook, if one is set, and how its deliveries are going.
Works while the owner has paused the bot.
- Returns:
The
WebhookInfo, with the address, whether it has a secret, how many updates are waiting, and the failures and last error of the deliveries.- Return type:
- set_webhook(url, secret=None)¶
Sets a webhook: from now on the server POSTs every update to
url.Setting it ends any poll in progress, and the server refuses polls while it is set. You rarely need this:
create_webhook_appwith aurlregisters the webhook every time the bot starts.- Parameters:
url (str) – The address:
https, up to 1024 characters, with no user name, password or#fragment, pointing to a public IP address.secret (str | None) – 1 to 256 characters of
A-Z a-z 0-9 _ -, sent back in theX-PGPChat-Bot-Api-Secret-Tokenheader of every delivery. Always set one: without it, anyone who finds the address can send fake updates.
- Returns:
The
WebhookInfoof the new webhook.- Raises:
BadWebhookError – The server refused the address, for example because it points to a private IP address.
- Return type:
- delete_webhook()¶
Removes the webhook, so the bot can poll again.
The updates that queued meanwhile are not lost: the next poll gets them. Works while the owner has paused the bot.
- Returns:
The
WebhookInfo, now with nourl;pendingcounts the waiting updates.- Return type:
- publish_key(key=None)¶
Publishes the bot’s public key, which makes its private chats end-to-end encrypted.
It sends the public part of the key (
PUT /key) with a fingerprint computed from the key itself, so the two always match: a wrong one would make phones seal to a key the bot does not have. A v6 key is refused before anything is sent, because phones can’t use it. Afterwardsmeis read again and shows the new mode. Publishing does not change the key the bot uses: to switch to a new key, userotate_key.- Parameters:
key (BotKey | None) – The key to publish.
Nonepublishes the bot’s own key.- Returns:
The
PublishedKey, with the fingerprint and the bot’s new mode.- Raises:
ConfigurationError – There is no key to publish.
InvalidKeyError – The key is a v6 key.
- Return type:
Warning
Publishing is one-way: there is no API to remove the key. Only the bot’s owner can make the bot readable again, from the console in the app.
- rotate_key(new_key)¶
Publishes
new_keyand makes it the bot’s key.From then on, phones seal to the new key. Messages sealed to the old one may still be on their way, or waiting on the server, for up to 72 hours: the old key stays loaded, and is still tried for decryption as long as this process runs. When you restart the bot, pass the old key in
pgp_previous_keysfor at least 72 hours.- Parameters:
new_key (BotKey) – The new private key.
BotKey.generatemakes one of the kind phones can use.- Returns:
The
PublishedKey, with the new fingerprint.- Raises:
InvalidKeyError –
new_keyis a v6 key, which phones can’t use.- Return type:
Example
Make a new key, save it, and switch to it:
new_key = BotKey.generate("Weather", username="weatherbot", passphrase=passphrase) new_key.save("bot-key-2026-11.asc", passphrase=None) bot.rotate_key(new_key)
- revoke_token()¶
Burns the bot’s token, for good.
From then on every call with this token fails, the bot’s own included, until the owner makes a new token in the console; the owner is told at once. Use it when the token has leaked. It works while the owner has paused the bot.
- Returns:
True. A retry after a timeout that finds the token burnt already counts as done.- Return type:
Warning
There is no undo: every call made with the token fails from then on, with
AuthenticationError, and a polling bot stops.
- download_media(media, *, max_bytes=MEDIA_MAX_BYTES)¶
Downloads the file behind a
Media, decrypted when it came from an encrypted chat.The file comes from the
/media/<id>address of the API server; the token is not sent, because that address does not use it. An attachment of an encrypted chat is decrypted with the bot’s key. In a private chat with the bot this always works. In an encrypted group the phones seal attachments only to the chat’s members, so it works only when the bot’s privacy mode is off; otherwise it raisesNotARecipientError.- Parameters:
- Returns:
The file’s bytes, decrypted when needed.
- Raises:
NotARecipientError – The file was not sealed to any of the bot’s keys.
DecryptionError – The bot has no private key, or the file can’t be decrypted: it is damaged, it opens to more than
max_bytes, or it is compressed. Phones never compress attachments, and a compressed file could open to many gigabytes.ValidationError – The file is bigger than
max_bytes, or the address is not a media address of this server.NotFoundError – The server has no such file.
- Return type:
Example
Save every attachment of a message:
@bot.on_message(f.has_media) def save(message): for item in message.media: data = bot.download_media(item) Path(f"{item.id}.bin").write_bytes(data)
Note
The message that carried the attachment had its signature checked, but the file itself is not checked against a signature.
- trust_key(user_id, fingerprint)¶
Accepts
fingerprintas the key ofuser_idfrom now on.With
pin_keys=Truethe bot refuses a person’s key when it is not the one pinned for them, withKeyChangedError. Once you have checked the new key with the person, out of band, call this to pin it: sends to them work again, and their new messages reach the handlers. Their messages that failed meanwhile wait in the failed list, andretry_failedruns them again. The pin is saved in the storage.- Parameters:
- Raises:
ValidationError –
fingerprintis not 40 or 64 hex digits.
Example
After checking the fingerprint with the person:
bot.trust_key(user_id, "3F1A9C0D52E7B8A46C110D93E2F47A559B10C6D2")
Note
The bot opens a
SQLiteStoragewhen it starts and closes it when it stops: with one, call this whilerunruns (from a handler, from a hook, or from another thread).
- failed_updates(limit=100)¶
Lists the updates whose handlers failed, oldest first.
An update fails when a handler raises and no error handler takes the error. It is confirmed to the server like any other, so it does not block the queue, and kept in the failed list with the error. For an update from an encrypted chat, a handler’s own exception is kept as its type and the place it was raised, without its message, which could quote the decrypted text. The list keeps the newest ones only: 1000 with a
MemoryStorage, 10 000 per bot with aSQLiteStorage(theirmax_failedoption).- Parameters:
limit (int) – The most updates to return.
- Returns:
The failed updates, each a
FailedUpdatewithupdate_id,payload(the update as the server sent it),errorandfailed_at.- Return type:
Example
Look at what failed:
for item in bot.failed_updates(): print(item.update_id, item.failed_at, item.error)
Note
The bot opens a
SQLiteStoragewhen it starts and closes it when it stops: with one, call this whilerunruns (from a handler, from a hook, or from another thread).
- retry_failed(update_ids=None)¶
Puts failed updates back in the queue, to run through the handlers again.
They run as if they had just arrived: at once when the bot is running, otherwise at its next start. A common pattern is to fix the bug, restart the bot, and call this.
- Parameters:
update_ids (Sequence[int] | None) – The IDs of the updates to retry.
Noneretries all of them.- Returns:
How many updates were put back.
- Return type:
Note
The bot opens a
SQLiteStoragewhen it starts and closes it when it stops: with one, call this whilerunruns (from a handler, from a hook, or from another thread).
- create_webhook_app(
- secret,
- *,
- url=None,
- path=None,
- max_body=1024 * 1024,
- ack_timeout=8.0,
- allow_no_secret=False,
Makes the ASGI application the server POSTs updates to.
The app checks the secret of every delivery, stores the update and runs the handlers. Serve it with any ASGI server (
uvicorn bot:app): there it starts and stops the bot itself. Or add it as a route of a FastAPI or Starlette application, and passlifespan=bot.lifespanto that application (seelifespan). The bot keeps the last app made here, and registers itsurlwhen it starts.When the server gets its
200depends on the storage: with aSQLiteStorage, as soon as the update is written to it; with aMemoryStorage, only once the handlers are done, so the bot handles one update at a time. Use aSQLiteStoragefor webhooks.- Parameters:
secret (str | None) – The secret the server sends with every delivery, in the
X-PGPChat-Bot-Api-Secret-Tokenheader: 1 to 256 characters ofA-Z a-z 0-9 _ -.generate_secretmakes a good one. A delivery with a missing or wrong secret gets401.url (str | None) – Your public
httpsaddress. When given, the webhook and its secret are registered with the server every time the bot starts.path (str | None) – Answer only on this path, and
404elsewhere. Leave it out when another application routes to this one.max_body (int) – The largest body accepted, in bytes (1 MiB by default); a bigger one gets
413.ack_timeout (float) – With a
MemoryStorage, how many seconds to wait for the handlers before answering503, so that the server delivers the update again later. Under 10, the server’s own limit.allow_no_secret (bool) –
Trueallowssecret=None. Anyone who finds the address could then send fake updates.
- Returns:
The
WebhookApp.- Raises:
ConfigurationError –
secretisNonewithoutallow_no_secret=True, orack_timeoutis not between 0 and 10 seconds (both excluded).ValidationError –
secrethas characters the server does not accept, orurlis not an absolutehttporhttpsaddress of up to 1024 characters, or it carries a user name, a password or a#fragment.
- Return type:
Example
A webhook bot, served with
uvicorn bot:app:bot = Bot(storage="bot.db") app = bot.create_webhook_app( secret=os.environ["WEBHOOK_SECRET"], url="https://bot.example.org/pgpchat", )
Note
Inside FastAPI or Starlette, add it with
app.add_route(...), notapp.mount(...): a mount answers the address without the trailing slash with a redirect, which the server counts as a failed delivery, and the queue stops.
- discard_failed(update_ids=None)¶
Forgets failed updates, for good.
- Parameters:
update_ids (Sequence[int] | None) – The IDs of the updates to forget.
Noneforgets all of them.- Returns:
How many updates were forgotten.
- Return type:
Note
The bot opens a
SQLiteStoragewhen it starts and closes it when it stops: with one, call this whilerunruns (from a handler, from a hook, or from another thread).
- lifespan(app=None)¶
Starts the bot with a FastAPI or Starlette application, and stops it at shutdown.
Pass it as
FastAPI(lifespan=bot.lifespan)orStarlette(lifespan=bot.lifespan)when the app made bycreate_webhook_appis a route of that application: inside another application, the webhook app gets no start and stop events of its own. On entry the bot starts asBot.startdoes, and registers the webhook if the app has aurl; on exit it stops asBot.stopdoes. Without it, the bot starts at the first delivery and logs a warning.- Parameters:
app (Any) – The application, passed in by the framework. Not used.
- Yields:
Nothing; the bot runs until the application shuts down.
- Return type:
AsyncIterator[None]
Examples
With FastAPI:
webhook = bot.create_webhook_app( secret=os.environ["WEBHOOK_SECRET"], url="https://example.org/pgpchat" ) app = FastAPI(lifespan=bot.lifespan) app.add_route("/pgpchat", webhook, methods=["POST"])
An application with a lifespan of its own enters this one inside it:
@asynccontextmanager async def lifespan(app): async with bot.lifespan(app): await database.connect() yield await database.disconnect()
- property me: BotInfo | None¶
The bot’s own details, as
GET /melast returned them.Noneuntil they are first read: when the bot starts, or byget_me. The bot reads them again when it needs to (after publishing a key, for example), andget_mewithrefresh=Truedoes it when you ask.
- on_shutdown(func)¶
Registers
functo run when the bot stops, while it can still send.Use it as a decorator.
funcgets the bot as its only argument. It runs once the running handlers have finished, or onceshutdown_timeouthas run out, and before the storage and the connections close, so it can still call the API. Hooks run in the order they were registered; an error in one is logged, not raised, and the next one still runs. The hooks don’t run when the bot did not start, or when a startup hook failed.- Parameters:
func (Callable[[...], Any]) – The hook: an
async deffunction on aBot, a plain function on aSyncBot.- Returns:
funcitself, unchanged.- Raises:
TypeError –
funcis not callable.- Return type:
Example
Close what a startup hook opened:
@bot.on_shutdown async def closing(bot): await database.disconnect()
- on_startup(func)¶
Registers
functo run when the bot starts, once it is connected.Use it as a decorator.
funcgets the bot as its only argument. It runs afterGET /me(someis set and API calls work) and once the storage is open, before the bot takes new updates: the place to publish the command menu or to open a database pool. Hooks run in the order they were registered. If one raises, the bot does not start, and the error is raised where the bot was started (run,Bot.start, …).- Parameters:
func (Callable[[...], Any]) – The hook: an
async deffunction on aBot, a plain function on aSyncBot. The other kind makes the bot refuse to start, with aConfigurationError.- Returns:
funcitself, unchanged.- Raises:
TypeError –
funcis not callable.- Return type:
Example
Publish the command menu as soon as the bot is up:
@bot.on_startup async def ready(bot): await bot.sync_commands()
On a
SyncBotthe hook is a plain function that callsbot.sync_commands().Note
With a
SQLiteStorage, the updates left pending by the last run are queued just before the hooks run, so they can start while a hook is still running.
- request_stop()¶
Asks a running bot to stop, and returns at once.
Safe to call from a handler, from another thread or from a signal handler.
run(orBot.start_polling) then stops the way it does on Ctrl+C: no new updates, the running handlers getshutdown_timeoutseconds to finish, then everything closes. A webhook app answers503from then on, so the server keeps the updates and delivers them again later; stopping the web server is up to you. Does nothing when the bot is not running.Example
A command that stops the bot (on a
SyncBot, the same withoutasyncandawait):@bot.command("shutdown", filters=(f.user(ADMIN_ID),)) async def shutdown(message): await message.reply("Bye") bot.request_stop()