From 19dc192efe6136ad386e2378f63b45730ffee56a Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 25 Feb 2026 17:49:55 +1100 Subject: [PATCH] feat: gateway to chat to goose - telegram etc (#7199) --- Cargo.lock | 30 ++ crates/goose-cli/src/cli.rs | 60 +++ crates/goose-cli/src/commands/gateway.rs | 82 +++ crates/goose-cli/src/commands/mod.rs | 1 + crates/goose-server/Cargo.toml | 1 + crates/goose-server/src/commands/agent.rs | 11 + crates/goose-server/src/routes/gateway.rs | 225 ++++++++ crates/goose-server/src/routes/mod.rs | 2 + crates/goose-server/src/state.rs | 5 +- crates/goose/Cargo.toml | 1 + crates/goose/src/gateway/handler.rs | 510 ++++++++++++++++++ crates/goose/src/gateway/manager.rs | 416 ++++++++++++++ crates/goose/src/gateway/mod.rs | 102 ++++ crates/goose/src/gateway/pairing.rs | 189 +++++++ crates/goose/src/gateway/telegram.rs | 399 ++++++++++++++ crates/goose/src/gateway/telegram_format.rs | 279 ++++++++++ crates/goose/src/lib.rs | 1 + crates/goose/src/session/session_manager.rs | 3 + ui/desktop/openapi.json | 3 +- ui/desktop/package-lock.json | 42 +- ui/desktop/src/api/types.gen.ts | 2 +- .../src/components/settings/SettingsView.tsx | 23 +- .../settings/app/AppSettingsSection.tsx | 2 - .../gateways/GatewaySettingsSection.tsx | 419 ++++++++++++++ .../settings/tunnel/TunnelSection.tsx | 4 +- 25 files changed, 2794 insertions(+), 18 deletions(-) create mode 100644 crates/goose-cli/src/commands/gateway.rs create mode 100644 crates/goose-server/src/routes/gateway.rs create mode 100644 crates/goose/src/gateway/handler.rs create mode 100644 crates/goose/src/gateway/manager.rs create mode 100644 crates/goose/src/gateway/mod.rs create mode 100644 crates/goose/src/gateway/pairing.rs create mode 100644 crates/goose/src/gateway/telegram.rs create mode 100644 crates/goose/src/gateway/telegram_format.rs create mode 100644 ui/desktop/src/components/settings/gateways/GatewaySettingsSection.tsx diff --git a/Cargo.lock b/Cargo.lock index c5409c4e14..ef826b2754 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4142,6 +4142,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width 0.2.2", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -4284,6 +4293,7 @@ dependencies = [ "paste", "pctx_code_mode", "posthog-rs", + "pulldown-cmark", "rand 0.8.5", "regex", "reqwest 0.13.2", @@ -4504,6 +4514,7 @@ dependencies = [ "serde_path_to_error", "serde_yaml", "socket2 0.6.2", + "tempfile", "thiserror 1.0.69", "tokio", "tokio-stream", @@ -7493,6 +7504,25 @@ dependencies = [ "psl-types", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +dependencies = [ + "bitflags 2.10.0", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "pulp" version = "0.21.5" diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 080e8975e6..d1a9bf54d5 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -592,6 +592,37 @@ enum SchedulerCommand { CronHelp {}, } +#[derive(Subcommand)] +enum GatewayCommand { + #[command(about = "Show gateway status")] + Status {}, + + #[command(about = "Start a gateway")] + Start { + #[arg(help = "Gateway type (e.g., 'telegram')")] + gateway_type: String, + + #[arg( + long = "bot-token", + help = "Bot token for the gateway platform", + long_help = "Authentication token for the gateway platform (e.g., Telegram bot token)" + )] + bot_token: String, + }, + + #[command(about = "Stop a running gateway")] + Stop { + #[arg(help = "Gateway type to stop (e.g., 'telegram')")] + gateway_type: String, + }, + + #[command(about = "Generate a pairing code for a gateway")] + Pair { + #[arg(help = "Gateway type to generate pairing code for")] + gateway_type: String, + }, +} + #[derive(Subcommand)] enum RecipeCommand { /// Validate a recipe file @@ -785,6 +816,16 @@ enum Command { command: SchedulerCommand, }, + /// Manage gateways for external platform integrations (e.g., Telegram) + #[command( + about = "Manage gateways for external platform integrations", + visible_alias = "gw" + )] + Gateway { + #[command(subcommand)] + command: GatewayCommand, + }, + /// Update the goose CLI version #[command(about = "Update the goose CLI version")] Update { @@ -998,6 +1039,7 @@ fn get_command_name(command: &Option) -> &'static str { Some(Command::Project {}) => "project", Some(Command::Projects) => "projects", Some(Command::Run { .. }) => "run", + Some(Command::Gateway { .. }) => "gateway", Some(Command::Schedule { .. }) => "schedule", Some(Command::Update { .. }) => "update", Some(Command::Recipe { .. }) => "recipe", @@ -1390,6 +1432,23 @@ async fn handle_run_command( } } +async fn handle_gateway_command(command: GatewayCommand) -> Result<()> { + use crate::commands::gateway; + + match command { + GatewayCommand::Status {} => gateway::handle_gateway_status().await, + GatewayCommand::Start { + gateway_type, + bot_token, + } => { + let platform_config = serde_json::json!({ "bot_token": bot_token }); + gateway::handle_gateway_start(gateway_type, platform_config).await + } + GatewayCommand::Stop { gateway_type } => gateway::handle_gateway_stop(gateway_type).await, + GatewayCommand::Pair { gateway_type } => gateway::handle_gateway_pair(gateway_type).await, + } +} + async fn handle_schedule_command(command: SchedulerCommand) -> Result<()> { match command { SchedulerCommand::Add { @@ -1714,6 +1773,7 @@ pub async fn cli() -> anyhow::Result<()> { ) .await } + Some(Command::Gateway { command }) => handle_gateway_command(command).await, Some(Command::Schedule { command }) => handle_schedule_command(command).await, Some(Command::Update { canary, diff --git a/crates/goose-cli/src/commands/gateway.rs b/crates/goose-cli/src/commands/gateway.rs new file mode 100644 index 0000000000..91ed93cc45 --- /dev/null +++ b/crates/goose-cli/src/commands/gateway.rs @@ -0,0 +1,82 @@ +use anyhow::Result; +use goose::execution::manager::AgentManager; +use goose::gateway::manager::GatewayManager; +use std::sync::Arc; + +pub async fn handle_gateway_status() -> Result<()> { + let agent_manager = AgentManager::instance().await?; + let gateway_manager = Arc::new(GatewayManager::new(agent_manager)?); + let statuses = gateway_manager.status().await; + + if statuses.is_empty() { + println!("No gateways configured."); + return Ok(()); + } + + for status in statuses { + let state = if status.running { "running" } else { "stopped" }; + println!( + "{}: {} ({} paired users)", + status.gateway_type, + state, + status.paired_users.len() + ); + for user in &status.paired_users { + println!( + " - {}/{} (session: {})", + user.platform, + user.display_name.as_deref().unwrap_or(&user.user_id), + user.session_id + ); + } + } + + Ok(()) +} + +pub async fn handle_gateway_start( + gateway_type: String, + platform_config: serde_json::Value, +) -> Result<()> { + let agent_manager = AgentManager::instance().await?; + let gateway_manager = Arc::new(GatewayManager::new(agent_manager)?); + + let mut config = goose::gateway::GatewayConfig { + gateway_type, + platform_config, + max_sessions: 0, + }; + + let gw = goose::gateway::create_gateway(&mut config)?; + gateway_manager.start_gateway(config, gw).await?; + + println!("Gateway started. Press Ctrl+C to stop."); + + tokio::signal::ctrl_c().await?; + gateway_manager.stop_all().await; + + Ok(()) +} + +pub async fn handle_gateway_stop(gateway_type: String) -> Result<()> { + let agent_manager = AgentManager::instance().await?; + let gateway_manager = Arc::new(GatewayManager::new(agent_manager)?); + gateway_manager.stop_gateway(&gateway_type).await?; + println!("Gateway '{}' stopped.", gateway_type); + Ok(()) +} + +pub async fn handle_gateway_pair(gateway_type: String) -> Result<()> { + let agent_manager = AgentManager::instance().await?; + let gateway_manager = Arc::new(GatewayManager::new(agent_manager)?); + let (code, expires_at) = gateway_manager.generate_pairing_code(&gateway_type).await?; + + let expires = chrono::DateTime::from_timestamp(expires_at, 0) + .map(|dt| dt.format("%H:%M:%S").to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + println!("Pairing code: {}", code); + println!("Expires at: {}", expires); + + Ok(()) +} diff --git a/crates/goose-cli/src/commands/mod.rs b/crates/goose-cli/src/commands/mod.rs index 1498e5efe7..74cb50fc40 100644 --- a/crates/goose-cli/src/commands/mod.rs +++ b/crates/goose-cli/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod configure; +pub mod gateway; pub mod info; pub mod project; pub mod recipe; diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml index bd3dc56558..d724852dea 100644 --- a/crates/goose-server/Cargo.toml +++ b/crates/goose-server/Cargo.toml @@ -67,3 +67,4 @@ path = "src/bin/generate_schema.rs" tower = "0.5.2" env-lock = { workspace = true } wiremock = { workspace = true } +tempfile.workspace = true diff --git a/crates/goose-server/src/commands/agent.rs b/crates/goose-server/src/commands/agent.rs index b9799156c6..e5bc0fb975 100644 --- a/crates/goose-server/src/commands/agent.rs +++ b/crates/goose-server/src/commands/agent.rs @@ -26,6 +26,12 @@ async fn shutdown_signal() { } pub async fn run() -> Result<()> { + // Install the rustls crypto provider early, before any spawned tasks (tunnel, + // gateways, etc.) try to open TLS connections. Both `ring` and `aws-lc-rs` + // features are enabled on rustls (via different transitive deps), so rustls + // cannot auto-detect a provider — we must pick one explicitly. + let _ = rustls::crypto::ring::default_provider().install_default(); + crate::logging::setup_logging(Some("goosed"))?; let settings = configuration::Settings::new()?; @@ -55,6 +61,11 @@ pub async fn run() -> Result<()> { tunnel_manager.check_auto_start().await; }); + let gateway_manager = app_state.gateway_manager.clone(); + tokio::spawn(async move { + gateway_manager.check_auto_start().await; + }); + axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await?; diff --git a/crates/goose-server/src/routes/gateway.rs b/crates/goose-server/src/routes/gateway.rs new file mode 100644 index 0000000000..e49299ae4b --- /dev/null +++ b/crates/goose-server/src/routes/gateway.rs @@ -0,0 +1,225 @@ +use crate::routes::errors::ErrorResponse; +use crate::state::AppState; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{delete, get, post}, + Json, Router, +}; +use goose::gateway::manager::GatewayStatus; +use goose::gateway::GatewayConfig; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use utoipa::ToSchema; + +#[derive(Deserialize, ToSchema)] +pub struct StartGatewayRequest { + pub gateway_type: String, + pub platform_config: serde_json::Value, + #[serde(default)] + pub max_sessions: usize, +} + +#[derive(Deserialize, ToSchema)] +pub struct StopGatewayRequest { + pub gateway_type: String, +} + +#[derive(Deserialize, ToSchema)] +pub struct RestartGatewayRequest { + pub gateway_type: String, +} + +#[derive(Deserialize, ToSchema)] +pub struct RemoveGatewayRequest { + pub gateway_type: String, +} + +#[derive(Deserialize, ToSchema)] +pub struct CreatePairingRequest { + pub gateway_type: String, +} + +#[derive(Serialize, ToSchema)] +pub struct PairingCodeResponse { + pub code: String, + pub expires_at: i64, +} + +#[utoipa::path( + post, + path = "/gateway/start", + request_body = StartGatewayRequest, + responses( + (status = 200, description = "Gateway started"), + (status = 400, description = "Bad request", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse) + ) +)] +pub async fn start_gateway( + State(state): State>, + Json(request): Json, +) -> Response { + let mut config = GatewayConfig { + gateway_type: request.gateway_type, + platform_config: request.platform_config, + max_sessions: request.max_sessions, + }; + + let gw = match goose::gateway::create_gateway(&mut config) { + Ok(gw) => gw, + Err(e) => return ErrorResponse::bad_request(e.to_string()).into_response(), + }; + + match state.gateway_manager.start_gateway(config, gw).await { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ErrorResponse::bad_request(e.to_string()).into_response(), + } +} + +#[utoipa::path( + post, + path = "/gateway/stop", + request_body = StopGatewayRequest, + responses( + (status = 200, description = "Gateway stopped"), + (status = 404, description = "Gateway not found", body = ErrorResponse) + ) +)] +pub async fn stop_gateway( + State(state): State>, + Json(request): Json, +) -> Response { + match state + .gateway_manager + .stop_gateway(&request.gateway_type) + .await + { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ErrorResponse::not_found(e.to_string()).into_response(), + } +} + +#[utoipa::path( + post, + path = "/gateway/restart", + request_body = RestartGatewayRequest, + responses( + (status = 200, description = "Gateway restarted"), + (status = 400, description = "Bad request", body = ErrorResponse), + (status = 404, description = "No saved config", body = ErrorResponse) + ) +)] +pub async fn restart_gateway( + State(state): State>, + Json(request): Json, +) -> Response { + match state + .gateway_manager + .restart_gateway(&request.gateway_type) + .await + { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ErrorResponse::bad_request(e.to_string()).into_response(), + } +} + +#[utoipa::path( + post, + path = "/gateway/remove", + request_body = RemoveGatewayRequest, + responses( + (status = 200, description = "Gateway removed"), + (status = 500, description = "Internal server error", body = ErrorResponse) + ) +)] +pub async fn remove_gateway( + State(state): State>, + Json(request): Json, +) -> Response { + match state + .gateway_manager + .remove_gateway(&request.gateway_type) + .await + { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ErrorResponse::internal(e.to_string()).into_response(), + } +} + +#[utoipa::path( + get, + path = "/gateway/status", + responses( + (status = 200, description = "Gateway statuses", body = Vec) + ) +)] +pub async fn gateway_status(State(state): State>) -> Json> { + Json(state.gateway_manager.status().await) +} + +#[utoipa::path( + post, + path = "/gateway/pair", + request_body = CreatePairingRequest, + responses( + (status = 200, description = "Pairing code generated", body = PairingCodeResponse), + (status = 500, description = "Internal server error", body = ErrorResponse) + ) +)] +pub async fn create_pairing_code( + State(state): State>, + Json(request): Json, +) -> Response { + match state + .gateway_manager + .generate_pairing_code(&request.gateway_type) + .await + { + Ok((code, expires_at)) => ( + StatusCode::OK, + Json(PairingCodeResponse { code, expires_at }), + ) + .into_response(), + Err(e) => ErrorResponse::internal(e.to_string()).into_response(), + } +} + +#[utoipa::path( + delete, + path = "/gateway/pair/{platform}/{user_id}", + params( + ("platform" = String, Path, description = "Platform name"), + ("user_id" = String, Path, description = "Platform user ID") + ), + responses( + (status = 200, description = "User unpaired"), + (status = 404, description = "Pairing not found", body = ErrorResponse) + ) +)] +pub async fn unpair_user( + State(state): State>, + Path((platform, user_id)): Path<(String, String)>, +) -> Response { + match state.gateway_manager.unpair_user(&platform, &user_id).await { + Ok(true) => StatusCode::OK.into_response(), + Ok(false) => { + ErrorResponse::not_found(format!("No pairing found for {}/{}", platform, user_id)) + .into_response() + } + Err(e) => ErrorResponse::internal(e.to_string()).into_response(), + } +} + +pub fn routes(state: Arc) -> Router { + Router::new() + .route("/gateway/start", post(start_gateway)) + .route("/gateway/stop", post(stop_gateway)) + .route("/gateway/restart", post(restart_gateway)) + .route("/gateway/remove", post(remove_gateway)) + .route("/gateway/status", get(gateway_status)) + .route("/gateway/pair", post(create_pairing_code)) + .route("/gateway/pair/{platform}/{user_id}", delete(unpair_user)) + .with_state(state) +} diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index cbb956849b..22d4e3e6af 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -3,6 +3,7 @@ pub mod agent; pub mod config_management; pub mod dictation; pub mod errors; +pub mod gateway; pub mod local_inference; pub mod mcp_app_proxy; pub mod mcp_ui_proxy; @@ -40,6 +41,7 @@ pub fn configure(state: Arc, secret_key: String) -> Rout .merge(setup::routes(state.clone())) .merge(telemetry::routes(state.clone())) .merge(tunnel::routes(state.clone())) + .merge(gateway::routes(state.clone())) .merge(mcp_ui_proxy::routes(secret_key.clone())) .merge(mcp_app_proxy::routes(secret_key)) .merge(sampling::routes(state)) diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index 4cf836d42d..d222e4b9e5 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -14,6 +14,7 @@ use tokio::task::JoinHandle; use crate::tunnel::TunnelManager; use goose::agents::ExtensionLoadResult; +use goose::gateway::manager::GatewayManager; use goose::providers::local_inference::InferenceRuntime; type ExtensionLoadingTasks = @@ -23,9 +24,9 @@ type ExtensionLoadingTasks = pub struct AppState { pub(crate) agent_manager: Arc, pub recipe_file_hash_map: Arc>>, - /// Tracks sessions that have already emitted recipe telemetry to prevent double counting. recipe_session_tracker: Arc>>, pub tunnel_manager: Arc, + pub gateway_manager: Arc, pub extension_loading_tasks: ExtensionLoadingTasks, pub inference_runtime: Arc, } @@ -52,12 +53,14 @@ impl AppState { let agent_manager = AgentManager::instance().await?; let tunnel_manager = Arc::new(TunnelManager::new()); + let gateway_manager = Arc::new(GatewayManager::new(agent_manager.clone())?); Ok(Arc::new(Self { agent_manager, recipe_file_hash_map: Arc::new(Mutex::new(HashMap::new())), recipe_session_tracker: Arc::new(Mutex::new(HashSet::new())), tunnel_manager, + gateway_manager, extension_loading_tasks: Arc::new(Mutex::new(HashMap::new())), inference_runtime: InferenceRuntime::get_or_init(), })) diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index a0de55ef8f..4626d52311 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -126,6 +126,7 @@ ignore = { workspace = true } which = { workspace = true } pctx_code_mode = "^0.2.3" unbinder = "0.1.7" +pulldown-cmark = "0.13.0" llama-cpp-2 = { version = "0.1.133", features = ["sampler"] } encoding_rs = "0.8.35" diff --git a/crates/goose/src/gateway/handler.rs b/crates/goose/src/gateway/handler.rs new file mode 100644 index 0000000000..0c134da13a --- /dev/null +++ b/crates/goose/src/gateway/handler.rs @@ -0,0 +1,510 @@ +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use futures::StreamExt; +use tokio_util::sync::CancellationToken; + +use crate::agents::{AgentEvent, ExtensionConfig, SessionConfig}; +use crate::config::extensions::get_enabled_extensions; +use crate::config::paths::Paths; +use crate::config::Config; +use crate::conversation::message::{Message, MessageContent}; +use crate::execution::manager::AgentManager; +use crate::model::ModelConfig; +use crate::session::SessionType; +use crate::session::{EnabledExtensionsState, ExtensionState, Session}; + +use super::pairing::PairingStore; +use super::{Gateway, GatewayConfig, IncomingMessage, OutgoingMessage, PairingState, PlatformUser}; + +#[derive(Clone)] +pub struct GatewayHandler { + agent_manager: Arc, + pairing_store: Arc, + gateway: Arc, + config: GatewayConfig, +} + +impl GatewayHandler { + pub fn new( + agent_manager: Arc, + pairing_store: Arc, + gateway: Arc, + config: GatewayConfig, + ) -> Self { + Self { + agent_manager, + pairing_store, + gateway, + config, + } + } + + pub async fn handle_message(&self, message: IncomingMessage) -> anyhow::Result<()> { + let pairing = self.pairing_store.get(&message.user).await?; + + match pairing { + PairingState::Unpaired => { + if let Some(gateway_type) = self.try_consume_code(message.text.trim()).await? { + if gateway_type == self.config.gateway_type { + self.complete_pairing(&message.user).await?; + } else { + self.gateway + .send_message( + &message.user, + OutgoingMessage::Text { + body: "⚠️ That code is for a different gateway.".into(), + }, + ) + .await?; + } + } else { + self.gateway + .send_message( + &message.user, + OutgoingMessage::Text { + body: "Welcome! Enter your pairing code to connect to goose." + .into(), + }, + ) + .await?; + } + } + PairingState::PendingCode { code, expires_at } => { + let now = chrono::Utc::now().timestamp(); + if now > expires_at { + self.pairing_store + .set(&message.user, PairingState::Unpaired) + .await?; + self.gateway + .send_message( + &message.user, + OutgoingMessage::Text { + body: "Your pairing code expired. Please request a new one.".into(), + }, + ) + .await?; + } else if message.text.trim().eq_ignore_ascii_case(&code) { + self.complete_pairing(&message.user).await?; + } else { + self.gateway + .send_message( + &message.user, + OutgoingMessage::Text { + body: "Invalid code. Please try again.".into(), + }, + ) + .await?; + } + } + PairingState::Paired { session_id, .. } => { + self.relay_to_session(&message, &session_id).await?; + } + } + + Ok(()) + } + + async fn try_consume_code(&self, text: &str) -> anyhow::Result> { + let normalized = text.to_uppercase().replace(['-', ' '], ""); + if normalized.len() == 6 + && normalized + .chars() + .all(|c| "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".contains(c)) + { + return self.pairing_store.consume_pending_code(&normalized).await; + } + Ok(None) + } + + async fn complete_pairing(&self, user: &PlatformUser) -> anyhow::Result<()> { + let working_dir = gateway_working_dir(&user.platform, &user.user_id); + std::fs::create_dir_all(&working_dir)?; + + let session_name = format!( + "{}/{}", + user.platform, + user.display_name.as_deref().unwrap_or(&user.user_id) + ); + + let session = self + .agent_manager + .session_manager() + .create_session(working_dir, session_name, SessionType::Gateway) + .await?; + + let manager = self.agent_manager.session_manager(); + let config = Config::global(); + + // Store the current provider and model config on the session so the agent + // can be restored after LRU eviction, matching the start_agent flow. + let mut update = manager.update(&session.id); + if let Ok(provider) = config.get_goose_provider() { + update = update.provider_name(provider); + } + if let Ok(model_name) = config.get_goose_model() { + if let Ok(model_config) = ModelConfig::new(&model_name) { + update = update.model_config(model_config); + } + } + + // Store default extensions so load_extensions_from_session works. + let extensions = get_enabled_extensions(); + let extensions_state = EnabledExtensionsState::new(extensions); + let mut extension_data = session.extension_data.clone(); + if let Err(e) = extensions_state.to_extension_data(&mut extension_data) { + tracing::warn!(error = %e, "failed to initialize gateway session extensions"); + } else { + update = update.extension_data(extension_data); + } + + update.apply().await?; + + let now = chrono::Utc::now().timestamp(); + self.pairing_store + .set( + user, + PairingState::Paired { + session_id: session.id.clone(), + paired_at: now, + }, + ) + .await?; + + self.gateway + .send_message( + user, + OutgoingMessage::Text { + body: "Paired! You can now chat with goose.".into(), + }, + ) + .await?; + + Ok(()) + } + + /// Sync the session's provider, model, and extensions with the current + /// global config so gateway sessions always reflect what the user has + /// configured in the desktop app. Returns `true` if extensions changed + /// (which means the caller must recreate the agent so stale extension + /// processes are torn down). + async fn sync_session_config(&self, session: &Session) -> anyhow::Result { + let config = Config::global(); + let manager = self.agent_manager.session_manager(); + + // --- current global config --- + let current_provider = config.get_goose_provider().ok(); + let current_model_name = config.get_goose_model().ok(); + let current_extensions = get_enabled_extensions(); + + // --- what the session has --- + let session_extensions: Vec = + EnabledExtensionsState::from_extension_data(&session.extension_data) + .map(|s| s.extensions) + .unwrap_or_default(); + + let provider_changed = current_provider.as_deref() != session.provider_name.as_deref(); + let model_changed = current_model_name.as_deref() + != session.model_config.as_ref().map(|m| m.model_name.as_str()); + let extensions_changed = current_extensions != session_extensions; + + if !provider_changed && !model_changed && !extensions_changed { + return Ok(false); + } + + tracing::info!( + session_id = %session.id, + provider_changed, + model_changed, + extensions_changed, + "syncing gateway session with current config" + ); + + let mut update = manager.update(&session.id); + + if let Some(ref provider) = current_provider { + update = update.provider_name(provider); + } + if let Some(ref model_name) = current_model_name { + if let Ok(model_config) = ModelConfig::new(model_name) { + update = update.model_config(model_config); + } + } + + if extensions_changed { + let extensions_state = EnabledExtensionsState::new(current_extensions); + let mut extension_data = session.extension_data.clone(); + if let Err(e) = extensions_state.to_extension_data(&mut extension_data) { + tracing::warn!(error = %e, "failed to update gateway session extensions"); + } else { + update = update.extension_data(extension_data); + } + } + + update.apply().await?; + Ok(extensions_changed) + } + + async fn relay_to_session( + &self, + message: &IncomingMessage, + session_id: &str, + ) -> anyhow::Result<()> { + self.gateway + .send_message(&message.user, OutgoingMessage::Typing) + .await?; + + let session = self + .agent_manager + .session_manager() + .get_session(session_id, false) + .await?; + + // Sync provider/model/extensions with the user's current desktop config. + // If extensions changed we must tear down the old agent so stale + // extension processes don't linger. + let extensions_changed = self.sync_session_config(&session).await?; + if extensions_changed { + let _ = self.agent_manager.remove_session(session_id).await; + } + + let agent = self + .agent_manager + .get_or_create_agent(session_id.to_string()) + .await?; + + // Re-read the session after sync so restore picks up the new values. + let session = self + .agent_manager + .session_manager() + .get_session(session_id, false) + .await?; + + // Ensure provider is configured (handles first use and LRU eviction). + if let Err(e) = agent.restore_provider_from_session(&session).await { + self.gateway + .send_message( + &message.user, + OutgoingMessage::Text { + body: format!("⚠️ Failed to configure provider: {e}"), + }, + ) + .await?; + return Ok(()); + } + + // Load extensions (skips any already loaded on the agent). + agent.load_extensions_from_session(&session).await; + + let cancel = CancellationToken::new(); + let user_message = Message::user().with_text(&message.text); + + // Cap tool-calling loops so the agent doesn't run away doing + // dozens of tool calls before responding. After this many + // LLM→tool round-trips the agent will stop and reply with + // whatever it has. + const GATEWAY_MAX_TURNS: u32 = 5; + + let session_config = SessionConfig { + id: session_id.to_string(), + schedule_id: None, + max_turns: Some(GATEWAY_MAX_TURNS), + retry_config: None, + }; + + let mut stream = match agent + .reply(user_message, session_config, Some(cancel)) + .await + { + Ok(s) => s, + Err(e) => { + self.gateway + .send_message( + &message.user, + OutgoingMessage::Text { + body: format!("⚠️ Failed to start agent: {e}"), + }, + ) + .await?; + return Ok(()); + } + }; + + // Telegram stops showing "typing…" after ~5 seconds. Re-send the + // indicator every 4 s so the user always sees activity while the + // agent is working (tool calls, LLM round-trips, etc.). + let typing_cancel = CancellationToken::new(); + let typing_gateway = self.gateway.clone(); + let typing_user = message.user.clone(); + let typing_handle = tokio::spawn({ + let cancel = typing_cancel.clone(); + async move { + let mut interval = tokio::time::interval(Duration::from_secs(4)); + interval.tick().await; // first tick is immediate, skip it + loop { + tokio::select! { + _ = cancel.cancelled() => break, + _ = interval.tick() => { + if let Err(e) = typing_gateway + .send_message(&typing_user, OutgoingMessage::Typing) + .await + { + tracing::debug!(error = %e, "failed to re-send typing indicator"); + break; + } + } + } + } + } + }); + + // Buffer text within a single assistant message so we send one + // Telegram message per LLM turn rather than per-chunk. When a + // ToolRequest appears in the same message we flush the buffer + // first — the user sees "Let me check…" immediately, then the + // typing indicator while the tool runs, then the next response. + let mut pending_text = String::new(); + let mut sent_any = false; + let mut event_count: u64 = 0; + + while let Some(event) = stream.next().await { + event_count += 1; + match event { + Ok(AgentEvent::Message(ref msg)) => { + tracing::debug!( + session_id, + role = ?msg.role, + content_items = msg.content.len(), + "gateway stream: message event #{event_count}" + ); + if msg.role == rmcp::model::Role::Assistant { + for content in &msg.content { + match content { + MessageContent::Text(t) => { + if !t.text.is_empty() { + pending_text.push_str(&t.text); + } + } + MessageContent::ToolRequest(req) => { + // Flush any accumulated text before + // the tool runs — the user sees the + // assistant's intent immediately. + if !pending_text.is_empty() { + let _ = self + .gateway + .send_message( + &message.user, + OutgoingMessage::Text { + body: std::mem::take(&mut pending_text), + }, + ) + .await; + sent_any = true; + } + if let Ok(call) = &req.tool_call { + tracing::debug!( + session_id, + tool = %call.name, + "gateway stream: tool request" + ); + let _ = self + .gateway + .send_message(&message.user, OutgoingMessage::Typing) + .await; + } + } + MessageContent::ToolResponse(resp) => { + tracing::debug!( + session_id, + id = %resp.id, + success = resp.tool_result.is_ok(), + "gateway stream: tool response" + ); + } + _ => {} + } + } + } + } + Ok(AgentEvent::McpNotification(_)) => { + tracing::debug!( + session_id, + "gateway stream: mcp notification #{event_count}" + ); + } + Ok(AgentEvent::ModelChange { + ref model, + ref mode, + }) => { + tracing::debug!( + session_id, + model, + mode, + "gateway stream: model change #{event_count}" + ); + } + Ok(AgentEvent::HistoryReplaced(_)) => { + tracing::debug!( + session_id, + "gateway stream: history replaced #{event_count}" + ); + } + Err(e) => { + tracing::error!(session_id, error = %e, "gateway stream: error at event #{event_count}"); + // Stop typing indicator before sending error. + typing_cancel.cancel(); + let _ = typing_handle.await; + self.gateway + .send_message( + &message.user, + OutgoingMessage::Text { + body: format!("⚠️ Agent error: {e}"), + }, + ) + .await?; + return Ok(()); + } + } + } + + // Stream finished — stop the typing indicator. + typing_cancel.cancel(); + let _ = typing_handle.await; + + tracing::debug!( + session_id, + event_count, + pending_text_len = pending_text.len(), + sent_any, + "gateway stream: finished" + ); + + // Send any remaining buffered text (this is typically the final + // assistant response after the last tool round-trip). + if !pending_text.is_empty() { + self.gateway + .send_message(&message.user, OutgoingMessage::Text { body: pending_text }) + .await?; + } else if !sent_any { + // Nothing was ever sent — let the user know. + self.gateway + .send_message( + &message.user, + OutgoingMessage::Text { + body: "(No response)".to_string(), + }, + ) + .await?; + } + + Ok(()) + } +} + +fn gateway_working_dir(platform: &str, user_id: &str) -> PathBuf { + Paths::config_dir() + .join("gateway") + .join(platform) + .join(user_id) +} diff --git a/crates/goose/src/gateway/manager.rs b/crates/goose/src/gateway/manager.rs new file mode 100644 index 0000000000..423ca6cf7e --- /dev/null +++ b/crates/goose/src/gateway/manager.rs @@ -0,0 +1,416 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; +use utoipa::ToSchema; + +use crate::config::Config; +use crate::execution::manager::AgentManager; + +use super::handler::GatewayHandler; +use super::pairing::PairingStore; +use super::{Gateway, GatewayConfig, PairingState, PlatformUser}; + +const GATEWAY_CONFIGS_KEY: &str = "gateway_configs"; + +fn secret_key_for(gateway_type: &str) -> String { + format!("gateway_platform_config_{}", gateway_type) +} + +/// Serialized form stored in goose config (no secrets). +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SavedGatewayEntry { + gateway_type: String, + max_sessions: usize, +} + +#[allow(dead_code)] +pub struct GatewayInstance { + pub config: GatewayConfig, + pub gateway: Arc, + pub cancel: CancellationToken, + pub handle: tokio::task::JoinHandle<()>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct PairedUserInfo { + pub platform: String, + pub user_id: String, + pub display_name: Option, + pub session_id: String, + pub paired_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct GatewayStatus { + pub gateway_type: String, + pub running: bool, + pub configured: bool, + pub paired_users: Vec, + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub info: HashMap, +} + +pub struct GatewayManager { + gateways: RwLock>, + pairing_store: Arc, + agent_manager: Arc, +} + +impl GatewayManager { + pub fn new(agent_manager: Arc) -> anyhow::Result { + let pairing_store = Arc::new(PairingStore::new()?); + + Ok(Self { + gateways: RwLock::new(HashMap::new()), + pairing_store, + agent_manager, + }) + } + + #[allow(dead_code)] + pub fn pairing_store(&self) -> &Arc { + &self.pairing_store + } + + /// Load saved gateway configs and start them. Called once at server startup. + pub async fn check_auto_start(&self) { + let configs = match Self::load_saved_configs() { + Ok(configs) => configs, + Err(e) => { + tracing::warn!(error = %e, "failed to load saved gateway configs"); + return; + } + }; + + for mut config in configs { + let gateway = match super::create_gateway(&mut config) { + Ok(gw) => gw, + Err(e) => { + tracing::error!( + gateway = %config.gateway_type, + error = %e, + "failed to create saved gateway" + ); + continue; + } + }; + + if let Err(e) = self.start_gateway_internal(config.clone(), gateway).await { + tracing::error!( + gateway = %config.gateway_type, + error = %e, + "failed to auto-start gateway" + ); + } else { + tracing::info!(gateway = %config.gateway_type, "gateway auto-started"); + } + } + } + + /// Start a gateway and persist its config for auto-start on next launch. + pub async fn start_gateway( + &self, + config: GatewayConfig, + gateway: Arc, + ) -> anyhow::Result<()> { + self.start_gateway_internal(config.clone(), gateway).await?; + Self::save_config(&config)?; + Ok(()) + } + + /// Stop a gateway and clear its pairings. Config is kept so it can be restarted. + pub async fn stop_gateway(&self, gateway_type: &str) -> anyhow::Result<()> { + let instance = self + .gateways + .write() + .await + .remove(gateway_type) + .ok_or_else(|| anyhow::anyhow!("Gateway '{}' is not running", gateway_type))?; + + instance.cancel.cancel(); + let _ = instance.handle.await; + + match self + .pairing_store + .remove_all_for_platform(gateway_type) + .await + { + Ok(count) if count > 0 => { + tracing::info!(gateway = %gateway_type, count, "cleared pairings on stop"); + } + Err(e) => { + tracing::warn!(gateway = %gateway_type, error = %e, "failed to clear pairings on stop"); + } + _ => {} + } + + tracing::info!(gateway = %gateway_type, "gateway stopped"); + Ok(()) + } + + /// Stop a gateway (if running), clear pairings, and remove its saved config entirely. + pub async fn remove_gateway(&self, gateway_type: &str) -> anyhow::Result<()> { + // Stop if running (ignore error if not running). + if let Some(instance) = self.gateways.write().await.remove(gateway_type) { + instance.cancel.cancel(); + let _ = instance.handle.await; + } + + if let Err(e) = self + .pairing_store + .remove_all_for_platform(gateway_type) + .await + { + tracing::warn!(gateway = %gateway_type, error = %e, "failed to clear pairings on remove"); + } + + Self::remove_saved_config(gateway_type); + tracing::info!(gateway = %gateway_type, "gateway removed"); + Ok(()) + } + + /// Restart a stopped gateway using its saved config. + pub async fn restart_gateway(&self, gateway_type: &str) -> anyhow::Result<()> { + if self.gateways.read().await.contains_key(gateway_type) { + anyhow::bail!("Gateway '{}' is already running", gateway_type); + } + + let configs = Self::load_saved_configs()?; + let mut config = configs + .into_iter() + .find(|c| c.gateway_type == gateway_type) + .ok_or_else(|| anyhow::anyhow!("No saved config for gateway '{}'", gateway_type))?; + + let gateway = super::create_gateway(&mut config)?; + self.start_gateway_internal(config, gateway).await?; + tracing::info!(gateway = %gateway_type, "gateway restarted"); + Ok(()) + } + + async fn start_gateway_internal( + &self, + config: GatewayConfig, + gateway: Arc, + ) -> anyhow::Result<()> { + let gw_type = config.gateway_type.clone(); + + if self.gateways.read().await.contains_key(&gw_type) { + anyhow::bail!("Gateway '{}' is already running", gw_type); + } + + gateway.validate_config().await?; + + let cancel = CancellationToken::new(); + let handler = GatewayHandler::new( + self.agent_manager.clone(), + self.pairing_store.clone(), + gateway.clone(), + config.clone(), + ); + + let gateway_clone = gateway.clone(); + let cancel_clone = cancel.clone(); + let gateway_type_for_task = gw_type.clone(); + + let handle = tokio::spawn(async move { + if let Err(e) = gateway_clone.start(handler, cancel_clone).await { + tracing::error!(gateway = %gateway_type_for_task, error = %e, "gateway stopped with error"); + } + }); + + let instance = GatewayInstance { + config, + gateway, + cancel, + handle, + }; + + self.gateways.write().await.insert(gw_type, instance); + Ok(()) + } + + #[allow(dead_code)] + pub async fn stop_all(&self) { + let instances: Vec<(String, GatewayInstance)> = + self.gateways.write().await.drain().collect(); + for (gateway_type, instance) in instances { + instance.cancel.cancel(); + let _ = instance.handle.await; + if let Err(e) = self + .pairing_store + .remove_all_for_platform(&gateway_type) + .await + { + tracing::warn!(gateway = %gateway_type, error = %e, "failed to clear pairings on stop"); + } + tracing::info!(gateway = %gateway_type, "gateway stopped"); + } + } + + #[allow(dead_code)] + pub async fn is_running(&self, gateway_type: &str) -> bool { + self.gateways.read().await.contains_key(gateway_type) + } + + #[allow(dead_code)] + pub async fn list_running(&self) -> Vec { + self.gateways.read().await.keys().cloned().collect() + } + + pub async fn status(&self) -> Vec { + let running = self.gateways.read().await; + let mut statuses = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for (gw_type, instance) in running.iter() { + seen.insert(gw_type.clone()); + let paired_users = self + .pairing_store + .list_paired_users(gw_type) + .await + .unwrap_or_default() + .into_iter() + .map(|(user, session_id, paired_at)| PairedUserInfo { + platform: user.platform, + user_id: user.user_id, + display_name: user.display_name, + session_id, + paired_at, + }) + .collect(); + + statuses.push(GatewayStatus { + gateway_type: gw_type.clone(), + running: true, + configured: true, + paired_users, + info: instance.gateway.info(), + }); + } + + // Include configured-but-stopped gateways. + if let Ok(saved) = Self::load_saved_configs() { + for config in saved { + if seen.contains(&config.gateway_type) { + continue; + } + statuses.push(GatewayStatus { + gateway_type: config.gateway_type, + running: false, + configured: true, + paired_users: Vec::new(), + info: HashMap::new(), + }); + } + } + + statuses.sort_by(|a, b| a.gateway_type.cmp(&b.gateway_type)); + statuses + } + + pub async fn unpair_user(&self, platform: &str, user_id: &str) -> anyhow::Result { + let user = PlatformUser { + platform: platform.to_string(), + user_id: user_id.to_string(), + display_name: None, + }; + let state = self.pairing_store.get(&user).await?; + if matches!(state, PairingState::Paired { .. }) { + self.pairing_store.remove(&user).await?; + Ok(true) + } else { + Ok(false) + } + } + + pub async fn generate_pairing_code(&self, gateway_type: &str) -> anyhow::Result<(String, i64)> { + let code = PairingStore::generate_code(); + let expires_at = chrono::Utc::now().timestamp() + 300; + self.pairing_store + .store_pending_code(&code, gateway_type, expires_at) + .await?; + Ok((code, expires_at)) + } + + // --- Config persistence --- + + fn load_saved_configs() -> anyhow::Result> { + let config = Config::global(); + + let entries: Vec = match config.get_param(GATEWAY_CONFIGS_KEY) { + Ok(entries) => entries, + Err(_) => return Ok(Vec::new()), + }; + + let mut configs = Vec::new(); + for entry in entries { + let platform_config: serde_json::Value = + match config.get_secret(&secret_key_for(&entry.gateway_type)) { + Ok(v) => v, + Err(e) => { + tracing::warn!( + gateway = %entry.gateway_type, + error = %e, + "skipping gateway with missing platform config secret" + ); + continue; + } + }; + + configs.push(GatewayConfig { + gateway_type: entry.gateway_type, + platform_config, + max_sessions: entry.max_sessions, + }); + } + + Ok(configs) + } + + fn save_config(gw_config: &GatewayConfig) -> anyhow::Result<()> { + let config = Config::global(); + + // Save platform_config (contains secrets like bot tokens) to the secret store. + config + .set_secret( + &secret_key_for(&gw_config.gateway_type), + &gw_config.platform_config, + ) + .map_err(|e| anyhow::anyhow!("failed to save gateway secret: {}", e))?; + + // Load existing entries, add/replace this one, save back. + let mut entries: Vec = + config.get_param(GATEWAY_CONFIGS_KEY).unwrap_or_default(); + entries.retain(|e| e.gateway_type != gw_config.gateway_type); + entries.push(SavedGatewayEntry { + gateway_type: gw_config.gateway_type.clone(), + max_sessions: gw_config.max_sessions, + }); + + config + .set_param(GATEWAY_CONFIGS_KEY, &entries) + .map_err(|e| anyhow::anyhow!("failed to save gateway config: {}", e))?; + + Ok(()) + } + + fn remove_saved_config(gateway_type: &str) { + let config = Config::global(); + + // Remove the secret. + if let Err(e) = config.delete_secret(&secret_key_for(gateway_type)) { + tracing::warn!(error = %e, "failed to remove gateway secret"); + } + + // Remove from the config entries list. + let mut entries: Vec = + config.get_param(GATEWAY_CONFIGS_KEY).unwrap_or_default(); + entries.retain(|e| e.gateway_type != gateway_type); + if let Err(e) = config.set_param(GATEWAY_CONFIGS_KEY, &entries) { + tracing::warn!(error = %e, "failed to update gateway config list"); + } + } +} diff --git a/crates/goose/src/gateway/mod.rs b/crates/goose/src/gateway/mod.rs new file mode 100644 index 0000000000..6a38525567 --- /dev/null +++ b/crates/goose/src/gateway/mod.rs @@ -0,0 +1,102 @@ +pub mod handler; +pub mod manager; +pub mod pairing; +pub mod telegram; +pub mod telegram_format; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use tokio_util::sync::CancellationToken; +use utoipa::ToSchema; + +use handler::GatewayHandler; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlatformUser { + pub platform: String, + pub user_id: String, + pub display_name: Option, +} + +impl PartialEq for PlatformUser { + fn eq(&self, other: &Self) -> bool { + self.platform == other.platform && self.user_id == other.user_id + } +} + +impl Eq for PlatformUser {} + +impl std::hash::Hash for PlatformUser { + fn hash(&self, state: &mut H) { + self.platform.hash(state); + self.user_id.hash(state); + } +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct IncomingMessage { + pub user: PlatformUser, + pub text: String, + pub platform_message_id: Option, + pub attachments: Vec, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct Attachment { + pub filename: String, + pub mime_type: String, + pub data: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum OutgoingMessage { + Text { body: String }, + Typing, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum PairingState { + Unpaired, + PendingCode { code: String, expires_at: i64 }, + Paired { session_id: String, paired_at: i64 }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct GatewayConfig { + pub gateway_type: String, + pub platform_config: serde_json::Value, + pub max_sessions: usize, +} + +#[async_trait] +#[allow(dead_code)] +pub trait Gateway: Send + Sync + 'static { + fn gateway_type(&self) -> &str; + + async fn start(&self, handler: GatewayHandler, cancel: CancellationToken) + -> anyhow::Result<()>; + + async fn send_message( + &self, + user: &PlatformUser, + message: OutgoingMessage, + ) -> anyhow::Result<()>; + + async fn validate_config(&self) -> anyhow::Result<()>; + + fn info(&self) -> HashMap { + HashMap::new() + } +} + +pub fn create_gateway(config: &mut GatewayConfig) -> anyhow::Result> { + match config.gateway_type.as_str() { + "telegram" => Ok(std::sync::Arc::new(telegram::TelegramGateway::new(config)?)), + other => anyhow::bail!("Unknown gateway type: {}", other), + } +} diff --git a/crates/goose/src/gateway/pairing.rs b/crates/goose/src/gateway/pairing.rs new file mode 100644 index 0000000000..6ae272436a --- /dev/null +++ b/crates/goose/src/gateway/pairing.rs @@ -0,0 +1,189 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; + +use crate::config::Config; + +use super::{PairingState, PlatformUser}; + +const PAIRINGS_CONFIG_KEY: &str = "gateway_pairings"; +const PENDING_CODES_CONFIG_KEY: &str = "gateway_pending_codes"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct StoredPairing { + platform: String, + user_id: String, + display_name: Option, + state: PairingState, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct StoredPendingCode { + code: String, + gateway_type: String, + expires_at: i64, +} + +pub struct PairingStore { + pairings: RwLock>, +} + +impl PairingStore { + pub fn new() -> anyhow::Result { + let pairings = Self::load_pairings_from_config(); + Ok(Self { + pairings: RwLock::new(pairings), + }) + } + + fn load_pairings_from_config() -> HashMap { + let config = Config::global(); + let entries: Vec = config.get_param(PAIRINGS_CONFIG_KEY).unwrap_or_default(); + let mut map = HashMap::new(); + for entry in entries { + let user = PlatformUser { + platform: entry.platform, + user_id: entry.user_id, + display_name: entry.display_name, + }; + map.insert(user, entry.state); + } + map + } + + fn save_pairings_to_config( + pairings: &HashMap, + ) -> anyhow::Result<()> { + let entries: Vec = pairings + .iter() + .map(|(user, state)| StoredPairing { + platform: user.platform.clone(), + user_id: user.user_id.clone(), + display_name: user.display_name.clone(), + state: state.clone(), + }) + .collect(); + Config::global() + .set_param(PAIRINGS_CONFIG_KEY, &entries) + .map_err(|e| anyhow::anyhow!("failed to save gateway pairings: {}", e)) + } + + fn load_pending_codes() -> Vec { + Config::global() + .get_param(PENDING_CODES_CONFIG_KEY) + .unwrap_or_default() + } + + fn save_pending_codes(codes: &[StoredPendingCode]) -> anyhow::Result<()> { + Config::global() + .set_param(PENDING_CODES_CONFIG_KEY, codes) + .map_err(|e| anyhow::anyhow!("failed to save pending codes: {}", e)) + } + + pub async fn get(&self, user: &PlatformUser) -> anyhow::Result { + let pairings = self.pairings.read().await; + Ok(pairings + .get(user) + .cloned() + .unwrap_or(PairingState::Unpaired)) + } + + pub async fn set(&self, user: &PlatformUser, state: PairingState) -> anyhow::Result<()> { + let mut pairings = self.pairings.write().await; + pairings.insert(user.clone(), state); + Self::save_pairings_to_config(&pairings) + } + + pub async fn remove(&self, user: &PlatformUser) -> anyhow::Result<()> { + let mut pairings = self.pairings.write().await; + pairings.remove(user); + Self::save_pairings_to_config(&pairings) + } + + pub async fn store_pending_code( + &self, + code: &str, + gateway_type: &str, + expires_at: i64, + ) -> anyhow::Result<()> { + let mut codes = Self::load_pending_codes(); + codes.retain(|c| c.code != code); + codes.push(StoredPendingCode { + code: code.to_string(), + gateway_type: gateway_type.to_string(), + expires_at, + }); + Self::save_pending_codes(&codes) + } + + pub async fn consume_pending_code(&self, code: &str) -> anyhow::Result> { + let mut codes = Self::load_pending_codes(); + let pos = codes.iter().position(|c| c.code == code); + let Some(pos) = pos else { + return Ok(None); + }; + + let entry = codes.remove(pos); + Self::save_pending_codes(&codes)?; + + let now = chrono::Utc::now().timestamp(); + if now > entry.expires_at { + return Ok(None); + } + + Ok(Some(entry.gateway_type)) + } + + pub fn generate_code() -> String { + use rand::Rng; + let chars: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + let mut rng = rand::thread_rng(); + (0..6) + .map(|_| chars[rng.gen_range(0..chars.len())] as char) + .collect() + } + + pub async fn remove_all_for_platform(&self, platform: &str) -> anyhow::Result { + let mut pairings = self.pairings.write().await; + let before = pairings.len(); + pairings.retain(|user, _| user.platform != platform); + let removed = before - pairings.len(); + Self::save_pairings_to_config(&pairings)?; + Ok(removed) + } + + pub async fn list_paired_users( + &self, + gateway_type: &str, + ) -> anyhow::Result> { + let pairings = self.pairings.read().await; + let mut result = Vec::new(); + for (user, state) in pairings.iter() { + if user.platform == gateway_type { + if let PairingState::Paired { + session_id, + paired_at, + } = state + { + result.push((user.clone(), session_id.clone(), *paired_at)); + } + } + } + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_code_generation() { + let code = PairingStore::generate_code(); + assert_eq!(code.len(), 6); + assert!(code + .chars() + .all(|c| "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".contains(c))); + } +} diff --git a/crates/goose/src/gateway/telegram.rs b/crates/goose/src/gateway/telegram.rs new file mode 100644 index 0000000000..08d6f0e514 --- /dev/null +++ b/crates/goose/src/gateway/telegram.rs @@ -0,0 +1,399 @@ +use super::{ + Gateway, GatewayConfig, GatewayHandler, IncomingMessage, OutgoingMessage, PlatformUser, +}; +use async_trait::async_trait; +use reqwest::Client; +use serde::Deserialize; +use tokio_util::sync::CancellationToken; + +const TELEGRAM_API_BASE: &str = "https://api.telegram.org"; +const POLL_TIMEOUT_SECS: u64 = 30; +const MAX_MESSAGE_LENGTH: usize = 4096; +const RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(5); + +pub struct TelegramGateway { + bot_token: String, + client: Client, +} + +#[derive(Debug, Deserialize)] +struct TelegramUpdate { + update_id: i64, + message: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramMessage { + message_id: i64, + from: Option, + chat: TelegramChat, + text: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramUser { + first_name: String, + last_name: Option, + #[allow(dead_code)] + username: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramChat { + id: i64, + #[allow(dead_code)] + #[serde(rename = "type")] + chat_type: String, +} + +#[derive(Debug, Deserialize)] +struct TelegramResponse { + ok: bool, + result: Option, + description: Option, +} + +impl TelegramGateway { + pub fn new(config: &GatewayConfig) -> anyhow::Result { + let bot_token = config.platform_config["bot_token"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing bot_token in platform_config"))? + .to_string(); + + Ok(Self { + bot_token, + client: Client::new(), + }) + } + + fn api_url(&self, method: &str) -> String { + format!("{}/bot{}/{}", TELEGRAM_API_BASE, self.bot_token, method) + } + + async fn get_updates(&self, offset: Option) -> anyhow::Result> { + let mut params = serde_json::json!({ + "timeout": POLL_TIMEOUT_SECS, + "allowed_updates": ["message"], + }); + if let Some(offset) = offset { + params["offset"] = serde_json::json!(offset); + } + + let resp: TelegramResponse> = self + .client + .post(self.api_url("getUpdates")) + .json(¶ms) + .timeout(std::time::Duration::from_secs(POLL_TIMEOUT_SECS + 10)) + .send() + .await? + .json() + .await?; + + resp.result.ok_or_else(|| { + anyhow::anyhow!( + "Telegram API error: {}", + resp.description.unwrap_or_default() + ) + }) + } + + async fn send_text(&self, chat_id: i64, text: &str) -> anyhow::Result<()> { + let html = super::telegram_format::markdown_to_telegram_html(text); + for chunk in split_message(&html, MAX_MESSAGE_LENGTH) { + let resp = self + .client + .post(self.api_url("sendMessage")) + .json(&serde_json::json!({ + "chat_id": chat_id, + "text": chunk, + "parse_mode": "HTML", + })) + .send() + .await?; + + if let Ok(body) = resp.json::>().await { + if !body.ok { + tracing::warn!( + error = body.description.as_deref().unwrap_or("unknown"), + "Telegram rejected HTML, falling back to plain text" + ); + for plain_chunk in split_message(text, MAX_MESSAGE_LENGTH) { + self.client + .post(self.api_url("sendMessage")) + .json(&serde_json::json!({ + "chat_id": chat_id, + "text": plain_chunk, + })) + .send() + .await?; + } + return Ok(()); + } + } + } + Ok(()) + } + + async fn send_chat_action(&self, chat_id: i64, action: &str) -> anyhow::Result<()> { + self.client + .post(self.api_url("sendChatAction")) + .json(&serde_json::json!({ + "chat_id": chat_id, + "action": action, + })) + .send() + .await?; + Ok(()) + } + + fn to_platform_user(tg_msg: &TelegramMessage) -> PlatformUser { + PlatformUser { + platform: "telegram".to_string(), + user_id: tg_msg.chat.id.to_string(), + display_name: tg_msg.from.as_ref().map(|u| { + let mut name = u.first_name.clone(); + if let Some(ref last) = u.last_name { + name.push(' '); + name.push_str(last); + } + name + }), + } + } +} + +#[async_trait] +impl Gateway for TelegramGateway { + fn gateway_type(&self) -> &str { + "telegram" + } + + async fn start( + &self, + handler: GatewayHandler, + cancel: CancellationToken, + ) -> anyhow::Result<()> { + let mut offset: Option = None; + + tracing::info!("Telegram gateway starting long-poll loop"); + + loop { + tokio::select! { + _ = cancel.cancelled() => { + tracing::info!("Telegram gateway shutting down"); + break; + } + result = self.get_updates(offset) => { + match result { + Ok(updates) => { + for update in updates { + offset = Some(update.update_id + 1); + + let Some(tg_msg) = update.message else { + continue; + }; + let text = match tg_msg.text { + Some(ref t) => t.clone(), + None => continue, + }; + + let user = Self::to_platform_user(&tg_msg); + let incoming = IncomingMessage { + user, + text, + platform_message_id: Some(tg_msg.message_id.to_string()), + attachments: vec![], + }; + + let handler = handler.clone(); + tokio::spawn(async move { + if let Err(e) = handler.handle_message(incoming).await { + tracing::error!(error = %e, "error handling Telegram message"); + } + }); + } + } + Err(e) => { + tracing::error!(error = %e, "Telegram poll error"); + tokio::time::sleep(RETRY_DELAY).await; + } + } + } + } + } + + Ok(()) + } + + async fn send_message( + &self, + user: &PlatformUser, + message: OutgoingMessage, + ) -> anyhow::Result<()> { + let chat_id: i64 = user + .user_id + .parse() + .map_err(|_| anyhow::anyhow!("invalid chat_id: {}", user.user_id))?; + + match message { + OutgoingMessage::Text { body } => { + self.send_text(chat_id, &body).await?; + } + OutgoingMessage::Typing => { + self.send_chat_action(chat_id, "typing").await?; + } + } + + Ok(()) + } + + async fn validate_config(&self) -> anyhow::Result<()> { + let resp: TelegramResponse = self + .client + .get(self.api_url("getMe")) + .send() + .await? + .json() + .await?; + + if !resp.ok { + anyhow::bail!( + "invalid Telegram bot token: {}", + resp.description.unwrap_or_default() + ); + } + + if let Some(result) = &resp.result { + if let Some(username) = result.get("username").and_then(|v| v.as_str()) { + tracing::info!(bot = %username, "Telegram bot verified"); + } + } + + Ok(()) + } +} + +#[allow(clippy::string_slice)] +fn split_message(text: &str, max_len: usize) -> Vec { + if text.len() <= max_len { + return vec![text.to_string()]; + } + + let mut chunks = Vec::new(); + let mut remaining = text; + + while !remaining.is_empty() { + if remaining.len() <= max_len { + chunks.push(remaining.to_string()); + break; + } + + let mut cut = max_len; + while cut > 0 && !remaining.is_char_boundary(cut) { + cut -= 1; + } + if cut == 0 { + cut = remaining + .char_indices() + .nth(1) + .map(|(i, _)| i) + .unwrap_or(remaining.len()); + } + + let split_at = remaining[..cut] + .rfind('\n') + .or_else(|| remaining[..cut].rfind(' ')) + .map(|pos| pos + 1) + .unwrap_or(cut); + + chunks.push(remaining[..split_at].to_string()); + remaining = &remaining[split_at..]; + } + + chunks +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_short_message() { + let chunks = split_message("hello world", 4096); + assert_eq!(chunks, vec!["hello world"]); + } + + #[test] + fn split_at_newline() { + let text = format!("{}\n{}", "a".repeat(4000), "b".repeat(200)); + let chunks = split_message(&text, 4096); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].len(), 4001); + assert_eq!(chunks[1].len(), 200); + } + + #[test] + fn split_at_space() { + let text = format!("{} {}", "a".repeat(4000), "b".repeat(200)); + let chunks = split_message(&text, 4096); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].len(), 4001); + assert_eq!(chunks[1].len(), 200); + } + + #[test] + fn split_no_boundary() { + let text = "a".repeat(5000); + let chunks = split_message(&text, 4096); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].len(), 4096); + assert_eq!(chunks[1].len(), 904); + } + + #[test] + fn split_exact_boundary() { + let text = "a".repeat(4096); + let chunks = split_message(&text, 4096); + assert_eq!(chunks.len(), 1); + } + + #[test] + fn split_empty() { + let chunks = split_message("", 4096); + assert_eq!(chunks, vec![""]); + } + + #[test] + fn split_multiple_chunks() { + let text = format!( + "{}\n{}\n{}", + "a".repeat(4000), + "b".repeat(4000), + "c".repeat(4000) + ); + let chunks = split_message(&text, 4096); + assert_eq!(chunks.len(), 3); + } + + #[test] + fn split_multibyte_chars() { + let text = "🦆".repeat(1025); // 4100 bytes + let chunks = split_message(&text, 4096); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].chars().count(), 1024); + assert_eq!(chunks[1].chars().count(), 1); + } + + #[test] + fn split_preserves_content() { + let text = format!( + "{} {} {}", + "a".repeat(3000), + "b".repeat(3000), + "c".repeat(3000) + ); + let chunks = split_message(&text, 4096); + let reassembled: String = chunks.join(""); + assert_eq!(reassembled, text); + } +} diff --git a/crates/goose/src/gateway/telegram_format.rs b/crates/goose/src/gateway/telegram_format.rs new file mode 100644 index 0000000000..1fa3ec8514 --- /dev/null +++ b/crates/goose/src/gateway/telegram_format.rs @@ -0,0 +1,279 @@ +use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; + +/// Convert markdown into Telegram-compatible HTML. +/// +/// Telegram supports only a minimal HTML subset with no attributes beyond +/// `href` on ``. Unsupported tags or attributes (e.g. `class`) cause the +/// API to reject the message outright. +pub fn markdown_to_telegram_html(markdown: &str) -> String { + let options = Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES; + let parser = Parser::new_ext(markdown, options); + + let mut output = String::with_capacity(markdown.len()); + let mut list_number: Option = None; + + for event in parser { + match event { + Event::Start(tag) => match tag { + Tag::Paragraph => {} + Tag::Heading { .. } => output.push_str(""), + Tag::Strong => output.push_str(""), + Tag::Emphasis => output.push_str(""), + Tag::Strikethrough => output.push_str(""), + Tag::CodeBlock(_) => output.push_str("
"),
+                Tag::Link { dest_url, .. } => {
+                    output.push_str(&format!("", escape_html(&dest_url)));
+                }
+                Tag::List(start) => {
+                    list_number = start;
+                }
+                Tag::Item => {
+                    if let Some(n) = list_number.as_mut() {
+                        output.push_str(&format!("{}. ", n));
+                        *n += 1;
+                    } else {
+                        output.push_str("• ");
+                    }
+                }
+                Tag::BlockQuote(_) => output.push_str("
"), + _ => {} + }, + Event::End(tag_end) => match tag_end { + TagEnd::Paragraph => output.push('\n'), + TagEnd::Heading(_) => output.push_str("\n"), + TagEnd::Strong => output.push_str(""), + TagEnd::Emphasis => output.push_str(""), + TagEnd::Strikethrough => output.push_str(""), + TagEnd::CodeBlock => output.push_str("
\n"), + TagEnd::Link => output.push_str(""), + TagEnd::List(_) => { + list_number = None; + } + TagEnd::Item => output.push('\n'), + TagEnd::BlockQuote(_) => output.push_str("\n"), + _ => {} + }, + Event::Text(text) => output.push_str(&escape_html(&text)), + Event::Code(code) => { + output.push_str(""); + output.push_str(&escape_html(&code)); + output.push_str(""); + } + Event::SoftBreak | Event::HardBreak => output.push('\n'), + Event::Rule => output.push_str("———\n"), + _ => {} + } + } + + let collapsed = collapse_newlines(&output); + collapsed.trim().to_string() +} + +/// Collapse runs of 3+ newlines down to 2, preserving whitespace inside `
` blocks.
+fn collapse_newlines(text: &str) -> String {
+    let mut result = String::with_capacity(text.len());
+    let mut in_pre = false;
+    let mut chars = text.chars().peekable();
+
+    while let Some(ch) = chars.next() {
+        if ch == '<' {
+            let rest: String = chars.clone().take(4).collect();
+            if !in_pre && (rest.starts_with("pre>") || rest.starts_with("pre ")) {
+                in_pre = true;
+            }
+            if in_pre && rest.starts_with("/pre") {
+                in_pre = false;
+            }
+            result.push(ch);
+        } else if !in_pre && ch == '\n' {
+            let mut count = 1;
+            while chars.peek() == Some(&'\n') {
+                chars.next();
+                count += 1;
+            }
+            for _ in 0..count.min(2) {
+                result.push('\n');
+            }
+        } else {
+            result.push(ch);
+        }
+    }
+
+    result
+}
+
+fn escape_html(text: &str) -> String {
+    text.replace('&', "&")
+        .replace('<', "<")
+        .replace('>', ">")
+        .replace('"', """)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn plain_text_unchanged() {
+        assert_eq!(markdown_to_telegram_html("Hello world"), "Hello world");
+    }
+
+    #[test]
+    fn bold_and_italic() {
+        assert_eq!(
+            markdown_to_telegram_html("This is **bold** and *italic*"),
+            "This is bold and italic"
+        );
+    }
+
+    #[test]
+    fn inline_code() {
+        assert_eq!(
+            markdown_to_telegram_html("Use `cargo build` to compile"),
+            "Use cargo build to compile"
+        );
+    }
+
+    #[test]
+    fn code_block_no_class_attribute() {
+        let html = markdown_to_telegram_html("```rust\nfn main() {}\n```");
+        assert!(
+            !html.contains("class="),
+            "Telegram rejects class attributes: {html}"
+        );
+        assert!(html.contains("
"));
+        assert!(html.contains("fn main() {}"));
+        assert!(html.contains("
")); + } + + #[test] + fn code_block_no_language() { + let html = markdown_to_telegram_html("```\nhello\n```"); + assert!(html.contains("
"));
+        assert!(html.contains("hello"));
+    }
+
+    #[test]
+    fn heading() {
+        assert_eq!(markdown_to_telegram_html("# Title"), "Title");
+    }
+
+    #[test]
+    fn unordered_list() {
+        let html = markdown_to_telegram_html("- one\n- two\n- three");
+        assert!(html.contains("• one"));
+        assert!(html.contains("• two"));
+        assert!(html.contains("• three"));
+    }
+
+    #[test]
+    fn ordered_list() {
+        let html = markdown_to_telegram_html("1. first\n2. second\n3. third");
+        assert!(html.contains("1. first"));
+        assert!(html.contains("2. second"));
+        assert!(html.contains("3. third"));
+    }
+
+    #[test]
+    fn link() {
+        let html = markdown_to_telegram_html("Visit [Rust](https://rust-lang.org) docs");
+        assert!(html.contains("Rust"));
+    }
+
+    #[test]
+    fn html_entities_escaped() {
+        assert_eq!(
+            markdown_to_telegram_html("1 < 2 & 3 > 0"),
+            "1 < 2 & 3 > 0"
+        );
+    }
+
+    #[test]
+    fn strikethrough() {
+        assert_eq!(markdown_to_telegram_html("~~deleted~~"), "deleted");
+    }
+
+    #[test]
+    fn blockquote() {
+        let html = markdown_to_telegram_html("> This is a quote");
+        assert!(html.contains("
")); + assert!(html.contains("This is a quote")); + assert!(html.contains("
")); + } + + #[test] + fn horizontal_rule() { + let html = markdown_to_telegram_html("above\n\n---\n\nbelow"); + assert!(html.contains("———")); + } + + #[test] + fn complex_llm_response() { + let md = r#"# Summary + +Here's what I found: + +1. **First item** - this is important +2. **Second item** - also relevant + +```python +print("hello") +``` + +For more info, visit [docs](https://example.com). + +> Note: this is a blockquote + +That's all!"#; + let html = markdown_to_telegram_html(md); + assert!(!html.contains("class="), "no class attributes: {html}"); + assert!(html.contains("Summary")); + assert!(html.contains("First item")); + assert!(html.contains("1. ")); + assert!(html.contains("2. ")); + assert!(html.contains("
"));
+        assert!(html.contains("print("hello")"));
+        assert!(html.contains(""));
+        assert!(html.contains("That's all!"));
+    }
+
+    #[test]
+    fn no_trailing_whitespace() {
+        let html = markdown_to_telegram_html("Hello\n\n");
+        assert!(!html.ends_with('\n'));
+        assert!(!html.ends_with(' '));
+    }
+
+    #[test]
+    fn no_excessive_newlines() {
+        let md = "Paragraph one.\n\n\n\nParagraph two.\n\n\n\n\nParagraph three.";
+        let html = markdown_to_telegram_html(md);
+        assert!(!html.contains("\n\n\n"));
+    }
+
+    #[test]
+    fn list_to_paragraph_spacing() {
+        let md = "- one\n- two\n\nNext paragraph.";
+        let html = markdown_to_telegram_html(md);
+        assert!(!html.contains("\n\n\n"));
+    }
+
+    #[test]
+    fn code_block_preserves_internal_newlines() {
+        let md = "```\nline1\n\n\nline2\n```";
+        let html = markdown_to_telegram_html(md);
+        assert!(html.contains("line1\n\n\nline2"));
+    }
+
+    #[test]
+    fn compact_output() {
+        let md = "Sure! Here's a quick summary:\n\n## Key Points\n\n- Point one\n- Point two\n\nThat's it!";
+        let html = markdown_to_telegram_html(md);
+        let newline_count = html.chars().filter(|c| *c == '\n').count();
+        assert!(
+            newline_count <= 7,
+            "too many newlines ({newline_count}): {html}"
+        );
+    }
+}
diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs
index 322927d093..28c13c13f1 100644
--- a/crates/goose/src/lib.rs
+++ b/crates/goose/src/lib.rs
@@ -7,6 +7,7 @@ pub mod conversation;
 pub mod dictation;
 pub mod download_manager;
 pub mod execution;
+pub mod gateway;
 pub mod goose_apps;
 pub mod hints;
 pub mod logging;
diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs
index bb8e5eacf9..68bd430316 100644
--- a/crates/goose/src/session/session_manager.rs
+++ b/crates/goose/src/session/session_manager.rs
@@ -31,6 +31,7 @@ pub enum SessionType {
     SubAgent,
     Hidden,
     Terminal,
+    Gateway,
 }
 
 impl std::fmt::Display for SessionType {
@@ -41,6 +42,7 @@ impl std::fmt::Display for SessionType {
             SessionType::Hidden => write!(f, "hidden"),
             SessionType::Scheduled => write!(f, "scheduled"),
             SessionType::Terminal => write!(f, "terminal"),
+            SessionType::Gateway => write!(f, "gateway"),
         }
     }
 }
@@ -55,6 +57,7 @@ impl std::str::FromStr for SessionType {
             "hidden" => Ok(SessionType::Hidden),
             "scheduled" => Ok(SessionType::Scheduled),
             "terminal" => Ok(SessionType::Terminal),
+            "gateway" => Ok(SessionType::Gateway),
             _ => Err(anyhow::anyhow!("Invalid session type: {}", s)),
         }
     }
diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json
index b6dece5553..d996d2c003 100644
--- a/ui/desktop/openapi.json
+++ b/ui/desktop/openapi.json
@@ -7434,7 +7434,8 @@
           "scheduled",
           "sub_agent",
           "hidden",
-          "terminal"
+          "terminal",
+          "gateway"
         ]
       },
       "SessionsQuery": {
diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json
index 7b28b7e272..dd6b13ccbe 100644
--- a/ui/desktop/package-lock.json
+++ b/ui/desktop/package-lock.json
@@ -241,6 +241,7 @@
       "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@babel/code-frame": "^7.29.0",
         "@babel/generator": "^7.29.0",
@@ -623,6 +624,7 @@
         }
       ],
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=20.19.0"
       },
@@ -663,6 +665,7 @@
         }
       ],
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=20.19.0"
       }
@@ -1120,6 +1123,7 @@
       "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "chalk": "^4.1.1",
         "fs-extra": "^9.0.1",
@@ -2340,6 +2344,7 @@
       "integrity": "sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@inquirer/checkbox": "^3.0.1",
         "@inquirer/confirm": "^4.0.1",
@@ -2815,6 +2820,7 @@
       "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.0.tgz",
       "integrity": "sha512-qOdO524oPMkUsOJTrsH9vz/HN3B5pKyW+9zIW51A9kDMVe7ON70drz1ouoyoyOcfzc+oxhkQ6jWmbyKnlWmYqA==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@hono/node-server": "^1.19.9",
         "ajv": "^8.17.1",
@@ -5860,8 +5866,7 @@
       "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
       "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
       "dev": true,
-      "license": "MIT",
-      "peer": true
+      "license": "MIT"
     },
     "node_modules/@types/babel__core": {
       "version": "7.20.5",
@@ -6149,6 +6154,7 @@
       "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "undici-types": "~7.16.0"
       }
@@ -6190,6 +6196,7 @@
       "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
       "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "csstype": "^3.2.2"
       }
@@ -6200,6 +6207,7 @@
       "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
       "devOptional": true,
       "license": "MIT",
+      "peer": true,
       "peerDependencies": {
         "@types/react": "^19.2.0"
       }
@@ -6340,6 +6348,7 @@
       "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@typescript-eslint/scope-manager": "8.56.1",
         "@typescript-eslint/types": "8.56.1",
@@ -6738,6 +6747,7 @@
       "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@vitest/utils": "4.0.18",
         "fflate": "^0.8.2",
@@ -6986,6 +6996,7 @@
       "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "bin": {
         "acorn": "bin/acorn"
       },
@@ -7058,6 +7069,7 @@
       "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
       "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "fast-deep-equal": "^3.1.3",
         "fast-uri": "^3.0.1",
@@ -7598,6 +7610,7 @@
         }
       ],
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "baseline-browser-mapping": "^2.9.0",
         "caniuse-lite": "^1.0.30001759",
@@ -8900,8 +8913,7 @@
       "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
       "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
       "dev": true,
-      "license": "MIT",
-      "peer": true
+      "license": "MIT"
     },
     "node_modules/dom-helpers": {
       "version": "5.2.1",
@@ -8959,6 +8971,7 @@
       "dev": true,
       "hasInstallScript": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@electron/get": "^2.0.0",
         "@types/node": "^24.9.0",
@@ -9966,6 +9979,7 @@
       "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.8.0",
         "@eslint-community/regexpp": "^4.12.1",
@@ -10449,6 +10463,7 @@
       "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
       "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "accepts": "^2.0.0",
         "body-parser": "^2.2.1",
@@ -11709,6 +11724,7 @@
       "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz",
       "integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==",
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=16.9.0"
       }
@@ -12765,6 +12781,7 @@
       "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@acemir/cssom": "^0.9.31",
         "@asamuzakjp/dom-selector": "^6.8.1",
@@ -14008,7 +14025,6 @@
       "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "bin": {
         "lz-string": "bin/bin.js"
       }
@@ -14029,6 +14045,7 @@
       "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@babel/parser": "^7.29.0",
         "@babel/types": "^7.29.0",
@@ -16419,6 +16436,7 @@
         }
       ],
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "nanoid": "^3.3.11",
         "picocolors": "^1.1.1",
@@ -16520,7 +16538,6 @@
       "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "ansi-regex": "^5.0.1",
         "ansi-styles": "^5.0.0",
@@ -16536,7 +16553,6 @@
       "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">=10"
       },
@@ -16892,6 +16908,7 @@
       "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
       "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=0.10.0"
       }
@@ -16901,6 +16918,7 @@
       "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
       "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "scheduler": "^0.27.0"
       },
@@ -16922,8 +16940,7 @@
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
       "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
       "dev": true,
-      "license": "MIT",
-      "peer": true
+      "license": "MIT"
     },
     "node_modules/react-markdown": {
       "version": "10.1.0",
@@ -18910,7 +18927,8 @@
       "version": "4.2.1",
       "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
       "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
-      "license": "MIT"
+      "license": "MIT",
+      "peer": true
     },
     "node_modules/tailwindcss-animate": {
       "version": "1.0.7",
@@ -19441,6 +19459,7 @@
       "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
       "dev": true,
       "license": "Apache-2.0",
+      "peer": true,
       "bin": {
         "tsc": "bin/tsc",
         "tsserver": "bin/tsserver"
@@ -19865,6 +19884,7 @@
       "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "esbuild": "^0.27.0",
         "fdir": "^6.5.0",
@@ -19955,6 +19975,7 @@
       "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@vitest/expect": "4.0.18",
         "@vitest/mocker": "4.0.18",
@@ -20603,6 +20624,7 @@
       "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
       "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
       "license": "MIT",
+      "peer": true,
       "funding": {
         "url": "https://github.com/sponsors/colinhacks"
       }
diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts
index ea39254cdd..98ce500928 100644
--- a/ui/desktop/src/api/types.gen.ts
+++ b/ui/desktop/src/api/types.gen.ts
@@ -1211,7 +1211,7 @@ export type SessionListResponse = {
     sessions: Array;
 };
 
-export type SessionType = 'user' | 'scheduled' | 'sub_agent' | 'hidden' | 'terminal';
+export type SessionType = 'user' | 'scheduled' | 'sub_agent' | 'hidden' | 'terminal' | 'gateway';
 
 export type SessionsQuery = {
     limit: number;
diff --git a/ui/desktop/src/components/settings/SettingsView.tsx b/ui/desktop/src/components/settings/SettingsView.tsx
index f67e362fd8..ba533da72f 100644
--- a/ui/desktop/src/components/settings/SettingsView.tsx
+++ b/ui/desktop/src/components/settings/SettingsView.tsx
@@ -11,6 +11,9 @@ import { ExtensionConfig } from '../../api';
 import { MainPanelLayout } from '../Layout/MainPanelLayout';
 import { Bot, Share2, Monitor, MessageSquare, FileText, Keyboard, HardDrive } from 'lucide-react';
 import { useState, useEffect, useRef } from 'react';
+import TunnelSection from './tunnel/TunnelSection';
+import GatewaySettingsSection from './gateways/GatewaySettingsSection';
+import { getTunnelStatus } from '../../api/sdk.gen';
 import ChatSettingsSection from './chat/ChatSettingsSection';
 import KeyboardShortcutsSection from './keyboard/KeyboardShortcutsSection';
 import LocalInferenceSection from './localInference/LocalInferenceSection';
@@ -33,6 +36,7 @@ export default function SettingsView({
   viewOptions: SettingsViewOptions;
 }) {
   const [activeTab, setActiveTab] = useState('models');
+  const [tunnelDisabled, setTunnelDisabled] = useState(false);
   const hasTrackedInitialTab = useRef(false);
 
   const handleTabChange = (tab: string) => {
@@ -55,6 +59,7 @@ export default function SettingsView({
         chat: 'chat',
         prompts: 'prompts',
         keyboard: 'keyboard',
+        gateway: 'sharing',
         'local-inference': 'local-inference',
       };
 
@@ -72,6 +77,16 @@ export default function SettingsView({
     }
   }, [activeTab]);
 
+  useEffect(() => {
+    getTunnelStatus()
+      .then(({ data }) => {
+        setTunnelDisabled(data?.state === 'disabled');
+      })
+      .catch(() => {
+        setTunnelDisabled(false);
+      });
+  }, []);
+
   useEffect(() => {
     const handleKeyDown = (event: KeyboardEvent) => {
       if (event.key === 'Escape' && !event.defaultPrevented) {
@@ -183,9 +198,15 @@ export default function SettingsView({
                   value="sharing"
                   className="mt-0 focus-visible:outline-none focus-visible:ring-0"
                 >
-                  
+
+ {!tunnelDisabled && ( +
+ + +
+ )}
diff --git a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx index b0acb7b82c..2e65254029 100644 --- a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx +++ b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx @@ -4,7 +4,6 @@ import { Button } from '../../ui/button'; import { Settings, ChevronDown, ChevronUp } from 'lucide-react'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog'; import UpdateSection from './UpdateSection'; -import TunnelSection from '../tunnel/TunnelSection'; import { COST_TRACKING_ENABLED, UPDATES_ENABLED } from '../../../updates'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; @@ -336,7 +335,6 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti {/* Navigation Settings */} - diff --git a/ui/desktop/src/components/settings/gateways/GatewaySettingsSection.tsx b/ui/desktop/src/components/settings/gateways/GatewaySettingsSection.tsx new file mode 100644 index 0000000000..a5a59733c9 --- /dev/null +++ b/ui/desktop/src/components/settings/gateways/GatewaySettingsSection.tsx @@ -0,0 +1,419 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Button } from '../../ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card'; +import { Input } from '../../ui/input'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '../../ui/dialog'; +import { Loader2, Copy, Check, Square, Trash2, ExternalLink, User } from 'lucide-react'; +import { getApiUrl } from '../../../config'; + +interface PairedUserInfo { + platform: string; + user_id: string; + display_name: string | null; + session_id: string; + paired_at: number; +} + +interface GatewayStatus { + gateway_type: string; + running: boolean; + configured: boolean; + paired_users: PairedUserInfo[]; + info?: Record; +} + +interface PairingCodeResponse { + code: string; + expires_at: number; +} + +async function gatewayFetch(endpoint: string, options: globalThis.RequestInit = {}) { + const secretKey = await window.electron.getSecretKey(); + const url = getApiUrl(endpoint); + return fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': secretKey, + ...options.headers, + }, + }); +} + +export default function GatewaySettingsSection() { + const [gateways, setGateways] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [pairingCode, setPairingCode] = useState(null); + const [pairingGatewayType, setPairingGatewayType] = useState(null); + const [copiedCode, setCopiedCode] = useState(false); + + const fetchStatus = useCallback(async () => { + try { + const response = await gatewayFetch('/gateway/status'); + if (response.ok) { + setGateways(await response.json()); + } + } catch (err) { + console.error('Failed to fetch gateway status:', err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchStatus(); + const interval = setInterval(fetchStatus, 5000); + return () => clearInterval(interval); + }, [fetchStatus]); + + const doPost = async (endpoint: string, body: object, errorMsg: string) => { + setError(null); + try { + const response = await gatewayFetch(endpoint, { + method: 'POST', + body: JSON.stringify(body), + }); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.message || errorMsg); + } + await fetchStatus(); + } catch (err) { + setError(err instanceof Error ? err.message : errorMsg); + } + }; + + const handleUnpairUser = async (platform: string, userId: string) => { + setError(null); + try { + const response = await gatewayFetch(`/gateway/pair/${platform}/${userId}`, { + method: 'DELETE', + }); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.message || 'Failed to unpair user'); + } + await fetchStatus(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to unpair user'); + } + }; + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + setCopiedCode(true); + setTimeout(() => setCopiedCode(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + if (loading) { + return ( +
+ + Loading... +
+ ); + } + + const telegram = gateways.find((g) => g.gateway_type === 'telegram'); + + return ( + <> + {error && ( +
+ {error} +
+ )} + + + doPost('/gateway/start', { gateway_type: 'telegram', platform_config: config, max_sessions: 0 }, 'Failed to start') + } + onRestart={() => doPost('/gateway/restart', { gateway_type: 'telegram' }, 'Failed to start')} + onStop={() => doPost('/gateway/stop', { gateway_type: 'telegram' }, 'Failed to stop')} + onRemove={() => doPost('/gateway/remove', { gateway_type: 'telegram' }, 'Failed to remove')} + onGenerateCode={async () => { + setError(null); + try { + const response = await gatewayFetch('/gateway/pair', { + method: 'POST', + body: JSON.stringify({ gateway_type: 'telegram' }), + }); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.message || 'Failed to generate pairing code'); + } + const data: PairingCodeResponse = await response.json(); + setPairingCode(data); + setPairingGatewayType('telegram'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to generate pairing code'); + } + }} + onUnpairUser={handleUnpairUser} + /> + + { + setPairingCode(null); + setPairingGatewayType(null); + }} + code={pairingCode} + gatewayType={pairingGatewayType} + onCopy={copyToClipboard} + copied={copiedCode} + /> + + ); +} + +function PairedUsersList({ + users, + onUnpairUser, +}: { + users: PairedUserInfo[]; + onUnpairUser: (platform: string, userId: string) => void; +}) { + if (users.length === 0) return null; + + return ( +
+

Paired Users

+ {users.map((user) => ( +
+
+ + {user.display_name || user.user_id} +
+ +
+ ))} +
+ ); +} + +function TelegramGatewayCard({ + status, + onStart, + onRestart, + onStop, + onRemove, + onGenerateCode, + onUnpairUser, +}: { + status: GatewayStatus | undefined; + onStart: (config: Record) => Promise; + onRestart: () => Promise; + onStop: () => Promise; + onRemove: () => Promise; + onGenerateCode: () => void; + onUnpairUser: (platform: string, userId: string) => void; +}) { + const [botToken, setBotToken] = useState(''); + const [busy, setBusy] = useState(false); + const running = status?.running ?? false; + const configured = status?.configured ?? false; + + const wrap = (fn: () => Promise) => async () => { + setBusy(true); + try { await fn(); } finally { setBusy(false); } + }; + + const handleFirstStart = wrap(async () => { + if (!botToken.trim()) return; + await onStart({ bot_token: botToken.trim() }); + setBotToken(''); + }); + + return ( + + +
+ + Telegram + {running && ( + + Running + + )} + {!running && configured && ( + + Stopped + + )} + +
+ {running && ( + <> + + + + )} + {!running && configured && ( + <> + + + + )} +
+
+
+ + {!running && !configured && ( + <> +
+

+ Open{' '} + + @BotFather + + + {' '}on your phone, send /newbot, and follow + the prompts to name your bot. BotFather will reply with an API token — paste it below. +

+
+
+ setBotToken(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleFirstStart()} + className="text-sm" + /> + +
+ + )} + {status && } + + + ); +} + +function PairingCodeModal({ + open, + onClose, + code, + gatewayType, + onCopy, + copied, +}: { + open: boolean; + onClose: () => void; + code: PairingCodeResponse | null; + gatewayType: string | null; + onCopy: (text: string) => void; + copied: boolean; +}) { + const [timeRemaining, setTimeRemaining] = useState(0); + + useEffect(() => { + if (!code) return; + + const updateTimer = () => { + const remaining = Math.max(0, code.expires_at - Math.floor(Date.now() / 1000)); + setTimeRemaining(remaining); + if (remaining === 0) { + onClose(); + } + }; + + updateTimer(); + const interval = setInterval(updateTimer, 1000); + return () => clearInterval(interval); + }, [code, onClose]); + + if (!code) return null; + + const minutes = Math.floor(timeRemaining / 60); + const seconds = timeRemaining % 60; + + return ( + !isOpen && onClose()}> + + + Pairing Code + + +
+
+
+ + {code.code} + + +
+
+ +

+ Send this code to your{' '} + {gatewayType} bot to pair. +

+ +
+ Expires in {minutes}:{seconds.toString().padStart(2, '0')} +
+
+ + + + +
+
+ ); +} diff --git a/ui/desktop/src/components/settings/tunnel/TunnelSection.tsx b/ui/desktop/src/components/settings/tunnel/TunnelSection.tsx index 100b45360b..8d29da795f 100644 --- a/ui/desktop/src/components/settings/tunnel/TunnelSection.tsx +++ b/ui/desktop/src/components/settings/tunnel/TunnelSection.tsx @@ -127,7 +127,7 @@ export default function TunnelSection() { <> - Remote Access + Mobile App
@@ -205,7 +205,7 @@ export default function TunnelSection() { - Remote Access Connection + Mobile App Connection {tunnelInfo.state === 'running' && (