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

ValidationError

a parameter breaks one of the server’s rules. Nothing was sent. field names it

AuthenticationError (401)

the token is missing, revoked or invalid

BotPausedError (403)

the owner paused the bot. Only get_me, get_webhook, delete_webhook and revoke_token work meanwhile

RateLimitError (429)

too many calls, after the client waited as much as its RetryPolicy allows

ServerError (5xx)

the server failed, after the retries

UnexpectedResponseError

the answer was not the API’s JSON (a proxy error page, a cut answer)

NetworkError, RequestTimeoutError

no answer at all, after the retries

PayloadError

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_message and edit_message, delete_message, answer_callback, leave_chat, revoke_token): retried after network errors and 5xx, up to retry.attempts tries in all.

  • Not repeated after a network error: get_updates (the polling loop decides) and send_typing (a late indicator is useless).

  • 429 is always waited out, up to rate_limit_retries times, each wait at most max_rate_limit_wait seconds.

  • Other 4xx answers 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 RateLimiter says so, and returns the answer as a typed object. A call that is safe to repeat is tried again after a network error or a 5xx answer, and 429 answers are waited out, as the RetryPolicy allows. 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: text and armored go out as you give them, and the attachments of an encrypted chat come back as OpenPGP ciphertext. Bot does the OpenPGP part on top of this client, and gives you its own client as bot.api.

Use it as an async context manager, or call aclose when you are done. A client belongs to one event loop: used from a new one (two asyncio.run() calls in one script, say), it opens a new connection pool there.

The token travels only in the Authorization header. It is never in repr(), in error messages or in logs (token_hint shows the part that is safe to show), and a client cannot be pickled.

bot_id

The bot’s user ID, read from the token.

Type:

int

api_url

The server’s address, without /bot/v1 and the trailing slash.

Type:

str

timeout

The timeouts of every request, made from timeout.

Type:

httpx2.Timeout

retry

The retry rules in use.

Type:

RetryPolicy

rate_limiter

The limiter that paces the calls, or None when 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, a Secret, or a function that returns one (called once, here). None reads PGPCHAT_BOT_TOKEN, or the file named by PGPCHAT_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/v1 is removed. None reads PGPCHAT_API, and without it uses https://api.pgpchat.app.

  • timeout (float | httpx2.Timeout | None) – How long one request may take, in seconds, or an httpx2.Timeout. None means 30 seconds, 10 of them at most to connect; a number n means n seconds, at most 10 of them to connect. A long poll may read for 15 seconds more than it asks the server to wait, and download_media has limits of its own.

  • retry (RetryPolicy | None) – When and how often to try again. None means RetryPolicy(): 4 tries in all for a call that is safe to repeat, and up to 5 429 answers waited out, each for at most 90 seconds.

  • rate_limiter (RateLimiter | bool) – The client-side pacing. True makes a new RateLimiter with the default numbers, False turns pacing off, and a RateLimiter of 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, call api.rate_limiter.update_limits(me.limits) with the BotInfo of get_me.

  • allow_insecure_http (bool) – Allows an http:// api_url on a host other than localhost, 127.0.0.1 or ::1. Only for a test server on your own network: over plain HTTP the token travels in clear.

  • user_agent (str | None) – The User-Agent header. None means PGPCbot/<version> (Python <version>).

  • http_client (httpx2.AsyncClient | None) – An httpx2.AsyncClient of your own, to share its connection pool. The client never closes it, and raises ConfigurationError on a call made after you closed it. transport and max_connections are then ignored.

  • transport (httpx2.AsyncBaseTransport | None) – An httpx2 transport 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_url is not acceptable: not http or https, plain http to another host without allow_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_client you 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 a 5xx answer.

Returns:

The bot as the server holds it. limits types the limits this version knows, and Limits.get reads any other by its JSON name; queued counts the updates waiting.

Return type:

