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:
def _caesar_filter(caesar_telegram_id: int) -> filters.BaseFilter:
return filters.User(user_id=caesar_telegram_id) & filters.ChatType.PRIVATEThis enforces two constraints simultaneously:
- User identity — only the configured Caesar Telegram ID passes
- 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
| Handler | Filter | Source |
|---|---|---|
/status | caesar | bot.py → _handle_status() |
/list | caesar | bot.py → _handle_list() |
/create | caesar | bot.py → _handle_create() |
/help | caesar | bot.py → _handle_help() |
/remove | caesar | commands.py → handle_remove() |
/edict | caesar | commands.py → handle_edict() |
/edicta | caesar | commands.py → handle_edicta() |
/revoke | caesar | commands.py → handle_revoke() |
/acta | caesar | commands.py → handle_acta() |
/history | caesar | commands.py → handle_history() |
/reset | caesar | commands.py → handle_reset() |
| Free text | TEXT & ~COMMAND & caesar | bot.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
| Property | Value | Source |
|---|---|---|
| Algorithm | TOTP (RFC 6238) | pyotp library |
| Window | 30 seconds | pyotp default |
| Drift tolerance | ±30 seconds | _DRIFT_STEPS = 1 |
| Max attempts | 3 | _MAX_ATTEMPTS = 3 (hardcoded) |
| TTL | 120 seconds | _TTL_SECONDS = 120 (hardcoded) |
| Comparison | Timing-safe | hmac.compare_digest via pyotp |
| Store key | (chat_id, user_id) | Prevents group-chat bypasses |
| OTP cleanup | Best-effort deletion | OTP messages removed from chat history |
| Fail closed | Yes | Malformed secrets/codes return False |
TOTP-Gated Actions
| Action | Command | Payload |
|---|---|---|
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:
<reply_context sender="John Doe" truncated="true">
quoted message text here (max 500 chars)
</reply_context>
Caesar's actual message here| Feature | Detail |
|---|---|
| Sender attribution | Extracted from reply_to_message.from_user (first + last name) |
| Truncation | Messages > 500 chars truncated with truncated="true" attribute |
| XML escaping | Both 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
| Markdown | Telegram HTML | Notes |
|---|---|---|
**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 | • item | Unicode bullet character |
1. item | 1. item | Plain numbered text |
 | [image: alt] | No inline images in Telegram |
--- | ──────────────────── | Box-drawing character (20 chars) |
| Raw HTML | <tag> | 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 Type | Display | Example |
|---|---|---|
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:
| Prefix | Gerund | Example |
|---|---|---|
list_ | Listing | list_edicta → "Listing edicta" |
read_ | Reading | read_edictum → "Reading edictum" |
search_ | Searching | search_documents → "Searching documents" |
write_ | Writing | write_commentarium → "Writing commentarium" |
publish_ | Publishing | publish_actum → "Publishing actum" |
create_ | Creating | create_centurio → "Creating centurio" |
remove_ | Removing | remove_centurio → "Removing centurio" |
revoke_ | Revoking | revoke_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.Eventwhen response is ready or error occurs - Cancelled and awaited in
finallyblock 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:
- Split at
\n\n(paragraph breaks) - If a single paragraph exceeds the limit, hard-split at character boundary
- First chunk edits the status message (
edit_html) - Remaining chunks sent as new reply messages (
reply_html) - Multiple centurio responses each go through the full split pipeline