Skip to content

Telegram Integration

How Legio connects to Telegram — authentication, message handling, TOTP authorization, markdown rendering, and the live status system.

Authentication

Every incoming update passes through _caesar_filter — a composite filter built at handler registration time:

python
def _caesar_filter(caesar_telegram_id: int) -> filters.BaseFilter:
    return filters.User(user_id=caesar_telegram_id) & filters.ChatType.PRIVATE

This enforces two constraints simultaneously:

  1. User identity — only the configured Caesar Telegram ID passes
  2. Chat type — only private (1:1) chats pass (group messages rejected)

All handlers are registered with this filter. Non-Caesar messages are silently dropped at the framework level — no per-handler auth checks needed.

Message Handling

Complete Handler Registration

HandlerFilterSource
/statuscaesarbot.py_handle_status()
/listcaesarbot.py_handle_list()
/createcaesarbot.py_handle_create()
/helpcaesarbot.py_handle_help()
/removecaesarcommands.pyhandle_remove()
/edictcaesarcommands.pyhandle_edict()
/edictacaesarcommands.pyhandle_edicta()
/revokecaesarcommands.pyhandle_revoke()
/actacaesarcommands.pyhandle_acta()
/historycaesarcommands.pyhandle_history()
/resetcaesarcommands.pyhandle_reset()
Free textTEXT & ~COMMAND & caesarbot.py_handle_message()

Free-Form Message Flow

If an error occurs at any point, the status message is edited to ❌ An error occurred — no internal details are leaked.

TOTP Authorization Flow

Destructive actions (/remove, /revoke) require TOTP verification:

TOTP Security Properties

PropertyValueSource
AlgorithmTOTP (RFC 6238)pyotp library
Window30 secondspyotp default
Drift tolerance±30 seconds_DRIFT_STEPS = 1
Max attempts3_MAX_ATTEMPTS = 3 (hardcoded)
TTL120 seconds_TTL_SECONDS = 120 (hardcoded)
ComparisonTiming-safehmac.compare_digest via pyotp
Store key(chat_id, user_id)Prevents group-chat bypasses
OTP cleanupBest-effort deletionOTP messages removed from chat history
Fail closedYesMalformed secrets/codes return False

TOTP-Gated Actions

ActionCommandPayload
remove_centurio/remove <name>{"name": "<centurio>"}
revoke_edictum/revoke <name>{"name": "<edictum>"}

Reply Context

When Caesar replies to an existing message in Telegram (quoting it), the quoted text is extracted and prepended as XML context:

xml
<reply_context sender="John Doe" truncated="true">
quoted message text here (max 500 chars)
</reply_context>

Caesar's actual message here
FeatureDetail
Sender attributionExtracted from reply_to_message.from_user (first + last name)
TruncationMessages > 500 chars truncated with truncated="true" attribute
XML escapingBoth sender names and text escaped via escape() and quoteattr()
Fallback sender"unknown" if no user info available

Markdown Rendering

LLM responses (markdown) are rendered to Telegram-compatible HTML via a custom mistune v3 renderer built on HTMLRenderer(escape=True).

Conversion Table

MarkdownTelegram HTMLNotes
**bold**<b>bold</b>
*italic*<i>italic</i>
`code`<code>code</code>
```lang<pre><code class="language-lang">Fenced code blocks
> quote<blockquote>quote</blockquote>
~~strike~~<s>strike</s>Via mistune strikethrough plugin
[text](url)<a href="url">text</a>URL HTML-escaped
# Heading<b>Heading</b>Telegram has no heading tags
- item• itemUnicode bullet character
1. item1. itemPlain numbered text
![alt](url)[image: alt]No inline images in Telegram
---────────────────────Box-drawing character (20 chars)
Raw HTML&lt;tag&gt;Escaped — never passed through

Security

The renderer is built on HTMLRenderer(escape=True), which HTML-escapes all text by default. Additional protections:

  • Raw HTML blocks and inline HTML are explicitly escaped (never trusted)
  • Link URLs are escaped via html.escape() to prevent attribute injection
  • Blank lines are collapsed to prevent excessive whitespace
  • Module-level singleton avoids re-creating the parser on every call

Live Status Updates

During long-running operations, the bot provides feedback by editing the status message:

⏳ Thinking...
⏳ Reading edicta...
⏳ Dispatching to vorenus...
⏳ Writing results...

Status Derivation

Status messages are derived from SDK intermediate AssistantMessage blocks:

Block TypeDisplayExample
ToolUseBlock (dispatch)⏳ Dispatching to {name}...⏳ Dispatching to vorenus...
ToolUseBlock (other)⏳ {Gerund} {noun}...⏳ Reading edicta...
ThinkingBlock⏳ Thinking...
TextBlock⏳ {first 80 chars}...⏳ Based on my analysis...

Verb-to-Gerund Mapping

Tool names are converted to human-readable descriptions:

PrefixGerundExample
list_Listinglist_edicta → "Listing edicta"
read_Readingread_edictum → "Reading edictum"
search_Searchingsearch_documents → "Searching documents"
write_Writingwrite_commentarium → "Writing commentarium"
publish_Publishingpublish_actum → "Publishing actum"
create_Creatingcreate_centurio → "Creating centurio"
remove_Removingremove_centurio → "Removing centurio"
revoke_Revokingrevoke_edictum → "Revoking edictum"

Rate Limiting

Updates are rate-limited via _make_status_callback():

  • Minimum interval: 3 seconds between edits
  • Implementation: time.monotonic() comparison, skip if too recent
  • Failure handling: Edit failures logged at DEBUG, silently suppressed
  • Purpose: Respects Telegram's API limits (~20 edits/minute)

Typing Indicator

_keep_typing() runs as a background asyncio.Task:

  • Sends Telegram "typing" action every 5 seconds
  • Stopped via asyncio.Event when response is ready or error occurs
  • Cancelled and awaited in finally block to prevent leaks

Centurio Attribution

When Caesar @mentions centuriones, each response gets an attribution header:

⚔️ vorenus — Research Specialist
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

The actual response content here...
  • The description is the first non-heading line from the centurio's prompt.md
  • Names and descriptions are HTML-escaped to prevent Telegram parse errors
  • Legatus responses (default voice) have no attribution header

Message Splitting

Telegram has a 4096-character limit per message. Responses longer than 4000 characters (leaving buffer for HTML tags) are split:

  1. Split at \n\n (paragraph breaks)
  2. If a single paragraph exceeds the limit, hard-split at character boundary
  3. First chunk edits the status message (edit_html)
  4. Remaining chunks sent as new reply messages (reply_html)
  5. Multiple centurio responses each go through the full split pipeline

Built with Roman discipline.