Files
optimclaw/channels-src/telegram/src/lib.rs
T
d144484b06 feat: WASM channel attachments with LLM pipeline integration (#596)
* feat: add inbound attachment support to WASM channel system

Add attachment record to WIT interface and implement inbound media
parsing across all four channel implementations (Telegram, Slack,
WhatsApp, Discord). Attachments flow from WASM channels through
EmittedMessage to IncomingMessage with validation (size limits,
MIME allowlist, count caps) at the host boundary.

- Add `attachment` record to `emitted-message` in wit/channel.wit
- Add `IncomingAttachment` struct to channel.rs and re-export
- Add host-side validation (20MB total, 10 max, MIME allowlist)
- Telegram: parse photo, document, audio, video, voice, sticker
- Slack: parse file attachments with url_private
- WhatsApp: parse image, audio, video, document with captions
- Discord: backward-compatible empty attachments
- Update FEATURE_PARITY.md section 7
- Add fixture-based tests per channel and host integration tests

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: integrate outbound attachment support and reconcile WIT types (#409)

Reconcile PR #409's outbound attachment work with our inbound attachment
support into a unified design:

WIT type split:
- `inbound-attachment` in channel-host: metadata-only (id, mime_type,
  filename, size_bytes, source_url, storage_key, extracted_text)
- `attachment` in channel: raw bytes (filename, mime_type, data) on
  agent-response for outbound sending

Outbound features (from PR #409):
- `on-broadcast` WIT export for proactive messages without prior inbound
- Telegram: multipart sendPhoto/sendDocument with auto photo→document
  fallback for files >10MB
- wrapper.rs: `call_on_broadcast`, `read_attachments` from disk,
  attachment params threaded through `call_on_respond`
- HTTP tool: `save_to` param for binary downloads to /tmp/ (50MB limit,
  path traversal protection, SSRF-safe redirect following)
- Message tool: allow /tmp/ paths for attachments alongside base_dir
- Credential env var fallback in inject_channel_credentials

Channel updates:
- All 4 channels implement on_broadcast (Telegram full, others stub)
- Telegram: polling_enabled config, adjusted poll timeout
- Inbound attachment types renamed to InboundAttachment in all channels

Tests: 1965 passing (9 new), 0 clippy warnings

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: add audio transcription pipeline and extensible WIT attachment design

Add host-side transcription middleware (OpenAI Whisper) that detects audio
attachments with inline data on incoming messages and transcribes them
automatically. Refactor WIT inbound-attachment to use extras-json and a
store-attachment-data host function instead of typed fields, so future
attachment properties (dimensions, codec, etc.) don't require WIT changes
that invalidate all channel plugins.

- Add src/transcription/ module: TranscriptionProvider trait,
  TranscriptionMiddleware, AudioFormat enum, OpenAI Whisper provider
- Add src/config/transcription.rs: TRANSCRIPTION_ENABLED/MODEL/BASE_URL
- Wire middleware into agent message loop via AgentDeps
- WIT: replace data + duration-secs with extras-json + store-attachment-data
- Host: parse extras-json for well-known keys, merge stored binary data
- Telegram: download voice files via store-attachment-data, add duration
  to extras-json, add /file/bot to HTTP allowlist, voice-only placeholder
- Add reqwest multipart feature for Whisper API uploads
- 5 regression tests for transcription middleware

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: wire attachment processing into LLM pipeline with multimodal image support

Attachments on incoming messages are now augmented into user text via XML tags
before entering the turn system, and images with data are passed as multimodal
content parts (base64 data URIs) to LLM providers. This enables audio transcripts,
document text, and image content to reach the LLM without changes to ChatMessage
serialization or provider interfaces.

- Add src/agent/attachments.rs with augment_with_attachments() and 9 unit tests
- Add ContentPart/ImageUrl types to llm::provider with OpenAI-compatible serde
- Carry image_content_parts transiently on Turn (skipped in serialization)
- Update nearai_chat and rig_adapter to serialize multimodal content
- Add 3 e2e tests verifying attachments flow through the full agent loop

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: CI failures — formatting, version bumps, and Telegram voice test

- Fix cargo fmt formatting in attachments.rs, nearai_chat.rs, rig_adapter.rs,
  e2e_attachments.rs
- Bump channel registry versions 0.1.0 → 0.2.0 (discord, slack, telegram,
  whatsapp) to satisfy version-bump CI check
- Fix Telegram test_extract_attachments_voice: add missing required `duration`
  field to voice fixture JSON

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: bump WIT channel version to 0.3.0, fix Telegram voice test, add pre-commit hook

- Bump wit/channel.wit package version 0.2.0 → 0.3.0 (interface changed with
  store-attachment-data)
- Update WIT_CHANNEL_VERSION constant and registry wit_version fields to match
- Fix Telegram test_extract_attachments_voice: gate voice download behind
  #[cfg(target_arch = "wasm32")] so host functions aren't called in native tests,
  update assertions for generated filename and extras_json duration
- Add @0.3.0 linker stubs in wit_compat.rs
- Add .githooks/pre-commit hook that runs scripts/check-version-bumps.sh when
  WIT or extension sources are staged
- Symlink commit-msg regression hook into .githooks/

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor: extract voice download from extract_attachments into handle_message

Move download_voice_file + store_attachment_data calls out of
extract_attachments into a separate download_and_store_voice function
called from handle_message. This keeps extract_attachments as a pure
data-mapping function with no host calls, making it fully testable
in native unit tests without #[cfg(target_arch)] gates.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review comments — security, correctness, and code quality

Security fixes:
- Add path validation to read_attachments (restrict to /tmp/) preventing
  arbitrary file reads from compromised tools
- Escape XML special characters in attachment filenames, MIME types, and
  extracted text to prevent prompt injection via tag spoofing
- Percent-encode file_id in Telegram getFile URL to prevent query injection
- Clone SecretString directly instead of expose_secret().to_string()

Correctness fixes:
- Fix store_attachment_data overwrite accounting: subtract old entry size
  before adding new to prevent inflated totals and false rejections
- Use max(reported, stored_size) for attachment size accounting to prevent
  WASM channels from under-reporting size_bytes to bypass limits
- Add application/octet-stream to MIME allowlist (channels default unknown
  types to this)

Code quality:
- Extract send_response helper in Telegram, deduplicating on_respond and
  on_broadcast
- Rename misleading Discord test to test_parse_slash_command_interaction
- Fix .githooks/commit-msg to use relative symlink (portable across machines)

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: add tool_upgrade command + fix TOCTOU in save_to path validation

Add `tool_upgrade` — a new extension management tool that automatically
detects and reinstalls WASM extensions with outdated WIT versions.
Preserves authentication secrets during upgrade. Supports upgrading a
single extension by name or all installed WASM tools/channels at once.

Fix TOCTOU in `validate_save_to_path`: validate the path *before*
creating parent directories, so traversal paths like `/tmp/../../etc/`
cannot cause filesystem mutations outside /tmp before being rejected.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: unify WIT package version to 0.3.0 across tool.wit and all capabilities

tool.wit and channel.wit share the `near:agent` package namespace, so they
must declare the same version. Bumps tool.wit from 0.2.0 to 0.3.0 and
updates all capabilities files and registry entries to match.

Fixes `cargo component build` failure: "package identifier near:[email protected]
does not match previous package name of near:[email protected]"

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: move WIT file comments after package declaration

WIT treats `//` comments before `package` as doc comments. When both
tool.wit and channel.wit had header comments, the parser rejected them
as "doc comments on multiple 'package' items". Move comments after the
package declaration in both files.

Also bumps tool registry versions to 0.2.0 to match the WIT 0.3.0 bump.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: display extension versions in gateway Extensions tab

Add version field to InstalledExtension and RegistryEntry types, pipe
through the web API (ExtensionInfo, RegistryEntryInfo), and render as
a badge in the gateway UI for both installed and available extensions.

For installed WASM extensions, version is read from the capabilities
file with a fallback to the registry entry when the local file has no
version (old installations). Bump all extension Cargo.toml and registry
JSON versions from 0.1.0 to 0.2.0 to keep them in sync.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: add document text extraction middleware for PDF, Office, and text files

Extract text from document attachments (PDF, DOCX, PPTX, XLSX, RTF, plain text,
code files) so the LLM can reason about uploaded documents. Uses pdf-extract for
PDFs, zip+XML parsing for Office XML formats, and UTF-8 decode for text files.
Wired into the agent loop after transcription middleware.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: download document files in Telegram channel for text extraction

The DocumentExtractionMiddleware needs file bytes in the attachment `data`
field, but only voice files were being downloaded. Document attachments
(PDFs, DOCX, etc.) had empty `data` and a source_url with a credential
placeholder that only works inside the WASM host's http_request.

Add `download_and_store_documents()` that downloads non-voice, non-image,
non-audio attachments via the existing two-step getFile→download flow and
stores bytes via `store_attachment_data` for host-side extraction.

Also rename `download_voice_file` → `download_telegram_file` since it's
generic for any file_id.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: allow Office MIME types and increase file download limit for Telegram

Two issues preventing document extraction from Telegram:

1. PPTX/DOCX/XLSX MIME types (application/vnd.*) were dropped by the
   WASM host attachment allowlist — add application/vnd., application/msword,
   and application/rtf prefixes.

2. Telegram file downloads over 10 MB failed with "Response body too large" —
   set max_response_bytes to 20 MB in Telegram capabilities.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: report document extraction errors back to user instead of silently skipping

- Bump max_response_bytes to 50 MB for Telegram file downloads
- When document extraction fails (too large, download error, parse error),
  set extracted_text to a user-friendly error message instead of leaving it
  None. This ensures the LLM tells the user what went wrong.
- On Telegram download failure, set extracted_text with the error so the
  user sees feedback even when the file never reaches the extraction middleware.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: store extracted document text in workspace memory for search/recall

After document extraction succeeds, write the extracted text to workspace
memory at `documents/{date}/{filename}`. This enables:
- Full-text and semantic search over past uploaded documents
- Cross-conversation recall ("what did that PDF say?")
- Automatic chunking and embedding via the workspace pipeline

Documents are stored with metadata header (uploader, channel, date, MIME type).
Error messages (extraction failures) are not stored — only successful extractions.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: CI failures — formatting, unused assignment warning

- Run cargo fmt on document_extraction and agent_loop modules
- Suppress unused_assignments warning on trace_llm_ref (used only
  behind #[cfg(feature = "libsql")])

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review comments — security, correctness, and code quality

Security fixes:
- Remove SSRF-prone download() from DocumentExtractionMiddleware (#13)
- Sanitize filenames in workspace path to prevent directory traversal (#11)
- Pre-check file size before reading in WASM wrapper to prevent OOM (#2)
- Percent-encode file_id in Telegram source URLs (#7)

Correctness fixes:
- Clear image_content_parts on turn end to prevent memory leak (#1)
- Find first *successful* transcription instead of first overall (#3)
- Enforce data.len() size limit in document extraction (#10)
- Use UTF-8 safe truncation with char_indices() (#12)

Robustness & code quality:
- Add 120s timeout to OpenAI Whisper HTTP client (#5)
- Trim trailing slash from Whisper base_url (#6)
- Allow ~/.ironclaw/ paths in WASM wrapper (#8)
- Return error from on_broadcast in Slack/Discord/WhatsApp (#9)
- Fix doc comment in HTTP tool (#4)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: formatting — cargo fmt

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address latest PR review — doc comments, error messages, version bumps

- Fix DocumentExtractionMiddleware doc comment (no longer downloads from source_url)
- Fix error message: "no inline data" instead of "no download URL"
- Log error + fallback instead of silent unwrap_or_default on Whisper HTTP client
- Bump all capabilities.json versions from 0.1.0 to 0.2.0 to match Cargo.toml

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: remove unsupported profile: minimal from CI workflows [skip-regression-check]

dtolnay/rust-toolchain@stable does not accept the 'profile' input
(it was a parameter for the deprecated actions-rs/toolchain action).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: merge with latest main — resolve compilation errors and PR review nits

- Add version: None to RegistryEntry/InstalledExtension test constructors
- Fix MessageContent type mismatches in nearai_chat tests (String → MessageContent::Text)
- Fix .contains() calls on MessageContent — use .as_text().unwrap()
- Remove redundant trace_llm_ref = None assignment in test_rig
- Check data size before clone in document extraction to avoid unnecessary allocation

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-07 18:01:40 +00:00

2609 lines
86 KiB
Rust

// Telegram API types have fields reserved for future use (entities, reply threading, etc.)
#![allow(dead_code)]
//! Telegram Bot API channel for IronClaw.
//!
//! This WASM component implements the channel interface for handling Telegram
//! webhooks and sending messages back via the Bot API.
//!
//! # Features
//!
//! - Webhook-based message receiving
//! - Private chat (DM) support
//! - Group chat support with @mention triggering
//! - Reply threading support
//! - User name extraction
//!
//! # Security
//!
//! - Bot token is injected by host during HTTP requests
//! - WASM never sees raw credentials
//! - Optional webhook secret validation by host
// Generate bindings from the WIT file
wit_bindgen::generate!({
world: "sandboxed-channel",
path: "../../wit/channel.wit",
});
use serde::{Deserialize, Serialize};
// Re-export generated types
use exports::near::agent::channel::{
AgentResponse, Attachment, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
OutgoingHttpResponse, PollConfig, StatusType, StatusUpdate,
};
use near::agent::channel_host::{self, EmittedMessage, InboundAttachment};
// ============================================================================
// Telegram API Types
// ============================================================================
/// Telegram Update object (webhook payload).
/// https://core.telegram.org/bots/api#update
#[derive(Debug, Deserialize)]
struct TelegramUpdate {
/// Unique update identifier.
update_id: i64,
/// New incoming message.
message: Option<TelegramMessage>,
/// Edited message.
edited_message: Option<TelegramMessage>,
/// Channel post (we ignore these for now).
channel_post: Option<TelegramMessage>,
}
/// Telegram Message object.
/// https://core.telegram.org/bots/api#message
#[derive(Debug, Deserialize)]
struct TelegramMessage {
/// Unique message identifier.
message_id: i64,
/// Sender (empty for channel posts).
from: Option<TelegramUser>,
/// Chat the message belongs to.
chat: TelegramChat,
/// Message text.
text: Option<String>,
/// Caption for media (photo, video, document, etc.).
#[serde(default)]
caption: Option<String>,
/// Original message if this is a reply.
reply_to_message: Option<Box<TelegramMessage>>,
/// Bot command entities (for /commands).
entities: Option<Vec<MessageEntity>>,
/// Photo sizes (Telegram sends multiple sizes; last is largest).
#[serde(default)]
photo: Option<Vec<PhotoSize>>,
/// Document attachment.
document: Option<TelegramDocument>,
/// Audio attachment.
audio: Option<TelegramAudio>,
/// Video attachment.
video: Option<TelegramVideo>,
/// Voice message.
voice: Option<TelegramVoice>,
/// Sticker.
sticker: Option<TelegramSticker>,
}
/// Telegram PhotoSize object.
#[derive(Debug, Deserialize)]
struct PhotoSize {
file_id: String,
file_unique_id: String,
width: i32,
height: i32,
file_size: Option<i64>,
}
/// Telegram Document object.
#[derive(Debug, Deserialize)]
struct TelegramDocument {
file_id: String,
file_unique_id: String,
file_name: Option<String>,
mime_type: Option<String>,
file_size: Option<i64>,
}
/// Telegram Audio object.
#[derive(Debug, Deserialize)]
struct TelegramAudio {
file_id: String,
file_unique_id: String,
duration: Option<u32>,
file_name: Option<String>,
mime_type: Option<String>,
file_size: Option<i64>,
}
/// Telegram Video object.
#[derive(Debug, Deserialize)]
struct TelegramVideo {
file_id: String,
file_unique_id: String,
duration: Option<u32>,
file_name: Option<String>,
mime_type: Option<String>,
file_size: Option<i64>,
}
/// Telegram Voice message object.
#[derive(Debug, Deserialize)]
struct TelegramVoice {
file_id: String,
file_unique_id: String,
duration: u32,
mime_type: Option<String>,
file_size: Option<i64>,
}
/// Telegram Sticker object.
#[derive(Debug, Deserialize)]
struct TelegramSticker {
file_id: String,
file_unique_id: String,
#[serde(rename = "type")]
sticker_type: Option<String>,
file_size: Option<i64>,
}
/// Telegram User object.
/// https://core.telegram.org/bots/api#user
#[derive(Debug, Deserialize)]
struct TelegramUser {
/// Unique user identifier.
id: i64,
/// True if this is a bot.
is_bot: bool,
/// User's first name.
first_name: String,
/// User's last name.
last_name: Option<String>,
/// Username (without @).
username: Option<String>,
}
/// Telegram Chat object.
/// https://core.telegram.org/bots/api#chat
#[derive(Debug, Deserialize)]
struct TelegramChat {
/// Unique chat identifier.
id: i64,
/// Type of chat: private, group, supergroup, or channel.
#[serde(rename = "type")]
chat_type: String,
/// Title for groups/channels.
title: Option<String>,
/// Username for private chats.
username: Option<String>,
}
/// Message entity (for parsing @mentions, commands, etc.).
/// https://core.telegram.org/bots/api#messageentity
#[derive(Debug, Deserialize)]
struct MessageEntity {
/// Type: mention, bot_command, etc.
#[serde(rename = "type")]
entity_type: String,
/// Offset in UTF-16 code units.
offset: i64,
/// Length in UTF-16 code units.
length: i64,
/// For "mention" type, the mentioned user.
user: Option<TelegramUser>,
}
/// Telegram File object returned by getFile.
/// https://core.telegram.org/bots/api#file
#[derive(Debug, Deserialize)]
struct TelegramFile {
/// Identifier for this file.
#[allow(dead_code)]
file_id: String,
/// File path for downloading. Use https://api.telegram.org/file/bot<token>/<file_path>.
file_path: Option<String>,
}
/// Telegram API response wrapper.
#[derive(Debug, Deserialize)]
struct TelegramApiResponse<T> {
/// True if the request was successful.
ok: bool,
/// Error description if not ok.
description: Option<String>,
/// Result on success.
result: Option<T>,
}
/// Response from sendMessage.
#[derive(Debug, Deserialize)]
struct SentMessage {
message_id: i64,
}
/// Workspace path for storing polling state.
const POLLING_STATE_PATH: &str = "state/last_update_id";
/// Workspace path for persisting owner_id across WASM callbacks.
const OWNER_ID_PATH: &str = "state/owner_id";
/// Workspace path for persisting dm_policy across WASM callbacks.
const DM_POLICY_PATH: &str = "state/dm_policy";
/// Workspace path for persisting allow_from (JSON array) across WASM callbacks.
const ALLOW_FROM_PATH: &str = "state/allow_from";
/// Channel name for pairing store (used by pairing host APIs).
const CHANNEL_NAME: &str = "telegram";
/// Workspace path for persisting bot_username for mention detection in groups.
const BOT_USERNAME_PATH: &str = "state/bot_username";
/// Workspace path for persisting respond_to_all_group_messages flag.
const RESPOND_TO_ALL_GROUP_PATH: &str = "state/respond_to_all_group_messages";
// ============================================================================
// Channel Metadata
// ============================================================================
/// Metadata stored with emitted messages for response routing.
#[derive(Debug, Serialize, Deserialize)]
struct TelegramMessageMetadata {
/// Chat ID where the message was received.
chat_id: i64,
/// Original message ID (for reply_to_message_id).
message_id: i64,
/// User ID who sent the message.
user_id: i64,
/// Whether this is a private (DM) chat.
is_private: bool,
}
/// Channel configuration injected by host.
///
/// The host injects runtime values like tunnel_url and webhook_secret.
/// The channel doesn't need to know about polling vs webhook mode - it just
/// checks if tunnel_url is set to determine behavior.
#[derive(Debug, Deserialize)]
struct TelegramConfig {
/// Bot username (without @) for mention detection in groups.
#[serde(default)]
bot_username: Option<String>,
/// Telegram user ID of the bot owner. When set, only messages from this
/// user are processed. All others are silently dropped.
#[serde(default)]
owner_id: Option<i64>,
/// DM policy: "pairing" (default), "allowlist", or "open".
#[serde(default)]
dm_policy: Option<String>,
/// Allowed sender IDs/usernames from config (merged with pairing-approved store).
#[serde(default)]
allow_from: Option<Vec<String>>,
/// Whether to respond to all group messages (not just mentions).
#[serde(default)]
respond_to_all_group_messages: bool,
/// Public tunnel URL for webhook mode (injected by host from global settings).
/// When set, webhook mode is enabled and polling is disabled.
#[serde(default)]
tunnel_url: Option<String>,
/// Secret token for webhook validation (injected by host from secrets store).
/// Telegram will include this in the X-Telegram-Bot-Api-Secret-Token header.
#[serde(default)]
webhook_secret: Option<String>,
/// When true, use polling mode even if tunnel_url is available.
#[serde(default)]
polling_enabled: bool,
}
// ============================================================================
// Channel Implementation
// ============================================================================
struct TelegramChannel;
#[derive(Debug, Clone, PartialEq, Eq)]
enum TelegramStatusAction {
Typing,
Notify(String),
}
const TELEGRAM_STATUS_MAX_CHARS: usize = 600;
fn truncate_status_message(input: &str, max_chars: usize) -> String {
let mut iter = input.chars();
let truncated: String = iter.by_ref().take(max_chars).collect();
if iter.next().is_some() {
format!("{}...", truncated)
} else {
truncated
}
}
fn status_message_for_user(update: &StatusUpdate) -> Option<String> {
let message = update.message.trim();
if message.is_empty() {
None
} else {
Some(truncate_status_message(message, TELEGRAM_STATUS_MAX_CHARS))
}
}
fn get_updates_url(offset: i64, timeout_secs: u32) -> String {
format!(
"https://api.telegram.org/bot{{TELEGRAM_BOT_TOKEN}}/getUpdates?offset={}&timeout={}&allowed_updates=[\"message\",\"edited_message\"]",
offset, timeout_secs
)
}
fn classify_status_update(update: &StatusUpdate) -> Option<TelegramStatusAction> {
match update.status {
StatusType::Thinking => Some(TelegramStatusAction::Typing),
StatusType::Done | StatusType::Interrupted => None,
// Tool telemetry can be noisy in chat; keep it as typing-only UX.
StatusType::ToolStarted | StatusType::ToolCompleted | StatusType::ToolResult => None,
StatusType::Status => {
let msg = update.message.trim();
if msg.eq_ignore_ascii_case("Done")
|| msg.eq_ignore_ascii_case("Interrupted")
|| msg.eq_ignore_ascii_case("Awaiting approval")
|| msg.eq_ignore_ascii_case("Rejected")
{
None
} else {
status_message_for_user(update).map(TelegramStatusAction::Notify)
}
}
StatusType::ApprovalNeeded
| StatusType::JobStarted
| StatusType::AuthRequired
| StatusType::AuthCompleted => {
status_message_for_user(update).map(TelegramStatusAction::Notify)
}
}
}
impl Guest for TelegramChannel {
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
channel_host::log(
channel_host::LogLevel::Debug,
&format!("Telegram channel config: {}", config_json),
);
let config: TelegramConfig = serde_json::from_str(&config_json)
.map_err(|e| format!("Failed to parse config: {}", e))?;
channel_host::log(channel_host::LogLevel::Info, "Telegram channel starting");
if let Some(ref username) = config.bot_username {
channel_host::log(
channel_host::LogLevel::Info,
&format!("Bot username: @{}", username),
);
}
// Persist owner_id so subsequent callbacks (on_http_request, on_poll) can read it
if let Some(owner_id) = config.owner_id {
if let Err(e) = channel_host::workspace_write(OWNER_ID_PATH, &owner_id.to_string()) {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to persist owner_id: {}", e),
);
}
channel_host::log(
channel_host::LogLevel::Info,
&format!("Owner restriction enabled: user {}", owner_id),
);
} else {
// Clear any stale owner_id from a previous config
let _ = channel_host::workspace_write(OWNER_ID_PATH, "");
channel_host::log(
channel_host::LogLevel::Warn,
"No owner_id configured, bot is open to all users",
);
}
// Persist dm_policy and allow_from for DM pairing in handle_message
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing").to_string();
let _ = channel_host::workspace_write(DM_POLICY_PATH, &dm_policy);
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
.unwrap_or_else(|_| "[]".to_string());
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
// Persist bot_username and respond_to_all_group_messages for group handling
let _ = channel_host::workspace_write(
BOT_USERNAME_PATH,
&config.bot_username.unwrap_or_default(),
);
let _ = channel_host::workspace_write(
RESPOND_TO_ALL_GROUP_PATH,
&config.respond_to_all_group_messages.to_string(),
);
// Mode: use polling if explicitly enabled, otherwise use webhooks when tunnel available.
let webhook_mode = config.tunnel_url.is_some() && !config.polling_enabled;
if webhook_mode {
channel_host::log(
channel_host::LogLevel::Info,
"Webhook mode enabled (tunnel configured)",
);
// Register webhook with Telegram API — propagate errors so a bad token
// causes activation to fail rather than silently succeeding.
if let Some(ref tunnel_url) = config.tunnel_url {
// Clear any stale webhook first to avoid 409 Conflict
let _ = delete_webhook();
channel_host::log(
channel_host::LogLevel::Info,
&format!("Registering webhook: {}/webhook/telegram", tunnel_url),
);
register_webhook(tunnel_url, config.webhook_secret.as_deref())
.map_err(|e| format!("Failed to register webhook: {}", e))?;
}
} else {
channel_host::log(
channel_host::LogLevel::Info,
"Polling mode enabled (no tunnel configured)",
);
// Delete any existing webhook before polling. Telegram returns success
// when no webhook exists, so any error here (e.g. 401) means a bad token.
delete_webhook()
.map_err(|e| format!("Bot token validation failed: {}", e))?;
}
// Configure polling only if not in webhook mode
let poll = if !webhook_mode {
Some(PollConfig {
interval_ms: 30000, // 30 seconds minimum
enabled: true,
})
} else {
None
};
// Webhook secret validation is handled by the host
let require_secret = config.webhook_secret.is_some();
Ok(ChannelConfig {
display_name: "Telegram".to_string(),
http_endpoints: vec![HttpEndpointConfig {
path: "/webhook/telegram".to_string(),
methods: vec!["POST".to_string()],
require_secret,
}],
poll,
})
}
fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse {
// Check if webhook secret validation passed (if required)
// The host validates X-Telegram-Bot-Api-Secret-Token header and sets secret_validated
// If require_secret was true in config but validation failed, secret_validated will be false
if !req.secret_validated {
// This means require_secret was set but the secret didn't match
// We still check the field even though the host should have already rejected invalid requests
// This is defense in depth
channel_host::log(
channel_host::LogLevel::Warn,
"Webhook request with invalid or missing secret token",
);
// Return 401 but Telegram will keep retrying, so this is just for logging
// In practice, the host should reject these before they reach us
}
// Parse the request body as UTF-8
let body_str = match std::str::from_utf8(&req.body) {
Ok(s) => s,
Err(_) => {
return json_response(400, serde_json::json!({"error": "Invalid UTF-8 body"}));
}
};
// Parse as Telegram Update
let update: TelegramUpdate = match serde_json::from_str(body_str) {
Ok(u) => u,
Err(e) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to parse Telegram update: {}", e),
);
// Still return 200 to prevent Telegram from retrying
return json_response(200, serde_json::json!({"ok": true}));
}
};
// Handle the update
handle_update(update);
// Always respond 200 quickly (Telegram expects fast responses)
json_response(200, serde_json::json!({"ok": true}))
}
fn on_poll() {
// Read last offset from workspace storage
let offset = match channel_host::workspace_read(POLLING_STATE_PATH) {
Some(s) => s.parse::<i64>().unwrap_or(0),
None => 0,
};
channel_host::log(
channel_host::LogLevel::Debug,
&format!("Polling getUpdates with offset {}", offset),
);
let headers_json = serde_json::json!({}).to_string();
let primary_url = get_updates_url(offset, 25);
// 35s HTTP timeout outlives Telegram's 30s server-side long-poll.
// If the TCP connection drops, retry once immediately with a short poll
// so we don't wait a full extra tick (~30s) before delivering updates.
let result = match channel_host::http_request(
"GET",
&primary_url,
&headers_json,
None,
Some(35_000),
) {
Ok(response) => Ok(response),
Err(primary_err) => {
channel_host::log(
channel_host::LogLevel::Warn,
&format!(
"getUpdates request failed ({}), retrying once immediately",
primary_err
),
);
let retry_url = get_updates_url(offset, 3);
channel_host::http_request("GET", &retry_url, &headers_json, None, Some(8_000))
.map_err(|retry_err| {
format!("primary error: {}; retry error: {}", primary_err, retry_err)
})
}
};
match result {
Ok(response) => {
if response.status != 200 {
let body_str = String::from_utf8_lossy(&response.body);
channel_host::log(
channel_host::LogLevel::Error,
&format!("getUpdates returned {}: {}", response.status, body_str),
);
return;
}
// Parse response
let api_response: Result<TelegramApiResponse<Vec<TelegramUpdate>>, _> =
serde_json::from_slice(&response.body);
match api_response {
Ok(resp) if resp.ok => {
if let Some(updates) = resp.result {
let mut new_offset = offset;
for update in updates {
// Track highest update_id for next poll
if update.update_id >= new_offset {
new_offset = update.update_id + 1;
}
// Process the update (emits messages)
handle_update(update);
}
// Save new offset if it changed
if new_offset != offset {
if let Err(e) = channel_host::workspace_write(
POLLING_STATE_PATH,
&new_offset.to_string(),
) {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to save polling offset: {}", e),
);
}
}
}
}
Ok(resp) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!(
"Telegram API error: {}",
resp.description.unwrap_or_else(|| "unknown".to_string())
),
);
}
Err(e) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to parse getUpdates response: {}", e),
);
}
}
}
Err(e) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("getUpdates request failed: {}", e),
);
}
}
}
fn on_respond(response: AgentResponse) -> Result<(), String> {
let metadata: TelegramMessageMetadata = serde_json::from_str(&response.metadata_json)
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
send_response(metadata.chat_id, &response, Some(metadata.message_id))
}
fn on_broadcast(user_id: String, response: AgentResponse) -> Result<(), String> {
let chat_id: i64 = user_id
.parse()
.map_err(|e| format!("Invalid chat_id '{}': {}", user_id, e))?;
send_response(chat_id, &response, None)
}
fn on_status(update: StatusUpdate) {
let action = match classify_status_update(&update) {
Some(action) => action,
None => return,
};
// Parse chat_id from metadata
let metadata: TelegramMessageMetadata = match serde_json::from_str(&update.metadata_json) {
Ok(m) => m,
Err(_) => {
channel_host::log(
channel_host::LogLevel::Debug,
"on_status: no valid Telegram metadata, skipping status update",
);
return;
}
};
match action {
TelegramStatusAction::Typing => {
// POST /sendChatAction with action "typing"
let payload = serde_json::json!({
"chat_id": metadata.chat_id,
"action": "typing"
});
let payload_bytes = match serde_json::to_vec(&payload) {
Ok(b) => b,
Err(_) => return,
};
let headers = serde_json::json!({
"Content-Type": "application/json"
});
let result = channel_host::http_request(
"POST",
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendChatAction",
&headers.to_string(),
Some(&payload_bytes),
None,
);
if let Err(e) = result {
channel_host::log(
channel_host::LogLevel::Debug,
&format!("sendChatAction failed: {}", e),
);
}
}
TelegramStatusAction::Notify(prompt) => {
// Send user-visible status updates for actionable events.
if let Err(first_err) =
send_message(metadata.chat_id, &prompt, Some(metadata.message_id), None)
{
channel_host::log(
channel_host::LogLevel::Warn,
&format!(
"Failed to send status reply ({}), retrying without reply context",
first_err
),
);
if let Err(retry_err) = send_message(metadata.chat_id, &prompt, None, None) {
channel_host::log(
channel_host::LogLevel::Debug,
&format!(
"Failed to send status message without reply context: {}",
retry_err
),
);
}
}
}
}
}
fn on_shutdown() {
channel_host::log(
channel_host::LogLevel::Info,
"Telegram channel shutting down",
);
}
}
// ============================================================================
// Send Message Helper
// ============================================================================
/// Errors from send_message, split so callers can match on parse-entity failures.
enum SendError {
/// Telegram returned 400 with "can't parse entities" (Markdown issue).
ParseEntities(String),
/// Any other failure.
Other(String),
}
impl std::fmt::Display for SendError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SendError::ParseEntities(detail) => write!(f, "parse entities error: {}", detail),
SendError::Other(msg) => write!(f, "{}", msg),
}
}
}
/// Send a message via the Telegram Bot API.
///
/// Returns the sent message_id on success. When `parse_mode` is set and
/// Telegram returns a 400 "can't parse entities" error, returns
/// `SendError::ParseEntities` so the caller can retry without formatting.
fn send_message(
chat_id: i64,
text: &str,
reply_to_message_id: Option<i64>,
parse_mode: Option<&str>,
) -> Result<i64, SendError> {
let mut payload = serde_json::json!({
"chat_id": chat_id,
"text": text,
});
if let Some(message_id) = reply_to_message_id {
payload["reply_to_message_id"] = serde_json::Value::Number(message_id.into());
}
if let Some(mode) = parse_mode {
payload["parse_mode"] = serde_json::Value::String(mode.to_string());
}
let payload_bytes = serde_json::to_vec(&payload)
.map_err(|e| SendError::Other(format!("Failed to serialize payload: {}", e)))?;
let headers = serde_json::json!({ "Content-Type": "application/json" });
let result = channel_host::http_request(
"POST",
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
&headers.to_string(),
Some(&payload_bytes),
None,
);
match result {
Ok(http_response) => {
if http_response.status == 400 {
let body_str = String::from_utf8_lossy(&http_response.body);
if body_str.contains("can't parse entities") {
return Err(SendError::ParseEntities(body_str.to_string()));
}
return Err(SendError::Other(format!(
"Telegram API returned 400: {}",
body_str
)));
}
if http_response.status != 200 {
let body_str = String::from_utf8_lossy(&http_response.body);
return Err(SendError::Other(format!(
"Telegram API returned status {}: {}",
http_response.status, body_str
)));
}
let api_response: TelegramApiResponse<SentMessage> =
serde_json::from_slice(&http_response.body)
.map_err(|e| SendError::Other(format!("Failed to parse response: {}", e)))?;
if !api_response.ok {
return Err(SendError::Other(format!(
"Telegram API error: {}",
api_response
.description
.unwrap_or_else(|| "unknown".to_string())
)));
}
Ok(api_response.result.map(|r| r.message_id).unwrap_or(0))
}
Err(e) => Err(SendError::Other(format!("HTTP request failed: {}", e))),
}
}
// ============================================================================
// Voice File Download
// ============================================================================
/// Download a voice file from Telegram by file_id.
///
/// 1. Call getFile to get the file_path.
/// 2. Download the file bytes from /file/bot{TOKEN}/{file_path}.
/// Percent-encode a string for safe use as a URL query parameter value.
fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
out.push_str(&format!("%{:02X}", b));
}
}
}
out
}
fn download_telegram_file(file_id: &str) -> Result<Vec<u8>, String> {
// Reject file_id containing curly braces to prevent credential placeholder injection
if file_id.contains('{') || file_id.contains('}') {
return Err("invalid file_id: contains forbidden characters".to_string());
}
// Step 1: Call getFile to get file_path
let get_file_url = format!(
"https://api.telegram.org/bot{{TELEGRAM_BOT_TOKEN}}/getFile?file_id={}",
percent_encode(file_id)
);
let headers = serde_json::json!({});
let result =
channel_host::http_request("GET", &get_file_url, &headers.to_string(), None, None);
let response = result.map_err(|e| format!("getFile request failed: {}", e))?;
if response.status != 200 {
let body_str = String::from_utf8_lossy(&response.body);
return Err(format!("getFile returned {}: {}", response.status, body_str));
}
let api_response: TelegramApiResponse<TelegramFile> =
serde_json::from_slice(&response.body)
.map_err(|e| format!("Failed to parse getFile response: {}", e))?;
if !api_response.ok {
return Err(format!(
"getFile API error: {}",
api_response
.description
.unwrap_or_else(|| "unknown".to_string())
));
}
let file = api_response
.result
.ok_or_else(|| "getFile returned no result".to_string())?;
let file_path = file
.file_path
.ok_or_else(|| "getFile returned no file_path".to_string())?;
// Sanitize file_path against credential placeholder injection
if file_path.contains('{') || file_path.contains('}') {
return Err("invalid file_path: contains forbidden characters".to_string());
}
// Step 2: Download the actual file bytes
let download_url = format!(
"https://api.telegram.org/file/bot{{TELEGRAM_BOT_TOKEN}}/{}",
file_path
);
let result =
channel_host::http_request("GET", &download_url, &headers.to_string(), None, None);
let response = result.map_err(|e| format!("File download failed: {}", e))?;
if response.status != 200 {
return Err(format!(
"File download returned status {}",
response.status
));
}
Ok(response.body)
}
// ============================================================================
// Attachment Sending (Photo / Document)
// ============================================================================
/// Maximum photo size for Telegram sendPhoto (10 MB).
const MAX_PHOTO_SIZE: usize = 10 * 1024 * 1024;
/// Write a multipart/form-data text field.
fn write_multipart_field(body: &mut Vec<u8>, boundary: &str, name: &str, value: &str) {
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
body.extend_from_slice(
format!("Content-Disposition: form-data; name=\"{}\"\r\n\r\n", name).as_bytes(),
);
body.extend_from_slice(value.as_bytes());
body.extend_from_slice(b"\r\n");
}
/// Write a multipart/form-data file field.
fn write_multipart_file(
body: &mut Vec<u8>,
boundary: &str,
field: &str,
filename: &str,
content_type: &str,
data: &[u8],
) {
// Sanitize filename: strip quotes, newlines, and non-ASCII to prevent header injection
let safe_filename: String = filename
.chars()
.filter(|c| *c != '"' && *c != '\r' && *c != '\n' && *c != '\\' && c.is_ascii())
.collect();
let safe_filename = if safe_filename.is_empty() {
"file".to_string()
} else {
safe_filename
};
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
body.extend_from_slice(
format!(
"Content-Disposition: form-data; name=\"{}\"; filename=\"{}\"\r\n",
field, safe_filename
)
.as_bytes(),
);
body.extend_from_slice(format!("Content-Type: {}\r\n\r\n", content_type).as_bytes());
body.extend_from_slice(data);
body.extend_from_slice(b"\r\n");
}
/// Send a photo via the Telegram Bot API (multipart upload).
///
/// Falls back to `send_document()` if the photo exceeds 10 MB.
fn send_photo(
chat_id: i64,
filename: &str,
mime_type: &str,
data: &[u8],
reply_to_message_id: Option<i64>,
) -> Result<(), String> {
if data.len() > MAX_PHOTO_SIZE {
channel_host::log(
channel_host::LogLevel::Info,
&format!(
"Photo {} exceeds 10MB ({}), sending as document",
filename,
data.len()
),
);
return send_document(chat_id, filename, mime_type, data, reply_to_message_id);
}
let boundary = format!("ironclaw-{}", channel_host::now_millis());
let mut body = Vec::new();
write_multipart_field(&mut body, &boundary, "chat_id", &chat_id.to_string());
if let Some(msg_id) = reply_to_message_id {
write_multipart_field(&mut body, &boundary, "reply_to_message_id", &msg_id.to_string());
}
write_multipart_file(&mut body, &boundary, "photo", filename, mime_type, data);
body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes());
let headers = serde_json::json!({
"Content-Type": format!("multipart/form-data; boundary={}", boundary)
});
let result = channel_host::http_request(
"POST",
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendPhoto",
&headers.to_string(),
Some(&body),
Some(60_000), // 60s timeout for file uploads
);
match result {
Ok(resp) if resp.status == 200 => {
channel_host::log(
channel_host::LogLevel::Debug,
&format!("Sent photo '{}' to chat {}", filename, chat_id),
);
Ok(())
}
Ok(resp) => {
let body_str = String::from_utf8_lossy(&resp.body);
Err(format!(
"sendPhoto failed (HTTP {}): {}",
resp.status, body_str
))
}
Err(e) => Err(format!("sendPhoto HTTP request failed: {}", e)),
}
}
/// Send a document via the Telegram Bot API (multipart upload).
fn send_document(
chat_id: i64,
filename: &str,
mime_type: &str,
data: &[u8],
reply_to_message_id: Option<i64>,
) -> Result<(), String> {
let boundary = format!("ironclaw-{}", channel_host::now_millis());
let mut body = Vec::new();
write_multipart_field(&mut body, &boundary, "chat_id", &chat_id.to_string());
if let Some(msg_id) = reply_to_message_id {
write_multipart_field(&mut body, &boundary, "reply_to_message_id", &msg_id.to_string());
}
write_multipart_file(&mut body, &boundary, "document", filename, mime_type, data);
body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes());
let headers = serde_json::json!({
"Content-Type": format!("multipart/form-data; boundary={}", boundary)
});
let result = channel_host::http_request(
"POST",
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendDocument",
&headers.to_string(),
Some(&body),
Some(60_000), // 60s timeout for file uploads
);
match result {
Ok(resp) if resp.status == 200 => {
channel_host::log(
channel_host::LogLevel::Debug,
&format!("Sent document '{}' to chat {}", filename, chat_id),
);
Ok(())
}
Ok(resp) => {
let body_str = String::from_utf8_lossy(&resp.body);
Err(format!(
"sendDocument failed (HTTP {}): {}",
resp.status, body_str
))
}
Err(e) => Err(format!("sendDocument HTTP request failed: {}", e)),
}
}
/// Image MIME types that Telegram's sendPhoto API supports.
const PHOTO_MIME_TYPES: &[&str] = &[
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
];
/// Send a full agent response (attachments + text) to a chat.
///
/// Shared implementation for both `on_respond` and `on_broadcast`.
fn send_response(
chat_id: i64,
response: &AgentResponse,
reply_to_message_id: Option<i64>,
) -> Result<(), String> {
// Send attachments first (photos/documents)
for attachment in &response.attachments {
send_attachment(chat_id, attachment, reply_to_message_id)?;
}
// Skip text if empty and we already sent attachments
if response.content.is_empty() && !response.attachments.is_empty() {
return Ok(());
}
// Try Markdown, fall back to plain text on parse errors
match send_message(chat_id, &response.content, reply_to_message_id, Some("Markdown")) {
Ok(_) => Ok(()),
Err(SendError::ParseEntities(_)) => {
send_message(chat_id, &response.content, reply_to_message_id, None)
.map(|_| ())
.map_err(|e| format!("Plain-text retry also failed: {}", e))
}
Err(e) => Err(e.to_string()),
}
}
/// Send a single attachment, choosing sendPhoto or sendDocument based on MIME type.
fn send_attachment(
chat_id: i64,
attachment: &Attachment,
reply_to_message_id: Option<i64>,
) -> Result<(), String> {
if PHOTO_MIME_TYPES.contains(&attachment.mime_type.as_str()) {
send_photo(
chat_id,
&attachment.filename,
&attachment.mime_type,
&attachment.data,
reply_to_message_id,
)
} else {
send_document(
chat_id,
&attachment.filename,
&attachment.mime_type,
&attachment.data,
reply_to_message_id,
)
}
}
// ============================================================================
// Webhook Management
// ============================================================================
/// Delete any existing webhook with Telegram API.
///
/// Called during on_start() when switching to polling mode.
/// Telegram doesn't allow getUpdates while a webhook is active.
fn delete_webhook() -> Result<(), String> {
let headers = serde_json::json!({
"Content-Type": "application/json"
});
let result = channel_host::http_request(
"POST",
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/deleteWebhook",
&headers.to_string(),
None,
None,
);
match result {
Ok(response) => {
if response.status != 200 {
let body_str = String::from_utf8_lossy(&response.body);
return Err(format!("HTTP {}: {}", response.status, body_str));
}
let api_response: TelegramApiResponse<bool> = serde_json::from_slice(&response.body)
.map_err(|e| format!("Failed to parse response: {}", e))?;
if !api_response.ok {
return Err(format!(
"Telegram API error: {}",
api_response
.description
.unwrap_or_else(|| "unknown".to_string())
));
}
channel_host::log(
channel_host::LogLevel::Info,
"Webhook deleted successfully (switching to polling mode)",
);
Ok(())
}
Err(e) => Err(format!("HTTP request failed: {}", e)),
}
}
/// Register webhook URL with Telegram API.
///
/// Called during on_start() when tunnel_url is configured.
fn register_webhook(tunnel_url: &str, webhook_secret: Option<&str>) -> Result<(), String> {
let webhook_url = format!("{}/webhook/telegram", tunnel_url);
// Build setWebhook request body
let mut body = serde_json::json!({
"url": webhook_url,
"allowed_updates": ["message", "edited_message"]
});
if let Some(secret) = webhook_secret {
body["secret_token"] = serde_json::Value::String(secret.to_string());
}
let body_bytes =
serde_json::to_vec(&body).map_err(|e| format!("Failed to serialize body: {}", e))?;
let headers = serde_json::json!({
"Content-Type": "application/json"
});
// Make HTTP request to Telegram API
// Note: {TELEGRAM_BOT_TOKEN} is replaced by host with the actual token
let result = channel_host::http_request(
"POST",
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/setWebhook",
&headers.to_string(),
Some(&body_bytes),
None,
);
let mut response = match result {
Ok(response) => response,
Err(e) => return Err(format!("HTTP request failed: {}", e)),
};
let mut retried = false;
if response.status == 409 {
channel_host::log(
channel_host::LogLevel::Warn,
"409 Conflict -- deleting existing webhook and retrying",
);
let _ = delete_webhook();
retried = true;
response = match channel_host::http_request(
"POST",
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/setWebhook",
&headers.to_string(),
Some(&body_bytes),
None,
) {
Ok(resp) => resp,
Err(e) => return Err(format!("HTTP request failed (after 409 retry): {}", e)),
};
}
if response.status != 200 {
let body_str = String::from_utf8_lossy(&response.body);
let context = if retried { " (after 409 retry)" } else { "" };
return Err(format!("HTTP {}{}: {}", response.status, context, body_str));
}
// Parse Telegram API response
let api_response: TelegramApiResponse<serde_json::Value> =
serde_json::from_slice(&response.body)
.map_err(|e| format!("Failed to parse response: {}", e))?;
if !api_response.ok {
let context = if retried { " (after 409 retry)" } else { "" };
return Err(format!(
"Telegram API error{}: {}",
context,
api_response
.description
.unwrap_or_else(|| "unknown".to_string())
));
}
let context = if retried { " (after retry)" } else { "" };
channel_host::log(
channel_host::LogLevel::Info,
&format!("Webhook registered successfully{}: {}", context, webhook_url),
);
Ok(())
}
// ============================================================================
// Pairing Reply
// ============================================================================
/// Send a pairing code message to a chat. Used when an unknown user DMs the bot.
fn send_pairing_reply(chat_id: i64, code: &str) -> Result<(), String> {
send_message(
chat_id,
&format!(
"To pair with this bot, run: `ironclaw pairing approve telegram {}`",
code
),
None,
Some("Markdown"),
)
.map(|_| ())
.map_err(|e| e.to_string())
}
// ============================================================================
// Update Handling
// ============================================================================
/// Process a Telegram update and emit messages if applicable.
fn handle_update(update: TelegramUpdate) {
// Handle regular messages
if let Some(message) = update.message {
handle_message(message);
}
// Optionally handle edited messages the same way
if let Some(message) = update.edited_message {
handle_message(message);
}
}
/// Build extras-json with optional duration.
fn extras_json(duration_secs: Option<u32>) -> String {
match duration_secs {
Some(d) => format!(r#"{{"duration_secs":{}}}"#, d),
None => String::new(),
}
}
/// Build an inbound attachment with the standard fields.
fn make_inbound_attachment(
id: String,
mime_type: String,
filename: Option<String>,
size_bytes: Option<u64>,
source_url: Option<String>,
extracted_text: Option<String>,
duration_secs: Option<u32>,
) -> InboundAttachment {
InboundAttachment {
id,
mime_type,
filename,
size_bytes,
source_url,
storage_key: None,
extracted_text,
extras_json: extras_json(duration_secs),
}
}
/// Extract attachments from a Telegram message.
fn extract_attachments(message: &TelegramMessage) -> Vec<InboundAttachment> {
let mut attachments = Vec::new();
let get_file_url = |file_id: &str| {
format!(
"https://api.telegram.org/bot{{TELEGRAM_BOT_TOKEN}}/getFile?file_id={}",
percent_encode(file_id)
)
};
// Photo: Telegram sends multiple sizes; use the largest (last).
if let Some(ref photos) = message.photo {
if let Some(largest) = photos.last() {
attachments.push(make_inbound_attachment(
largest.file_id.clone(),
"image/jpeg".to_string(),
None,
largest.file_size.map(|s| s as u64),
Some(get_file_url(&largest.file_id)),
None,
None,
));
}
}
// Document
if let Some(ref doc) = message.document {
attachments.push(make_inbound_attachment(
doc.file_id.clone(),
doc.mime_type.clone().unwrap_or_else(|| "application/octet-stream".to_string()),
doc.file_name.clone(),
doc.file_size.map(|s| s as u64),
Some(get_file_url(&doc.file_id)),
None,
None,
));
}
// Audio
if let Some(ref audio) = message.audio {
attachments.push(make_inbound_attachment(
audio.file_id.clone(),
audio.mime_type.clone().unwrap_or_else(|| "audio/mpeg".to_string()),
audio.file_name.clone(),
audio.file_size.map(|s| s as u64),
Some(get_file_url(&audio.file_id)),
None,
audio.duration,
));
}
// Video
if let Some(ref video) = message.video {
attachments.push(make_inbound_attachment(
video.file_id.clone(),
video.mime_type.clone().unwrap_or_else(|| "video/mp4".to_string()),
video.file_name.clone(),
video.file_size.map(|s| s as u64),
Some(get_file_url(&video.file_id)),
None,
video.duration,
));
}
// Voice
if let Some(ref voice) = message.voice {
let mime_type = voice
.mime_type
.clone()
.unwrap_or_else(|| "audio/ogg".to_string());
attachments.push(make_inbound_attachment(
voice.file_id.clone(),
mime_type,
Some(format!("voice_{}.ogg", voice.file_id)),
voice.file_size.map(|s| s as u64),
Some(get_file_url(&voice.file_id)),
None,
Some(voice.duration),
));
}
// Sticker
if let Some(ref sticker) = message.sticker {
attachments.push(make_inbound_attachment(
sticker.file_id.clone(),
"image/webp".to_string(),
None,
sticker.file_size.map(|s| s as u64),
Some(get_file_url(&sticker.file_id)),
None,
None,
));
}
attachments
}
/// Download voice file bytes and store them via the host for transcription.
///
/// Separated from `extract_attachments` so that function stays pure (no host
/// calls) and remains testable in native unit tests.
fn download_and_store_voice(attachments: &[InboundAttachment]) {
for att in attachments {
// Voice attachments have a generated filename like "voice_<id>.ogg"
let is_voice = att
.filename
.as_ref()
.is_some_and(|f| f.starts_with("voice_"));
if !is_voice {
continue;
}
match download_telegram_file(&att.id) {
Ok(bytes) => {
channel_host::log(
channel_host::LogLevel::Info,
&format!("Downloaded voice file: {} bytes", bytes.len()),
);
if let Err(e) = channel_host::store_attachment_data(&att.id, &bytes) {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to store voice data: {}", e),
);
}
}
Err(e) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to download voice file: {}", e),
);
}
}
}
}
/// Returns true if the attachment should be downloaded for document text extraction.
///
/// Excludes voice (handled by transcription), image (vision pipeline),
/// audio (transcription), and video attachments.
fn is_downloadable_document(att: &InboundAttachment) -> bool {
let is_voice = att
.filename
.as_ref()
.is_some_and(|f| f.starts_with("voice_"));
if is_voice {
return false;
}
if att.mime_type.starts_with("image/")
|| att.mime_type.starts_with("audio/")
|| att.mime_type.starts_with("video/")
{
return false;
}
true
}
/// Download document file bytes and store them via the host for text extraction.
///
/// Downloads any attachment that isn't voice or image so the host-side
/// `DocumentExtractionMiddleware` can extract text from PDFs, Office docs, etc.
///
/// On failure, sets `extracted_text` to an error message so the user gets feedback.
fn download_and_store_documents(attachments: &mut [InboundAttachment]) {
for att in attachments.iter_mut() {
if !is_downloadable_document(att) {
continue;
}
match download_telegram_file(&att.id) {
Ok(bytes) => {
channel_host::log(
channel_host::LogLevel::Info,
&format!(
"Downloaded document file: {} bytes, mime={}",
bytes.len(),
att.mime_type
),
);
if let Err(e) = channel_host::store_attachment_data(&att.id, &bytes) {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to store document data: {}", e),
);
}
}
Err(e) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to download document file: {}", e),
);
let name = att.filename.as_deref().unwrap_or("document");
att.extracted_text = Some(format!(
"[Failed to download '{name}': {e}. \
The file may be too large or unavailable. Please try a smaller file.]"
));
}
}
}
}
/// Process a single message.
fn handle_message(message: TelegramMessage) {
// Extract attachments from media fields (pure data mapping, no host calls)
let mut attachments = extract_attachments(&message);
// Download and store voice attachments for host-side transcription
download_and_store_voice(&attachments);
// Download and store document attachments for host-side text extraction
download_and_store_documents(&mut attachments);
// Use text or caption (for media messages)
let has_voice = message.voice.is_some();
let content = message
.text
.filter(|t| !t.is_empty())
.or_else(|| message.caption.filter(|c| !c.is_empty()))
.unwrap_or_else(|| {
if has_voice {
"[Voice note]".to_string()
} else {
String::new()
}
});
// Allow messages with attachments even if text content is empty
if content.is_empty() && attachments.is_empty() {
return;
}
// Skip messages without a sender (channel posts)
let from = match message.from {
Some(f) => f,
None => return,
};
// Skip bot messages to avoid loops
if from.is_bot {
return;
}
let is_private = message.chat.chat_type == "private";
// Owner validation: when owner_id is set, only that user can message
let owner_id_str = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty());
if let Some(ref id_str) = owner_id_str {
if let Ok(owner_id) = id_str.parse::<i64>() {
if from.id != owner_id {
channel_host::log(
channel_host::LogLevel::Debug,
&format!(
"Dropping message from non-owner user {} (owner: {})",
from.id, owner_id
),
);
return;
}
}
} else {
// No owner_id: apply authorization based on dm_policy and allow_from
// This applies to both private and group chats when owner_id is null
let dm_policy =
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
// For private chats with non-open policy, check allowlist
// For group chats with non-open policy, also check allowlist
if dm_policy != "open" {
// Build effective allow list: config allow_from + pairing store
let mut allowed: Vec<String> = channel_host::workspace_read(ALLOW_FROM_PATH)
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) {
allowed.extend(store_allowed);
}
let id_str = from.id.to_string();
let username_opt = from.username.as_deref();
let is_allowed = allowed.contains(&"*".to_string())
|| allowed.contains(&id_str)
|| username_opt.map_or(false, |u| allowed.contains(&u.to_string()));
if !is_allowed {
if is_private && dm_policy == "pairing" {
// Upsert pairing request and send reply (only for private chats)
let meta = serde_json::json!({
"chat_id": message.chat.id,
"user_id": from.id,
"username": username_opt,
})
.to_string();
match channel_host::pairing_upsert_request(CHANNEL_NAME, &id_str, &meta) {
Ok(result) => {
channel_host::log(
channel_host::LogLevel::Info,
&format!(
"Pairing request for user {} (chat {}): code {}",
from.id, message.chat.id, result.code
),
);
if result.created {
let _ = send_pairing_reply(message.chat.id, &result.code);
}
}
Err(e) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Pairing upsert failed: {}", e),
);
}
}
} else if !is_private {
// For group chats with non-open dm_policy, just log and drop
channel_host::log(
channel_host::LogLevel::Debug,
&format!(
"Dropping message from unauthorized user {} in group chat",
from.id
),
);
}
return;
}
}
}
// For group chats, only respond if bot was mentioned or respond_to_all is enabled
if !is_private {
let respond_to_all = channel_host::workspace_read(RESPOND_TO_ALL_GROUP_PATH)
.as_deref()
.unwrap_or("false")
== "true";
if !respond_to_all {
let has_command = content.starts_with('/');
let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default();
let has_bot_mention = if bot_username.is_empty() {
content.contains('@')
} else {
let mention = format!("@{}", bot_username);
content.to_lowercase().contains(&mention.to_lowercase())
};
if !has_command && !has_bot_mention {
channel_host::log(
channel_host::LogLevel::Debug,
&format!("Ignoring group message without mention: {}", content),
);
return;
}
}
}
// Build user display name
let user_name = if let Some(ref last) = from.last_name {
format!("{} {}", from.first_name, last)
} else {
from.first_name.clone()
};
// Build metadata for response routing
let metadata = TelegramMessageMetadata {
chat_id: message.chat.id,
message_id: message.message_id,
user_id: from.id,
is_private,
};
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default();
let content_to_emit = match content_to_emit_for_agent(
&content,
if bot_username.is_empty() {
None
} else {
Some(bot_username.as_str())
},
) {
Some(value) => value,
// Allow attachment-only messages even without text
None if !attachments.is_empty() => String::new(),
None => return,
};
// Emit the message to the agent
channel_host::emit_message(&EmittedMessage {
user_id: from.id.to_string(),
user_name: Some(user_name),
content: content_to_emit,
thread_id: None, // Telegram doesn't have threads in the same way
metadata_json,
attachments,
});
channel_host::log(
channel_host::LogLevel::Debug,
&format!(
"Emitted message from user {} in chat {}",
from.id, message.chat.id
),
);
}
/// Clean message text by removing bot commands and @mentions at the start.
/// When bot_username is set, only strips that specific mention; otherwise strips any leading @mention.
fn clean_message_text(text: &str, bot_username: Option<&str>) -> String {
let mut result = text.trim().to_string();
// Remove leading /command
if result.starts_with('/') {
if let Some(space_idx) = result.find(' ') {
result = result[space_idx..].trim_start().to_string();
} else {
// Just a command with no text
return String::new();
}
}
// Remove leading @mention
if result.starts_with('@') {
if let Some(bot) = bot_username {
let mention = format!("@{}", bot);
let mention_lower = mention.to_lowercase();
let result_lower = result.to_lowercase();
if result_lower.starts_with(&mention_lower) {
let rest = result[mention.len()..].trim_start();
if rest.is_empty() {
return String::new();
}
result = rest.to_string();
} else if let Some(space_idx) = result.find(' ') {
// Different leading @mention - only strip if it's the bot
let first_word = &result[..space_idx];
if first_word.eq_ignore_ascii_case(&mention) {
result = result[space_idx..].trim_start().to_string();
}
}
} else {
// No bot_username: strip any leading @mention
if let Some(space_idx) = result.find(' ') {
result = result[space_idx..].trim_start().to_string();
} else {
return String::new();
}
}
}
result
}
/// Decide which user content should be emitted to the agent loop.
///
/// - `/start` emits a placeholder so the agent can greet the user
/// - bare slash commands are passed through for Submission parsing
/// - empty/mention-only messages are ignored
/// - otherwise cleaned text is emitted
fn content_to_emit_for_agent(content: &str, bot_username: Option<&str>) -> Option<String> {
let cleaned_text = clean_message_text(content, bot_username);
let trimmed_content = content.trim();
if trimmed_content.eq_ignore_ascii_case("/start") {
return Some("[User started the bot]".to_string());
}
if cleaned_text.is_empty() && trimmed_content.starts_with('/') {
return Some(trimmed_content.to_string());
}
if cleaned_text.is_empty() {
return None;
}
Some(cleaned_text)
}
// ============================================================================
// Utilities
// ============================================================================
/// Create a JSON HTTP response.
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
let body = serde_json::to_vec(&value).unwrap_or_default();
let headers = serde_json::json!({"Content-Type": "application/json"});
OutgoingHttpResponse {
status,
headers_json: headers.to_string(),
body,
}
}
// Export the component
export!(TelegramChannel);
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clean_message_text() {
// Without bot_username: strips any leading @mention
assert_eq!(clean_message_text("/start hello", None), "hello");
assert_eq!(clean_message_text("@bot hello world", None), "hello world");
assert_eq!(clean_message_text("/start", None), "");
assert_eq!(clean_message_text("@botname", None), "");
assert_eq!(clean_message_text("just text", None), "just text");
assert_eq!(clean_message_text(" spaced ", None), "spaced");
// With bot_username: only strips @MyBot, not @alice
assert_eq!(clean_message_text("@MyBot hello", Some("MyBot")), "hello");
assert_eq!(clean_message_text("@mybot hi", Some("MyBot")), "hi");
assert_eq!(
clean_message_text("@alice hello", Some("MyBot")),
"@alice hello"
);
assert_eq!(clean_message_text("@MyBot", Some("MyBot")), "");
}
#[test]
fn test_clean_message_text_bare_commands() {
// Bare commands return empty (the caller decides what to emit)
assert_eq!(clean_message_text("/start", None), "");
assert_eq!(clean_message_text("/interrupt", None), "");
assert_eq!(clean_message_text("/stop", None), "");
assert_eq!(clean_message_text("/help", None), "");
assert_eq!(clean_message_text("/undo", None), "");
assert_eq!(clean_message_text("/ping", None), "");
// Commands with args: command prefix stripped, args returned
assert_eq!(clean_message_text("/start hello", None), "hello");
assert_eq!(clean_message_text("/help me please", None), "me please");
assert_eq!(
clean_message_text("/model claude-opus-4-6", None),
"claude-opus-4-6"
);
}
/// Tests for the content_to_emit logic in handle_message.
/// Since handle_message uses WASM host calls, test the extracted decision function.
#[test]
fn test_content_to_emit_logic() {
// /start → welcome placeholder
assert_eq!(
content_to_emit_for_agent("/start", None),
Some("[User started the bot]".to_string())
);
assert_eq!(
content_to_emit_for_agent("/Start", None),
Some("[User started the bot]".to_string())
);
assert_eq!(
content_to_emit_for_agent(" /start ", None),
Some("[User started the bot]".to_string())
);
// /start with args → pass args through
assert_eq!(
content_to_emit_for_agent("/start hello", None),
Some("hello".to_string())
);
// Control commands → pass through raw so Submission::parse() can match
assert_eq!(
content_to_emit_for_agent("/interrupt", None),
Some("/interrupt".to_string())
);
assert_eq!(
content_to_emit_for_agent("/stop", None),
Some("/stop".to_string())
);
assert_eq!(
content_to_emit_for_agent("/help", None),
Some("/help".to_string())
);
assert_eq!(
content_to_emit_for_agent("/undo", None),
Some("/undo".to_string())
);
assert_eq!(
content_to_emit_for_agent("/redo", None),
Some("/redo".to_string())
);
assert_eq!(
content_to_emit_for_agent("/ping", None),
Some("/ping".to_string())
);
assert_eq!(
content_to_emit_for_agent("/tools", None),
Some("/tools".to_string())
);
assert_eq!(
content_to_emit_for_agent("/compact", None),
Some("/compact".to_string())
);
assert_eq!(
content_to_emit_for_agent("/clear", None),
Some("/clear".to_string())
);
assert_eq!(
content_to_emit_for_agent("/version", None),
Some("/version".to_string())
);
assert_eq!(
content_to_emit_for_agent("/approve", None),
Some("/approve".to_string())
);
assert_eq!(
content_to_emit_for_agent("/always", None),
Some("/always".to_string())
);
assert_eq!(
content_to_emit_for_agent("/deny", None),
Some("/deny".to_string())
);
assert_eq!(
content_to_emit_for_agent("/yes", None),
Some("/yes".to_string())
);
assert_eq!(
content_to_emit_for_agent("/no", None),
Some("/no".to_string())
);
// Commands with args → cleaned text (command stripped)
assert_eq!(
content_to_emit_for_agent("/help me please", None),
Some("me please".to_string())
);
// Plain text → pass through
assert_eq!(
content_to_emit_for_agent("hello world", None),
Some("hello world".to_string())
);
assert_eq!(
content_to_emit_for_agent("just text", None),
Some("just text".to_string())
);
// Empty / whitespace → skip (None)
assert_eq!(content_to_emit_for_agent("", None), None);
assert_eq!(content_to_emit_for_agent(" ", None), None);
// Bare @mention without bot → skip
assert_eq!(content_to_emit_for_agent("@botname", None), None);
// With bot username configured: other mentions are preserved.
assert_eq!(
content_to_emit_for_agent("@alice hello", Some("MyBot")),
Some("@alice hello".to_string())
);
}
#[test]
fn test_config_with_owner_id() {
let json = r#"{"owner_id": 123456789}"#;
let config: TelegramConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.owner_id, Some(123456789));
}
#[test]
fn test_config_without_owner_id() {
let json = r#"{}"#;
let config: TelegramConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.owner_id, None);
}
#[test]
fn test_config_with_null_owner_id() {
let json = r#"{"owner_id": null}"#;
let config: TelegramConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.owner_id, None);
}
#[test]
fn test_config_full() {
let json = r#"{
"bot_username": "my_bot",
"owner_id": 42,
"respond_to_all_group_messages": true
}"#;
let config: TelegramConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.bot_username, Some("my_bot".to_string()));
assert_eq!(config.owner_id, Some(42));
assert!(config.respond_to_all_group_messages);
}
#[test]
fn test_parse_update() {
let json = r#"{
"update_id": 123,
"message": {
"message_id": 456,
"from": {
"id": 789,
"is_bot": false,
"first_name": "John",
"last_name": "Doe"
},
"chat": {
"id": 789,
"type": "private"
},
"text": "Hello bot"
}
}"#;
let update: TelegramUpdate = serde_json::from_str(json).unwrap();
assert_eq!(update.update_id, 123);
let message = update.message.unwrap();
assert_eq!(message.message_id, 456);
assert_eq!(message.text.unwrap(), "Hello bot");
let from = message.from.unwrap();
assert_eq!(from.id, 789);
assert_eq!(from.first_name, "John");
}
#[test]
fn test_parse_message_with_caption() {
let json = r#"{
"message_id": 1,
"from": {"id": 1, "is_bot": false, "first_name": "A"},
"chat": {"id": 1, "type": "private"},
"caption": "What's in this image?"
}"#;
let msg: TelegramMessage = serde_json::from_str(json).unwrap();
assert_eq!(msg.text, None);
assert_eq!(msg.caption.as_deref(), Some("What's in this image?"));
}
#[test]
fn test_get_updates_url_includes_offset_and_timeout() {
let url = get_updates_url(444_809_884, 30);
assert!(url.contains("offset=444809884"));
assert!(url.contains("timeout=30"));
assert!(url.contains("allowed_updates=[\"message\",\"edited_message\"]"));
}
#[test]
fn test_classify_status_update_thinking() {
let update = StatusUpdate {
status: StatusType::Thinking,
message: "Thinking...".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(
classify_status_update(&update),
Some(TelegramStatusAction::Typing)
);
}
#[test]
fn test_classify_status_update_approval_needed() {
let update = StatusUpdate {
status: StatusType::ApprovalNeeded,
message: "Approval needed for tool 'http_request'".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(
classify_status_update(&update),
Some(TelegramStatusAction::Notify(
"Approval needed for tool 'http_request'".to_string()
))
);
}
#[test]
fn test_classify_status_update_done_ignored() {
let update = StatusUpdate {
status: StatusType::Done,
message: "Done".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(classify_status_update(&update), None);
}
#[test]
fn test_classify_status_update_auth_required() {
let update = StatusUpdate {
status: StatusType::AuthRequired,
message: "Authentication required for weather.".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(
classify_status_update(&update),
Some(TelegramStatusAction::Notify(
"Authentication required for weather.".to_string()
))
);
}
#[test]
fn test_classify_status_update_tool_started_ignored() {
let update = StatusUpdate {
status: StatusType::ToolStarted,
message: "Tool started: http_request".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(classify_status_update(&update), None);
}
#[test]
fn test_classify_status_update_tool_completed_ignored() {
let update = StatusUpdate {
status: StatusType::ToolCompleted,
message: "Tool completed: http_request (ok)".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(classify_status_update(&update), None);
}
#[test]
fn test_classify_status_update_job_started_notify() {
let update = StatusUpdate {
status: StatusType::JobStarted,
message: "Job started: Daily sync".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(
classify_status_update(&update),
Some(TelegramStatusAction::Notify(
"Job started: Daily sync".to_string()
))
);
}
#[test]
fn test_classify_status_update_auth_completed_notify() {
let update = StatusUpdate {
status: StatusType::AuthCompleted,
message: "Authentication completed for weather.".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(
classify_status_update(&update),
Some(TelegramStatusAction::Notify(
"Authentication completed for weather.".to_string()
))
);
}
#[test]
fn test_classify_status_update_tool_result_ignored() {
let update = StatusUpdate {
status: StatusType::ToolResult,
message: "Tool result: http_request ...".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(classify_status_update(&update), None);
}
#[test]
fn test_classify_status_update_awaiting_approval_ignored() {
let update = StatusUpdate {
status: StatusType::Status,
message: "Awaiting approval".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(classify_status_update(&update), None);
}
#[test]
fn test_classify_status_update_interrupted_ignored() {
let update = StatusUpdate {
status: StatusType::Interrupted,
message: "Interrupted".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(classify_status_update(&update), None);
}
#[test]
fn test_classify_status_update_status_done_ignored_case_insensitive() {
let update = StatusUpdate {
status: StatusType::Status,
message: "done".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(classify_status_update(&update), None);
}
#[test]
fn test_classify_status_update_status_interrupted_ignored() {
let update = StatusUpdate {
status: StatusType::Status,
message: "interrupted".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(classify_status_update(&update), None);
}
#[test]
fn test_classify_status_update_status_rejected_ignored() {
let update = StatusUpdate {
status: StatusType::Status,
message: "Rejected".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(classify_status_update(&update), None);
}
#[test]
fn test_classify_status_update_status_notify() {
let update = StatusUpdate {
status: StatusType::Status,
message: "Context compaction started".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(
classify_status_update(&update),
Some(TelegramStatusAction::Notify(
"Context compaction started".to_string()
))
);
}
#[test]
fn test_status_message_for_user_ignores_blank() {
let update = StatusUpdate {
status: StatusType::AuthRequired,
message: " ".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(status_message_for_user(&update), None);
}
#[test]
fn test_truncate_status_message_appends_ellipsis() {
let input = "abcdefghijklmnopqrstuvwxyz";
let output = truncate_status_message(input, 10);
assert_eq!(output, "abcdefghij...");
}
#[test]
fn test_status_message_for_user_truncates_long_input() {
let update = StatusUpdate {
status: StatusType::AuthRequired,
message: "x".repeat(700),
metadata_json: "{}".to_string(),
};
let msg = status_message_for_user(&update).expect("expected message");
assert!(msg.len() <= TELEGRAM_STATUS_MAX_CHARS + 3);
assert!(msg.ends_with("..."));
}
// === Attachment extraction fixture tests ===
#[test]
fn test_extract_attachments_photo() {
let json = r#"{
"message_id": 1,
"from": {"id": 1, "is_bot": false, "first_name": "A"},
"chat": {"id": 1, "type": "private"},
"caption": "What is this?",
"photo": [
{"file_id": "small_id", "file_unique_id": "s1", "width": 90, "height": 90, "file_size": 1234},
{"file_id": "large_id", "file_unique_id": "l1", "width": 800, "height": 600, "file_size": 54321}
]
}"#;
let msg: TelegramMessage = serde_json::from_str(json).unwrap();
let attachments = extract_attachments(&msg);
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].id, "large_id"); // Largest photo
assert_eq!(attachments[0].mime_type, "image/jpeg");
assert_eq!(attachments[0].size_bytes, Some(54321));
assert!(attachments[0].source_url.as_ref().unwrap().contains("large_id"));
}
#[test]
fn test_extract_attachments_document() {
let json = r#"{
"message_id": 2,
"from": {"id": 1, "is_bot": false, "first_name": "A"},
"chat": {"id": 1, "type": "private"},
"document": {
"file_id": "doc_abc",
"file_unique_id": "d1",
"file_name": "report.pdf",
"mime_type": "application/pdf",
"file_size": 102400
},
"caption": "Here is the report"
}"#;
let msg: TelegramMessage = serde_json::from_str(json).unwrap();
let attachments = extract_attachments(&msg);
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].id, "doc_abc");
assert_eq!(attachments[0].mime_type, "application/pdf");
assert_eq!(attachments[0].filename, Some("report.pdf".to_string()));
assert_eq!(attachments[0].size_bytes, Some(102400));
}
#[test]
fn test_extract_attachments_voice() {
let json = r#"{
"message_id": 3,
"from": {"id": 1, "is_bot": false, "first_name": "A"},
"chat": {"id": 1, "type": "private"},
"voice": {
"file_id": "voice_xyz",
"file_unique_id": "v1",
"duration": 5,
"mime_type": "audio/ogg",
"file_size": 9000
}
}"#;
let msg: TelegramMessage = serde_json::from_str(json).unwrap();
let attachments = extract_attachments(&msg);
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].id, "voice_xyz");
assert_eq!(attachments[0].mime_type, "audio/ogg");
assert_eq!(
attachments[0].filename.as_deref(),
Some("voice_voice_xyz.ogg")
);
assert!(attachments[0]
.extras_json
.contains("\"duration_secs\":5"));
}
#[test]
fn test_extract_attachments_video() {
let json = r#"{
"message_id": 4,
"from": {"id": 1, "is_bot": false, "first_name": "A"},
"chat": {"id": 1, "type": "private"},
"video": {
"file_id": "vid_1",
"file_unique_id": "vv1",
"file_name": "clip.mp4",
"mime_type": "video/mp4",
"file_size": 5000000
},
"caption": "Check this out"
}"#;
let msg: TelegramMessage = serde_json::from_str(json).unwrap();
let attachments = extract_attachments(&msg);
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].id, "vid_1");
assert_eq!(attachments[0].mime_type, "video/mp4");
assert_eq!(attachments[0].filename, Some("clip.mp4".to_string()));
}
#[test]
fn test_extract_attachments_audio() {
let json = r#"{
"message_id": 5,
"from": {"id": 1, "is_bot": false, "first_name": "A"},
"chat": {"id": 1, "type": "private"},
"audio": {
"file_id": "audio_1",
"file_unique_id": "a1",
"file_name": "song.mp3",
"mime_type": "audio/mpeg",
"file_size": 3000000
}
}"#;
let msg: TelegramMessage = serde_json::from_str(json).unwrap();
let attachments = extract_attachments(&msg);
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].id, "audio_1");
assert_eq!(attachments[0].mime_type, "audio/mpeg");
assert_eq!(attachments[0].filename, Some("song.mp3".to_string()));
}
#[test]
fn test_extract_attachments_sticker() {
let json = r#"{
"message_id": 6,
"from": {"id": 1, "is_bot": false, "first_name": "A"},
"chat": {"id": 1, "type": "private"},
"sticker": {
"file_id": "sticker_1",
"file_unique_id": "st1",
"type": "regular",
"file_size": 20000
}
}"#;
let msg: TelegramMessage = serde_json::from_str(json).unwrap();
let attachments = extract_attachments(&msg);
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].id, "sticker_1");
assert_eq!(attachments[0].mime_type, "image/webp");
}
#[test]
fn test_extract_attachments_text_only_empty() {
let json = r#"{
"message_id": 7,
"from": {"id": 1, "is_bot": false, "first_name": "A"},
"chat": {"id": 1, "type": "private"},
"text": "Hello"
}"#;
let msg: TelegramMessage = serde_json::from_str(json).unwrap();
let attachments = extract_attachments(&msg);
assert!(attachments.is_empty());
}
#[test]
fn test_extract_attachments_multiple_types() {
let json = r#"{
"message_id": 8,
"from": {"id": 1, "is_bot": false, "first_name": "A"},
"chat": {"id": 1, "type": "private"},
"photo": [
{"file_id": "photo_1", "file_unique_id": "p1", "width": 100, "height": 100}
],
"document": {
"file_id": "doc_1",
"file_unique_id": "d1",
"file_name": "file.txt",
"mime_type": "text/plain"
}
}"#;
let msg: TelegramMessage = serde_json::from_str(json).unwrap();
let attachments = extract_attachments(&msg);
// Both photo and document should be extracted
assert_eq!(attachments.len(), 2);
}
#[test]
fn test_parse_update_with_photo_fallback_content() {
// A photo-only message (no text, no caption) should have empty content
// but still produce attachments
let json = r#"{
"message_id": 9,
"from": {"id": 42, "is_bot": false, "first_name": "Test"},
"chat": {"id": 42, "type": "private"},
"photo": [
{"file_id": "ph1", "file_unique_id": "u1", "width": 320, "height": 240}
]
}"#;
let msg: TelegramMessage = serde_json::from_str(json).unwrap();
// Content is empty (no text, no caption)
assert!(msg.text.is_none());
assert!(msg.caption.is_none());
// But attachments exist
let attachments = extract_attachments(&msg);
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].id, "ph1");
}
#[test]
fn test_is_downloadable_document() {
let make = |mime: &str, filename: Option<&str>| InboundAttachment {
id: "test".to_string(),
mime_type: mime.to_string(),
filename: filename.map(|s| s.to_string()),
size_bytes: Some(1024),
source_url: None,
storage_key: None,
extracted_text: None,
extras_json: String::new(),
};
// PDFs and Office docs should be downloaded
assert!(is_downloadable_document(&make("application/pdf", Some("report.pdf"))));
assert!(is_downloadable_document(&make(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
Some("doc.docx"),
)));
assert!(is_downloadable_document(&make("text/plain", Some("notes.txt"))));
// Voice, image, audio, video should NOT be downloaded
assert!(!is_downloadable_document(&make("audio/ogg", Some("voice_123.ogg"))));
assert!(!is_downloadable_document(&make("image/jpeg", None)));
assert!(!is_downloadable_document(&make("audio/mpeg", Some("song.mp3"))));
assert!(!is_downloadable_document(&make("video/mp4", Some("clip.mp4"))));
}
}