mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Add Telegram Bot API channel as WASM module
Implements a loadable WASM channel for Telegram following the existing Slack channel pattern: - Webhook-based message receiving at /webhook/telegram - Private chat and group chat support (with @mention filtering) - Reply threading via reply_to_message_id - User name extraction from Telegram user objects - Bot token injection by host (never exposed to WASM) Files: - channels-src/telegram/src/lib.rs - Main implementation - channels-src/telegram/Cargo.toml - Dependencies - channels-src/telegram/telegram.capabilities.json - Permissions - channels-src/telegram/build.sh - Build script To use: copy telegram.wasm and telegram.capabilities.json to ~/.near-agent/channels/ and configure telegram_bot_token secret. Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
be18812376
commit
1605939e2a
Generated
+401
@@ -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"
|
||||
@@ -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
|
||||
Executable
+43
@@ -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
|
||||
@@ -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<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,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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<String>,
|
||||
|
||||
/// 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<ChannelConfig, String> {
|
||||
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<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_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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user