AsyncClient and Client¶
The low-level clients have one method per operation of the Bot API. They check the input,
pace the calls, retry when it is safe, and return typed objects. They don’t encrypt
anything: in an encrypted chat you send armored= yourself, or use Bot, which does
it for you.
from pgpcbot import AsyncClient, Client
async with AsyncClient() as api: # token from PGPCHAT_BOT_TOKEN
me = await api.get_me()
with Client() as api: # the same, blocking
me = api.get_me()
Client has exactly the same methods as AsyncClient, with the same
parameters, without await. A Client is safe to share between threads; an
AsyncClient belongs to one event loop.
Errors every method can raise¶
Error |
When |
|---|---|
a parameter breaks one of the server’s rules. Nothing was
sent. |
|
|
the token is missing, revoked or invalid |
|
the owner paused the bot. Only |
|
too many calls, after the client waited as much as its
|
|
the server failed, after the retries |
the answer was not the API’s JSON (a proxy error page, a cut answer) |
|
no answer at all, after the retries |
|
the answer is missing something this version needs |
How retries work¶
Safe to repeat (all reads,
set_webhook,delete_webhook,set_key, the command calls,send_messageandedit_message,delete_message,answer_callback,leave_chat,revoke_token): retried after network errors and5xx, up toretry.attemptstries in all.Not repeated after a network error:
get_updates(the polling loop decides) andsend_typing(a late indicator is useless).429is always waited out, up torate_limit_retriestimes, each wait at mostmax_rate_limit_waitseconds.Other
4xxanswers are never retried.
send_message is safe to repeat because of its sendId; edit_message because the same
edit twice gives the same result. See Errors and Rate limits.
AsyncClient¶
- class AsyncClient(
- token=None,
- *,
- api_url=None,
- timeout=None,
- retry=None,
- rate_limiter=True,
- allow_insecure_http=False,
- user_agent=None,
- http_client=None,
- transport=None,
- max_connections=20,
The Bot API, one awaitable method per operation.
Each method checks its arguments against the server’s rules before anything is sent, waits when the client-side
RateLimitersays so, and returns the answer as a typed object. A call that is safe to repeat is tried again after a network error or a5xxanswer, and429answers are waited out, as theRetryPolicyallows. Every API call can raise the errors listed in Errors every method can raise; each method lists the errors specific to it.The client never encrypts or decrypts:
textandarmoredgo out as you give them, and the attachments of an encrypted chat come back as OpenPGP ciphertext.Botdoes the OpenPGP part on top of this client, and gives you its own client asbot.api.Use it as an async context manager, or call
aclosewhen you are done. A client belongs to one event loop: used from a new one (twoasyncio.run()calls in one script, say), it opens a new connection pool there.The token travels only in the
Authorizationheader. It is never inrepr(), in error messages or in logs (token_hintshows the part that is safe to show), and a client cannot be pickled.- timeout¶
The timeouts of every request, made from
timeout.- Type:
httpx2.Timeout
- retry¶
The retry rules in use.
- Type:
- rate_limiter¶
The limiter that paces the calls, or
Nonewhen pacing is off.- Type:
RateLimiter | None
Example
Who the bot is, then a message into a readable chat:
from pgpcbot import AsyncClient async with AsyncClient() as api: # token from PGPCHAT_BOT_TOKEN me = await api.get_me() await api.send_message(7781, f"Hello from @{me.username}")
Sets up the client. Nothing is sent to the server until the first call.
The token and the server address are checked here, so a wrong setup fails at once.
- 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; when both are set, the file wins.api_url (str | None) – The API server, like
https://api.pgpchat.app. A trailing/bot/v1is removed.NonereadsPGPCHAT_API, and without it useshttps://api.pgpchat.app.timeout (float | httpx2.Timeout | None) – How long one request may take, in seconds, or an
httpx2.Timeout.Nonemeans 30 seconds, 10 of them at most to connect; a numbernmeansnseconds, at most 10 of them to connect. A long poll may read for 15 seconds more than it asks the server to wait, anddownload_mediahas limits of its own.retry (RetryPolicy | None) – When and how often to try again.
NonemeansRetryPolicy(): 4 tries in all for a call that is safe to repeat, and up to 5429answers waited out, each for at most 90 seconds.rate_limiter (RateLimiter | bool) – The client-side pacing.
Truemakes a newRateLimiterwith the default numbers,Falseturns pacing off, and aRateLimiterof your own is used as it is (one limiter can pace several clients). The client does not read the server’s own numbers: for a server that uses others, callapi.rate_limiter.update_limits(me.limits)with theBotInfoofget_me.allow_insecure_http (bool) – Allows an
http://api_urlon a host other thanlocalhost,127.0.0.1or::1. Only for a test server on your own network: over plain HTTP the token travels in clear.user_agent (str | None) – The
User-Agentheader.NonemeansPGPCbot/<version> (Python <version>).http_client (httpx2.AsyncClient | None) – An
httpx2.AsyncClientof your own, to share its connection pool. The client never closes it, and raisesConfigurationErroron a call made after you closed it.transportandmax_connectionsare then ignored.transport (httpx2.AsyncBaseTransport | None) – An
httpx2transport for the client’s own pool: a proxy setup, or a mock transport in tests.max_connections (int) – How many connections the client’s own pool may open.
- Raises:
ConfigurationError – No token was given or found, the token is not a bot token, the token file cannot be read, or
api_urlis not acceptable: nothttporhttps, plainhttpto another host withoutallow_insecure_http, or with a user name, a password, a query or a fragment. The message never contains the token.
- async aclose()¶
Closes the client’s connection pool.
An
http_clientyou passed in is left open: closing it is up to you. The client can still be used afterwards, since the next call opens a new pool, and calling this twice does no harm.
- async get_me()¶
Fetches who the bot is, its mode, its status and the limits this server enforces.
Calls
GET /me. It works while the bot is paused, so a program can find out why nothing else does. Retried after a network error or a5xxanswer.- Returns:
The bot as the server holds it.
limitstypes the limits this version knows, andLimits.getreads any other by its JSON name;queuedcounts the updates waiting.- Return type:
Example
Read the server’s limits instead of hard-coding them:
me = await api.get_me() print(f"@{me.username}", me.mode, me.status, me.queued) print(me.limits.sends_per_group_per_minute) print(me.limits.get("botsPerChat")) # a limit this version does not name
- async get_updates(offset=None, limit=None, timeout=None)¶
Fetches the updates waiting for the bot, and confirms the ones already handled.
Calls
GET /updates(long polling). Before sending, it checks thatoffsetis an integer of at least 0,limitis between 1 and 100 andtimeoutbetween 0 and 50. With atimeout, the request may read fortimeout + 15seconds, so the client does not hang up before the server answers. Not retried after a network error or a5xxanswer: the polling loop decides what to do.Only one poll per bot runs at a time: a second call ends the first one with an empty list. Polling is refused while a webhook is set.
Bot.rundoes all of this for you.- Parameters:
offset (int | None) – Confirms every update with a lower
update_id: the server deletes them for good. Pass the highestupdate_idyou handled, plus one.Noneconfirms nothing and gets everything still in the queue.limit (int | None) – At most this many updates, 1 to 100.
Noneleaves it to the server, which sends up to 100.timeout (int | None) – Seconds the server may wait for an update to arrive, 0 to 50.
Noneor0answers at once.
- Returns:
The updates, oldest first. Empty when nothing arrived before the timeout.
- Raises:
ValidationError –
offset,limitortimeoutis out of range.WebhookActiveError – A webhook is set. Delete it before polling.
- Return type:
Example
Handle each update first, then confirm it with the next call:
offset = None while True: for update in await api.get_updates(offset, timeout=50): await handle(update) offset = update.update_id + 1
Warning
Confirming is final: the server deletes the confirmed updates at the start of the request. Never pass an
offsetbeyond what you have handled, or saved somewhere safe.
- async iter_updates(*, offset=None, limit=100, timeout=50)¶
Polls forever, yielding the updates one by one.
Calls
get_updatesin a loop, with the same checks. A batch is confirmed only when the next one is asked for, that is after your loop body ran for every update in it. If you break out of the loop, or an exception leaves it, nothing of the current batch is confirmed: the next poll gets the whole batch again, the updates you already handled included. Updates come at least once, and none is lost.An error of
get_updatesends the loop, a network error included, since polls are not retried. Catch it and start a new loop to go on.- Parameters:
- Yields:
Each update, oldest first.
- Raises:
ValidationError –
offset,limitortimeoutis out of range. It is raised when the loop starts, not when the method is called.WebhookActiveError – A webhook is set.
- Return type:
Example
Answer every message of a readable chat:
async for update in api.iter_updates(): if update.message is not None: await api.send_message(update.message.chat.id, "pong")
- async get_webhook()¶
Fetches the webhook, if one is set, and how its deliveries are going.
Calls
GET /webhook. It works while the bot is paused. Retried after a network error or a5xxanswer.- Returns:
The webhook state.
urlisNonewhen no webhook is set,pendingcounts the updates waiting, andfailures,last_errorandnext_attempt_attell how the deliveries fail, when they do.- Return type:
Example
See whether the deliveries are failing:
info = await api.get_webhook() if info.failures: print(f"{info.failures} failures in a row, last: {info.last_error}")
- async set_webhook(url, secret=None)¶
Sets a webhook: from now on, every update is POSTed to
url.Calls
POST /webhook. Before sending, it checks thaturlis an absolutehttporhttpsURL of at most 1024 characters, with a host and without a user name, a password or a fragment, and thatsecretis 1 to 256 characters ofA-Z,a-z,0-9,_and-. The server itself refuses plainhttpand addresses that are not public. Retried after a network error or a5xxanswer.Setting a webhook ends any poll in progress, and
get_updatesis refused while it is set.Bot.create_webhook_appcan register the webhook for you.- Parameters:
- Returns:
The webhook as the server saved it.
- Raises:
ValidationError –
urlorsecretbreaks one of the rules above.BadWebhookError – The server refused the URL, for example because it resolves to a private address.
- Return type:
Example
A webhook with a secret:
from pgpcbot import generate_secret secret = generate_secret() await api.set_webhook("https://bot.example.org/pgpchat", secret)
Warning
Always set a secret: without one, anyone who finds the URL can send fake updates.
- async delete_webhook()¶
Removes the webhook: the bot goes back to polling.
Calls
DELETE /webhook. The updates that queued while the webhook was set stay, for the next poll. It works while the bot is paused. Retried after a network error or a5xxanswer.- Returns:
The webhook state after the change.
urlisNone, andpendingcounts the updates waiting.- Return type:
Example
Switch from a webhook to polling:
info = await api.delete_webhook() print(info.pending, "updates waiting for the next poll")
- async set_key(public_key, fingerprint)¶
Publishes the bot’s OpenPGP public key: the bot becomes protected.
Calls
PUT /key. From then on the bot’s private chats are end-to-end encrypted: people’s phones seal every message to this key, and the bot must sendarmoredinto those chats. Calling it again with another key is a rotation; every phone learns the new fingerprint at once. Retried after a network error or a5xxanswer.Before sending, it checks that
public_keyis an armored public key block of at most 65536 characters, and refuses anything that holds a private key, so a private key never leaves the process. The fingerprint travels as its own field, so it has to match the key:Bot.publish_keycomputes it from the key itself, and refuses v6 keys, which the phones cannot use.- Parameters:
public_key (str) – The armored public key block, as
BotKey.export_publicwrites it.fingerprint (str) – The key’s fingerprint, 40 (v4) or 64 (v6) hex digits with no spaces in between. It is sent in upper case.
- Returns:
The fingerprint the server saved, and the bot’s mode, now
protected.- Raises:
ValidationError –
public_keyis not an armored public key block, is too long or holds a private key, orfingerprintis not 40 or 64 hex digits.- Return type:
Example
Publish the public half of the key the bot decrypts with:
key = BotKey.from_file("bot-key.asc", passphrase) await api.set_key(key.export_public(), key.fingerprint)
Warning
There is no API to remove the key: only the bot’s owner can make the bot open again, from the console. A wrong fingerprint makes the phones seal messages to a key the bot does not have.
- async get_commands(scope: None = None) list[CommandScope]¶
- async get_commands(scope: str) list[Command]
Fetches the command menus: every scope, or one.
Calls
GET /commands. Before sending, it checks thatscopeis one the server knows. Retried after a network error or a5xxanswer.- Parameters:
scope (str | None) –
Nonefor every scope with its list. Or one scope:"default","private","groups","groupAdmins", or"chat:<id>"for one chat the bot is in.- Returns:
Every scope with its commands, as a list of
CommandScope, whenscopeisNone. Otherwise that scope’s commands, as a list ofCommand.- Raises:
ValidationError –
scopeis not one of the above.- Return type:
list[CommandScope] | list[Command]
Example
All the menus, then one:
for scope in await api.get_commands(): print(scope.scope, [c.command for c in scope.commands]) private = await api.get_commands("private")
- async set_commands(commands, scope=None)¶
Replaces the whole command list of one scope.
Calls
PUT /commands. Before sending, each command is cleaned up the way the server does it: the name is trimmed, loses a leading/and is put in lower case, and must then be 1 to 32 letters, digits and underscores; in the description every run of white space becomes one space, and it must then be 1 to 256 characters, with no control characters. A name given twice keeps its first description, as on the server. Retried after a network error or a5xxanswer.Scopes go from narrow to wide: in a private chat
private, thendefault; in a groupchat:<id>, thengroupAdminsfor admins, thengroups, thendefault. An empty list does not hide a wider scope. The owner can edit the same lists: whoever writes last wins.- Parameters:
- Returns:
The scope and its commands, as the server saved them.
- Raises:
ValidationError – More than 100 commands, a command that breaks the rules above, or an unknown
scope.ChatNotFoundError – A
"chat:<id>"scope names a chat the bot is not in.
- Return type:
Example
A menu for everyone, and one for the admins of groups:
await api.set_commands([("start", "Say hello"), ("help", "What I can do")]) await api.set_commands([("ban", "Ban the replied user")], scope="groupAdmins")
- async delete_commands(scope=None)¶
Empties the command list of one scope.
Calls
DELETE /commands. Before sending, it checks thatscopeis one the server knows. An empty list does not hide a wider scope. Retried after a network error or a5xxanswer.- Parameters:
scope (str | None) – Which list:
"default","private","groups","groupAdmins"or"chat:<id>".Nonemeans"default".- Returns:
The scope, now with no commands.
- Raises:
ValidationError –
scopeis not one of the above.- Return type:
- async send_message(
- chat_id,
- text=None,
- *,
- armored=None,
- reply_to=None,
- markup=None,
- silent=None,
- send_id=None,
- chat_kind=None,
Sends a message:
textinto a readable chat,armoredinto an encrypted one.Calls
POST /messages. Give exactly one oftextandarmored;get_chattells which one a chat wants (itsmode). The client does not encrypt: in an encrypted chat, seal the message to every key ofget_chat_keysand to the bot’s own key, and sign it.BotKey.sealadds the bot’s key and the signature for you.Before sending, it checks what the server would refuse:
the IDs are positive integers;
textis not blank, has at most 4096 UTF-16 code units, and no control characters but newline and tab (carriage returns become newlines first);armoredholds-----BEGIN PGP MESSAGE-----and has at most 131072 characters;markupfollows the rules ofInlineKeyboard;send_id, when given, is a UUID.
The client paces the sends: by default one a second into a private chat, 20 a minute into a group or channel, 30 a second in all.
Retried after a network error or a
5xxanswer. ThesendIdmakes this safe: a repeat hands back the message already written instead of writing it twice.- Parameters:
chat_id (int) – A chat the bot is in.
text (str | None) – The message, for a readable chat.
armored (str | None) – An ASCII-armored OpenPGP message, for an encrypted chat.
reply_to (int | None) – The ID of a message of the same chat to quote. The server drops a wrong ID without an error.
markup (InlineKeyboard | Mapping[str, Any] | None) – Buttons under the message: an
InlineKeyboard, or its JSON form{"inlineKeyboard": [...]}. Buttons are never encrypted.silent (bool | None) – Channels only:
Trueposts without a notification.Noneleaves it to the server, which notifies.send_id (str | UUID | None) – A UUID that names this send, as a string or a
uuid.UUID.Nonemakes a new one. Pass your own to repeat the send safely later, after a crash for example.chat_kind (ChatKind | None) – The
ChatKindof the chat, for the rate limiter only: groups and channels are paced per minute.Nonepaces it like a private chat.
- Returns:
The message as the server stored it, with its
idanddate.- Raises:
ValidationError – An argument breaks one of the rules above, or both or neither of
textandarmoredwere given.WrongBodyKindError –
textinto an encrypted chat, orarmoredinto a readable one.expectedsays which one the chat wants.BadMarkupError – The server refused the keyboard.
ForbiddenError – The bot may not write in this chat.
BlockedByUserError – The person blocked the bot.
UserGoneError – The person closed their account.
ChatNotFoundError – No such chat, or the bot is not in it.
SendIdConflictError – This
send_idwas already used in another chat.FloodWaitError – Too many messages into this chat or in all, and the server asked for a longer wait, or more waits, than the
RetryPolicyallows.SlowModeError – The group’s slow mode, with the same limits on waiting.
- Return type:
Examples
Into a readable chat:
sent = await api.send_message(7781, "Backup finished.") print(sent.id, sent.date)
Into an encrypted group, sealed by hand:
keys = await api.get_chat_keys(chat_id) recipients = [PublicKey.from_user_key(k) for k in keys.keys] armored = bot_key.seal("Backup finished.", recipients) await api.send_message(chat_id, armored=armored, chat_kind=ChatKind.GROUP)
- async edit_message(
- chat_id,
- message_id,
- text=None,
- *,
- armored=None,
- markup=KEEP,
- chat_kind=None,
Changes the text, the buttons, or both, of one of the bot’s own messages.
Calls
PATCH /messages/{messageId}. Only the bot’s own ordinary messages can be edited, at any age, and people see the edit live. The new body follows the rules ofsend_message:textin a readable chat,armoredin an encrypted one. Before sending, it checks the IDs, the body and the keyboard, and that there is something to change. Edits are paced with the same numbers as sends, counted on their own; the 30 a second in all is shared with sends. Retried after a network error or a5xxanswer: the same edit twice gives the same result.- Parameters:
chat_id (int) – The chat the message is in.
message_id (int) – The message to change.
text (str | None) – The new text, for a readable chat.
armored (str | None) – The new armored OpenPGP message, for an encrypted chat.
markup (InlineKeyboard | Mapping[str, Any] | None) –
KEEP(the default) keeps the buttons,Noneor an empty keyboard removes them, and a keyboard replaces them.chat_kind (ChatKind | None) – The
ChatKindof the chat, for the rate limiter only, as insend_message.
- Returns:
The message after the edit.
- Raises:
ValidationError – An argument breaks one of the rules, both
textandarmoredwere given, or there is nothing to change: notext, noarmored, andmarkupleft atKEEP.WrongBodyKindError –
textinto an encrypted chat, orarmoredinto a readable one.expectedsays which one the chat wants.BadMarkupError – The server refused the keyboard.
ForbiddenError – Not the bot’s message, or not an ordinary one (a service notice).
BlockedByUserError – In a private chat, the person blocked the bot.
UserGoneError – In a private chat, the person closed their account.
ChatNotFoundError – No such chat, or the bot is not in it.
MessageNotFoundError – No such message.
- Return type:
Example
New text, then the buttons removed and the text kept:
await api.edit_message(chat_id, sent.id, "Backup finished: 14 GB.") await api.edit_message(chat_id, sent.id, markup=None)
- async delete_message(chat_id, message_id)¶
Deletes a message for everyone, for good.
Calls
DELETE /messages/{messageId}, withchatIdin the query. 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 cannot be deleted. Before sending, it checks that both IDs are positive integers.Retried after a network error or a
5xxanswer. If an earlier answer was lost and the retry getsmessage_not_found, the first try did delete the message, and the method returnsTrue. When no answer was lost, a missing message raisesMessageNotFoundError.- Parameters:
- Returns:
Trueonce the message is deleted.Falsein a rare race on the server, when the message disappeared between its lookup and the delete.- Raises:
ForbiddenError – The bot may not delete this message.
ChatNotFoundError – No such chat, or the bot is not in it.
MessageNotFoundError – No such message, or it is already gone.
- Return type:
- async answer_callback(callback_id, text=None, *, alert=False, url=None)¶
Answers a button press: stops the spinner on the person’s phone.
Calls
POST /callbacks/{callbackId}/answer. Answer within 60 seconds of the press; each press can be answered once. The message itself changes only if you edit it (edit_message). Before sending, it checkscallback_id,textandurl.Retried after a network error or a
5xxanswer. If an earlier answer was lost and the retry getscallback_answered, the first try worked, and the method returnsTrue.- Parameters:
callback_id (str) – The ID of the press, the
idof aCallbackQuery.text (str | None) – A short line shown over the chat, or in a box with
alert. Up to 200 characters on one line, with no control characters. Spaces at the ends are trimmed, and an empty text is left out.alert (bool) – Shows
textin a box the person has to close, instead of a short line.url (str | None) – An address the phone shows and offers to open: an absolute
httporhttpsURL of up to 1024 characters, without a user name or password. An empty string is left out.
- Returns:
Trueonce the answer reached the phone that pressed.- Raises:
ValidationError –
callback_idis not the ID of a press, ortextorurlbreaks one of the rules above.CallbackNotFoundError – No such press for this bot.
CallbackAnsweredError – The press was answered already.
CallbackExpiredError – More than 60 seconds went by since the press.
- Return type:
Example
Just stop the spinner, say a word, or show a box:
await api.answer_callback(query.id) await api.answer_callback(query.id, "Saved") await api.answer_callback(query.id, "Sold out", alert=True)
Warning
Answers are not encrypted, even in an encrypted chat.
- async get_chat(chat_id)¶
Fetches a chat the bot is in: its kind, its mode and, in a group, the bot’s seat.
Calls
GET /chats/{chatId}.modetells which body to send:textin anopenchat,armoredin aprotectedone. A private chat also carries the other person and whether they blocked the bot. Before sending, it checks thatchat_idis a positive integer. Retried after a network error or a5xxanswer.- Parameters:
chat_id (int) – The chat.
- Returns:
The chat as the server describes it.
- Raises:
ChatNotFoundError – No such chat, or the bot is not in it.
- Return type:
Example
Find out which body a chat wants:
chat = await api.get_chat(chat_id) body = "armored" if chat.protected else "text" print(chat.kind, chat.title, body)
- async get_chat_keys(chat_id)¶
Fetches the public keys a message into an encrypted chat must be sealed to.
Calls
GET /chats/{chatId}/keys. The answer holds the keys of the people in the chat, IDs and keys only, no names. The bot’s own key is not in it: add it yourself (BotKey.sealdoes). Other bots that only receive what is addressed to them are left out, as they are for the app. Before sending, it checks thatchat_idis a positive integer. Retried after a network error or a5xxanswer.- Parameters:
chat_id (int) – An encrypted chat the bot is in.
- Returns:
The keys, and in a group
member_count, which is larger than the number of keys when some members have none.- Raises:
ChatIsOpenError – The chat is readable: send
text, no keys are needed.ChatNotFoundError – No such chat, or the bot is not in it.
NoUserKeyError – A private chat whose person has no key.
- Return type:
Example
Read the keys, checking each one against the fingerprint the server lists:
keys = await api.get_chat_keys(chat_id) recipients = [PublicKey.from_user_key(k) for k in keys.keys]
Note
The keys come from the server, and a server that lies could hand out a key of its own. Key pinning (
pin_keys=TrueonBot) notices a key that changes later.
- async get_chat_members(chat_id)¶
Fetches the members of a group or channel, as a member in the bot’s seat sees them.
Calls
GET /chats/{chatId}/members. Where the member list is hidden, a bot that is not an admin sees the admins only, and a channel that keeps its admins to itself leaves them out. Names and handles only, never phone numbers. Before sending, it checks thatchat_idis a positive integer. Retried after a network error or a5xxanswer.- Parameters:
chat_id (int) – A group or channel the bot is in.
- Returns:
The members with their seats, and
member_count, which counts the list as returned.- Raises:
BadRequestError – The chat is private, so it has no member list.
ChatNotFoundError – No such chat, or the bot is not in it.
- Return type:
Example
The handles of the admins:
roster = await api.get_chat_members(group_id) admins = [m.user.username for m in roster.members if m.is_admin]
- async get_chat_member(chat_id, user_id)¶
Fetches one member’s seat in a group or channel.
Calls
GET /chats/{chatId}/members/{userId}. Before sending, it checks that both IDs are positive integers. Retried after a network error or a5xxanswer.- Parameters:
- Returns:
The person and their seat (status, admin rights, title).
- Raises:
BadRequestError – The chat is private, so it has no members.
ChatNotFoundError – No such chat, or the bot is not in it.
MemberNotFoundError – The person is not a member, or a hidden member list keeps them from the bot.
- Return type:
Example
Check a right before relying on it:
member = await api.get_chat_member(group_id, user_id) if member.can is not None and member.can.ban_users: ...
- async leave_chat(chat_id)¶
Leaves a group or channel.
Calls
POST /chats/{chatId}/leave. The chat is told, the way it would be about a person. A bot cannot leave a private chat; the person can block it. Before sending, it checks thatchat_idis a positive integer.Retried after a network error or a
5xxanswer. If an earlier answer was lost and the retry finds no chat, the first try worked, and the method returnsTrue.- Parameters:
chat_id (int) – The group or channel.
- Returns:
Trueonce the bot has left.- Raises:
BadRequestError – The chat is private and cannot be left.
ChatNotFoundError – No such chat, or the bot is not in it.
- Return type:
- async send_typing(chat_id, typing=True)¶
Shows “typing…” in a chat for a few seconds, or clears it.
Calls
POST /chats/{chatId}/typing. The indicator clears on its own after a few seconds, or when a message lands. One goes out per chat every 3 seconds at most; calls in between show nothing and returnFalse. Before sending, it checks thatchat_idis a positive integer. Not retried after a network error or a5xxanswer: a late indicator is useless.- Parameters:
- Returns:
Trueif the indicator went out,Falseif one went out to this chat less than 3 seconds ago.- Raises:
ForbiddenError – The bot may not write in this chat: it is restricted, or only a subscriber of a channel.
ChatNotFoundError – No such chat, or the bot is not in it.
- Return type:
Example
Show it while the bot works:
await api.send_typing(chat_id) report = await build_report() await api.send_message(chat_id, report)
- async resolve(username)¶
Turns a public handle into an ID.
Calls
GET /resolve/{username}. For the handle somebody types at the bot - “paga @anna”, “segui @notizie” - when no message has handed that ID over. People, groups and channels share one pool of names, so a handle belongs to exactly one of the three andResolved.kindsays which;Resolved.idis filled in either way. A leading@is stripped and the case does not matter. Before sending, it checks the handle against the server’s own rule. Retried after a network error or a5xxanswer.It finds anybody with a public handle, which is no more than the short link already shows a stranger with a browser. The ID on its own unlocks nothing: sending still needs the bot to be in the chat, and
AsyncClient.get_user_keystill needs a chat in common.- Parameters:
username (str) – The handle, with or without the
@.- Returns:
What the handle turned out to be.
- 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 a closed account.
FloodWaitError – More than 60 lookups this minute, where a handle nobody holds counts as five.
retry_aftersays how long to wait.
- Return type:
Example
Pay whoever was named:
found = await api.resolve(words.removeprefix("paga @")) if found.is_user: await api.send_message(chat_id, f"{found.mention} ha id {found.id}")
- async get_user_key(user_id)¶
Fetches one person’s public key.
Calls
GET /users/{userId}/key. It works only for someone the bot shares a chat with: anyone else isUserNotFoundError, whether or not they exist. Before sending, it checks thatuser_idis a positive integer. Retried after a network error or a5xxanswer.- Parameters:
user_id (int) – The person.
- Returns:
The person’s ID, the key’s fingerprint as the server lists it, and the armored public key.
- Raises:
UserNotFoundError – No such person, or no chat in common with the bot.
NoUserKeyError – The person never published a key.
- Return type:
Example
Read the key, and show its fingerprint the way people compare it:
user_key = await api.get_user_key(55) anna = PublicKey.from_user_key(user_key) print(format_fingerprint(anna.fingerprint))
- async revoke_token()¶
Burns this token, for good.
Calls
POST /token/revoke. Nothing works again, this client included, until the owner makes a new token in the console; the owner is told at once. It works while the bot is paused.Retried after a network error or a
5xxanswer. If an earlier answer was lost and the retry getsunauthorized, the first try worked, and the method returnsTrue.- Returns:
Trueonce the token is revoked.- Return type:
Example
For a program that finds out its token leaked:
if token_was_leaked: await api.revoke_token()
Warning
This cannot be undone. Every later call with this token raises
AuthenticationError.
- async download_media(media, *, max_bytes=MEDIA_MAX_BYTES)¶
Downloads the file behind a
Media, exactly as the server stores it.Fetches
GET /media/{id}on the API server, the address aMediacarries inurl. This is not an operation of the Bot API document, and it skips the rate limiter and the retries: an error is raised at once. The token is not sent, since the server serves media without it. Only/media/<id>addresses on this client’s API server are fetched, so a crafted URL in an update cannot send the client anywhere else. The download has timeouts of its own: 10 seconds to connect, and 120 seconds to wait for each piece of data.- Parameters:
- Returns:
The file’s bytes. For an attachment of an encrypted chat they are OpenPGP ciphertext, which
Bot.download_mediadecrypts.- Raises:
ValidationError –
mediais not a media file of this server, or the file is larger thanmax_bytes.NotFoundError – No such file.
codeis"media_not_found".ServerError – The server answered with a
5xxstatus.APIError – The server answered with a status other than 200.
codeis"http_<status>".NetworkError – No answer came.
RequestTimeoutErrorwhen it took too long.ConfigurationError – The
http_clientyou passed in was closed.
- Return type:
Example
The first attachment of a message:
data = await api.download_media(message.media[0])
Client¶
- class Client(
- token=None,
- *,
- api_url=None,
- timeout=None,
- retry=None,
- rate_limiter=True,
- allow_insecure_http=False,
- user_agent=None,
- http_client=None,
- transport=None,
- max_connections=20,
The Bot API, one blocking method per operation.
It has the same methods as
AsyncClient, with the same arguments: call them withoutawait. Each method checks its arguments against the server’s rules before anything is sent, waits when the client-sideRateLimitersays so, and returns the answer as a typed object. A call that is safe to repeat is tried again after a network error or a5xxanswer, and429answers are waited out, as theRetryPolicyallows. Every API call can raise the errors listed in Errors every method can raise; each method lists the errors specific to it.Each call blocks the calling thread until it is done, including the waits for the rate limiter and between retries. In
asynccode, useAsyncClientinstead.The client never encrypts or decrypts:
textandarmoredgo out as you give them, and the attachments of an encrypted chat come back as OpenPGP ciphertext.SyncBotdoes the OpenPGP part on top of this client, and gives you its own client asbot.api.One client can be shared by many threads: its connection pool and its rate limiter are safe to use from several threads at once. Use it as a context manager (
with), or callclosewhen you are done. Unlike anAsyncClient, a closed client does not open a new pool: make a new client instead.The token travels only in the
Authorizationheader. It is never inrepr(), in error messages or in logs (token_hintshows the part that is safe to show), and a client cannot be pickled.- timeout¶
The timeouts of every request, made from
timeout.- Type:
httpx2.Timeout
- retry¶
The retry rules in use.
- Type:
- rate_limiter¶
The limiter that paces the calls, or
Nonewhen pacing is off.- Type:
RateLimiter | None
Example
Who the bot is, then a message into a readable chat:
from pgpcbot import Client with Client() as api: # token from PGPCHAT_BOT_TOKEN me = api.get_me() api.send_message(7781, f"Hello from @{me.username}")
Sets up the client. Nothing is sent to the server until the first call.
The token and the server address are checked here, so a wrong setup fails at once. The connection pool is made here too, unless you pass an
http_client.- 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; when both are set, the file wins.api_url (str | None) – The API server, like
https://api.pgpchat.app. A trailing/bot/v1is removed.NonereadsPGPCHAT_API, and without it useshttps://api.pgpchat.app.timeout (float | httpx2.Timeout | None) – How long one request may take, in seconds, or an
httpx2.Timeout.Nonemeans 30 seconds, 10 of them at most to connect; a numbernmeansnseconds, at most 10 of them to connect. A long poll may read for 15 seconds more than it asks the server to wait, anddownload_mediahas limits of its own.retry (RetryPolicy | None) – When and how often to try again.
NonemeansRetryPolicy(): 4 tries in all for a call that is safe to repeat, and up to 5429answers waited out, each for at most 90 seconds.rate_limiter (RateLimiter | bool) – The client-side pacing.
Truemakes a newRateLimiterwith the default numbers,Falseturns pacing off, and aRateLimiterof your own is used as it is (one limiter can pace several clients). The client does not read the server’s own numbers: for a server that uses others, callapi.rate_limiter.update_limits(me.limits)with theBotInfoofget_me.allow_insecure_http (bool) – Allows an
http://api_urlon a host other thanlocalhost,127.0.0.1or::1. Only for a test server on your own network: over plain HTTP the token travels in clear.user_agent (str | None) – The
User-Agentheader.NonemeansPGPCbot/<version> (Python <version>).http_client (httpx2.Client | None) – An
httpx2.Clientof your own, to share its connection pool. The client never closes it, and raisesConfigurationErroron a call made after you closed it.transportandmax_connectionsare then ignored.transport (httpx2.BaseTransport | None) – An
httpx2transport for the client’s own pool: a proxy setup, or a mock transport in tests.max_connections (int) – How many connections the client’s own pool may open.
- Raises:
ConfigurationError – No token was given or found, the token is not a bot token, the token file cannot be read, or
api_urlis not acceptable: nothttporhttps, plainhttpto another host withoutallow_insecure_http, or with a user name, a password, a query or a fragment. The message never contains the token.
- close()¶
Closes the client’s connection pool, for good.
An
http_clientyou passed in is left open: closing it is up to you, and the client goes on working with it until you do. Otherwise the client cannot be used afterwards: every later call raisesConfigurationError, so make a new client to go on. Calling this twice does no harm.
- get_me()¶
Fetches who the bot is, its mode, its status and the limits this server enforces.
Calls
GET /me. It works while the bot is paused, so a program can find out why nothing else does. Retried after a network error or a5xxanswer.- Returns:
The bot as the server holds it.
limitstypes the limits this version knows, andLimits.getreads any other by its JSON name;queuedcounts the updates waiting.- Return type:
Example
Read the server’s limits instead of hard-coding them:
me = api.get_me() print(f"@{me.username}", me.mode, me.status, me.queued) print(me.limits.sends_per_group_per_minute) print(me.limits.get("botsPerChat")) # a limit this version does not name
- get_updates(offset=None, limit=None, timeout=None)¶
Fetches the updates waiting for the bot, and confirms the ones already handled.
Calls
GET /updates(long polling). Before sending, it checks thatoffsetis an integer of at least 0,limitis between 1 and 100 andtimeoutbetween 0 and 50. With atimeout, the call blocks until an update arrives or the time is up, and the request may read fortimeout + 15seconds, so the client does not hang up before the server answers. Not retried after a network error or a5xxanswer: the polling loop decides what to do.Only one poll per bot runs at a time: a second call ends the first one with an empty list. Polling is refused while a webhook is set.
SyncBot.rundoes all of this for you.- Parameters:
offset (int | None) – Confirms every update with a lower
update_id: the server deletes them for good. Pass the highestupdate_idyou handled, plus one.Noneconfirms nothing and gets everything still in the queue.limit (int | None) – At most this many updates, 1 to 100.
Noneleaves it to the server, which sends up to 100.timeout (int | None) – Seconds the server may wait for an update to arrive, 0 to 50.
Noneor0answers at once.
- Returns:
The updates, oldest first. Empty when nothing arrived before the timeout.
- Raises:
ValidationError –
offset,limitortimeoutis out of range.WebhookActiveError – A webhook is set. Delete it before polling.
- Return type:
Example
Handle each update first, then confirm it with the next call:
offset = None while True: for update in api.get_updates(offset, timeout=50): handle(update) offset = update.update_id + 1
Warning
Confirming is final: the server deletes the confirmed updates at the start of the request. Never pass an
offsetbeyond what you have handled, or saved somewhere safe.
- iter_updates(*, offset=None, limit=100, timeout=50)¶
Polls forever, yielding the updates one by one.
Calls
get_updatesin a loop, with the same checks. A batch is confirmed only when the next one is asked for, that is after your loop body ran for every update in it. If you break out of the loop, or an exception leaves it, nothing of the current batch is confirmed: the next poll gets the whole batch again, the updates you already handled included. Updates come at least once, and none is lost.An error of
get_updatesends the loop, a network error included, since polls are not retried. Catch it and start a new loop to go on.- Parameters:
- Yields:
Each update, oldest first.
- Raises:
ValidationError –
offset,limitortimeoutis out of range. It is raised when the loop starts, not when the method is called.WebhookActiveError – A webhook is set.
- Return type:
Example
Answer every message of a readable chat:
for update in api.iter_updates(): if update.message is not None: api.send_message(update.message.chat.id, "pong")
- get_webhook()¶
Fetches the webhook, if one is set, and how its deliveries are going.
Calls
GET /webhook. It works while the bot is paused. Retried after a network error or a5xxanswer.- Returns:
The webhook state.
urlisNonewhen no webhook is set,pendingcounts the updates waiting, andfailures,last_errorandnext_attempt_attell how the deliveries fail, when they do.- Return type:
Example
See whether the deliveries are failing:
info = api.get_webhook() if info.failures: print(f"{info.failures} failures in a row, last: {info.last_error}")
- set_webhook(url, secret=None)¶
Sets a webhook: from now on, every update is POSTed to
url.Calls
POST /webhook. Before sending, it checks thaturlis an absolutehttporhttpsURL of at most 1024 characters, with a host and without a user name, a password or a fragment, and thatsecretis 1 to 256 characters ofA-Z,a-z,0-9,_and-. The server itself refuses plainhttpand addresses that are not public. Retried after a network error or a5xxanswer.Setting a webhook ends any poll in progress, and
get_updatesis refused while it is set.SyncBot.create_webhook_appcan register the webhook for you.- Parameters:
- Returns:
The webhook as the server saved it.
- Raises:
ValidationError –
urlorsecretbreaks one of the rules above.BadWebhookError – The server refused the URL, for example because it resolves to a private address.
- Return type:
Example
A webhook with a secret:
from pgpcbot import generate_secret secret = generate_secret() api.set_webhook("https://bot.example.org/pgpchat", secret)
Warning
Always set a secret: without one, anyone who finds the URL can send fake updates.
- delete_webhook()¶
Removes the webhook: the bot goes back to polling.
Calls
DELETE /webhook. The updates that queued while the webhook was set stay, for the next poll. It works while the bot is paused. Retried after a network error or a5xxanswer.- Returns:
The webhook state after the change.
urlisNone, andpendingcounts the updates waiting.- Return type:
Example
Switch from a webhook to polling:
info = api.delete_webhook() print(info.pending, "updates waiting for the next poll")
- set_key(public_key, fingerprint)¶
Publishes the bot’s OpenPGP public key: the bot becomes protected.
Calls
PUT /key. From then on the bot’s private chats are end-to-end encrypted: people’s phones seal every message to this key, and the bot must sendarmoredinto those chats. Calling it again with another key is a rotation; every phone learns the new fingerprint at once. Retried after a network error or a5xxanswer.Before sending, it checks that
public_keyis an armored public key block of at most 65536 characters, and refuses anything that holds a private key, so a private key never leaves the process. The fingerprint travels as its own field, so it has to match the key:Bot.publish_key, whichSyncBothas too, computes it from the key itself, and refuses v6 keys, which the phones cannot use.- Parameters:
public_key (str) – The armored public key block, as
BotKey.export_publicwrites it.fingerprint (str) – The key’s fingerprint, 40 (v4) or 64 (v6) hex digits with no spaces in between. It is sent in upper case.
- Returns:
The fingerprint the server saved, and the bot’s mode, now
protected.- Raises:
ValidationError –
public_keyis not an armored public key block, is too long or holds a private key, orfingerprintis not 40 or 64 hex digits.- Return type:
Example
Publish the public half of the key the bot decrypts with:
key = BotKey.from_file("bot-key.asc", passphrase) api.set_key(key.export_public(), key.fingerprint)
Warning
There is no API to remove the key: only the bot’s owner can make the bot open again, from the console. A wrong fingerprint makes the phones seal messages to a key the bot does not have.
- get_commands(scope: None = None) list[CommandScope]¶
- get_commands(scope: str) list[Command]
Fetches the command menus: every scope, or one.
Calls
GET /commands. Before sending, it checks thatscopeis one the server knows. Retried after a network error or a5xxanswer.- Parameters:
scope (str | None) –
Nonefor every scope with its list. Or one scope:"default","private","groups","groupAdmins", or"chat:<id>"for one chat the bot is in.- Returns:
Every scope with its commands, as a list of
CommandScope, whenscopeisNone. Otherwise that scope’s commands, as a list ofCommand.- Raises:
ValidationError –
scopeis not one of the above.- Return type:
list[CommandScope] | list[Command]
Example
All the menus, then one:
for scope in api.get_commands(): print(scope.scope, [c.command for c in scope.commands]) private = api.get_commands("private")
- set_commands(commands, scope=None)¶
Replaces the whole command list of one scope.
Calls
PUT /commands. Before sending, each command is cleaned up the way the server does it: the name is trimmed, loses a leading/and is put in lower case, and must then be 1 to 32 letters, digits and underscores; in the description every run of white space becomes one space, and it must then be 1 to 256 characters, with no control characters. A name given twice keeps its first description, as on the server. Retried after a network error or a5xxanswer.Scopes go from narrow to wide: in a private chat
private, thendefault; in a groupchat:<id>, thengroupAdminsfor admins, thengroups, thendefault. An empty list does not hide a wider scope. The owner can edit the same lists: whoever writes last wins.- Parameters:
- Returns:
The scope and its commands, as the server saved them.
- Raises:
ValidationError – More than 100 commands, a command that breaks the rules above, or an unknown
scope.ChatNotFoundError – A
"chat:<id>"scope names a chat the bot is not in.
- Return type:
Example
A menu for everyone, and one for the admins of groups:
api.set_commands([("start", "Say hello"), ("help", "What I can do")]) api.set_commands([("ban", "Ban the replied user")], scope="groupAdmins")
- delete_commands(scope=None)¶
Empties the command list of one scope.
Calls
DELETE /commands. Before sending, it checks thatscopeis one the server knows. An empty list does not hide a wider scope. Retried after a network error or a5xxanswer.- Parameters:
scope (str | None) – Which list:
"default","private","groups","groupAdmins"or"chat:<id>".Nonemeans"default".- Returns:
The scope, now with no commands.
- Raises:
ValidationError –
scopeis not one of the above.- Return type:
- send_message(
- chat_id,
- text=None,
- *,
- armored=None,
- reply_to=None,
- markup=None,
- silent=None,
- send_id=None,
- chat_kind=None,
Sends a message:
textinto a readable chat,armoredinto an encrypted one.Calls
POST /messages. Give exactly one oftextandarmored;get_chattells which one a chat wants (itsmode). The client does not encrypt: in an encrypted chat, seal the message to every key ofget_chat_keysand to the bot’s own key, and sign it.BotKey.sealadds the bot’s key and the signature for you.Before sending, it checks what the server would refuse:
the IDs are positive integers;
textis not blank, has at most 4096 UTF-16 code units, and no control characters but newline and tab (carriage returns become newlines first);armoredholds-----BEGIN PGP MESSAGE-----and has at most 131072 characters;markupfollows the rules ofInlineKeyboard;send_id, when given, is a UUID.
The client paces the sends: by default one a second into a private chat, 20 a minute into a group or channel, 30 a second in all. A send that must wait its turn blocks the thread until then.
Retried after a network error or a
5xxanswer. ThesendIdmakes this safe: a repeat hands back the message already written instead of writing it twice.- Parameters:
chat_id (int) – A chat the bot is in.
text (str | None) – The message, for a readable chat.
armored (str | None) – An ASCII-armored OpenPGP message, for an encrypted chat.
reply_to (int | None) – The ID of a message of the same chat to quote. The server drops a wrong ID without an error.
markup (InlineKeyboard | Mapping[str, Any] | None) – Buttons under the message: an
InlineKeyboard, or its JSON form{"inlineKeyboard": [...]}. Buttons are never encrypted.silent (bool | None) – Channels only:
Trueposts without a notification.Noneleaves it to the server, which notifies.send_id (str | UUID | None) – A UUID that names this send, as a string or a
uuid.UUID.Nonemakes a new one. Pass your own to repeat the send safely later, after a crash for example.chat_kind (ChatKind | None) – The
ChatKindof the chat, for the rate limiter only: groups and channels are paced per minute.Nonepaces it like a private chat.
- Returns:
The message as the server stored it, with its
idanddate.- Raises:
ValidationError – An argument breaks one of the rules above, or both or neither of
textandarmoredwere given.WrongBodyKindError –
textinto an encrypted chat, orarmoredinto a readable one.expectedsays which one the chat wants.BadMarkupError – The server refused the keyboard.
ForbiddenError – The bot may not write in this chat.
BlockedByUserError – The person blocked the bot.
UserGoneError – The person closed their account.
ChatNotFoundError – No such chat, or the bot is not in it.
SendIdConflictError – This
send_idwas already used in another chat.FloodWaitError – Too many messages into this chat or in all, and the server asked for a longer wait, or more waits, than the
RetryPolicyallows.SlowModeError – The group’s slow mode, with the same limits on waiting.
- Return type:
Examples
Into a readable chat:
sent = api.send_message(7781, "Backup finished.") print(sent.id, sent.date)
Into an encrypted group, sealed by hand:
keys = api.get_chat_keys(chat_id) recipients = [PublicKey.from_user_key(k) for k in keys.keys] armored = bot_key.seal("Backup finished.", recipients) api.send_message(chat_id, armored=armored, chat_kind=ChatKind.GROUP)
- edit_message(
- chat_id,
- message_id,
- text=None,
- *,
- armored=None,
- markup=KEEP,
- chat_kind=None,
Changes the text, the buttons, or both, of one of the bot’s own messages.
Calls
PATCH /messages/{messageId}. Only the bot’s own ordinary messages can be edited, at any age, and people see the edit live. The new body follows the rules ofsend_message:textin a readable chat,armoredin an encrypted one. Before sending, it checks the IDs, the body and the keyboard, and that there is something to change. Edits are paced with the same numbers as sends, counted on their own; the 30 a second in all is shared with sends. Retried after a network error or a5xxanswer: the same edit twice gives the same result.- Parameters:
chat_id (int) – The chat the message is in.
message_id (int) – The message to change.
text (str | None) – The new text, for a readable chat.
armored (str | None) – The new armored OpenPGP message, for an encrypted chat.
markup (InlineKeyboard | Mapping[str, Any] | None) –
KEEP(the default) keeps the buttons,Noneor an empty keyboard removes them, and a keyboard replaces them.chat_kind (ChatKind | None) – The
ChatKindof the chat, for the rate limiter only, as insend_message.
- Returns:
The message after the edit.
- Raises:
ValidationError – An argument breaks one of the rules, both
textandarmoredwere given, or there is nothing to change: notext, noarmored, andmarkupleft atKEEP.WrongBodyKindError –
textinto an encrypted chat, orarmoredinto a readable one.expectedsays which one the chat wants.BadMarkupError – The server refused the keyboard.
ForbiddenError – Not the bot’s message, or not an ordinary one (a service notice).
BlockedByUserError – In a private chat, the person blocked the bot.
UserGoneError – In a private chat, the person closed their account.
ChatNotFoundError – No such chat, or the bot is not in it.
MessageNotFoundError – No such message.
- Return type:
Example
New text, then the buttons removed and the text kept:
api.edit_message(chat_id, sent.id, "Backup finished: 14 GB.") api.edit_message(chat_id, sent.id, markup=None)
- delete_message(chat_id, message_id)¶
Deletes a message for everyone, for good.
Calls
DELETE /messages/{messageId}, withchatIdin the query. 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 cannot be deleted. Before sending, it checks that both IDs are positive integers.Retried after a network error or a
5xxanswer. If an earlier answer was lost and the retry getsmessage_not_found, the first try did delete the message, and the method returnsTrue. When no answer was lost, a missing message raisesMessageNotFoundError.- Parameters:
- Returns:
Trueonce the message is deleted.Falsein a rare race on the server, when the message disappeared between its lookup and the delete.- Raises:
ForbiddenError – The bot may not delete this message.
ChatNotFoundError – No such chat, or the bot is not in it.
MessageNotFoundError – No such message, or it is already gone.
- Return type:
- answer_callback(callback_id, text=None, *, alert=False, url=None)¶
Answers a button press: stops the spinner on the person’s phone.
Calls
POST /callbacks/{callbackId}/answer. Answer within 60 seconds of the press; each press can be answered once. The message itself changes only if you edit it (edit_message). Before sending, it checkscallback_id,textandurl.Retried after a network error or a
5xxanswer. If an earlier answer was lost and the retry getscallback_answered, the first try worked, and the method returnsTrue.- Parameters:
callback_id (str) – The ID of the press, the
idof aCallbackQuery.text (str | None) – A short line shown over the chat, or in a box with
alert. Up to 200 characters on one line, with no control characters. Spaces at the ends are trimmed, and an empty text is left out.alert (bool) – Shows
textin a box the person has to close, instead of a short line.url (str | None) – An address the phone shows and offers to open: an absolute
httporhttpsURL of up to 1024 characters, without a user name or password. An empty string is left out.
- Returns:
Trueonce the answer reached the phone that pressed.- Raises:
ValidationError –
callback_idis not the ID of a press, ortextorurlbreaks one of the rules above.CallbackNotFoundError – No such press for this bot.
CallbackAnsweredError – The press was answered already.
CallbackExpiredError – More than 60 seconds went by since the press.
- Return type:
Example
Just stop the spinner, say a word, or show a box:
api.answer_callback(query.id) api.answer_callback(query.id, "Saved") api.answer_callback(query.id, "Sold out", alert=True)
Warning
Answers are not encrypted, even in an encrypted chat.
- get_chat(chat_id)¶
Fetches a chat the bot is in: its kind, its mode and, in a group, the bot’s seat.
Calls
GET /chats/{chatId}.modetells which body to send:textin anopenchat,armoredin aprotectedone. A private chat also carries the other person and whether they blocked the bot. Before sending, it checks thatchat_idis a positive integer. Retried after a network error or a5xxanswer.- Parameters:
chat_id (int) – The chat.
- Returns:
The chat as the server describes it.
- Raises:
ChatNotFoundError – No such chat, or the bot is not in it.
- Return type:
Example
Find out which body a chat wants:
chat = api.get_chat(chat_id) body = "armored" if chat.protected else "text" print(chat.kind, chat.title, body)
- get_chat_keys(chat_id)¶
Fetches the public keys a message into an encrypted chat must be sealed to.
Calls
GET /chats/{chatId}/keys. The answer holds the keys of the people in the chat, IDs and keys only, no names. The bot’s own key is not in it: add it yourself (BotKey.sealdoes). Other bots that only receive what is addressed to them are left out, as they are for the app. Before sending, it checks thatchat_idis a positive integer. Retried after a network error or a5xxanswer.- Parameters:
chat_id (int) – An encrypted chat the bot is in.
- Returns:
The keys, and in a group
member_count, which is larger than the number of keys when some members have none.- Raises:
ChatIsOpenError – The chat is readable: send
text, no keys are needed.ChatNotFoundError – No such chat, or the bot is not in it.
NoUserKeyError – A private chat whose person has no key.
- Return type:
Example
Read the keys, checking each one against the fingerprint the server lists:
keys = api.get_chat_keys(chat_id) recipients = [PublicKey.from_user_key(k) for k in keys.keys]
Note
The keys come from the server, and a server that lies could hand out a key of its own. Key pinning (
pin_keys=TrueonSyncBot) notices a key that changes later.
- get_chat_members(chat_id)¶
Fetches the members of a group or channel, as a member in the bot’s seat sees them.
Calls
GET /chats/{chatId}/members. Where the member list is hidden, a bot that is not an admin sees the admins only, and a channel that keeps its admins to itself leaves them out. Names and handles only, never phone numbers. Before sending, it checks thatchat_idis a positive integer. Retried after a network error or a5xxanswer.- Parameters:
chat_id (int) – A group or channel the bot is in.
- Returns:
The members with their seats, and
member_count, which counts the list as returned.- Raises:
BadRequestError – The chat is private, so it has no member list.
ChatNotFoundError – No such chat, or the bot is not in it.
- Return type:
Example
The handles of the admins:
roster = api.get_chat_members(group_id) admins = [m.user.username for m in roster.members if m.is_admin]
- property token_hint: str¶
The token with everything hidden but the bot ID and the last four characters.
It looks like
pgpbot_412.****3f2a, the way the console shows a token, and is safe to print or log. The full token is never inrepr(), in error messages or in logs.
- get_chat_member(chat_id, user_id)¶
Fetches one member’s seat in a group or channel.
Calls
GET /chats/{chatId}/members/{userId}. Before sending, it checks that both IDs are positive integers. Retried after a network error or a5xxanswer.- Parameters:
- Returns:
The person and their seat (status, admin rights, title).
- Raises:
BadRequestError – The chat is private, so it has no members.
ChatNotFoundError – No such chat, or the bot is not in it.
MemberNotFoundError – The person is not a member, or a hidden member list keeps them from the bot.
- Return type:
Example
Check a right before relying on it:
member = api.get_chat_member(group_id, user_id) if member.can is not None and member.can.ban_users: ...
- leave_chat(chat_id)¶
Leaves a group or channel.
Calls
POST /chats/{chatId}/leave. The chat is told, the way it would be about a person. A bot cannot leave a private chat; the person can block it. Before sending, it checks thatchat_idis a positive integer.Retried after a network error or a
5xxanswer. If an earlier answer was lost and the retry finds no chat, the first try worked, and the method returnsTrue.- Parameters:
chat_id (int) – The group or channel.
- Returns:
Trueonce the bot has left.- Raises:
BadRequestError – The chat is private and cannot be left.
ChatNotFoundError – No such chat, or the bot is not in it.
- Return type:
- send_typing(chat_id, typing=True)¶
Shows “typing…” in a chat for a few seconds, or clears it.
Calls
POST /chats/{chatId}/typing. The indicator clears on its own after a few seconds, or when a message lands. One goes out per chat every 3 seconds at most; calls in between show nothing and returnFalse. Before sending, it checks thatchat_idis a positive integer. Not retried after a network error or a5xxanswer: a late indicator is useless.- Parameters:
- Returns:
Trueif the indicator went out,Falseif one went out to this chat less than 3 seconds ago.- Raises:
ForbiddenError – The bot may not write in this chat: it is restricted, or only a subscriber of a channel.
ChatNotFoundError – No such chat, or the bot is not in it.
- Return type:
Example
Show it while the bot works:
api.send_typing(chat_id) report = build_report() api.send_message(chat_id, report)
- resolve(username)¶
Turns a public handle into an ID.
Calls
GET /resolve/{username}. For the handle somebody types at the bot - “paga @anna”, “segui @notizie” - when no message has handed that ID over. People, groups and channels share one pool of names, so a handle belongs to exactly one of the three andResolved.kindsays which;Resolved.idis filled in either way. A leading@is stripped and the case does not matter. Before sending, it checks the handle against the server’s own rule. Retried after a network error or a5xxanswer.It finds anybody with a public handle, which is no more than the short link already shows a stranger with a browser. The ID on its own unlocks nothing: sending still needs the bot to be in the chat, and
Client.get_user_keystill needs a chat in common.- Parameters:
username (str) – The handle, with or without the
@.- Returns:
What the handle turned out to be.
- 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 a closed account.
FloodWaitError – More than 60 lookups this minute, where a handle nobody holds counts as five.
retry_aftersays how long to wait.
- Return type:
Example
Pay whoever was named:
found = api.resolve(words.removeprefix("paga @")) if found.is_user: api.send_message(chat_id, f"{found.mention} ha id {found.id}")
- get_user_key(user_id)¶
Fetches one person’s public key.
Calls
GET /users/{userId}/key. It works only for someone the bot shares a chat with: anyone else isUserNotFoundError, whether or not they exist. Before sending, it checks thatuser_idis a positive integer. Retried after a network error or a5xxanswer.- Parameters:
user_id (int) – The person.
- Returns:
The person’s ID, the key’s fingerprint as the server lists it, and the armored public key.
- Raises:
UserNotFoundError – No such person, or no chat in common with the bot.
NoUserKeyError – The person never published a key.
- Return type:
Example
Read the key, and show its fingerprint the way people compare it:
user_key = api.get_user_key(55) anna = PublicKey.from_user_key(user_key) print(format_fingerprint(anna.fingerprint))
- revoke_token()¶
Burns this token, for good.
Calls
POST /token/revoke. Nothing works again, this client included, until the owner makes a new token in the console; the owner is told at once. It works while the bot is paused.Retried after a network error or a
5xxanswer. If an earlier answer was lost and the retry getsunauthorized, the first try worked, and the method returnsTrue.- Returns:
Trueonce the token is revoked.- Return type:
Example
For a program that finds out its token leaked:
if token_was_leaked: api.revoke_token()
Warning
This cannot be undone. Every later call with this token raises
AuthenticationError.
- download_media(media, *, max_bytes=MEDIA_MAX_BYTES)¶
Downloads the file behind a
Media, exactly as the server stores it.Fetches
GET /media/{id}on the API server, the address aMediacarries inurl. This is not an operation of the Bot API document, and it skips the rate limiter and the retries: an error is raised at once. The token is not sent, since the server serves media without it. Only/media/<id>addresses on this client’s API server are fetched, so a crafted URL in an update cannot send the client anywhere else. The download has timeouts of its own: 10 seconds to connect, and 120 seconds to wait for each piece of data.- Parameters:
- Returns:
The file’s bytes. For an attachment of an encrypted chat they are OpenPGP ciphertext, which
Bot.download_mediadecrypts, and so does the same method ofSyncBot.- Raises:
ValidationError –
mediais not a media file of this server, or the file is larger thanmax_bytes.NotFoundError – No such file.
codeis"media_not_found".ServerError – The server answered with a
5xxstatus.APIError – The server answered with a status other than 200.
codeis"http_<status>".NetworkError – No answer came.
RequestTimeoutErrorwhen it took too long.ConfigurationError – The client was closed, or the
http_clientyou passed in was.
- Return type:
Example
The first attachment of a message:
data = api.download_media(message.media[0])
Retries and rate limits¶
- class RetryPolicy(
- attempts=4,
- backoff_base=0.5,
- backoff_max=20.0,
- rate_limit_retries=5,
- max_rate_limit_wait=90.0,
How hard a client tries again before it gives up.
Pass one as
retry=toBot,SyncBot,AsyncClientorClient; without it they useRetryPolicy(). The fields below are the constructor’s keyword arguments. A policy is frozen, so one can be shared.RetryPolicy(attempts=1, rate_limit_retries=0)turns retries off.Two kinds of failure are tried again, each with its own budget:
No answer, or a
5xx(aNetworkErroror aServerError), for the calls that are safe to repeat: every operation exceptget_updatesandsend_typing. Sends are safe because each carries asendId. The wait before retryn(0for the first) is random, between 0 andmin(backoff_max, backoff_base * 2**n)seconds (“full jitter”), so clients that failed together don’t all come back at the same moment.A
429, for any call, since the server refused it before doing anything. The client waits as long as the server asks (retry_after, or one second when it gives none), plus a fraction of a second.
Other
4xxanswers are never retried, and media downloads are not retried at all. When a budget runs out, the last error is raised.- attempts¶
Tries in total, the first one included, for a call that gets no answer or a
5xx.1means no retry. Tries that ended in a429do not count.- Type:
- backoff_base¶
The longest wait before the first retry, in seconds. It doubles at each retry.
- Type:
- rate_limit_retries¶
How many
429answers one call waits out. The next one is raised as aRateLimitError;0raises the first.- Type:
- max_rate_limit_wait¶
A
429that asks for a longer wait than this, in seconds, is raised at once as aRateLimitErrorinstead of waited out.- Type:
- Raises:
ValueError –
attemptsis below1, orrate_limit_retries,backoff_baseorbackoff_maxis negative.
Example
>>> from pgpcbot import RetryPolicy >>> policy = RetryPolicy(attempts=6, backoff_max=30.0) >>> policy.attempts, policy.backoff_base, policy.rate_limit_retries (6, 0.5, 5) >>> 0.0 <= policy.backoff(3) <= 4.0 True >>> RetryPolicy(attempts=0) Traceback (most recent call last): ... ValueError: attempts must be at least 1
- backoff(retry)¶
Picks the wait before a retry after no answer or a
5xx.The wait is random, between 0 and
min(backoff_max, backoff_base * 2**retry)seconds (“full jitter”).
- class RateLimiter(limits=None, *, clock=time.monotonic)¶
Paces API calls with the same sliding windows the server counts with.
Before each call the client asks the limiter, which waits until every window the call counts in has room, then books the call. It never refuses a call, so the server rarely has to answer
429. The windows, with the fields ofLimitsthat size them:every API call:
api_calls_per_minute(600) in any 60 seconds;every send and edit, all chats together:
sends_per_second(30) in any second;sends into one private chat:
sends_per_private_per_second(1) in any second;sends into one group or channel:
sends_per_group_per_minute(20) in any 60 seconds.
Edits are counted per chat in windows of their own, with the same numbers as sends. A chat whose kind is not known gets the private window. A call that has to wait books nothing, as on the server. A group’s slow mode and the limit on typing indicators are left to the server.
Every
Bot,SyncBot,AsyncClientandClientmakes a limiter unless told otherwise (rate_limit=on a bot,rate_limiter=on a client;Falseturns pacing off). The one a bot makes takes the server’s numbers fromGET /mewhen the bot starts. One you pass keeps the numbers you gave it. A limiter only counts the calls of its own process, and can be shared by several clients, async and sync, and by several threads.Example
Two processes that share a token can split the budget:
from pgpcbot import Bot, Limits, RateLimiter half = Limits(sends_per_second=15, api_calls_per_minute=300) bot = Bot(rate_limit=RateLimiter(half))
Sets up empty windows, sized by
limits.- Parameters:
limits (Limits | None) – The numbers to pace with.
NonemeansLimits(), the server’s documented defaults. Onlyapi_calls_per_minute,sends_per_second,sends_per_private_per_secondandsends_per_group_per_minuteare used.clock (Callable[[], float]) – Where the limiter reads the time, in seconds; only differences matter. The waits themselves always sleep for real.
- update_limits(limits)¶
Switches to new numbers, at once.
Calls already booked stay booked. A bot calls this with the numbers of
GET /mewhen it starts, but only on a limiter it made itself.- Parameters:
limits (Limits) – The new numbers. Only
api_calls_per_minute,sends_per_second,sends_per_private_per_secondandsends_per_group_per_minuteare used.
- penalize(seconds, *, chat_id=None, edit=False, calls=False)¶
Keeps a window shut for
seconds, after the server answered429anyway.A
429can still come, for example when another process uses the same token. The clients call this with theretry_afterof aFloodWaitErroror of arate_limitedRateLimitError, so the other calls that would hit the same limit wait too, instead of being refused one by one. A penalty never shortens a longer one already in place, and zero or less does nothing.
- acquire(*, send=False, edit=False, chat_id=None, chat_kind=None)¶
Blocks the calling thread until the call may go, then books it.
- Parameters:
send (bool) – The call sends or edits a message, so it counts in the send windows too, not only in the calls per minute.
edit (bool) – The send is an edit: it counts in the chat’s edit window, not its send window.
chat_id (int | None) – The chat the message goes into.
Nonecounts the send in the overall window only.chat_kind (ChatKind | None) – The kind of that chat, which picks its window: per minute for a group or a channel, per second for anything else,
Noneincluded.
- Returns:
How long it waited, in seconds;
0.0when there was room at once.- Return type:
- async acquire_async(*, send=False, edit=False, chat_id=None, chat_kind=None)¶
Waits, without blocking the event loop, until the call may go, then books it.
- Parameters:
send (bool) – The call sends or edits a message, so it counts in the send windows too, not only in the calls per minute.
edit (bool) – The send is an edit: it counts in the chat’s edit window, not its send window.
chat_id (int | None) – The chat the message goes into.
Nonecounts the send in the overall window only.chat_kind (ChatKind | None) – The kind of that chat, which picks its window: per minute for a group or a channel, per second for anything else,
Noneincluded.
- Returns:
How long it waited, in seconds;
0.0when there was room at once.- Return type:
Secrets¶
- class Secret(value)¶
A string that does not show itself, for a token or a passphrase.
repr(),str()and f-strings giveSecret('***'), so the value stays out of logs, error reports and debugger views that print variables. It refuses to be pickled, so it does not end up in a cache or a queue either.bool(secret)isFalsefor an empty string. Callrevealwhere the value is needed.The token (
token=ofBot,SyncBot,AsyncClientandClient) and the passphrase of a key being loaded (pgp_passphrase=,BotKey.from_armored,BotKey.from_file,BotKey.from_env) can be given as aSecret, or as a function that returns the value as a string or aSecret.It guards against accidents; it is not encryption. The value is an ordinary string in the process’s memory, so a memory dump, a debugger or any code in the process can still read it. The plain string that
revealreturns has none of this protection.Example
>>> from pgpcbot import Secret >>> token = Secret("pgpbot_...") >>> token Secret('***') >>> print(f"using {token}") using Secret('***') >>> token.reveal() 'pgpbot_...' >>> import pickle >>> pickle.dumps(token) Traceback (most recent call last): ... TypeError: a Secret cannot be pickled
Wraps
value.