Webhooks¶
With a webhook, the server sends each update to your web address as soon as it happens. You need a public HTTPS address. In exchange there is no polling, and the bot can run inside a web application you already have.
How the server delivers¶
One update per request, a
POSTwith a JSON body, in order.If you set a secret, every request carries it in the
X-PGPChat-Bot-Api-Secret-Tokenheader.Any
2xxanswer within 10 seconds means “delivered”. Anything else (an error, a timeout, a redirect) means “try again”: the server waits 1 second, then 2, 4, and so on up to one hour, and sends the same update again. Later updates wait behind it.After ten failures in a row, the owner is told in the app. Updates keep queuing for up to 72 hours.
The address must be
https, must not contain a user name, password or#fragment, and must point to a public IP address.
Because the server retries until it gets a 2xx, an update can arrive twice. The library
recognises repeats by their updateId and handles each update once.
The webhook app¶
bot.create_webhook_app() returns a standard ASGI application that does everything above
correctly:
import os
from pgpcbot import Bot, SQLiteStorage
bot = Bot(storage=SQLiteStorage("bot.db"))
@bot.on_message()
async def echo(message):
await message.reply(message.text or "...")
app = bot.create_webhook_app(
secret=os.environ["WEBHOOK_SECRET"],
url="https://bot.example.org/pgpchat", # registered with the server at start-up
)
uvicorn bot:app --host 127.0.0.1 --port 8080
What it does with each request:
Request |
Answer |
|---|---|
secret missing or wrong |
|
not a |
|
body bigger than |
|
body sent too slowly (over 10 seconds) |
|
a different path, when you set |
|
a valid update |
|
a request with the right secret but a body that is not an update |
|
The last row is on purpose. If the app answered 400, the server would retry the same broken
delivery for days and every later update would wait behind it. The secret proves the request
came from the server, so the problem is logged for you to look at, and the queue moves on.
Options¶
Option |
Default |
Meaning |
|---|---|---|
|
required |
the secret the server sends with each delivery. Make one with |
|
|
your public address. If given, the app registers it (and the secret) with the server every time it starts |
|
|
answer only on this path, |
|
1 MiB |
the largest body accepted |
|
|
with |
|
|
allow |
Make a secret once and keep it with your other secrets:
python -c "from pgpcbot import generate_secret; print(generate_secret())"
When is an update “safe”?¶
The answer to the server depends on the storage:
SQLiteStorage(recommended): the app answers200as soon as the update is written to the database. The handlers run right after. A crash loses nothing: the update is on disk and runs at the next start. The server can send the next update at once, so many chats are handled at the same time.MemoryStorage: there is nothing on disk, so the app answers200only when the handlers are done. If they take longer thanack_timeout, it answers503and the server sends the update again a little later; by then it is usually done, and the app answers200without running it again. The server sends one update at a time and waits for each answer, so withMemoryStoragethe bot effectively handles one update at a time.
Use SQLiteStorage for webhooks.
With FastAPI¶
from fastapi import FastAPI
webhook = bot.create_webhook_app(secret=os.environ["WEBHOOK_SECRET"], url="https://example.org/pgpchat")
app = FastAPI(lifespan=bot.lifespan)
app.add_route("/pgpchat", webhook, methods=["POST"])
@app.get("/health")
async def health():
return {"ok": True}
lifespan=bot.lifespan starts the bot when the application starts and stops it cleanly at
shutdown. It is needed because the webhook app, inside another application, does not get start
and stop events of its own. If you forget it, the bot starts at the first delivery and logs a
warning.
A route, not a mount
app.mount("/pgpchat", webhook) answers a POST to /pgpchat with a redirect to
/pgpchat/, and the server counts a redirect as a failed delivery: the queue stops. Use
add_route as above, or register the address with the trailing slash
(url="https://example.org/pgpchat/") if you do mount it.
If your application already has a lifespan, start the bot inside it:
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app):
async with bot.lifespan(app):
await database.connect()
yield
await database.disconnect()
app = FastAPI(lifespan=lifespan)
With Starlette¶
from starlette.applications import Starlette
from starlette.routing import Route
app = Starlette(
routes=[Route("/pgpchat", bot.create_webhook_app(secret=os.environ["WEBHOOK_SECRET"]), methods=["POST"])],
lifespan=bot.lifespan,
)
With another framework¶
WebhookApp.handle() does the checks and the processing without ASGI. Give it the headers and
the raw body, and send back what it returns:
webhook = bot.create_webhook_app(secret=os.environ["WEBHOOK_SECRET"])
result = await webhook.handle(request_headers, raw_body)
# result.status, result.body (a dict to send as JSON), result.headers
Start the bot yourself first (await bot.start()) and stop it at the end (await bot.stop()).
Behind a reverse proxy¶
The usual setup is nginx or Caddy for HTTPS, in front of uvicorn on 127.0.0.1:
location /pgpchat {
proxy_pass http://127.0.0.1:8080;
proxy_read_timeout 15s;
client_max_body_size 1m;
}
Pass the body untouched and keep the
X-PGPChat-Bot-Api-Secret-Tokenheader.Don’t answer with a redirect (for example from
/pgpchatto/pgpchat/): for the server a redirect is a failed delivery. Register the exact address the app answers on.
Several worker processes¶
With SQLiteStorage you can run several web workers (for example uvicorn --workers 4)
against the same database file:
every worker accepts deliveries and writes them to the database;
only one worker runs the handlers, the one that holds the database lock;
if that worker dies, another one takes over and runs what is waiting.
The database must be on a local disk shared by the workers, not on a network share.
Switching between webhook and polling¶
A webhook stops polling:
GET /updatesis refused while a webhook is set.bot.run()refuses to start, unless you passdrop_webhook=True.await bot.delete_webhook()(orpgpcbot webhook delete) goes back to polling. Updates that queued meanwhile are not lost.pgpcbot webhook infoshows the address, how many updates are waiting, and the last error.