BotInfo

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 that offset is an integer of at least 0, limit is between 1 and 100 and timeout between 0 and 50. With a timeout, the request may read for timeout + 15 seconds, so the client does not hang up before the server answers. Not retried after a network error or a 5xx answer: 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.run does 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 highest update_id you handled, plus one. None confirms nothing and gets everything still in the queue.

  • limit (int | None) – At most this many updates, 1 to 100. None leaves 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. None or 0 answers at once.

Returns:

The updates, oldest first. Empty when nothing arrived before the timeout.

Raises:
Return type:

list[Update]

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 offset beyond 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_updates in 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_updates ends the loop, a network error included, since polls are not retried. Catch it and start a new loop to go on.

Parameters:
  • offset (int | None) – The offset of the first poll. None starts with everything still in the queue.

  • limit (int) – At most this many updates per poll, 1 to 100.

  • timeout (int) – Seconds each poll may wait for an update to arrive, 0 to 50.

Yields:

Each update, oldest first.

Raises:
  • ValidationError – offset, limit or timeout is out of range. It is raised when the loop starts, not when the method is called.

  • WebhookActiveError – A webhook is set.

Return type:

AsyncIterator[Update]

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 a 5xx answer.

Returns:

The webhook state. url is None when no webhook is set, pending counts the updates waiting, and failures, last_error and next_attempt_at tell how the deliveries fail, when they do.

Return type:

WebhookInfo

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 that url is an absolute http or https URL of at most 1024 characters, with a host and without a user name, a password or a fragment, and that secret is 1 to 256 characters of A-Z, a-z, 0-9, _ and -. The server itself refuses plain http and addresses that are not public. Retried after a network error or a 5xx answer.

Setting a webhook ends any poll in progress, and get_updates is refused while it is set. Bot.create_webhook_app can register the webhook for you.

Parameters:
  • url (str) – The address the server delivers the updates to.

  • secret (str | None) – Sent back on every delivery in the X-PGPChat-Bot-Api-Secret-Token header, so your endpoint can refuse anything without it. None sets no secret.

Returns:

The webhook as the server saved it.

Raises:
  • ValidationError – url or secret breaks one of the rules above.

  • BadWebhookError – The server refused the URL, for example because it resolves to a private address.

Return type:

WebhookInfo

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 a 5xx answer.

Returns:

The webhook state after the change. url is None, and pending counts the updates waiting.

Return type:

WebhookInfo

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 send armored into 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 a 5xx answer.

Before sending, it checks that public_key is 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 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_public writes 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_key is not an armored public key block, is too long or holds a private key, or fingerprint is not 40 or 64 hex digits.

Return type:

PublishedKey

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 that scope is one the server knows. Retried after a network error or a 5xx answer.

Parameters:

scope (str | None) – None for 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, when scope is None. Otherwise that scope’s commands, as a list of Command.

Raises:

ValidationError – scope is 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 a 5xx answer.

Scopes go from narrow to wide: in a private chat private, then default; in a group chat:<id>, then groupAdmins for admins, then groups, then default. An empty list does not hide a wider scope. The owner can edit the same lists: whoever writes last wins.

Parameters:
  • commands (Iterable[Command | Mapping[str, Any] | tuple[str, str]]) – Up to 100 commands. Each is a Command, a dict with "command" and "description", or a (name, description) pair.

  • scope (str | None) – Which list: "default", "private", "groups", "groupAdmins" or "chat:<id>". None means "default".

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:

CommandScope

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 that scope is one the server knows. An empty list does not hide a wider scope. Retried after a network error or a 5xx answer.

Parameters:

scope (str | None) – Which list: "default", "private", "groups", "groupAdmins" or "chat:<id>". None means "default".

Returns:

The scope, now with no commands.

Raises:

ValidationError – scope is not one of the above.

Return type:

CommandScope

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: text into a readable chat, armored into an encrypted one.

Calls POST /messages. Give exactly one of text and armored; get_chat tells which one a chat wants (its mode). The client does not encrypt: in an encrypted chat, seal the message to every key of get_chat_keys and to the bot’s own key, and sign it. BotKey.seal adds the bot’s key and the signature for you.

