diff --git a/CLAUDE.md b/CLAUDE.md index ba2887cb..6ae73405 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -351,7 +351,7 @@ Inspired by [OpenClaw](https://github.com/openclaw/openclaw), the workspace prov ### Key Principles -1. **"Memory is files, not RAM"** - If you want to remember something, write it explicitly +1. **"Memory is database, not RAM"** - If you want to remember something, write it explicitly 2. **Flexible structure** - Create any directory/file hierarchy you need 3. **Self-documenting** - Use README.md files to describe directory structure 4. **Hybrid search** - Combines FTS (keyword) + vector (semantic) via Reciprocal Rank Fusion diff --git a/channels-src/telegram/Cargo.lock b/channels-src/telegram/Cargo.lock new file mode 100644 index 00000000..a6e5c3ac --- /dev/null +++ b/channels-src/telegram/Cargo.lock @@ -0,0 +1,401 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "leb128" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spdx" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3" +dependencies = [ + "smallvec", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "telegram-channel" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "wit-bindgen", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-encoder" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e913f9242315ca39eff82aee0e19ee7a372155717ff0eb082c741e435ce25ed1" +dependencies = [ + "leb128", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185dfcd27fa5db2e6a23906b54c28199935f71d9a27a1a27b3a88d6fee2afae7" +dependencies = [ + "anyhow", + "indexmap", + "serde", + "serde_derive", + "serde_json", + "spdx", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25" +dependencies = [ + "ahash", + "bitflags", + "hashbrown 0.14.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2b3e15cd6068f233926e7d8c7c588b2ec4fb7cc7bf3824115e7c7e2a8485a3" +dependencies = [ + "wit-bindgen-rt", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b632a5a0fa2409489bd49c9e6d99fcc61bb3d4ce9d1907d44662e75a28c71172" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7947d0131c7c9da3f01dfde0ab8bd4c4cf3c5bd49b6dba0ae640f1fa752572ea" +dependencies = [ + "bitflags", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4329de4186ee30e2ef30a0533f9b3c123c019a237a7c82d692807bf1b3ee2697" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "177fb7ee1484d113b4792cc480b1ba57664bbc951b42a4beebe573502135b1fc" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b505603761ed400c90ed30261f44a768317348e49f1864e82ecdc3b2744e5627" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae2a7999ed18efe59be8de2db9cb2b7f84d88b27818c79353dfc53131840fe1a" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57cf3aa6855b23711ee9852dfc97dfaa51c45feaba5b645d0c777414d494a961" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a616990af1a287837c4fe6596ad77ef57948f787e46ce28e166facc0cc1cb75" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/channels-src/telegram/Cargo.toml b/channels-src/telegram/Cargo.toml new file mode 100644 index 00000000..d33266e5 --- /dev/null +++ b/channels-src/telegram/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "telegram-channel" +version = "0.1.0" +edition = "2021" +description = "Telegram Bot API channel for NEAR Agent" +license = "MIT OR Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +# WIT bindgen for WASM component model +wit-bindgen = "0.36" + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[profile.release] +# Optimize for size +opt-level = "s" +lto = true +strip = true +codegen-units = 1 diff --git a/channels-src/telegram/build.sh b/channels-src/telegram/build.sh new file mode 100755 index 00000000..531ab762 --- /dev/null +++ b/channels-src/telegram/build.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Build the Telegram channel WASM component +# +# Prerequisites: +# - Rust with wasm32-wasip2 target: rustup target add wasm32-wasip2 +# - wasm-tools for component creation: cargo install wasm-tools +# +# Output: +# - telegram.wasm - WASM component ready for deployment +# - telegram.capabilities.json - Capabilities file (copy alongside .wasm) + +set -euo pipefail + +cd "$(dirname "$0")" + +echo "Building Telegram channel WASM component..." + +# Build the WASM module +cargo build --release --target wasm32-wasip2 + +# Convert to component model (if not already a component) +# wasm-tools component new is idempotent on components +WASM_PATH="target/wasm32-wasip2/release/telegram_channel.wasm" + +if [ -f "$WASM_PATH" ]; then + # Create component if needed + wasm-tools component new "$WASM_PATH" -o telegram.wasm 2>/dev/null || cp "$WASM_PATH" telegram.wasm + + # Optimize the component + wasm-tools strip telegram.wasm -o telegram.wasm + + echo "Built: telegram.wasm ($(du -h telegram.wasm | cut -f1))" + echo "" + echo "To install:" + echo " mkdir -p ~/.near-agent/channels" + echo " cp telegram.wasm telegram.capabilities.json ~/.near-agent/channels/" + echo "" + echo "Then add your bot token to secrets:" + echo " # Set TELEGRAM_BOT_TOKEN in your environment or secrets store" +else + echo "Error: WASM output not found at $WASM_PATH" + exit 1 +fi diff --git a/channels-src/telegram/src/lib.rs b/channels-src/telegram/src/lib.rs new file mode 100644 index 00000000..e4d97f88 --- /dev/null +++ b/channels-src/telegram/src/lib.rs @@ -0,0 +1,548 @@ +// Telegram API types have fields reserved for future use (entities, reply threading, etc.) +#![allow(dead_code)] + +//! Telegram Bot API channel for NEAR Agent. +//! +//! 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, +}; +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, + + /// Edited message. + edited_message: Option, + + /// Channel post (we ignore these for now). + channel_post: Option, +} + +/// 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, + + /// Chat the message belongs to. + chat: TelegramChat, + + /// Message text. + text: Option, + + /// Original message if this is a reply. + reply_to_message: Option>, + + /// Bot command entities (for /commands). + entities: Option>, +} + +/// 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, + + /// Username (without @). + username: Option, +} + +/// 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, + + /// Username for private chats. + username: Option, +} + +/// 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, +} + +/// Telegram API response wrapper. +#[derive(Debug, Deserialize)] +struct TelegramApiResponse { + /// True if the request was successful. + ok: bool, + + /// Error description if not ok. + description: Option, + + /// Result on success. + result: Option, +} + +/// Response from sendMessage. +#[derive(Debug, Deserialize)] +struct SentMessage { + message_id: i64, +} + +// ============================================================================ +// 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 from capabilities file. +#[derive(Debug, Deserialize)] +struct TelegramConfig { + /// Bot username (without @) for mention detection in groups. + #[serde(default)] + bot_username: Option, + + /// Whether to respond to all group messages (not just mentions). + #[serde(default)] + respond_to_all_group_messages: bool, + + /// Whether to use polling instead of webhooks. + #[serde(default)] + polling_enabled: bool, + + /// Polling interval in milliseconds (if polling enabled). + #[serde(default = "default_poll_interval")] + poll_interval_ms: u32, +} + +fn default_poll_interval() -> u32 { + 30000 // 30 seconds (minimum allowed) +} + +// ============================================================================ +// Channel Implementation +// ============================================================================ + +struct TelegramChannel; + +impl Guest for TelegramChannel { + fn on_start(config_json: String) -> Result { + 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), + ); + } + + // Configure polling if enabled + let poll = if config.polling_enabled { + Some(PollConfig { + interval_ms: config.poll_interval_ms.max(30000), // Enforce minimum + enabled: true, + }) + } else { + None + }; + + Ok(ChannelConfig { + display_name: "Telegram".to_string(), + http_endpoints: vec![HttpEndpointConfig { + path: "/webhook/telegram".to_string(), + methods: vec!["POST".to_string()], + require_secret: false, // Telegram doesn't use signing secrets by default + }], + poll, + }) + } + + fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse { + // 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() { + // Polling mode: call getUpdates API + // For now, we focus on webhook mode. Polling can be added later. + channel_host::log( + channel_host::LogLevel::Debug, + "Polling tick (not implemented yet)", + ); + } + + 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 = + 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_shutdown() { + channel_host::log(channel_host::LogLevel::Info, "Telegram channel shutting down"); + } +} + +// ============================================================================ +// 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; + } + + 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_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"); + } +} diff --git a/channels-src/telegram/telegram.capabilities.json b/channels-src/telegram/telegram.capabilities.json new file mode 100644 index 00000000..23a4af65 --- /dev/null +++ b/channels-src/telegram/telegram.capabilities.json @@ -0,0 +1,42 @@ +{ + "type": "channel", + "name": "telegram", + "description": "Telegram Bot API channel for receiving and responding to Telegram messages", + "capabilities": { + "http": { + "allowlist": [ + { "host": "api.telegram.org", "path_prefix": "/bot" } + ], + "credentials": { + "telegram_bot": { + "secret_name": "telegram_bot_token", + "location": { "type": "url_path", "placeholder": "{TELEGRAM_BOT_TOKEN}" }, + "host_patterns": ["api.telegram.org"] + } + }, + "rate_limit": { + "requests_per_minute": 30, + "requests_per_hour": 1000 + } + }, + "secrets": { + "allowed_names": ["telegram_*"] + }, + "channel": { + "allowed_paths": ["/webhook/telegram"], + "allow_polling": true, + "min_poll_interval_ms": 30000, + "workspace_prefix": "channels/telegram/", + "emit_rate_limit": { + "messages_per_minute": 100, + "messages_per_hour": 5000 + } + } + }, + "config": { + "bot_username": null, + "respond_to_all_group_messages": false, + "polling_enabled": false, + "poll_interval_ms": 30000 + } +} diff --git a/channels-src/telegram/telegram.wasm b/channels-src/telegram/telegram.wasm new file mode 100644 index 00000000..be4c20b9 Binary files /dev/null and b/channels-src/telegram/telegram.wasm differ