# 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_` | ``, `` in HTML parse mode | | Fenced code | `
` |
| Tables | Flattened to lines; Telegram has no table |
| Links | `` |
| 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

| Variable | Meaning |
|---|---|
| `TELEGRAM_BOT_TOKEN` | Injected from OpenBao at start |
| `TELEGRAM_CHANNEL_ID` | The channel this adapter publishes to |
| `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.