Before sending, it checks what the server would refuse:

  • the IDs are positive integers;

  • text is not blank, has at most 4096 UTF-16 code units, and no control characters but newline and tab (carriage returns become newlines first);

  • armored holds -----BEGIN PGP MESSAGE----- and has at most 131072 characters;

  • markup follows the rules of InlineKeyboard;

  • 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 5xx answer. The sendId makes 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: True posts without a notification. None leaves it to the server, which notifies.

  • send_id (str | UUID | None) – A UUID that names this send, as a string or a uuid.UUID. None makes a new one. Pass your own to repeat the send safely later, after a crash for example.

  • chat_kind (ChatKind | None) – The ChatKind of the chat, for the rate limiter only: groups and channels are paced per minute. None paces it like a private chat.

Returns:

The message as the server stored it, with its id and date.

Raises:
Return type:

SentMessageBase

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 of send_message: text in a readable chat, armored in 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 a 5xx answer: 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, None or an empty keyboard removes them, and a keyboard replaces them.

  • chat_kind (ChatKind | None) – The ChatKind of the chat, for the rate limiter only, as in send_message.

Returns:

The message after the edit.

Raises:
  • ValidationError – An argument breaks one of the rules, both text and armored were given, or there is nothing to change: no text, no armored, and markup left at KEEP.

  • WrongBodyKindError – text into an encrypted chat, or armored into a readable one. expected says 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:

SentMessageBase

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}, with chatId in 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 5xx answer. If an earlier answer was lost and the retry gets message_not_found, the first try did delete the message, and the method returns True. When no answer was lost, a missing message raises MessageNotFoundError.

Parameters:
  • chat_id (int) – The chat the message is in.

  • message_id (int) – The message to delete.

Returns:

True once the message is deleted. False in a rare race on the server, when the message disappeared between its lookup and the delete.

Raises:
Return type:

bool

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 checks callback_id, text and url.

Retried after a network error or a 5xx answer. If an earlier answer was lost and the retry gets callback_answered, the first try worked, and the method returns True.

Parameters:
  • callback_id (str) – The ID of the press, the id of a CallbackQuery.

  • 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 text in 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 http or https URL of up to 1024 characters, without a user name or password. An empty string is left out.

Returns:

True once the answer reached the phone that pressed.

Raises:
Return type:

bool

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}. mode tells which body to send: text in an open chat, armored in a protected one. A private chat also carries the other person and whether they blocked the bot. Before sending, it checks that chat_id is a positive integer. Retried after a network error or a 5xx answer.

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:

Chat

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.seal does). Other bots that only receive what is addressed to them are left out, as they are for the app. Before sending, it checks that chat_id is a positive integer. Retried after a network error or a 5xx answer.

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:
Return type:

ChatKeys

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=True on Bot) 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 that chat_id is a positive integer. Retried after a network error or a 5xx answer.

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:
Return type:

ChatMembers

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 a 5xx answer.

Parameters:
  • chat_id (int) – A group or channel the bot is in.

  • user_id (int) – The person.

Returns:

The person and their seat (status, admin rights, title).

Raises:
Return type:

Member

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 that chat_id is a positive integer.

Retried after a network error or a 5xx answer. If an earlier answer was lost and the retry finds no chat, the first try worked, and the method returns True.

Parameters:

chat_id (int) – The group or channel.

Returns:

True once the bot has left.

Raises:
Return type:

bool

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 return False. Before sending, it checks that chat_id is a positive integer. Not retried after a network error or a 5xx answer: a late indicator is useless.

Parameters:
  • chat_id (int) – A chat the bot may write in.

  • typing (bool) – False clears the indicator early.

Returns:

True if the indicator went out, False if 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:

bool

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 and Resolved.kind says which; Resolved.id is 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 a 5xx answer.

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_key still 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_after says how long to wait.

Return type:

Resolved

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 is UserNotFoundError, whether or not they exist. Before sending, it checks that user_id is a positive integer. Retried after a network error or a 5xx answer.

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:
Return type:

