Providers¶
Three messengers, one conversation layer. This page is what differs between them and how to add a fourth.
What an adapter is¶
A translator. Provider payload in, IncomingEvent out. OutgoingMessage in,
provider API call out. That is all.
An adapter has no database session, no service, and no idea what a permission
is. test_the_adapter_never_reaches_the_database parses every module in
adapters/ and fails the build if one imports SQLAlchemy or a service — it
walks the import graph rather than searching text, so a comment mentioning a
model cannot fail the build and a real import cannot hide in a string.
The three¶
Telegram¶
The reference implementation. Supports everything in Capabilities: inline
buttons, message editing, deletion, file upload and download, deep links,
webhooks, polling.
Official Bot API only. https://api.telegram.org/bot{token}/{method}.
Bale¶
Bale's bot API is the Telegram Bot API with a different host — same methods,
same update envelope, same inline keyboards. So BaleAdapter is four lines of
configuration on top of BotApiAdapter, the shared dialect, and inherits every
behaviour the Telegram tests already cover.
https://tapi.bale.ai/bot{token}/{method}, deep links at ble.ir.
It signs no webhooks — see security.md.
Only the official API is used. Bale also has a web client whose endpoints can be observed and an account login that issues an OTP to a real phone number. Neither belongs in a product.
Rubika¶
Genuinely different, so it is a real adapter rather than a subclass. Three differences drive everything:
The envelope. {"status": "OK", "data": {...}} — no ok, no result.
Buttons are not callbacks. A press arrives as an ordinary new message
carrying aux_data.button_id. There is no callback id, nothing is spinning,
and answer_callback is a documented no-op that exists so the layer above can
call it without asking which provider it is talking to.
Keypads carry the payload in the id. Rubika hands back id on a press, so
that is where the callback data goes — not a separate field.
Also: uploads are two steps (ask for a URL, then post the bytes to it), paging
uses an opaque next_offset_id cursor rather than a numeric offset, and there
is no long-polling.
https://botapi.rubika.ir/v3/{token}/{method}.
Eitaa¶
Not built. Deliberately deferred — see the roadmap. Provider.EITAA
exists in the enum so identities can reference it, and asking the runner for it
fails at startup with "no adapter" rather than an AttributeError three screens
in.
Capabilities¶
Each adapter declares what it can actually do. Anything false is switched off in the UI rather than attempted and failed.
| Telegram | Bale | Rubika | |
|---|---|---|---|
| Inline buttons | ✓ | ✓ | ✓ (as inline_keypad) |
| Edit a message | ✓ | ✓ | ✓ (text and keypad separately) |
| Answer a callback | ✓ | ✓ | — (nothing to answer) |
| Webhook signature | ✓ | — | — |
| Conceal text (spoiler) | ✓ | — | — |
Coloured button (style) |
✓ | — | — |
| Long polling | ✓ | ✓ | — (cursor paging) |
The last two are off for Bale although botapi.py is shared with it, and that
asymmetry is a decision rather than an omission. Bale is the Telegram Bot API
on another host by its own documentation, but the roadmap
records that neither Bale nor Rubika has been tested against its live API, and
the two failure modes are not symmetric: an unknown reply_markup key that is
ignored costs nothing, while one that is rejected costs the whole message, so
the reader loses the screen rather than the colour. spoiler set that
precedent and button_style follows it. Turning either on is one keyword in
bale.py, the day somebody tests it live.
Rubika is a different case. Its keypad is {id, type, button_text}, which has
nowhere to put a style at all, so build_buttons drops the field instead of
refusing the button — the degradation this layer promises, rather than a
refusal.
Button.style is validated against BUTTON_STYLES in adapters/base.py when
the button is constructed, so a typo fails in a screen test rather than on a
send; an unrecognised style is a bad reply_markup, which Telegram refuses
whole. No screen sets a style yet: the capability exists, and nothing is
coloured by it.
The conformance suite¶
tests/test_adapters_bale_rubika.py ends with the same assertions run
against all three adapters:
- declares a
ProviderandCapabilities - returns a message id that can be edited later — without one, the single-screen UX appends forever
- actually sends the buttons it was given
- ignores an update it has no business with
- refuses a deep link with no username
- reports an unreachable provider as unreachable, not as "no updates"
- reports a refusal as a refusal
- implements the whole protocol
That last one matters more than it looks. A missing method is a crash the first time a user opens that screen — in production, on a provider nobody tested by hand.
Three outcomes, not two¶
fetch_updates returns an UpdateBatch with three possible states:
batch.ok # updates arrived (possibly zero)
batch.refused # the provider answered, and said no
batch.unreachable # nothing answered at all
Collapsing the last two into "no updates" is how a bot spins silently against a revoked token for a week. The runner backs off differently for each: an unreachable provider is usually a blip and retries soon; a refusal is a revoked token or a flood wait and backs off harder, because hammering makes both worse.
Adding a provider¶
If its API is Telegram-shaped, it is four lines:
class NewAdapter(BotApiAdapter):
provider = Provider.NEW
api_root = "https://api.example.com"
deep_link_template = "https://example.com/{username}?start={payload}"
secret_header = "X-Example-Secret" # or "" if it signs nothing
If it is not, write a real adapter. The checklist:
- Add the value to
Providerinmodules/identity/models.py. - Add it to
MESSENGERSif a person can link it. - Write
adapters/<name>.pyimplementing the protocol inadapters/base.py. - Declare honest
Capabilities. A false one is a feature switched off, not a feature that fails. - Register it in
ADAPTERSinapps/bot/runner.py. - Add
<NAME>_BOT_TOKENand<NAME>_BOT_USERNAMEtoshared/settings.py. - Add it to
ADAPTERSin the conformance test. The suite will tell you what is missing. - Add a migration only if it needs a column. Usually it does not.
Then run one process per provider: a second unit with its own environment file, sharing the same database.
What is out of bounds¶
No reverse-engineered web-client endpoints. No logging in as a human with an OTP sent to a real phone. No unofficial client library as a core dependency.
A bot posts as a bot. Anything else is one terms-of-service change away from taking every deployment down at once, and it puts a real person's account at risk to save writing an adapter.
Transaction descriptions and receipt presentation¶
A normal entry on a single-currency book saves on the amount; the description
is optional and is added afterwards from the saved card, which offers it. A book
holding more than one currency still asks after the amount — the currency, the
rate if the market quote is unavailable, then the description — because a rate
applied on someone's behalf is worth showing before it is kept. See
conversation-design.md. A photo/document/voice
attachment and its caption can complete the entry atomically through
LedgerService.record. The current transaction model stores
one provider-scoped attachment. Opening a receipt on its original messenger sends
the media with the transaction summary and edit buttons. Telegram/Bale summaries
exceeding the 1024 UTF-16 caption budget are sent untruncated as a reply to the
media. A refused media resend falls back to the summary with a visible warning.
Another provider displays the summary and identifies the original messenger
instead of forwarding that provider's opaque file ID.