Appearance
Payload Rendering Reference
Audience: consumers integrating with hooka who need to predict how their title/text will render in Telegram before sending. If you have ever wondered "why did my message become a blockquote / a bullet / a paragraph", this is the file.
The authoritative implementation lives in server.go, in formatTelegramText and the helpers it calls (formatTelegramHeader, formatTelegramBody, formatTelegramLabel, formatTelegramInline, formatTelegramInlineMultiline, applyTelegramInlineMarkdown).
1) The parsing model (text body)
text is split into lines on \n (CRLF is normalized). Each line is then classified by formatTelegramBody:
- A blank line flushes the current section and paragraph buffers, and also drains pending compact bullets so adjacent groups stay separate.
- A line that matches the regex
^([\p{L}][\p{L}\p{N} &'()_./-]{0,40}):\s*(.*)$and whose value does not begin with//is treated as the start of a labelled section (e.g.Status: okorWhy It Matters: ...). - While inside a section, every following non-blank line is appended to that section's body until a blank line, another labelled line, or end-of-text terminates the section.
- Any non-blank line that does not start a section and does not belong to one is appended to a paragraph buffer and emitted as plain text.
That regex permits up to 41 characters of label text using letters, digits, spaces, and &'()_./-. Things that look like URL: https://... qualify; error: [E2BIG] does too; 123abc: does not (must start with a letter); name with very long sentence: ... over 41 chars does not.
2) Rendering rules (post-PR #29)
After parsing, each section is rendered one of three ways:
2.1 Compact bullet
If a section's body is a single line (no embedded \n), and the label is not on the footer special-case list (§2.3), it renders as a bullet:
• <b>Label</b>: valueAdjacent compact bullets are joined into one block (separated by \n), so a CI-status payload with seven Key: Value lines renders as one tight seven-bullet block — not seven blockquotes.
A blank line between compact sections is a deliberate group break: the collector drains at the blank line so Status: ok\n\nOwner: alice renders as two bullet blocks (joined by \n\n), not one merged block.
2.2 Multi-line section → blockquote
If a section's body contains a \n (i.e. the label was followed by one or more body lines before the next blank/labelled line), it renders as:
<blockquote><b>Label</b>
body line 1
body line 2</blockquote>The heavy visual treatment is reserved for genuine paragraph/quote content. The body inside the blockquote is run through formatTelegramInlineMultiline, which preserves line breaks while applying inline markdown (links, bold, code, fenced ``` blocks).
2.3 Footer special-cases
The following labels (case-insensitive) are always rendered into a separate footer block, regardless of whether the body is one line or many:
confidence— special-cased:high/medium/lowbecome<b>High</b>/ etc.; anything else falls back to the inline renderer.tweet,thread,source,sources,report,reports,link,links,date,author,analyst,company,ticker,detail,pdf- Korean equivalents:
작성일(date),작성자(author),애널리스트(analyst),종목(ticker),기업(company),상세(detail),링크(link)
Footer lines render as <b>Label</b>: value (joined by \n) and appear after the body blocks, separated by a blank line.
2.4 Bare paragraphs
Lines that do not match the labelled-section regex are accumulated into a paragraph and emitted as one plain-text block (joined by spaces). Inline markdown (**bold**, `code`, [link](url), bare URLs) is applied.
2.5 Per-payload format flag
The optional top-level format field on a publish request lets a consumer pin the body-rendering semantics per message instead of relying on the heuristics in §1–2.4. Allowed values:
format | Body treatment |
|---|---|
| (absent/empty) | Unchanged historical default. raw_text:true → verbatim passthrough; else if the body already contains Telegram HTML → auto-promoted raw body (with a warning logged); else the structured section/blockquote heuristic of §1–2.4. Existing callers who never send format get byte-for-byte identical output. |
structured | The structured section/blockquote heuristic (§2.1–2.4) — i.e. today's default — but always applied regardless of raw_text. It keeps the same HTML safety net as the default: a body that already carries Telegram HTML is passed through rather than double-escaped. |
compact | Header + a bullet-only body. Every labelled section becomes a single • <b>Label</b>: value bullet, multi-line section bodies are flattened to one line (never promoted to a blockquote), and footer special-case labels (§2.3) still group at the bottom. Use this for terse, one-line-per-field notifications. |
raw | Header + the body HTML-escaped and passed through verbatim — no section synthesis, no blockquotes, no markdown promotion. The title/priority header still renders (escaped) exactly as in the other modes. This is the injection-safe "show my text as-is under parse_mode=HTML" mode. Anonymous ingest does not use raw; it uses an internal bullet renderer (Label: value lines become bullets, values HTML-escaped, no markdown/autolink). |
An unknown format value is rejected with 400 {"error":"invalid format; allowed values are: structured, compact, raw"} (in batch publish it is a per-message failure with code:"invalid_format"), so a typo fails loudly rather than silently falling back to the default renderer. Value matching is case-insensitive and whitespace-trimmed.
format:"raw" vs raw_text:true. They are different on purpose:
raw_text:truemeans "I am sending you Telegram HTML; trust and forward it unescaped." The body is emitted with no escaping and no header (only the title header is added — see the field table below). This is the right choice when you have pre-rendered safe HTML.format:"raw"means "show my plain text as-is, but keep it safe." The body is HTML-escaped so<b>arrives as the literal characters<b>, never as live markup. This is the right choice for untrusted or anonymous input where you do not want the sender to be able to inject Telegram HTML.
If both are set, format wins (it is the newer, more specific field): format:"raw" escapes even when raw_text:true is also present.
3) Worked examples
3.1 CI status — all single-line fields
Input:
title: ai-research-arm CI
text:
Status: success
Actionlint: success
Dashboard: success
Python-scripts: successRendered HTML:
html
<b>ai-research-arm CI</b>
• <b>Status</b>: success
• <b>Actionlint</b>: success
• <b>Dashboard</b>: success
• <b>Python-scripts</b>: successOne compact bullet block. Zero blockquotes.
3.2 Analyst brief — mixed metadata and paragraphs
Input:
title: Mirae Asset
priority: 4
text:
Date: 2026-05-09
Author: 유명간
Why It Matters: The headline number masks a steeper margin compression
that only shows up once you read past the cover-page summary.
Detail: https://example.com/detail
PDF: https://example.com/report.pdfRendered HTML:
html
❓ <b>Mirae Asset</b>
<blockquote><b>Why It Matters</b>
The headline number masks a steeper margin compression
that only shows up once you read past the cover-page summary.</blockquote>
<b>Date</b>: 2026-05-09
<b>Author</b>: 유명간
<b>Detail</b>: <a href="https://example.com/detail">example.com/detail</a>
<b>PDF</b>: <a href="https://example.com/report.pdf">example.com/report.pdf</a>Note the priority emoji prefix (priority=4 → ❓), the blockquote for the multi-line Why It Matters, and the date/author/detail/pdf landing in the footer.
3.3 All-footer-fields case
Input:
text:
Source: https://example.com/article
Confidence: highRendered HTML:
html
<b>Source</b>: <a href="https://example.com/article">example.com/article</a>
<b>Confidence</b>: <b>High</b>No body blocks; only the footer.
4) Field reference
Top-level fields of NotificationRequest (see models.go for the canonical struct):
| Field | Type | Notes |
|---|---|---|
chat_id | string | Direct delivery override; bypasses topic routing. |
message_thread_id | string | Telegram forum topic ID; overrides route default. |
title | string | Rendered as <b>{escaped title}</b> with a priority emoji prefix. |
text | string | Body. Parsed per §1–2; see also format (§2.5) and raw_text to opt out. |
parse_mode | string | Default HTML. Per-request override of route default. |
format | string | Optional render mode: structured, compact, or raw (§2.5). Absent/empty = unchanged historical default. Unknown value → 400. |
raw_text | bool | When true, text is passed through verbatim (no escaping); the server only adds the title header. See §2.5 for how this differs from format:"raw" and which wins when both are set. |
rich_message | object | Optional raw Telegram InputRichMessage. Exactly one non-empty markdown, html, or blocks is required. Telegram routes use sendRichMessage; text remains required and is stored/forwarded to webhook and iOS consumers. media, is_rtl, and skip_entity_detection pass through. |
priority | int | 1–5 (clamped). 4 → ❓, ≥5 → 🚨. Field name is priority but the value represents severity. See §6. |
tags | []string | Stored on the message; not rendered into the header. Used by watchers, search, and /topics/{topic}/messages. |
application_payload | JSON value | Opaque publisher-owned data. Stored inside the full publish envelope; never rendered, routed, or indexed. |
reply_markup | json.RawMessage | Forwarded verbatim to Telegram (inline keyboards). |
reply_parameters | json.RawMessage | Forwarded verbatim to Telegram. |
link_preview_options | json.RawMessage | Forwarded verbatim to Telegram. |
disable_web_page_preview | bool | Legacy alias that becomes link_preview_options: {"is_disabled": true} when link_preview_options is absent/null. |
disable_notification | bool | Silent send. |
protect_content | bool | Disable forwarding/saving. |
allow_paid_broadcast | bool | Telegram paid-broadcast opt-in. |
message_effect_id | string | Telegram message effect. |
store_only | bool | Persists to history/SSE without resolving or invoking delivery routes. |
When rich_message is present, title, text, parse_mode, format, and raw_text do not affect Telegram rendering. They are still retained in the original payload and title/text continue to serve history, search, webhook, and iOS consumers. Rich messages are sent once through Telegram's sendRichMessage endpoint rather than the 4,096-character sendMessage chunker. String-form markdown and html are limited to 32,768 characters; Telegram validates the evolving nested block/media contract. Because sendRichMessage does not accept link-preview controls, combining rich_message with non-null link_preview_options or disable_web_page_preview:true returns HTTP 400. The JSON path does not upload new files; media objects must use references Telegram already accepts, such as an existing file ID or URL.
Headers:
| Header | Notes |
|---|---|
Authorization: Bearer <token> | Required unless the topic has anonymous publish enabled. |
Content-Type: application/json | Required for JSON publish endpoints. |
Idempotency-Key | Per-topic dedup window; see §7. |
5) Auto-promotion to raw-body HTML
If text already contains Telegram-supported HTML tags (<b>, <i>, <u>, <s>, <a>, <code>, <pre>, <blockquote>, <span>, <tg-spoiler>, <tg-emoji>, etc.), formatTelegramText would double-escape them, so hooka auto-promotes the body to raw passthrough and logs a warning telling you to set raw_text: true explicitly. Setting raw_text: true is the preferred form because it avoids the warning and documents the intent.
6) Priority / severity
The field is named priority but the values represent severity:
| Value | Header prefix | Conventional meaning |
|---|---|---|
| 1 | (none) | Trace / debug |
| 2 | (none) | Info |
| 3 | (none) | Standard (default if unset) |
| 4 | ❓ | Needs attention / question / blocked |
| 5 | 🚨 | Critical / page-on-this |
Values ≤0 are clamped to 3; values >5 are clamped to 5. The emoji is chosen for semantic honesty over a generic ⚠️ — see the comment in telegramPriorityPrefix (server.go).
A future severity alias is being considered to better reflect the semantics; for now, send priority.
7) Idempotency contract
- Header:
Idempotency-Key: <string>. Recommended format is<consumer-name>-<event-type>-<unique-id>, e.g.ara-twitter-2026-05-17T09:43Zorci-deploy-${PIPELINE_ID}-${JOB_ID}. There is no enforced format; any non-empty string works. - Scope: keys are scoped per topic. The same key on two different topics does not dedup.
- Behavior: if the topic already has a row in
idempotency_keysfor this key, the server returns the originalmessage_idimmediately without re-dispatching to any route (no Telegram send, no webhook fan-out, no duplicate row inmessagesordeliveries). Seeserver.godispatchandstore.goFindIdempotencyMessage/InsertIdempotencyKey. - Retention window: 14 days by default (
IDEMPOTENCY_RETENTION_DAYS, range 1–3650; seeconfig.go). After this window the row is GC'd and the same key would be re-published as new. - Response:
/publishresponses includeidempotency_hit: true|falseand echo backidempotency_keyso consumers can correlate which retry collapsed. On a hit,message_idis the ORIGINAL message's ID anddelivered/failedare both 0 (dispatch short-circuited before any route work).
8) Topic naming convention
There is no enforced naming format beyond the route regex ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ (max 64 chars; must start alphanumeric; only A-Za-z0-9_- thereafter — see validateTopic in server.go).
Recommended pattern: <consumer-name>-<event-type>, e.g.
ara-telemetry— ai-research-arm telemetry streamara-twitter— ai-research-arm Twitter alertsmyproject-ci— your project's CI eventsmyproject-deploy— your project's deploy events
Hyphens are preferred over underscores for readability.
9) Dry-run validation
To preview rendering without sending, append ?dry_run=true to a POST /publish/{topic} call. The server validates the payload, runs it through the formatter, and returns:
json
{
"ok": true,
"dry_run": true,
"rendered": { "telegram_html": "<b>Title</b>\n\n• <b>Status</b>: ok" },
"would_publish_to": { "topic": "ara-ci", "platforms": ["telegram"] }
}No message is stored, no Telegram call is made, and no idempotency key is consumed. Validation errors return 400 with the same shape as a normal publish.
For a rich notification, the decoded input object is returned under telegram_rich_message instead of being presented as generated HTML:
json
{
"ok": true,
"dry_run": true,
"rendered": {
"telegram_rich_message": {"markdown": "# Deploy report"}
},
"would_publish_to": {"topic": "deployments", "platforms": ["telegram"]}
}If the topic has no routes and TELEGRAM_DEFAULT_CHAT_ID is unset, the response carries an additional warning field and platforms is empty — flagging that the real publish would fail at route resolution before any send:
json
{
"ok": true,
"dry_run": true,
"rendered": { "telegram_html": "..." },
"would_publish_to": { "topic": "no-route-topic", "platforms": [] },
"warning": "no routes configured for topic and TELEGRAM_DEFAULT_CHAT_ID is not set; real publish would fail with 400 \"no route found\""
}10) Anonymous publish + webhook routes (amplification guard)
A topic can opt into unauthenticated publishing by setting anonymous_publish on its ACL (POST /topics/{topic}/acl). A topic can also carry webhook routes that fan a publish out to arbitrary operator-configured HTTP targets. Combining the two would let an unauthenticated caller who merely knows the topic name drive the server's webhook fan-out — turning hooka into an SSRF-restricted-but-still amplifying proxy and letting that caller burn the delivery retry budget against third-party targets. Hooka refuses that combination (security finding H3):
Publish time (authoritative). If the resolved actor is anonymous (no/invalid token, admitted only by
anonymous_publish) and the target topic has any webhook-type route, the publish is rejected with403:json{ "ok": false, "error": "anonymous publish is not allowed to a topic with webhook routes; authenticate with a token scoped to this topic" }This applies to both
POST /publish/{topic}andPOST /publish/{topic}/batch. Anonymous publish to a telegram-only topic is unaffected and stays allowed. An authenticated caller (master key or a token scoped to the topic) is never blocked by this guard — it keys on the actor being anonymous, not on the topic having webhook routes.Config time (defense in depth). The dangerous state is also refused when you try to create it, with
409 Conflict:- Enabling
anonymous_publish(POST /topics/{topic}/acl) on a topic that already has a webhook route is rejected. (anonymous_readon the same topic is still allowed — the guard is publish-specific.) - Adding a
webhookroute (POST /topics/{topic}/routes) to a topic that already hasanonymous_publishenabled is rejected. (Atelegramroute is still allowed.)
The publish-time block remains the authoritative boundary even if a webhook route is introduced by a path that bypasses the config-time checks.
- Enabling
11) Cross-references
server.go—formatTelegramText,formatTelegramBody, parsing regex, label handling, inline markdown pipeline.models.go—NotificationRequest,StoredMessage,Route,Watcher, idempotency-related types.store.go—idempotency_keystable,FindIdempotencyMessage,InsertIdempotencyKey, retention cleanup.config.go—IDEMPOTENCY_RETENTION_DAYSand friends.INTEGRATION.md— endpoint walkthrough, auth, examples.openapi.yaml— machine-readable spec.