UserKey

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 5xx answer. If an earlier answer was lost and the retry gets unauthorized, the first try worked, and the method returns True.

Returns:

True once the token is revoked.

Return type:

bool

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 a Media carries in url. 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:
  • media (Media | str) – A Media, its /media/<id> path, its full URL on the API server, or the bare ID.

  • max_bytes (int) – The largest file to accept, 64 MiB by default. It is checked against the size the server declares, and again while downloading.

Returns:

The file’s bytes. For an attachment of an encrypted chat they are OpenPGP ciphertext, which Bot.download_media decrypts.

Raises:
Return type:

bytes

Example

The first attachment of a message:

data = await api.download_media(message.media[0])
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 in repr(), in error messages or in logs.

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 without await. Each method checks its arguments against the server’s rules before anything is sent, waits when the client-side RateLimiter says so, and returns the answer as a typed object. A call that is safe to repeat is tried again after a network error or a 5xx answer, and 429 answers are waited out, as the RetryPolicy allows. 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 async code, use AsyncClient instead.

The client never encrypts or decrypts: text and armored go out as you give them, and the attachments of an encrypted chat come back as OpenPGP ciphertext. SyncBot does the OpenPGP part on top of this client, and gives you its own client as bot.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 call close when you are done. Unlike an AsyncClient, a closed client does not open a new pool: make a new client instead.

The token travels only in the Authorization header. It is never in repr(), in error messages or in logs (token_hint shows the part that is safe to show), and a client cannot be pickled.

bot_id

The bot’s user ID, read from the token.

Type:

int

api_url

The server’s address, without /bot/v1 and the trailing slash.

Type:

str

timeout

The timeouts of every request, made from timeout.

Type:

httpx2.Timeout

retry

The retry rules in use.

Type:

RetryPolicy

rate_limiter

The limiter that paces the calls, or None when 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, a Secret, or a function that returns one (called once, here). None reads PGPCHAT_BOT_TOKEN, or the file named by PGPCHAT_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/v1 is removed. None reads PGPCHAT_API, and without it uses https://api.pgpchat.app.

  • timeout (float | httpx2.Timeout | None) – How long one request may take, in seconds, or an httpx2.Timeout. None means 30 seconds, 10 of them at most to connect; a number n means n seconds, at most 10 of them to connect. A long poll may read for 15 seconds more than it asks the server to wait, and download_media has limits of its own.

  • retry (RetryPolicy | None) – When and how often to try again. None means RetryPolicy(): 4 tries in all for a call that is safe to repeat, and up to 5 429 answers waited out, each for at most 90 seconds.

  • rate_limiter (RateLimiter | bool) – The client-side pacing. True makes a new RateLimiter with the default numbers, False turns pacing off, and a RateLimiter of 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, call api.rate_limiter.update_limits(me.limits) with the BotInfo of get_me.

  • allow_insecure_http (bool) – Allows an http:// api_url on a host other than localhost, 127.0.0.1 or ::1. Only for a test server on your own network: over plain HTTP the token travels in clear.

  • user_agent (str | None) – The User-Agent header. None means PGPCbot/<version> (Python <version>).

  • http_client (httpx2.Client | None) – An httpx2.Client of your own, to share its connection pool. The client never closes it, and raises ConfigurationError on a call made after you closed it. transport and max_connections are then ignored.

  • transport (httpx2.BaseTransport | None) – An httpx2 transport 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_url is not acceptable: not http or https, plain http to another host without allow_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_client you 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 raises ConfigurationError, 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 a 5xx answer.

Returns:

The bot as the server holds it. limits types the limits this version knows, and Limits.get reads any other by its JSON name; queued counts the updates waiting.

Return type:

