Files
optimclaw/channels-src/telegram/src/lib.rs
T
Illia PolosukhinandClaude Opus 4.6 48ab73574e Add owner-only access control for Telegram bot
Restrict the bot so only the configured owner_id can interact with it.
Non-owner messages are silently dropped with a debug log, keeping the
bot invisible to strangers. Owner ID is persisted to workspace storage
in on_start so stateless WASM callbacks can read it.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-05 21:52:35 -08:00

949 lines
31 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, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
OutgoingHttpResponse, PollConfig, StatusType, StatusUpdate,
};
use near::agent::channel_host::{self, EmittedMessage};
// ============================================================================
// 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>,
/// Original message if this is a reply.
reply_to_message: Option<Box<TelegramMessage>>,
/// Bot command entities (for /commands).
entities: Option<Vec<MessageEntity>>,
}
/// 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 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";
// ============================================================================
// 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>,
/// 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>,
}
// ============================================================================
// Channel Implementation
// ============================================================================
struct TelegramChannel;
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",
);
}
// Mode is determined by whether the host injected a tunnel_url
// If tunnel is configured, use webhooks. Otherwise, use polling.
let webhook_mode = config.tunnel_url.is_some();
if webhook_mode {
channel_host::log(
channel_host::LogLevel::Info,
"Webhook mode enabled (tunnel configured)",
);
// Register webhook with Telegram API
if let Some(ref tunnel_url) = config.tunnel_url {
channel_host::log(
channel_host::LogLevel::Info,
&format!("Registering webhook: {}/webhook/telegram", tunnel_url),
);
if let Err(e) = register_webhook(tunnel_url, config.webhook_secret.as_deref()) {
channel_host::log(
channel_host::LogLevel::Error,
&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 doesn't allow getUpdates while a webhook is active
if let Err(e) = delete_webhook() {
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Failed to delete webhook (may not exist): {}", 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),
);
// Build getUpdates URL with parameters
// - offset: Identifier of the first update to be returned
// - timeout: Long polling timeout in seconds (Telegram recommends 30+)
// - allowed_updates: Only get message updates
let url = format!(
"https://api.telegram.org/bot{{TELEGRAM_BOT_TOKEN}}/getUpdates?offset={}&timeout=30&allowed_updates=[\"message\",\"edited_message\"]",
offset
);
let headers = serde_json::json!({});
let result = channel_host::http_request("GET", &url, &headers.to_string(), None);
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> {
// Parse metadata to get chat info
let metadata: TelegramMessageMetadata = serde_json::from_str(&response.metadata_json)
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
// Build sendMessage payload
let mut payload = serde_json::json!({
"chat_id": metadata.chat_id,
"text": response.content,
"parse_mode": "Markdown",
});
// Reply to the original message for context
payload["reply_to_message_id"] = serde_json::Value::Number(metadata.message_id.into());
let payload_bytes = serde_json::to_vec(&payload)
.map_err(|e| format!("Failed to serialize payload: {}", e))?;
// Make HTTP request to Telegram API
// The bot token is injected into the URL by the host
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),
);
match result {
Ok(http_response) => {
if http_response.status != 200 {
let body_str = String::from_utf8_lossy(&http_response.body);
return Err(format!(
"Telegram API returned status {}: {}",
http_response.status, body_str
));
}
// Parse Telegram response
let api_response: TelegramApiResponse<SentMessage> =
serde_json::from_slice(&http_response.body)
.map_err(|e| format!("Failed to parse Telegram 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::Debug,
&format!(
"Sent message to chat {}: message_id={}",
metadata.chat_id,
api_response.result.map(|r| r.message_id).unwrap_or(0)
),
);
Ok(())
}
Err(e) => Err(format!("HTTP request failed: {}", e)),
}
}
fn on_status(update: StatusUpdate) {
// Only send typing indicator for Thinking status
if !matches!(update.status, StatusType::Thinking) {
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 typing indicator",
);
return;
}
};
// 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),
);
if let Err(e) = result {
channel_host::log(
channel_host::LogLevel::Debug,
&format!("sendChatAction failed: {}", e),
);
}
}
fn on_shutdown() {
channel_host::log(
channel_host::LogLevel::Info,
"Telegram channel shutting down",
);
}
}
// ============================================================================
// 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,
);
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),
);
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));
}
// 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 {
return Err(format!(
"Telegram API error: {}",
api_response
.description
.unwrap_or_else(|| "unknown".to_string())
));
}
channel_host::log(
channel_host::LogLevel::Info,
&format!("Webhook registered successfully: {}", webhook_url),
);
Ok(())
}
Err(e) => Err(format!("HTTP request failed: {}", e)),
}
}
// ============================================================================
// 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);
}
}
/// Process a single message.
fn handle_message(message: TelegramMessage) {
// Skip messages without text
let text = match message.text {
Some(t) if !t.is_empty() => t,
_ => 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;
}
// Owner validation: silently drop messages from non-owner users
if let Some(owner_id_str) = channel_host::workspace_read(OWNER_ID_PATH) {
if !owner_id_str.is_empty() {
if let Ok(owner_id) = owner_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;
}
}
}
}
let is_private = message.chat.chat_type == "private";
// For group chats, check if the bot was mentioned
// TODO: Read bot_username from config and check mentions
// For now, process all messages in private chats and groups
if !is_private {
// In groups, only respond if there's a bot mention or command
// This is a simplified check - proper implementation would use entities
let has_command = text.starts_with('/');
let has_mention = text.contains('@');
if !has_command && !has_mention {
channel_host::log(
channel_host::LogLevel::Debug,
&format!("Ignoring group message without mention: {}", text),
);
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());
// Clean the message text (strip bot mentions and commands)
let cleaned_text = clean_message_text(&text);
if cleaned_text.is_empty() {
return;
}
// Emit the message to the agent
channel_host::emit_message(&EmittedMessage {
user_id: from.id.to_string(),
user_name: Some(user_name),
content: cleaned_text,
thread_id: None, // Telegram doesn't have threads in the same way
metadata_json,
});
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.
fn clean_message_text(text: &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(space_idx) = result.find(' ') {
result = result[space_idx..].trim_start().to_string();
} else {
// Just a mention with no text
return String::new();
}
}
result
}
// ============================================================================
// 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() {
assert_eq!(clean_message_text("/start hello"), "hello");
assert_eq!(clean_message_text("@bot hello world"), "hello world");
assert_eq!(clean_message_text("/start"), "");
assert_eq!(clean_message_text("@botname"), "");
assert_eq!(clean_message_text("just text"), "just text");
assert_eq!(clean_message_text(" spaced "), "spaced");
}
#[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");
}
}