mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Add Telegram webhook support with credential injection
Enable instant message delivery for Telegram via webhooks instead of polling.
Key changes:
- Add tunnel URL configuration for local development (ngrok, cloudflare)
- Auto-register webhook with Telegram API on startup using setWebhook
- Implement webhook secret validation via X-Telegram-Bot-Api-Secret-Token header
- Add credential injection for bot token via URL placeholder substitution
- Fix metadata preservation in respond() to route replies correctly
- Fix serde flatten with Option<T> issue in capabilities schema parsing
The credential injection pattern replaces {TELEGRAM_BOT_TOKEN} placeholders
in URLs with the actual token from the secrets store, keeping credentials
out of WASM module memory until the HTTP request is made.
Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
4ab20ff939
commit
7955c9742e
Generated
+290
-3
@@ -84,6 +84,12 @@ version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "ambient-authority"
|
||||
version = "0.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
@@ -426,6 +432,84 @@ version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
|
||||
|
||||
[[package]]
|
||||
name = "cap-fs-ext"
|
||||
version = "3.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654"
|
||||
dependencies = [
|
||||
"cap-primitives",
|
||||
"cap-std",
|
||||
"io-lifetimes",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cap-net-ext"
|
||||
version = "3.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20a158160765c6a7d0d8c072a53d772e4cb243f38b04bfcf6b4939cfbe7482e7"
|
||||
dependencies = [
|
||||
"cap-primitives",
|
||||
"cap-std",
|
||||
"rustix 1.1.3",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cap-primitives"
|
||||
version = "3.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a"
|
||||
dependencies = [
|
||||
"ambient-authority",
|
||||
"fs-set-times",
|
||||
"io-extras",
|
||||
"io-lifetimes",
|
||||
"ipnet",
|
||||
"maybe-owned",
|
||||
"rustix 1.1.3",
|
||||
"rustix-linux-procfs",
|
||||
"windows-sys 0.59.0",
|
||||
"winx",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cap-rand"
|
||||
version = "3.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d"
|
||||
dependencies = [
|
||||
"ambient-authority",
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cap-std"
|
||||
version = "3.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a"
|
||||
dependencies = [
|
||||
"cap-primitives",
|
||||
"io-extras",
|
||||
"io-lifetimes",
|
||||
"rustix 1.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cap-time-ext"
|
||||
version = "3.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80"
|
||||
dependencies = [
|
||||
"ambient-authority",
|
||||
"cap-primitives",
|
||||
"iana-time-zone",
|
||||
"once_cell",
|
||||
"rustix 1.1.3",
|
||||
"winx",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cassowary"
|
||||
version = "0.3.0"
|
||||
@@ -936,13 +1020,33 @@ dependencies = [
|
||||
"dirs-sys-next",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
|
||||
dependencies = [
|
||||
"dirs-sys 0.3.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
"dirs-sys 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"redox_users 0.4.6",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1074,6 +1178,17 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "fd-lock"
|
||||
version = "4.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rustix 1.1.3",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.27"
|
||||
@@ -1112,6 +1227,17 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs-set-times"
|
||||
version = "0.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a"
|
||||
dependencies = [
|
||||
"io-lifetimes",
|
||||
"rustix 1.1.3",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "funty"
|
||||
version = "2.0.0"
|
||||
@@ -1698,6 +1824,22 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-extras"
|
||||
version = "0.18.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65"
|
||||
dependencies = [
|
||||
"io-lifetimes",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-lifetimes"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983"
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
@@ -1916,6 +2058,12 @@ version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "maybe-owned"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
@@ -1973,7 +2121,7 @@ dependencies = [
|
||||
"clap",
|
||||
"crossterm",
|
||||
"deadpool-postgres",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"dotenvy",
|
||||
"futures",
|
||||
"hkdf",
|
||||
@@ -2007,6 +2155,7 @@ dependencies = [
|
||||
"uuid",
|
||||
"wasmparser 0.220.1",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2856,6 +3005,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix-linux-procfs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"rustix 1.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.36"
|
||||
@@ -3158,6 +3317,15 @@ dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellexpand"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ccc8076840c4da029af4f87e4e8daeb0fca6b87bbb02e10cb60b791450e11e4"
|
||||
dependencies = [
|
||||
"dirs 4.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
@@ -3360,6 +3528,22 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-interface"
|
||||
version = "0.27.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cap-fs-ext",
|
||||
"cap-std",
|
||||
"fd-lock",
|
||||
"io-lifetimes",
|
||||
"rustix 0.38.44",
|
||||
"windows-sys 0.59.0",
|
||||
"winx",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tap"
|
||||
version = "1.0.1"
|
||||
@@ -4383,6 +4567,36 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-wasi"
|
||||
version = "28.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a8e04b9a4c68ad018b330a4f4914b82b01dc3582d715ce21a93564c7f26b19f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"bitflags 2.10.0",
|
||||
"bytes",
|
||||
"cap-fs-ext",
|
||||
"cap-net-ext",
|
||||
"cap-rand",
|
||||
"cap-std",
|
||||
"cap-time-ext",
|
||||
"fs-set-times",
|
||||
"futures",
|
||||
"io-extras",
|
||||
"io-lifetimes",
|
||||
"rustix 0.38.44",
|
||||
"system-interface",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"wasmtime",
|
||||
"wiggle",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-winch"
|
||||
version = "28.0.1"
|
||||
@@ -4412,6 +4626,15 @@ dependencies = [
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wast"
|
||||
version = "35.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68"
|
||||
dependencies = [
|
||||
"leb128",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wast"
|
||||
version = "244.0.0"
|
||||
@@ -4431,7 +4654,7 @@ version = "1.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbf35b87ed352f9ab6cd0732abde5a67dd6153dfd02c493e61459218b19456fa"
|
||||
dependencies = [
|
||||
"wast",
|
||||
"wast 244.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4474,6 +4697,48 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wiggle"
|
||||
version = "28.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b23e3dc273d1e35cab9f38a5f76487aeeedcfa6a3fb594e209ee7b6f8b41dcc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"bitflags 2.10.0",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
"wasmtime",
|
||||
"wiggle-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wiggle-generate"
|
||||
version = "28.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8738c5a7ef3a9de0fae10f8b84091a2aa4e059d8fef23de202ab689812b6bc6e"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"shellexpand",
|
||||
"syn 2.0.114",
|
||||
"witx",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wiggle-macro"
|
||||
version = "28.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e882267ac583e013a38a5aaeb83a49b219456ba3aa6e6772440f7213b176e8ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"wiggle-generate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@@ -4821,6 +5086,16 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winx"
|
||||
version = "0.36.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.46.0"
|
||||
@@ -4845,6 +5120,18 @@ dependencies = [
|
||||
"wasmparser 0.221.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "witx"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"wast 35.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.2"
|
||||
|
||||
@@ -79,6 +79,7 @@ pgvector = { version = "0.4", features = ["postgres"] }
|
||||
|
||||
# WASM sandbox for untrusted tool execution
|
||||
wasmtime = { version = "28", features = ["component-model"] }
|
||||
wasmtime-wasi = "28" # WASI support for component model
|
||||
wasmparser = "0.220" # WASM binary parsing for validation
|
||||
|
||||
# Cryptography for secrets management
|
||||
|
||||
@@ -154,6 +154,9 @@ struct SentMessage {
|
||||
message_id: i64,
|
||||
}
|
||||
|
||||
/// Workspace path for storing polling state.
|
||||
const POLLING_STATE_PATH: &str = "state/last_update_id";
|
||||
|
||||
// ============================================================================
|
||||
// Channel Metadata
|
||||
// ============================================================================
|
||||
@@ -186,12 +189,23 @@ struct TelegramConfig {
|
||||
respond_to_all_group_messages: bool,
|
||||
|
||||
/// Whether to use polling instead of webhooks.
|
||||
/// Automatically disabled if tunnel_url is set.
|
||||
#[serde(default)]
|
||||
polling_enabled: bool,
|
||||
|
||||
/// Polling interval in milliseconds (if polling enabled).
|
||||
#[serde(default = "default_poll_interval")]
|
||||
poll_interval_ms: u32,
|
||||
|
||||
/// Public tunnel URL for webhook mode (e.g., "https://abc123.ngrok.io").
|
||||
/// When set, webhook mode is enabled and polling is disabled.
|
||||
#[serde(default)]
|
||||
tunnel_url: Option<String>,
|
||||
|
||||
/// Secret token for webhook validation.
|
||||
/// Telegram will include this in the X-Telegram-Bot-Api-Secret-Token header.
|
||||
#[serde(default)]
|
||||
webhook_secret: Option<String>,
|
||||
}
|
||||
|
||||
fn default_poll_interval() -> u32 {
|
||||
@@ -218,8 +232,29 @@ impl Guest for TelegramChannel {
|
||||
);
|
||||
}
|
||||
|
||||
// Configure polling if enabled
|
||||
let poll = if config.polling_enabled {
|
||||
// Determine mode: webhook or polling
|
||||
// Webhook mode is enabled if tunnel_url is set, which disables polling
|
||||
let webhook_mode = config.tunnel_url.is_some();
|
||||
|
||||
if webhook_mode {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
"Webhook mode enabled (polling disabled)",
|
||||
);
|
||||
if let Some(ref url) = config.tunnel_url {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!("Tunnel URL: {}", url),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Configure polling only if not in webhook mode and polling is enabled
|
||||
let poll = if !webhook_mode && config.polling_enabled {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!("Polling enabled (interval: {}ms)", config.poll_interval_ms.max(30000)),
|
||||
);
|
||||
Some(PollConfig {
|
||||
interval_ms: config.poll_interval_ms.max(30000), // Enforce minimum
|
||||
enabled: true,
|
||||
@@ -228,18 +263,37 @@ impl Guest for TelegramChannel {
|
||||
None
|
||||
};
|
||||
|
||||
// Webhook secret validation is handled by the host (X-Telegram-Bot-Api-Secret-Token header)
|
||||
// The require_secret flag tells the host to validate the secret_validated field
|
||||
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: false, // Telegram doesn't use signing secrets by default
|
||||
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,
|
||||
@@ -269,12 +323,98 @@ impl Guest for TelegramChannel {
|
||||
}
|
||||
|
||||
fn on_poll() {
|
||||
// Polling mode: call getUpdates API
|
||||
// For now, we focus on webhook mode. Polling can be added later.
|
||||
// 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,
|
||||
"Polling tick (not implemented yet)",
|
||||
&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> {
|
||||
@@ -320,14 +460,15 @@ impl Guest for TelegramChannel {
|
||||
|
||||
// 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)
|
||||
})?;
|
||||
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())
|
||||
api_response
|
||||
.description
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
));
|
||||
}
|
||||
|
||||
@@ -347,7 +488,10 @@ impl Guest for TelegramChannel {
|
||||
}
|
||||
|
||||
fn on_shutdown() {
|
||||
channel_host::log(channel_host::LogLevel::Info, "Telegram channel shutting down");
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
"Telegram channel shutting down",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,8 +566,7 @@ fn handle_message(message: TelegramMessage) {
|
||||
is_private,
|
||||
};
|
||||
|
||||
let metadata_json =
|
||||
serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
|
||||
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);
|
||||
|
||||
Binary file not shown.
@@ -74,6 +74,12 @@ pub enum WasmChannelError {
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
#[error("Webhook registration failed for channel {name}: {reason}")]
|
||||
WebhookRegistration { name: String, reason: String },
|
||||
|
||||
#[error("HTTP request error: {0}")]
|
||||
HttpRequest(String),
|
||||
}
|
||||
|
||||
impl From<crate::tools::wasm::WasmError> for WasmChannelError {
|
||||
|
||||
@@ -59,7 +59,28 @@ impl WasmChannelLoader {
|
||||
let cap_file = ChannelCapabilitiesFile::from_bytes(&cap_bytes)
|
||||
.map_err(|e| WasmChannelError::InvalidCapabilities(e.to_string()))?;
|
||||
|
||||
// Debug: log raw capabilities
|
||||
tracing::debug!(
|
||||
channel = name,
|
||||
raw_capabilities = ?cap_file.capabilities,
|
||||
"Parsed capabilities file"
|
||||
);
|
||||
|
||||
let caps = cap_file.to_capabilities();
|
||||
|
||||
// Debug: log resulting capabilities
|
||||
tracing::info!(
|
||||
channel = name,
|
||||
http_allowed = caps.tool_capabilities.http.is_some(),
|
||||
http_allowlist_count = caps
|
||||
.tool_capabilities
|
||||
.http
|
||||
.as_ref()
|
||||
.map(|h| h.allowlist.len())
|
||||
.unwrap_or(0),
|
||||
"Channel capabilities loaded"
|
||||
);
|
||||
|
||||
let config = cap_file.config_json();
|
||||
let desc = cap_file.description.clone();
|
||||
|
||||
|
||||
@@ -99,4 +99,4 @@ pub use router::{
|
||||
};
|
||||
pub use runtime::{PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig};
|
||||
pub use schema::{ChannelCapabilitiesFile, ChannelConfig};
|
||||
pub use wrapper::{HttpResponse, WasmChannel};
|
||||
pub use wrapper::{HttpResponse, SharedWasmChannel, WasmChannel};
|
||||
|
||||
+65
-12
@@ -180,7 +180,6 @@ async fn health_handler(State(state): State<RouterState>) -> impl IntoResponse {
|
||||
}
|
||||
|
||||
/// Generic webhook handler that routes to the appropriate WASM channel.
|
||||
#[allow(dead_code)]
|
||||
async fn webhook_handler(
|
||||
State(state): State<RouterState>,
|
||||
method: Method,
|
||||
@@ -191,10 +190,21 @@ async fn webhook_handler(
|
||||
) -> impl IntoResponse {
|
||||
let full_path = format!("/webhook/{}", path);
|
||||
|
||||
tracing::info!(
|
||||
method = %method,
|
||||
path = %full_path,
|
||||
body_len = body.len(),
|
||||
"Webhook request received"
|
||||
);
|
||||
|
||||
// Find the channel for this path
|
||||
let channel = match state.router.get_channel_for_path(&full_path).await {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
path = %full_path,
|
||||
"No channel registered for webhook path"
|
||||
);
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
@@ -205,21 +215,48 @@ async fn webhook_handler(
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
channel = %channel.channel_name(),
|
||||
"Found channel for webhook"
|
||||
);
|
||||
|
||||
let channel_name = channel.channel_name();
|
||||
|
||||
// Check if secret is required
|
||||
if state.router.requires_secret(channel_name).await {
|
||||
// Try to get secret from query param or header
|
||||
let provided_secret = query.get("secret").cloned().or_else(|| {
|
||||
headers
|
||||
.get("X-Webhook-Secret")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
// Telegram uses X-Telegram-Bot-Api-Secret-Token header
|
||||
let provided_secret = query
|
||||
.get("secret")
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
headers
|
||||
.get("X-Telegram-Bot-Api-Secret-Token")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.or_else(|| {
|
||||
// Fallback to generic header
|
||||
headers
|
||||
.get("X-Webhook-Secret")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
tracing::debug!(
|
||||
channel = %channel_name,
|
||||
has_provided_secret = provided_secret.is_some(),
|
||||
provided_secret_len = provided_secret.as_ref().map(|s| s.len()),
|
||||
"Checking webhook secret"
|
||||
);
|
||||
|
||||
match provided_secret {
|
||||
Some(secret) => {
|
||||
if !state.router.validate_secret(channel_name, &secret).await {
|
||||
tracing::warn!(
|
||||
channel = %channel_name,
|
||||
"Webhook secret validation failed"
|
||||
);
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({
|
||||
@@ -227,8 +264,13 @@ async fn webhook_handler(
|
||||
})),
|
||||
);
|
||||
}
|
||||
tracing::debug!(channel = %channel_name, "Webhook secret validated");
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
channel = %channel_name,
|
||||
"Webhook secret required but not provided"
|
||||
);
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({
|
||||
@@ -251,6 +293,13 @@ async fn webhook_handler(
|
||||
|
||||
// Call the WASM channel
|
||||
let secret_validated = state.router.requires_secret(channel_name).await;
|
||||
|
||||
tracing::info!(
|
||||
channel = %channel_name,
|
||||
secret_validated = secret_validated,
|
||||
"Calling WASM channel on_http_request"
|
||||
);
|
||||
|
||||
match channel
|
||||
.call_on_http_request(
|
||||
method.as_str(),
|
||||
@@ -266,6 +315,13 @@ async fn webhook_handler(
|
||||
let status =
|
||||
StatusCode::from_u16(response.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
|
||||
tracing::info!(
|
||||
channel = %channel_name,
|
||||
status = %status,
|
||||
body_len = response.body.len(),
|
||||
"WASM channel on_http_request completed successfully"
|
||||
);
|
||||
|
||||
// Build response with headers
|
||||
let body_json: serde_json::Value = serde_json::from_slice(&response.body)
|
||||
.unwrap_or_else(|_| {
|
||||
@@ -296,25 +352,22 @@ async fn webhook_handler(
|
||||
/// Create an Axum router for WASM channel webhooks.
|
||||
///
|
||||
/// This router can be merged with the existing HTTP channel router.
|
||||
#[allow(dead_code)]
|
||||
pub fn create_wasm_channel_router(router: Arc<WasmChannelRouter>) -> Router {
|
||||
let state = RouterState::new(router);
|
||||
|
||||
Router::new()
|
||||
.route("/wasm-channels/health", get(health_handler))
|
||||
// Catch-all for webhook paths
|
||||
.route("/webhook/*path", get(webhook_handler))
|
||||
.route("/webhook/*path", post(webhook_handler))
|
||||
.route("/webhook/{*path}", get(webhook_handler))
|
||||
.route("/webhook/{*path}", post(webhook_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// HTTP server for WASM channel webhooks.
|
||||
#[allow(dead_code)]
|
||||
pub struct WasmChannelServer {
|
||||
router: Arc<WasmChannelRouter>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl WasmChannelServer {
|
||||
/// Create a new server.
|
||||
pub fn new(router: Arc<WasmChannelRouter>) -> Self {
|
||||
|
||||
@@ -101,8 +101,10 @@ impl ChannelCapabilitiesFile {
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ChannelCapabilitiesSchema {
|
||||
/// Tool capabilities (HTTP, secrets, workspace_read).
|
||||
/// Note: Using the struct directly (not Option) because #[serde(flatten)]
|
||||
/// with Option<T> doesn't work correctly when T has all-optional fields.
|
||||
#[serde(flatten)]
|
||||
pub tool: Option<ToolCapabilitiesFile>,
|
||||
pub tool: ToolCapabilitiesFile,
|
||||
|
||||
/// Channel-specific capabilities.
|
||||
#[serde(default)]
|
||||
@@ -112,11 +114,7 @@ pub struct ChannelCapabilitiesSchema {
|
||||
impl ChannelCapabilitiesSchema {
|
||||
/// Convert to runtime ChannelCapabilities.
|
||||
pub fn to_channel_capabilities(&self, channel_name: &str) -> ChannelCapabilities {
|
||||
let tool_caps = self
|
||||
.tool
|
||||
.as_ref()
|
||||
.map(|t| t.to_capabilities())
|
||||
.unwrap_or_default();
|
||||
let tool_caps = self.tool.to_capabilities();
|
||||
|
||||
let mut caps =
|
||||
ChannelCapabilities::for_channel(channel_name).with_tool_capabilities(tool_caps);
|
||||
|
||||
+752
-465
File diff suppressed because it is too large
Load Diff
+103
@@ -13,6 +13,7 @@ pub struct Config {
|
||||
pub database: DatabaseConfig,
|
||||
pub llm: LlmConfig,
|
||||
pub embeddings: EmbeddingsConfig,
|
||||
pub tunnel: TunnelConfig,
|
||||
pub channels: ChannelsConfig,
|
||||
pub agent: AgentConfig,
|
||||
pub safety: SafetyConfig,
|
||||
@@ -32,6 +33,7 @@ impl Config {
|
||||
database: DatabaseConfig::from_env()?,
|
||||
llm: LlmConfig::from_env()?,
|
||||
embeddings: EmbeddingsConfig::from_env()?,
|
||||
tunnel: TunnelConfig::from_env()?,
|
||||
channels: ChannelsConfig::from_env()?,
|
||||
agent: AgentConfig::from_env()?,
|
||||
safety: SafetyConfig::from_env()?,
|
||||
@@ -43,6 +45,81 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tunnel configuration for exposing the agent to the internet.
|
||||
///
|
||||
/// Used by channels and tools that need public webhook endpoints.
|
||||
/// The tunnel URL is shared across all channels (Telegram, Slack, etc.).
|
||||
///
|
||||
/// # Security Notes
|
||||
///
|
||||
/// **Webhook endpoints** (e.g., `/webhook/telegram`) should NOT use tunnel-level
|
||||
/// authentication because webhook providers (Telegram, Slack, GitHub) need
|
||||
/// unauthenticated access to POST updates. Security for webhooks comes from:
|
||||
/// - Webhook signature verification (provider-specific secrets)
|
||||
/// - IP allowlisting (if supported by provider)
|
||||
///
|
||||
/// **Non-webhook endpoints** (admin APIs, health checks) CAN be protected using
|
||||
/// tunnel provider features:
|
||||
/// - ngrok: Basic Auth, OAuth, IP restrictions
|
||||
/// - Cloudflare: Access policies, mTLS
|
||||
///
|
||||
/// These protections are configured in the tunnel provider, not here.
|
||||
///
|
||||
/// # Supported Providers
|
||||
///
|
||||
/// - **ngrok**: `ngrok http 8080` -> `https://abc123.ngrok.io`
|
||||
/// - **Cloudflare Tunnel**: `cloudflared tunnel --url http://localhost:8080`
|
||||
/// - **localtunnel**: `lt --port 8080`
|
||||
/// - Any service that provides a public HTTPS URL to localhost
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TunnelConfig {
|
||||
/// Public URL from tunnel provider (e.g., "https://abc123.ngrok.io").
|
||||
///
|
||||
/// When set, channels that support webhooks will register their endpoints
|
||||
/// with this base URL instead of using polling.
|
||||
pub public_url: Option<String>,
|
||||
}
|
||||
|
||||
impl TunnelConfig {
|
||||
fn from_env() -> Result<Self, ConfigError> {
|
||||
// Priority: env var > settings file
|
||||
let public_url = optional_env("TUNNEL_URL")?.or_else(|| {
|
||||
crate::settings::Settings::load()
|
||||
.tunnel
|
||||
.public_url
|
||||
.filter(|s| !s.is_empty())
|
||||
});
|
||||
|
||||
// Validate URL format if provided
|
||||
if let Some(ref url) = public_url {
|
||||
if !url.starts_with("https://") {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "TUNNEL_URL".to_string(),
|
||||
message: "must start with https:// (webhooks require HTTPS)".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self { public_url })
|
||||
}
|
||||
|
||||
/// Check if a tunnel is configured.
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.public_url.is_some()
|
||||
}
|
||||
|
||||
/// Get the webhook URL for a given path.
|
||||
///
|
||||
/// Returns `None` if no tunnel is configured.
|
||||
pub fn webhook_url(&self, path: &str) -> Option<String> {
|
||||
self.public_url.as_ref().map(|base| {
|
||||
let base = base.trim_end_matches('/');
|
||||
let path = path.trim_start_matches('/');
|
||||
format!("{}/{}", base, path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Database configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DatabaseConfig {
|
||||
@@ -231,12 +308,37 @@ fn default_session_path() -> PathBuf {
|
||||
pub struct ChannelsConfig {
|
||||
pub cli: CliConfig,
|
||||
pub http: Option<HttpConfig>,
|
||||
pub telegram: TelegramChannelConfig,
|
||||
/// Directory containing WASM channel modules (default: ~/.near-agent/channels/).
|
||||
pub wasm_channels_dir: std::path::PathBuf,
|
||||
/// Whether WASM channels are enabled.
|
||||
pub wasm_channels_enabled: bool,
|
||||
}
|
||||
|
||||
/// Telegram channel configuration.
|
||||
///
|
||||
/// The tunnel URL for webhook mode comes from the global `TunnelConfig`.
|
||||
/// This config only contains Telegram-specific settings.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TelegramChannelConfig {
|
||||
/// Secret token for webhook validation (optional but recommended).
|
||||
///
|
||||
/// When set, Telegram will include this value in the
|
||||
/// `X-Telegram-Bot-Api-Secret-Token` header of webhook requests.
|
||||
/// The agent validates this header to ensure requests come from Telegram.
|
||||
///
|
||||
/// Generate a secure random token (32+ characters recommended).
|
||||
pub webhook_secret: Option<String>,
|
||||
}
|
||||
|
||||
impl TelegramChannelConfig {
|
||||
fn from_env() -> Result<Self, ConfigError> {
|
||||
Ok(Self {
|
||||
webhook_secret: optional_env("TELEGRAM_WEBHOOK_SECRET")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CliConfig {
|
||||
pub enabled: bool,
|
||||
@@ -277,6 +379,7 @@ impl ChannelsConfig {
|
||||
enabled: cli_enabled,
|
||||
},
|
||||
http,
|
||||
telegram: TelegramChannelConfig::from_env()?,
|
||||
wasm_channels_dir: optional_env("WASM_CHANNELS_DIR")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_channels_dir),
|
||||
|
||||
+218
-3
@@ -9,13 +9,17 @@ use near_agent::{
|
||||
agent::{Agent, AgentDeps},
|
||||
channels::{
|
||||
AppEvent, ChannelManager, HttpChannel, ReplChannel, TuiChannel,
|
||||
wasm::{WasmChannelLoader, WasmChannelRuntime, WasmChannelRuntimeConfig},
|
||||
wasm::{
|
||||
RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter,
|
||||
WasmChannelRuntime, WasmChannelRuntimeConfig, WasmChannelServer,
|
||||
},
|
||||
},
|
||||
cli::{Cli, Command, run_tool_command},
|
||||
config::Config,
|
||||
history::Store,
|
||||
llm::{SessionConfig, create_llm_provider, create_session_manager},
|
||||
safety::SafetyLayer,
|
||||
secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore},
|
||||
settings::Settings,
|
||||
setup::{SetupConfig, SetupWizard},
|
||||
tools::{
|
||||
@@ -327,6 +331,23 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Create secrets store if master key is configured (needed for Telegram webhook registration)
|
||||
let secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>> =
|
||||
if let (Some(store), Some(master_key)) = (&store, config.secrets.master_key()) {
|
||||
match SecretsCrypto::new(master_key.clone()) {
|
||||
Ok(crypto) => Some(Arc::new(PostgresSecretsStore::new(
|
||||
store.pool(),
|
||||
Arc::new(crypto),
|
||||
))),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to initialize secrets crypto: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Load WASM channels if enabled
|
||||
if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() {
|
||||
match WasmChannelRuntime::new(WasmChannelRuntimeConfig::default()) {
|
||||
@@ -339,10 +360,140 @@ async fn main() -> anyhow::Result<()> {
|
||||
.await
|
||||
{
|
||||
Ok(results) => {
|
||||
// Create router for WASM channel webhooks
|
||||
let wasm_router = Arc::new(WasmChannelRouter::new());
|
||||
let mut has_webhook_channels = false;
|
||||
|
||||
for channel in results.loaded {
|
||||
tracing::info!("Loaded WASM channel: {}", channel.channel_name());
|
||||
channels.add(Box::new(channel));
|
||||
let channel_name = channel.channel_name().to_string();
|
||||
tracing::info!("Loaded WASM channel: {}", channel_name);
|
||||
|
||||
// Get webhook secret for this channel from secrets store
|
||||
let webhook_secret = if let Some(ref secrets) = secrets_store {
|
||||
let secret_name = format!("{}_webhook_secret", channel_name);
|
||||
secrets
|
||||
.get_decrypted("default", &secret_name)
|
||||
.await
|
||||
.ok()
|
||||
.map(|s| s.expose().to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Register channel with router for webhook handling
|
||||
// Use known webhook path based on channel name
|
||||
let webhook_path = format!("/webhook/{}", channel_name);
|
||||
let endpoints = vec![RegisteredEndpoint {
|
||||
channel_name: channel_name.clone(),
|
||||
path: webhook_path.clone(),
|
||||
methods: vec!["POST".to_string()],
|
||||
require_secret: webhook_secret.is_some(),
|
||||
}];
|
||||
|
||||
let channel_arc = Arc::new(channel);
|
||||
|
||||
// Clone webhook_secret before moving it to register()
|
||||
// We need it later for Telegram API registration
|
||||
let webhook_secret_for_telegram = webhook_secret.clone();
|
||||
|
||||
tracing::info!(
|
||||
channel = %channel_name,
|
||||
has_webhook_secret = webhook_secret.is_some(),
|
||||
"Registering channel with router"
|
||||
);
|
||||
|
||||
wasm_router
|
||||
.register(Arc::clone(&channel_arc), endpoints, webhook_secret)
|
||||
.await;
|
||||
has_webhook_channels = true;
|
||||
|
||||
// Set up Telegram channel credentials and optionally register webhook
|
||||
if channel_name == "telegram" {
|
||||
if let Some(ref secrets) = secrets_store {
|
||||
// Inject bot token for HTTP request URL substitution
|
||||
// This is needed for both webhook and polling modes
|
||||
match inject_telegram_credentials(
|
||||
&channel_arc,
|
||||
secrets.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
tracing::debug!("Telegram bot token injected");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to inject Telegram credentials: {}",
|
||||
e
|
||||
);
|
||||
tracing::warn!(
|
||||
"Telegram channel may not be able to send responses"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Register webhook if tunnel URL is configured
|
||||
// Use the SAME webhook_secret that the router expects (from secrets store)
|
||||
if let Some(ref tunnel_url) = config.tunnel.public_url {
|
||||
match register_telegram_webhook(
|
||||
&channel_arc,
|
||||
tunnel_url,
|
||||
webhook_secret_for_telegram.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
"Telegram webhook registered at {}/webhook/telegram",
|
||||
tunnel_url
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to register Telegram webhook: {}",
|
||||
e
|
||||
);
|
||||
tracing::warn!(
|
||||
"Telegram will fall back to polling mode"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Telegram channel loaded but secrets store not available"
|
||||
);
|
||||
tracing::warn!(
|
||||
"Set SECRETS_MASTER_KEY to enable Telegram bot token injection"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap in SharedWasmChannel for ChannelManager
|
||||
// Both the router and ChannelManager share the same underlying channel
|
||||
channels.add(Box::new(SharedWasmChannel::new(channel_arc)));
|
||||
}
|
||||
|
||||
// Start WASM channel webhook server if we have channels with webhooks
|
||||
if has_webhook_channels && config.tunnel.public_url.is_some() {
|
||||
let server = WasmChannelServer::new(wasm_router);
|
||||
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 8080));
|
||||
match server.start(addr).await {
|
||||
Ok(_handle) => {
|
||||
tracing::info!(
|
||||
"WASM channel webhook server started on {}",
|
||||
addr
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to start WASM channel webhook server: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (path, err) in &results.errors {
|
||||
tracing::warn!(
|
||||
"Failed to load WASM channel {}: {}",
|
||||
@@ -407,3 +558,67 @@ async fn main() -> anyhow::Result<()> {
|
||||
tracing::info!("Agent shutdown complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inject Telegram bot token into the channel's credentials.
|
||||
///
|
||||
/// This allows the WASM channel to use `{TELEGRAM_BOT_TOKEN}` in HTTP URLs
|
||||
/// without ever seeing the actual token value. Required for both webhook
|
||||
/// and polling modes to send responses.
|
||||
async fn inject_telegram_credentials(
|
||||
channel: &Arc<near_agent::channels::wasm::WasmChannel>,
|
||||
secrets: &dyn SecretsStore,
|
||||
) -> anyhow::Result<()> {
|
||||
tracing::info!("Injecting Telegram bot token into channel credentials");
|
||||
|
||||
// Get bot token from secrets
|
||||
let decrypted = secrets
|
||||
.get_decrypted("default", "telegram_bot_token")
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to get telegram_bot_token from secrets");
|
||||
anyhow::anyhow!("Failed to get Telegram bot token: {}", e)
|
||||
})?;
|
||||
|
||||
let bot_token = decrypted.expose();
|
||||
let token_len = bot_token.len();
|
||||
|
||||
// Inject the token into the channel's credentials for URL substitution
|
||||
channel
|
||||
.set_credential("TELEGRAM_BOT_TOKEN", bot_token.to_string())
|
||||
.await;
|
||||
|
||||
// Verify injection
|
||||
let creds = channel.get_credentials().await;
|
||||
tracing::info!(
|
||||
token_length = token_len,
|
||||
has_token = creds.contains_key("TELEGRAM_BOT_TOKEN"),
|
||||
credential_count = creds.len(),
|
||||
"Telegram bot token injected successfully"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register Telegram webhook for instant message delivery.
|
||||
///
|
||||
/// Calls the Telegram setWebhook API. Assumes credentials have already been
|
||||
/// injected via `inject_telegram_credentials` (gets token from channel).
|
||||
async fn register_telegram_webhook(
|
||||
channel: &Arc<near_agent::channels::wasm::WasmChannel>,
|
||||
tunnel_url: &str,
|
||||
webhook_secret: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
// Get the bot token via the public getter
|
||||
let credentials = channel.get_credentials().await;
|
||||
let bot_token = credentials.get("TELEGRAM_BOT_TOKEN").ok_or_else(|| {
|
||||
anyhow::anyhow!("Bot token not injected - call inject_telegram_credentials first")
|
||||
})?;
|
||||
|
||||
// Register the webhook with Telegram API
|
||||
channel
|
||||
.register_telegram_webhook(tunnel_url, bot_token, webhook_secret)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Webhook registration failed: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -17,11 +17,25 @@ pub struct Settings {
|
||||
#[serde(default)]
|
||||
pub setup_completed: bool,
|
||||
|
||||
/// Tunnel configuration for exposing the agent to the internet.
|
||||
#[serde(default)]
|
||||
pub tunnel: TunnelSettings,
|
||||
|
||||
/// Channel configuration.
|
||||
#[serde(default)]
|
||||
pub channels: ChannelSettings,
|
||||
}
|
||||
|
||||
/// Tunnel settings for public webhook endpoints.
|
||||
///
|
||||
/// The tunnel URL is shared across all channels that need webhooks.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TunnelSettings {
|
||||
/// Public URL from tunnel provider (e.g., "https://abc123.ngrok.io").
|
||||
#[serde(default)]
|
||||
pub public_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Channel-specific settings.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ChannelSettings {
|
||||
|
||||
+105
-1
@@ -13,8 +13,9 @@ use secrecy::{ExposeSecret, SecretString};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::secrets::{CreateSecretParams, PostgresSecretsStore, SecretsCrypto, SecretsStore};
|
||||
use crate::settings::Settings;
|
||||
use crate::setup::prompts::{
|
||||
confirm, optional_input, print_error, print_info, print_success, secret_input,
|
||||
confirm, input, optional_input, print_error, print_info, print_success, secret_input,
|
||||
};
|
||||
|
||||
/// Context for saving secrets during setup.
|
||||
@@ -58,6 +59,7 @@ impl SecretsContext {
|
||||
pub struct TelegramSetupResult {
|
||||
pub enabled: bool,
|
||||
pub bot_username: Option<String>,
|
||||
pub webhook_secret: Option<String>,
|
||||
}
|
||||
|
||||
/// Telegram Bot API response for getMe.
|
||||
@@ -94,9 +96,12 @@ pub async fn setup_telegram(secrets: &SecretsContext) -> Result<TelegramSetupRes
|
||||
if secrets.secret_exists("telegram_bot_token").await {
|
||||
print_info("Existing Telegram token found in database.");
|
||||
if !confirm("Replace existing token?", false).map_err(|e| e.to_string())? {
|
||||
// Still offer to configure webhook secret if not already done
|
||||
let webhook_secret = setup_telegram_webhook_secret(secrets).await?;
|
||||
return Ok(TelegramSetupResult {
|
||||
enabled: true,
|
||||
bot_username: None,
|
||||
webhook_secret,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -117,9 +122,13 @@ pub async fn setup_telegram(secrets: &SecretsContext) -> Result<TelegramSetupRes
|
||||
secrets.save_secret("telegram_bot_token", &token).await?;
|
||||
print_success("Token saved to database");
|
||||
|
||||
// Offer webhook secret configuration
|
||||
let webhook_secret = setup_telegram_webhook_secret(secrets).await?;
|
||||
|
||||
Ok(TelegramSetupResult {
|
||||
enabled: true,
|
||||
bot_username: username,
|
||||
webhook_secret,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -131,12 +140,107 @@ pub async fn setup_telegram(secrets: &SecretsContext) -> Result<TelegramSetupRes
|
||||
Ok(TelegramSetupResult {
|
||||
enabled: false,
|
||||
bot_username: None,
|
||||
webhook_secret: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set up a tunnel for exposing the agent to the internet.
|
||||
///
|
||||
/// This is shared across all channels that need webhook endpoints.
|
||||
/// Returns the tunnel URL if configured.
|
||||
pub fn setup_tunnel() -> Result<Option<String>, String> {
|
||||
// Check if already configured
|
||||
let settings = Settings::load();
|
||||
if let Some(ref url) = settings.tunnel.public_url {
|
||||
print_info(&format!("Existing tunnel configured: {}", url));
|
||||
if !confirm("Change tunnel configuration?", false).map_err(|e| e.to_string())? {
|
||||
return Ok(Some(url.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
print_info("Tunnel Configuration (for webhook endpoints):");
|
||||
print_info("A tunnel exposes your local agent to the internet, enabling:");
|
||||
print_info(" - Instant Telegram message delivery (instead of polling)");
|
||||
print_info(" - Future: Slack, Discord, GitHub webhooks");
|
||||
print_info("");
|
||||
print_info("Supported tunnel providers:");
|
||||
print_info(" - ngrok: ngrok http 8080");
|
||||
print_info(" - Cloudflare: cloudflared tunnel --url http://localhost:8080");
|
||||
print_info(" - localtunnel: lt --port 8080");
|
||||
print_info("");
|
||||
print_info("Security note: Webhook endpoints don't use tunnel-level auth.");
|
||||
print_info("Security comes from provider-specific secrets (e.g., Telegram webhook secret).");
|
||||
println!();
|
||||
|
||||
if !confirm("Configure a tunnel?", false).map_err(|e| e.to_string())? {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let tunnel_url =
|
||||
input("Tunnel URL (e.g., https://abc123.ngrok.io)").map_err(|e| e.to_string())?;
|
||||
|
||||
// Validate URL format
|
||||
if !tunnel_url.starts_with("https://") {
|
||||
print_error("URL must start with https:// (webhooks require HTTPS)");
|
||||
return Err("Invalid tunnel URL: must use HTTPS".to_string());
|
||||
}
|
||||
|
||||
// Remove trailing slash if present
|
||||
let tunnel_url = tunnel_url.trim_end_matches('/').to_string();
|
||||
|
||||
// Save to settings
|
||||
let mut settings = Settings::load();
|
||||
settings.tunnel.public_url = Some(tunnel_url.clone());
|
||||
settings
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save settings: {}", e))?;
|
||||
|
||||
print_success(&format!("Tunnel URL saved: {}", tunnel_url));
|
||||
print_info("");
|
||||
print_info("Make sure your tunnel is running before starting the agent.");
|
||||
print_info("You can also set TUNNEL_URL environment variable to override.");
|
||||
|
||||
Ok(Some(tunnel_url))
|
||||
}
|
||||
|
||||
/// Set up Telegram webhook secret for signature validation.
|
||||
///
|
||||
/// Returns the webhook secret if configured.
|
||||
async fn setup_telegram_webhook_secret(secrets: &SecretsContext) -> Result<Option<String>, String> {
|
||||
// Check if tunnel is configured
|
||||
let settings = Settings::load();
|
||||
if settings.tunnel.public_url.is_none() {
|
||||
print_info("");
|
||||
print_info("No tunnel configured. Telegram will use polling mode (30s+ delay).");
|
||||
print_info("Run setup again to configure a tunnel for instant delivery.");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
println!();
|
||||
print_info("Telegram Webhook Security:");
|
||||
print_info("A webhook secret adds an extra layer of security by validating");
|
||||
print_info("that requests actually come from Telegram's servers.");
|
||||
|
||||
if !confirm("Generate a webhook secret?", true).map_err(|e| e.to_string())? {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let secret = generate_webhook_secret();
|
||||
secrets
|
||||
.save_secret(
|
||||
"telegram_webhook_secret",
|
||||
&SecretString::from(secret.clone()),
|
||||
)
|
||||
.await?;
|
||||
print_success("Webhook secret generated and saved");
|
||||
|
||||
Ok(Some(secret))
|
||||
}
|
||||
|
||||
/// Validate a Telegram bot token by calling the getMe API.
|
||||
///
|
||||
/// Returns the bot's username if valid.
|
||||
|
||||
+3
-1
@@ -18,6 +18,8 @@ mod channels;
|
||||
mod prompts;
|
||||
mod wizard;
|
||||
|
||||
pub use channels::{SecretsContext, setup_http, setup_telegram, validate_telegram_token};
|
||||
pub use channels::{
|
||||
SecretsContext, setup_http, setup_telegram, setup_tunnel, validate_telegram_token,
|
||||
};
|
||||
pub use prompts::{confirm, print_header, print_step, secret_input, select_many, select_one};
|
||||
pub use wizard::{SetupConfig, SetupWizard};
|
||||
|
||||
+25
-2
@@ -14,7 +14,7 @@ use tokio_postgres::NoTls;
|
||||
use crate::llm::{SessionConfig, SessionManager};
|
||||
use crate::secrets::SecretsCrypto;
|
||||
use crate::settings::Settings;
|
||||
use crate::setup::channels::{SecretsContext, setup_http, setup_telegram};
|
||||
use crate::setup::channels::{SecretsContext, setup_http, setup_telegram, setup_tunnel};
|
||||
use crate::setup::prompts::{
|
||||
input, print_header, print_info, print_step, print_success, select_many, select_one,
|
||||
};
|
||||
@@ -316,6 +316,20 @@ impl SetupWizard {
|
||||
|
||||
/// Step 3: Channel configuration.
|
||||
async fn step_channels(&mut self) -> Result<(), SetupError> {
|
||||
// First, configure tunnel (shared across all channels that need webhooks)
|
||||
match setup_tunnel() {
|
||||
Ok(Some(url)) => {
|
||||
self.settings.tunnel.public_url = Some(url);
|
||||
}
|
||||
Ok(None) => {
|
||||
self.settings.tunnel.public_url = None;
|
||||
}
|
||||
Err(e) => {
|
||||
print_info(&format!("Tunnel setup skipped: {}", e));
|
||||
}
|
||||
}
|
||||
println!();
|
||||
|
||||
let options = [
|
||||
("CLI/TUI (always enabled)", true),
|
||||
("HTTP webhook", self.settings.channels.http_enabled),
|
||||
@@ -381,6 +395,10 @@ impl SetupWizard {
|
||||
println!(" Model: {}", model);
|
||||
}
|
||||
|
||||
if let Some(ref tunnel_url) = self.settings.tunnel.public_url {
|
||||
println!(" Tunnel: {}", tunnel_url);
|
||||
}
|
||||
|
||||
println!(" Channels:");
|
||||
println!(" - CLI/TUI: enabled");
|
||||
|
||||
@@ -390,7 +408,12 @@ impl SetupWizard {
|
||||
}
|
||||
|
||||
if self.settings.channels.telegram_enabled {
|
||||
println!(" - Telegram: enabled");
|
||||
let mode = if self.settings.tunnel.public_url.is_some() {
|
||||
"webhook"
|
||||
} else {
|
||||
"polling"
|
||||
};
|
||||
println!(" - Telegram: enabled ({})", mode);
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
@@ -79,13 +79,16 @@ pub struct WasmResourceLimiter {
|
||||
|
||||
impl WasmResourceLimiter {
|
||||
/// Create a new limiter with the given memory limit.
|
||||
///
|
||||
/// Note: max_instances is set to 10 to accommodate WASM Component Model
|
||||
/// which creates multiple internal instances (main component + WASI adapters).
|
||||
pub fn new(memory_limit: u64) -> Self {
|
||||
Self {
|
||||
memory_limit,
|
||||
memory_used: 0,
|
||||
max_tables: 10,
|
||||
tables_created: 0,
|
||||
max_instances: 1,
|
||||
max_instances: 10, // Component model needs multiple instances for WASI
|
||||
instances_created: 0,
|
||||
}
|
||||
}
|
||||
@@ -157,7 +160,7 @@ impl ResourceLimiter for WasmResourceLimiter {
|
||||
}
|
||||
|
||||
fn memories(&self) -> usize {
|
||||
// Allow one memory per instance
|
||||
// Allow multiple memories for component model with WASI
|
||||
self.max_instances as usize
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user