BotInfo

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 that offset is an integer of at least 0, limit is between 1 and 100 and timeout between 0 and 50. With a timeout, the call blocks until an update arrives or the time is up, and the request may read for timeout + 15 seconds, so the client does not hang up before the server answers. Not retried after a network error or a 5xx answer: 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.run does 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 highest update_id you handled, plus one. None confirms nothing and gets everything still in the queue.

  • limit (int | None) – At most this many updates, 1 to 100. None leaves 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. None or 0 answers at once.

Returns:

The updates, oldest first. Empty when nothing arrived before the timeout.

Raises:
Return type:

list[Update]

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 offset beyond 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_updates in 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_updates ends the loop, a network error included, since polls are not retried. Catch it and start a new loop to go on.

Parameters:
  • offset (int | None) – The offset of the first poll. None starts with everything still in the queue.

  • limit (int) – At most this many updates per poll, 1 to 100.

  • timeout (int) – Seconds each poll may wait for an update to arrive, 0 to 50.

Yields:

Each update, oldest first.

Raises:
  • ValidationError – offset, limit or timeout is out of range. It is raised when the loop starts, not when the method is called.

  • WebhookActiveError – A webhook is set.

Return type:

Iterator[Update]

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 a 5xx answer.

Returns:

The webhook state. url is None when no webhook is set, pending counts the updates waiting, and failures, last_error and next_attempt_at tell how the deliveries fail, when they do.

Return type:

WebhookInfo

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 that url is an absolute http or https URL of at most 1024 characters, with a host and without a user name, a password or a fragment, and that secret is 1 to 256 characters of A-Z, a-z, 0-9, _ and -. The server itself refuses plain http and addresses that are not public. Retried after a network error or a 5xx answer.

Setting a webhook ends any poll in progress, and get_updates is refused while it is set. SyncBot.create_webhook_app can register the webhook for you.

Parameters:
  • url (str) – The address the server delivers the updates to.

  • secret (str | None) – Sent back on every delivery in the X-PGPChat-Bot-Api-Secret-Token header, so your endpoint can refuse anything without it. None sets no secret.

Returns:

The webhook as the server saved it.

Raises:
  • ValidationError – url or secret breaks one of the rules above.

  • BadWebhookError – The server refused the URL, for example because it resolves to a private address.

Return type:

WebhookInfo

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 a 5xx answer.

Returns:

The webhook state after the change. url is None, and pending counts the updates waiting.

Return type:

WebhookInfo

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 send armored into 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 a 5xx answer.

Before sending, it checks that public_key is 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, which SyncBot has 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_public writes 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_key is not an armored public key block, is too long or holds a private key, or fingerprint is not 40 or 64 hex digits.

Return type:

PublishedKey

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 that scope is one the server knows. Retried after a network error or a 5xx answer.

Parameters:

scope (str | None) – None for 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, when scope is None. Otherwise that scope’s commands, as a list of Command.

Raises:

ValidationError – scope is 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 a 5xx answer.

Scopes go from narrow to wide: in a private chat private, then default; in a group chat:<id>, then groupAdmins for admins, then groups, then default. An empty list does not hide a wider scope. The owner can edit the same lists: whoever writes last wins.

Parameters:
  • commands (Iterable[Command | Mapping[str, Any] | tuple[str, str]]) – Up to 100 commands. Each is a Command, a dict with "command" and "description", or a (name, description) pair.

  • scope (str | None) – Which list: "default", "private", "groups", "groupAdmins" or "chat:<id>". None means "default".

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:

CommandScope

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 that scope is one the server knows. An empty list does not hide a wider scope. Retried after a network error or a 5xx answer.

Parameters:

scope (str | None) – Which list: "default", "private", "groups", "groupAdmins" or "chat:<id>". None means "default".

Returns:

The scope, now with no commands.

Raises:

ValidationError – scope is not one of the above.

Return type:

CommandScope

send_message(
chat_id,
text=None,
*,
armored=None,
reply_to=None,
markup=None,
silent=None,
send_id=None,
chat_kind=None,
)

Sends a message: text into a readable chat, armored into an encrypted one.

