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:
Illia Polosukhin
2026-02-04 21:19:42 -08:00
co-authored by Claude Opus 4.5
parent 4ab20ff939
commit 7955c9742e
17 changed files with 1769 additions and 509 deletions
+6
View File
@@ -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 {
+21
View File
@@ -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();
+1 -1
View File
@@ -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
View File
@@ -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 {
+4 -6
View File
@@ -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);
File diff suppressed because it is too large Load Diff
+103
View File
@@ -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
View File
@@ -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(())
}
+14
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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!();
+5 -2
View File
@@ -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
}
}