plan --check treated BotFather profile reassertion as drift, so a scheduled check could never go quiet. Reassertion is now its own kind; a changed spec digest is the drift signal. apply reasserts the profile, updates channel copy when the spec changes, and is tested against a fake Telegram surface. The adapter reads its chat id from resolved state and its token from OpenBao, never the operator session. IEI-2 records that an approved provisioning plan is not autonomous Daimon action. Assistant: grok Assistant-Session: 01a09d5a-92c1-7802-915b-92bee57fc9f0
138 lines
6.3 KiB
Markdown
138 lines
6.3 KiB
Markdown
# Telegram adapter — connector capability contract
|
|
|
|
The adapter is an ordinary HTTP service. fluid-core sits in front of it and
|
|
asks nothing of it beyond the endpoints below, so it may be written in whatever
|
|
suits the platform — grammY on Node is the obvious choice for the Telegram Bot
|
|
API.
|
|
|
|
## What the adapter owns
|
|
|
|
- Rendering a **composed post** into a Telegram message.
|
|
- Attaching the post's visual, where the revision calls for one.
|
|
- Talking to the Telegram Bot API.
|
|
- Custody of the bot token for the duration of a call.
|
|
- Idempotency: recognising a `post_id` it has already published.
|
|
|
|
## What the adapter must not do
|
|
|
|
- Create, rename or delete channels, groups or bots.
|
|
- Change membership, administrators or permissions.
|
|
- Post to any chat other than the one it is configured with.
|
|
- **Compose, rewrite, shorten or embellish post text.** The post arrives
|
|
written. If it does not fit, refuse it: a composition that exceeds the limit
|
|
was not condensed, and deciding what to cut is the composer's judgement.
|
|
- Publish a post carrying no `reviewed_by`, or none of the recognised consent
|
|
bases. Refuse with 403.
|
|
- Log, echo or forward the bot token, including in error messages.
|
|
|
|
## Endpoints
|
|
|
|
Exactly the three operations in `contracts/r1.openapi.yaml`, at the same paths.
|
|
The gateway validates requests against that contract before the adapter sees
|
|
them, so the adapter may assume shape but must still assume hostile content.
|
|
|
|
Note what the contract does **not** accept: a hall entry. The adapter never
|
|
reads `hall-of-helix`. Its only knowledge of an entry is the id and URL a post
|
|
cites, which it renders as a link.
|
|
|
|
## Backend capability contract
|
|
|
|
| Property | Value |
|
|
|---|---|
|
|
| Backend identity | Telegram Bot API (`api.telegram.org`) |
|
|
| Authentication | Bot token, injected at start from OpenBao; never in the image |
|
|
| Timeout | 30s per Telegram call |
|
|
| Retry | On 429 only, honouring `retry_after`. Never retry a `sendMessage` that may have succeeded |
|
|
| Circuit breaker | 5 consecutive failures opens for 60s |
|
|
| Rate limits | ~20 messages/minute to a channel; ~30 requests/second overall |
|
|
| Error mapping | 429 → 502 with `retry_after` preserved; 400 → 400; network failure → 502 |
|
|
| Tenant propagation | None; single tenant |
|
|
|
|
## The idempotency requirement
|
|
|
|
`POST /v1/channel-posts` is idempotent on `post_id`. The caller is automation
|
|
that may be retried, and a duplicate post is not something you can quietly undo
|
|
— subscribers have already seen it.
|
|
|
|
The adapter keeps its own mapping from `post_id` to the Telegram message id it
|
|
produced. On a repeat call it returns `200` with the existing publication rather
|
|
than posting again. If the body has changed, it edits the existing message.
|
|
|
|
A **recomposition** of the same source entry is a new `post_id`, not an edit.
|
|
It is a different piece of writing about the same work, and collapsing the two
|
|
would lose the comparison between them.
|
|
|
|
This state is the adapter's, not fluid-core's. The evidence store records that a
|
|
publication happened; the adapter records which Telegram messages implement it.
|
|
|
|
## Rendering rules
|
|
|
|
Telegram formatting is not Markdown. Post Markdown must be converted, not
|
|
passed through.
|
|
|
|
| Post construct | Telegram |
|
|
|---|---|
|
|
| `# Heading` | Bold line |
|
|
| `**bold**`, `_italic_` | `<b>`, `<i>` in HTML parse mode |
|
|
| Fenced code | `<pre>` |
|
|
| Tables | Flattened to lines; Telegram has no table |
|
|
| Links | `<a href>` |
|
|
| Source link | Appended as an attributed link to `source_entry_url` |
|
|
|
|
Anything the converter cannot represent faithfully must fail the publication
|
|
rather than degrade it silently.
|
|
|
|
Two constants regardless of revision: every post carries a link to its source
|
|
entry, and every post is attributable. `missing_source_attribution` is a hard
|
|
guardrail, and a post that cannot be checked against what the person actually
|
|
wrote is the failure this whole arrangement exists to prevent.
|
|
|
|
**Caption limit.** When the revision attaches a visual (R-2), Telegram caps the
|
|
caption at 1024 characters rather than 4096. The adapter must refuse a post
|
|
that exceeds the limit for the form it is publishing in, rather than truncating
|
|
it — the difference between those two limits is the entire subject of the first
|
|
experiment, and silently trimming would destroy the measurement.
|
|
|
|
## Telemetry the adapter should emit
|
|
|
|
The gateway observes the request path already. The adapter adds what only it can
|
|
see, by POSTing to the control plane's `/control/v1/telemetry`:
|
|
|
|
- `variant` — the composition strategy label the post carried, so engagement
|
|
can be attributed to it. This is what lets the campaign compare strategies it
|
|
owns without this interface understanding them.
|
|
- `unreviewed_publication` — always false in practice, since such a post is
|
|
refused; emitted so the guardrail has a series rather than an absence.
|
|
- `missing_source_attribution`, `missing_subject_consent` — likewise.
|
|
- Telegram rate-limit encounters, as error events with class `backend_failure`.
|
|
|
|
It must not emit subscriber identity in any form, and it must not emit post
|
|
body text as telemetry — the body belongs in the publication record, not in the
|
|
observation stream.
|
|
|
|
Engagement metrics (`engagement_rate`, `source_link_rate`, `forward_rate`) come
|
|
from Telegram's channel statistics as aggregates, never per-reader.
|
|
|
|
## Configuration
|
|
|
|
Chat id and bot token are derived, not hand-written. `provision adapter-config`
|
|
reads `presence/resolved/<campaign>.yaml` for the chat id and names the OpenBao
|
|
path of the bot token; the runtime injects the token from that path. The adapter
|
|
never reads the operator session.
|
|
|
|
```bash
|
|
provision adapter-config --spec <path> --channel test # or public
|
|
```
|
|
|
|
| Variable | Meaning |
|
|
|---|---|
|
|
| `TELEGRAM_BOT_TOKEN` | Injected from OpenBao at start (`bot-token` under the campaign subtree) |
|
|
| `TELEGRAM_BOT_TOKEN_REF` | The OpenBao location of that token; printed by `adapter-config`, never the value |
|
|
| `TELEGRAM_CHANNEL_ID` | The channel this adapter publishes to, taken from the resolved state |
|
|
| `FLUID_CONTROL_URL` | Where to POST telemetry |
|
|
| `HALL_BASE_URL` | Base URL for links back to hall-of-helix |
|
|
| `ATTACH_VISUAL` | Whether this revision attaches the portrait (R-2 true, R-3 false) |
|
|
|
|
One adapter instance serves one channel. The private test channel and the public
|
|
channel are separate instances with separate configuration, which is what keeps
|
|
a test publication from reaching subscribers by accident.
|