Calls POST /messages. Give exactly one of text and armored; get_chat tells which one a chat wants (its mode). The client does not encrypt: in an encrypted chat, seal the message to every key of get_chat_keys and to the bot’s own key, and sign it. BotKey.seal adds the bot’s key and the signature for you.

Before sending, it checks what the server would refuse:

  • the IDs are positive integers;

  • text is not blank, has at most 4096 UTF-16 code units, and no control characters but newline and tab (carriage returns become newlines first);

  • armored holds -----BEGIN PGP MESSAGE----- and has at most 131072 characters;

  • markup follows the rules of InlineKeyboard;

  • 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 5xx answer. The sendId makes 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: True posts without a notification. None leaves it to the server, which notifies.

  • send_id (str | UUID | None) – A UUID that names this send, as a string or a uuid.UUID. None makes a new one. Pass your own to repeat the send safely later, after a crash for example.

  • chat_kind (ChatKind | None) – The ChatKind of the chat, for the rate limiter only: groups and channels are paced per minute. None paces it like a private chat.

Returns:

The message as the server stored it, with its id and date.

Raises:
Return type:

SentMessageBase

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 of send_message: text in a readable chat, armored in 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 a 5xx answer: 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, None or an empty keyboard removes them, and a keyboard replaces them.

  • chat_kind (ChatKind | None) – The ChatKind of the chat, for the rate limiter only, as in send_message.

Returns:

The message after the edit.

Raises:
  • ValidationError – An argument breaks one of the rules, both text and armored were given, or there is nothing to change: no text, no armored, and markup left at KEEP.

  • WrongBodyKindError – text into an encrypted chat, or armored into a readable one. expected says 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:

SentMessageBase

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}, with chatId in 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 5xx answer. If an earlier answer was lost and the retry gets message_not_found, the first try did delete the message, and the method returns True. When no answer was lost, a missing message raises MessageNotFoundError.

Parameters:
  • chat_id (int) – The chat the message is in.

  • message_id (int) – The message to delete.

Returns:

True once the message is deleted. False in a rare race on the server, when the message disappeared between its lookup and the delete.

Raises:
Return type:

bool

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 checks callback_id, text and url.

Retried after a network error or a 5xx answer. If an earlier answer was lost and the retry gets callback_answered, the first try worked, and the method returns True.

Parameters:
  • callback_id (str) – The ID of the press, the id of a CallbackQuery.

  • 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 text in 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 http or https URL of up to 1024 characters, without a user name or password. An empty string is left out.

Returns:

True once the answer reached the phone that pressed.

Raises:
Return type:

bool

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}. mode tells which body to send: text in an open chat, armored in a protected one. A private chat also carries the other person and whether they blocked the bot. Before sending, it checks that chat_id is a positive integer. Retried after a network error or a 5xx answer.

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:

Chat

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.seal does). Other bots that only receive what is addressed to them are left out, as they are for the app. Before sending, it checks that chat_id is a positive integer. Retried after a network error or a 5xx answer.

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:
Return type:

ChatKeys

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=True on SyncBot) 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 that chat_id is a positive integer. Retried after a network error or a 5xx answer.

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:
Return type:

ChatMembers

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 in repr(), 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 a 5xx answer.

Parameters:
  • chat_id (int) – A group or channel the bot is in.

  • user_id (int) – The person.

Returns:

The person and their seat (status, admin rights, title).

Raises:
Return type:

Member

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 that chat_id is a positive integer.

Retried after a network error or a 5xx answer. If an earlier answer was lost and the retry finds no chat, the first try worked, and the method returns True.

Parameters:

chat_id (int) – The group or channel.

Returns:

True once the bot has left.

Raises:
Return type:

bool

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 return False. Before sending, it checks that chat_id is a positive integer. Not retried after a network error or a 5xx answer: a late indicator is useless.

Parameters:
  • chat_id (int) – A chat the bot may write in.

  • typing (bool) – False clears the indicator early.

Returns:

True if the indicator went out, False if 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:

bool

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 and Resolved.kind says which; Resolved.id is 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 a 5xx answer.

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_key still 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_after says how long to wait.

