Dynamic bots¶
For the deployment whose bots arrive while it runs: a client connects their own bot through your interface, and a container somewhere starts serving it seconds later without a redeploy.
Everything on Multiple bots still holds — identity, aliases, resolution, grouping. What changes is where the bots come from.
Providers¶
BOT_PROVIDERS names them by dotted path, in order:
TELEGRAM_BOT_DEFAULTS = {
'BOT_PROVIDERS': (
'django_aiogram.runtime.providers.from_settings',
'django_aiogram.runtime.providers.from_database',
),
}
from_settings reads TELEGRAM_BOTS and is the default on its own. from_database reads the
TelegramBot table. Where two sources name one identity the first keeps it, and the
collision is logged — so a bot pinned in settings cannot be taken over by a row.
A provider of your own is a callable answering with resolved bots, and it has one obligation:
Say what you read, or raise. Never answer "nothing" to mean "I could not look."
A read that raises leaves the running set exactly as it is. A read that answers nothing is held for one pass before it is believed, because twenty bots disappearing at once is far more often a query against the wrong database than twenty clients leaving together. A deployment that really did remove its last bot converges one interval later.
The rows¶
| Table | What it holds |
|---|---|
TelegramBot |
one bot: its identity, its token, which profile and queue it points at, its sparse overrides, and whether it is on |
TelegramBotProfile |
settings a group of bots shares, so twenty clients on one plan are one row |
TelegramQueue |
a declared queue and the pool that serves it |
TelegramBotLease |
which container is polling which bot |
Overrides are sparse and by key presence, the same rule the settings dicts follow: a column
per setting would need a migration per setting, and NULL cannot mean both "inherit" and
"RATE_LIMIT is off".
Nothing reads any of it until a provider is configured. A project running its bots from
settings.py writes no rows at all.
How a change arrives¶
Two ways, and the poll is the one that makes it arrive at all:
BOT_REFRESH_INTERVAL(30s) — every pass re-reads the whole desired set and compares. A pass over an unchanged table costs one aggregate per table.- A control message, published on the transport when a row is saved — and published on the caller's commit, so a save the transaction rolls back announces nothing.
The message says "read again", never "set the token to X". That is what makes a duplicate free, a loss cost one interval, and a reordered pair harmless. It is the same reason the supervisor is level-triggered: nothing acts on what changed, everything compares what is.
The admin never talks to Telegram inside a request for the same reason — see Intents below.
When a bot will not start¶
One bot's failure is its own: it is quarantined with a reason and the pass carries on to the others. What the failure was decides how long it is believed:
| Fate | What it is | What happens |
|---|---|---|
revoked |
401 — the token was revoked in BotFather, or the bot deleted | No retry. Only a new token brings it back, and a timer never will |
conflict |
409 — something else is polling this token, usually a deploy whose old process has not exited | Retried; it clears itself |
transient |
429, a closed socket, a 5xx | Retried with backoff — the ordinary weather |
unknown |
anything else: a broker that refused, a profile naming an unusable transport | Retried, because a guess is not evidence |
The reason and the moment it may be tried again are written on the row, which is what the admin
shows and what manage.py tgbot_bots prints.
A revoked token is the client's problem, and your project is what tells them. Two signals carry it:
from django.dispatch import receiver
from django_aiogram.runtime.lifecycle import bot_quarantined, bot_recovered
@receiver(bot_quarantined)
def tell_the_client(sender, bot_id, fate, reason, until, **kwargs):
if fate == 'revoked':
email_whoever_owns(bot_id, reason)
@receiver(bot_recovered)
def stop_telling_them(sender, bot_id, **kwargs):
clear_the_warning(bot_id)
until is None where nothing will retry it, which is exactly the case worth an email.
When a client deletes their bot¶
Telegram answers 401 and the bot is quarantined as revoked, with no retry: that is the same
path as a rotated token, and it is deliberate — the row stays, so the client reconnecting means
writing a new token rather than starting again.
What their queue costs you is a separate decision, because a queue per client is how a
deployment leaks — one Redis key, AMQP queue or consumer group per client that ever existed.
manage.py tgbot_prune_queues is the sweep, and REMOVED_QUEUE_POLICY says what it may do:
park (the default) leaves the queue and reports it, hold removes it once it is empty, drop
removes it with whatever is still in it. Nothing acts on this on its own.
Intents: what the admin asks for¶
Checking a token, registering a webhook, removing one — none of those may happen inside an HTTP request, because five hundred selected bots would be five hundred round trips with a person watching a spinner. So the admin writes the asking into the row, and a process with an event loop answers it:
Run it beside the bot containers. An intent is a person waiting for an answer, so the useful interval is seconds. The asking stays on the row until an answer is written — a worker that died holding one loses it to whoever asks next, rather than taking the only record of it away — and the answer is written only by the claim still held, so a slow worker cannot overwrite the answer to a question somebody asked after it. The result is one line a person can read, with the token redacted.
Which container polls which bot¶
Polling is exclusive per token: two processes on one bot is a 409 for whoever was there first. So a bot is held by a lease, renewed on every pass:
| Setting | Default | What it decides |
|---|---|---|
MAX_BOTS_PER_WORKER |
0 |
How many bots one polling process may hold; 0 is as many as it is given |
BOT_LEASE_SECONDS |
90 |
How long a lease is believed. W011 reports one that is not comfortably longer than BOT_REFRESH_INTERVAL |
A container that dies loses its bots once BOT_LEASE_SECONDS has passed, and another process
may take them then. An expired lease is not a revocation: nothing reaches into the old
container, so one that carries on — or comes back from a pause — polls the same token until a
reconciliation pass stops it, and Telegram answers 409 to whichever of them asked second. Webhooks need none of this — there is nothing to hold — and past
a hundred bots they are what this package recommends: nothing refuses polling there, it simply
costs a long-lived connection per bot. Scaling has the numbers.
The credential¶
A row holds a token, and the shipped storage writes it as it was given: what protects it then is
the database's own access control and the view_telegrambot_token permission the model
declares -- which is what the admin's show it link is behind, and why reading one is written
into the feed. Treat a dump of that table as a dump of every client's credential.
TOKEN_STORAGE behind the [crypto] extra encrypts it at rest, and
manage.py tgbot_rewrap_tokens moves a table that already has tokens in it.
Tokens is the page.
When it does not work¶
manage.py tgbot_bots— every bot that resolved, including the ones in rows, with its profile, queue, state and lease.manage.py checkcannot see those: the checks read settings.manage.py tgbot_bots --json— the same for a script.manage.py tgbot_queues— depth and whether anything is consuming.manage.py tgbot_intents— what is outstanding, and what came back.