Telegram Delivery¶
Horizon sends headlines to Telegram, deep-linked into the published digest site. The full digest is not sendable and never will be — see the limits below.
Why headlines and not the digest¶
Measured on a real digest: 38 461 characters, 11–50 items, items ranging
1 555–5 408 characters each. Telegram's sendMessage cap is 4 096 characters,
so a full digest is roughly ten messages before any formatting is considered.
Worse, Telegram's HTML parse mode accepts a short closed list of tags:
b/strong i/em u/ins s/strike/del a code pre
span.tg-spoiler tg-spoiler tg-emoji blockquote blockquote expandable
The digest contains <details>, <summary>, <ul>, <li>, ATX headings and
<a id> — none of which are on that list. An unsupported tag does not
degrade: Telegram answers Bad Request: can't parse entities and rejects the
whole message. There is no partial delivery.
blockquote expandable (Bot API 7.4) is the native collapsible primitive and
was considered for per-item messages. It was dropped once the site existed:
headlines plus a link are one message instead of twelve, with one notification
instead of twelve.
Configuration¶
"webhook": {
"enabled": true,
"url_env": "TELEGRAM_WEBHOOK_URL",
"platform": "generic",
"delivery": "headlines",
"link_base": "https://digest.example.com",
"request_body": {
"chat_id": "-1001234567890",
"parse_mode": "HTML",
"disable_web_page_preview": true,
"text": "#{headlines}"
}
}
| Field | Why |
|---|---|
platform: "generic" |
Telegram needs no platform of its own — generic already recognises its {"ok": false} error shape. |
delivery: "headlines" |
The axis that controls how many messages and what is in each. |
link_base |
Base of the published site. Links become {link_base}/{date}-{lang}/#{anchor}. Unset, links fall back to each item's source URL, so this works before the site exists. |
disable_web_page_preview |
Not optional. Without it Telegram renders a preview card for the first link and the message becomes a wall. |
request_body as an object |
Must be a JSON object, not a JSON string. Substitution into raw JSON text breaks on newlines. |
The token¶
Use the setup script. It reads the token from a hidden prompt, so the value never lands in shell history, a command line, or an agent transcript:
ssh -t <host> 'cd ~/horizon && .venv/bin/python scripts/setup_telegram.py'
The -t matters — the prompt needs a TTY. The script validates the token with
getMe, prints the bot's username (not the token), lists chat_id candidates
from getUpdates, and writes TELEGRAM_WEBHOOK_URL into .env with mode 600,
replacing any previous value in place.
Doing it by hand is the same thing:
TELEGRAM_WEBHOOK_URL=https://api.telegram.org/bot<TOKEN>/sendMessage
Never write ${TELEGRAM_TOKEN} inside request_body. Config-wide ${VAR}
expansion happens at load time (_expand_env_vars) and save_config writes the
model back, so running horizon-wizard afterwards would bake the secret onto
disk. url_env is read at send time and never persisted.
The token lives in the URL path, so redact_url strips it there specifically
— query-and-fragment redaction is not enough for Telegram.
chat_id for a channel starts with -100. Find it by messaging the bot and
reading https://api.telegram.org/bot<TOKEN>/getUpdates.
What the payload looks like¶
Built from DailySummarizer.build_view(), never from the rendered markdown —
that is what makes the unsupported tags structurally impossible rather than
merely filtered:
<b>Технологии</b>
1. <a href="https://digest.example.com/2026-08-06-ru/#item-tech-news-1">Заголовок</a> 8.0/10
2. <a href="https://digest.example.com/2026-08-06-ru/#item-tech-news-2">Другой</a> 7.0/10
Only <b> and <a href>. A test asserts the tag set of a built payload is a
subset of {b, a}; that test is what turns "message silently rejected" into a
red CI line.
Titles are escaped with html.escape. This is load-bearing, not defensive:
titles are model output over scraped content, so one literal < would cost the
whole day's digest. Titles are also capped at 200 characters so a single
pathological item cannot produce a line no chunk can hold.
Chunking¶
Lines are packed into chunks of ≤ 3 900 characters. Measured: a 30-item
digest renders to 4 660 characters of headlines — already over the cap — and
digest.max_items allows 50. In practice 30 items → 2 messages, 50 → 3.
The budget is deliberately conservative. Telegram documents its limit as 4 096
"after entities parsing", which would exclude <a href> markup from the count,
but an over-limit message is rejected: being wrong conservatively costs one
extra message, being wrong the other way costs the day.
Rate limits¶
Roughly 20 messages per minute to one group, about one per second per chat, 30
per second globally, with per-method buckets. Exceeding them returns HTTP 429
with retry_after.
Two or three messages a day sit far below all of these, which is why no retry or backoff logic exists. A failed day self-heals the next day. If delivery ever becomes chattier, that assumption needs revisiting.
Verifying without sending¶
horizon-webhook --dry-run
Prints the fully rendered request with the URL and headers redacted. Check two
things by eye: no tag other than <b> and <a>, and no token in the output.