Return type:

Resolved

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 is UserNotFoundError, whether or not they exist. Before sending, it checks that user_id is a positive integer. Retried after a network error or a 5xx answer.

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:
Return type:

UserKey

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 5xx answer. If an earlier answer was lost and the retry gets unauthorized, the first try worked, and the method returns True.

Returns:

True once the token is revoked.

Return type:

bool

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 a Media carries in url. 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:
  • media (Media | str) – A Media, its /media/<id> path, its full URL on the API server, or the bare ID.

  • max_bytes (int) – The largest file to accept, 64 MiB by default. It is checked against the size the server declares, and again while downloading.

Returns:

The file’s bytes. For an attachment of an encrypted chat they are OpenPGP ciphertext, which Bot.download_media decrypts, and so does the same method of SyncBot.

Raises:
Return type:

bytes

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= to Bot, SyncBot, AsyncClient or Client; without it they use RetryPolicy(). 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 (a NetworkError or a ServerError), for the calls that are safe to repeat: every operation except get_updates and send_typing. Sends are safe because each carries a sendId. The wait before retry n (0 for the first) is random, between 0 and min(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 4xx answers 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. 1 means no retry. Tries that ended in a 429 do not count.

Type:

int

backoff_base

The longest wait before the first retry, in seconds. It doubles at each retry.

Type:

float

backoff_max

The longest wait between two tries, in seconds.

Type:

float

rate_limit_retries

How many 429 answers one call waits out. The next one is raised as a RateLimitError; 0 raises the first.

Type:

int

max_rate_limit_wait

A 429 that asks for a longer wait than this, in seconds, is raised at once as a RateLimitError instead of waited out.

Type:

float

Raises:

ValueError – attempts is below 1, or rate_limit_retries, backoff_base or backoff_max is 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”).

Parameters:

retry (int) – Which retry this is, counting from 0 for the first.

Returns:

A number of seconds, never more than backoff_max.

Return type:

float

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 of Limits that 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, AsyncClient and Client makes a limiter unless told otherwise (rate_limit= on a bot, rate_limiter= on a client; False turns pacing off). The one a bot makes takes the server’s numbers from GET /me when 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. None means Limits(), the server’s documented defaults. Only api_calls_per_minute, sends_per_second, sends_per_private_per_second and sends_per_group_per_minute are 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 /me when 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_second and sends_per_group_per_minute are used.

penalize(seconds, *, chat_id=None, edit=False, calls=False)

Keeps a window shut for seconds, after the server answered 429 anyway.

A 429 can still come, for example when another process uses the same token. The clients call this with the retry_after of a FloodWaitError or of a rate_limited RateLimitError, 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.

Parameters:
  • seconds (float) – How long the window stays shut.

  • chat_id (int | None) – Shut the sends into this chat only. None shuts every send and edit.

  • edit (bool) – With chat_id, shut that chat’s edits instead of its sends.

  • calls (bool) – Shut every API call, whatever chat_id says.

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. None counts 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, None included.

Returns:

How long it waited, in seconds; 0.0 when there was room at once.

Return type:

float

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. None counts 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, None included.

Returns:

How long it waited, in seconds; 0.0 when there was room at once.

Return type:

float

Secrets

class Secret(value)

A string that does not show itself, for a token or a passphrase.

repr(), str() and f-strings give Secret('***'), 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) is False for an empty string. Call reveal where the value is needed.

The token (token= of Bot, SyncBot, AsyncClient and Client) and the passphrase of a key being loaded (pgp_passphrase=, BotKey.from_armored, BotKey.from_file, BotKey.from_env) can be given as a Secret, or as a function that returns the value as a string or a Secret.

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 reveal returns 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.

Parameters:

value (str) – The secret, as a string.

Raises:

TypeError – value is not a str: bytes, None, or another Secret.

reveal()

Returns the value.

Call it where the value is used, and don’t keep the result: the plain string can end up anywhere.

Returns:

The string this Secret holds.

Return type:

str