Files
optimclaw/channels-src/telegram/src/lib.rs
T
553c306c52 feat: full image support across all channels (#725)
* feat: full image support across all channels

End-to-end image handling: upload, generation, analysis, editing, and
rendering across web gateway, HTTP webhook, WASM (Telegram/Slack), and
REPL channels. Builds on the attachment infrastructure from #596 and
draws inspiration from PR #641's image pipeline approach — credit to
that PR's author for the sentinel JSON pattern and base64-in-JSON
upload design.

Key changes:
- Image upload in web UI (file picker, paste, preview strip)
- Image generation tool (FLUX/DALL-E via /v1/images/generations)
- Image edit tool (multipart /v1/images/edits with fallback)
- Image analysis tool (vision model for workspace images)
- Model detection utilities (image_models.rs, vision_models.rs)
- Sentinel JSON detection in dispatcher for generated image rendering
- StatusUpdate::ImageGenerated → SSE/WS/REPL/WASM broadcast
- HTTP webhook attachment support (base64, 5MB/file, 10MB total)
- WASM channel image download (Telegram via file API, Slack via host HTTP)
- Tool registration wiring in app.rs

[skip-regression-check]

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

* fix: address PR #725 review comments (16 issues)

- SecretString for API keys in all image tools (image_gen, image_edit, image_analyze)
- Binary image read via tokio::fs::read instead of DB-backed workspace.read()
- Replace Arc<Workspace> with Option<PathBuf> base_dir (workspace has no filesystem API)
- ApprovalRequirement::UnlessAutoApproved for cost-sensitive image tools
- Scope sentinel detection to image_generate/image_edit tool names only
- Skip ToolResult preview broadcast for image sentinels (avoids multi-MB base64 in SSE)
- Extract shared media_type_from_path() to builtin/mod.rs
- Rename fallback_chat_edit → fallback_generate with tracing::warn
- Increase gateway body limit from 1MB to 10MB for image uploads
- Increase webhook body limit to 15MB (base64 overhead)
- Log warning on invalid base64 in images_to_attachments
- Client-side image size limits (5MB/file, 5 images max) in app.js
- aria-label on attach button for accessibility
- Update body_too_large test for new 10MB limit

[skip-regression-check]

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

* fix: add Slack file size check before download (PR review item #15)

Skip downloading files larger than 20 MB in the Slack WASM channel to
avoid excessive memory use and slow downloads in the WASM runtime.
Logs a warning when a file is skipped. Also bumps channel versions
for Slack and Telegram (prior branch changes).

[skip-regression-check]

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

* style: cargo fmt

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

* fix(security): add path validation and approval requirement to image tools

Add sandbox path validation via validate_path() to both ImageAnalyzeTool
and ImageEditTool to prevent path traversal attacks that could exfiltrate
arbitrary files through external vision/edit APIs. Also fix
ImageAnalyzeTool::requires_approval to return UnlessAutoApproved,
consistent with ImageEditTool and ImageGenerateTool.

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

* fix: post-download size guards and empty data_url sentinel check

- Slack: add post-download size check on actual bytes when metadata
  size_bytes is absent, preventing bypass of the 20MB limit
- Telegram: add 20MB download size limit (matching Slack) enforced
  in download_telegram_file() after receiving response bytes
- Dispatcher: skip broadcasting ImageGenerated SSE event when
  data_url is empty from unwrap_or_default(), log warning instead

Closes correctness issues #3, #4, #5 from PR #725 review.

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

* fix: use mime_guess for media type detection, add alt attrs and media_type validation

- Replace hardcoded media type mapping with mime_guess crate (already in deps)
- Add alt attributes to img elements in web UI for accessibility
- Validate media_type starts with "image/" in images_to_attachments()
- Update bmp test assertion to match mime_guess behavior

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Zaki <[email protected]>
2026-03-09 03:41:27 +00:00

2661 lines
88 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
// ============================================================================
/// 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
}
/// Maximum file size to download (20 MB). Files larger than this are discarded
/// to avoid excessive memory use and slow downloads in the WASM runtime.
const MAX_DOWNLOAD_SIZE_BYTES: u64 = 20 * 1024 * 1024;
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
));
}
// Post-download size guard: Telegram metadata file_size is optional,
// so enforce the limit on actual downloaded bytes.
if response.body.len() as u64 > MAX_DOWNLOAD_SIZE_BYTES {
return Err(format!(
"Downloaded file exceeds {} MB limit ({} bytes)",
MAX_DOWNLOAD_SIZE_BYTES / (1024 * 1024),
response.body.len()
));
}
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),
);
}
}
}
}
/// Download image file bytes and store them via the host for the vision pipeline.
///
/// Separated from `extract_attachments` so that function stays pure (no host
/// calls) and remains testable in native unit tests.
fn download_and_store_images(attachments: &[InboundAttachment]) {
for att in attachments {
if !att.mime_type.starts_with("image/") {
continue;
}
match download_telegram_file(&att.id) {
Ok(bytes) => {
channel_host::log(
channel_host::LogLevel::Info,
&format!("Downloaded image 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 image data: {}", e),
);
}
}
Err(e) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to download image 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 image attachments for host-side vision pipeline
download_and_store_images(&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.is_some_and(|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"))));
}
#[test]
fn test_max_download_size_constant() {
// Verify the constant is 20 MB, matching the Slack channel limit
assert_eq!(MAX_DOWNLOAD_SIZE_BYTES, 20 * 1024 * 1024);
}
}