feat: gateway to chat to goose - telegram etc (#7199)

This commit is contained in:
Michael Neale
2026-02-25 17:49:55 +11:00
committed by GitHub
parent d1f4dc947d
commit 19dc192efe
25 changed files with 2794 additions and 18 deletions
Generated
+30
View File
@@ -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"
+60
View File
@@ -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<Command>) -> &'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,
+82
View File
@@ -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(())
}
+1
View File
@@ -1,4 +1,5 @@
pub mod configure;
pub mod gateway;
pub mod info;
pub mod project;
pub mod recipe;
+1
View File
@@ -67,3 +67,4 @@ path = "src/bin/generate_schema.rs"
tower = "0.5.2"
env-lock = { workspace = true }
wiremock = { workspace = true }
tempfile.workspace = true
+11
View File
@@ -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?;
+225
View File
@@ -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<Arc<AppState>>,
Json(request): Json<StartGatewayRequest>,
) -> 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<Arc<AppState>>,
Json(request): Json<StopGatewayRequest>,
) -> 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<Arc<AppState>>,
Json(request): Json<RestartGatewayRequest>,
) -> 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<Arc<AppState>>,
Json(request): Json<RemoveGatewayRequest>,
) -> 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<GatewayStatus>)
)
)]
pub async fn gateway_status(State(state): State<Arc<AppState>>) -> Json<Vec<GatewayStatus>> {
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<Arc<AppState>>,
Json(request): Json<CreatePairingRequest>,
) -> 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<Arc<AppState>>,
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<AppState>) -> 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)
}
+2
View File
@@ -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<crate::state::AppState>, 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))
+4 -1
View File
@@ -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<AgentManager>,
pub recipe_file_hash_map: Arc<Mutex<HashMap<String, PathBuf>>>,
/// Tracks sessions that have already emitted recipe telemetry to prevent double counting.
recipe_session_tracker: Arc<Mutex<HashSet<String>>>,
pub tunnel_manager: Arc<TunnelManager>,
pub gateway_manager: Arc<GatewayManager>,
pub extension_loading_tasks: ExtensionLoadingTasks,
pub inference_runtime: Arc<InferenceRuntime>,
}
@@ -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(),
}))
+1
View File
@@ -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"
+510
View File
@@ -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<AgentManager>,
pairing_store: Arc<PairingStore>,
gateway: Arc<dyn Gateway>,
config: GatewayConfig,
}
impl GatewayHandler {
pub fn new(
agent_manager: Arc<AgentManager>,
pairing_store: Arc<PairingStore>,
gateway: Arc<dyn Gateway>,
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<Option<String>> {
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<bool> {
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<ExtensionConfig> =
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)
}
+416
View File
@@ -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<dyn Gateway>,
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<String>,
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<PairedUserInfo>,
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub info: HashMap<String, String>,
}
pub struct GatewayManager {
gateways: RwLock<HashMap<String, GatewayInstance>>,
pairing_store: Arc<PairingStore>,
agent_manager: Arc<AgentManager>,
}
impl GatewayManager {
pub fn new(agent_manager: Arc<AgentManager>) -> anyhow::Result<Self> {
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<PairingStore> {
&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<dyn Gateway>,
) -> 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<dyn Gateway>,
) -> 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<String> {
self.gateways.read().await.keys().cloned().collect()
}
pub async fn status(&self) -> Vec<GatewayStatus> {
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<bool> {
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<Vec<GatewayConfig>> {
let config = Config::global();
let entries: Vec<SavedGatewayEntry> = 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<SavedGatewayEntry> =
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<SavedGatewayEntry> =
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");
}
}
}
+102
View File
@@ -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<String>,
}
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<H: std::hash::Hasher>(&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<String>,
pub attachments: Vec<Attachment>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Attachment {
pub filename: String,
pub mime_type: String,
pub data: Vec<u8>,
}
#[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<String, String> {
HashMap::new()
}
}
pub fn create_gateway(config: &mut GatewayConfig) -> anyhow::Result<std::sync::Arc<dyn Gateway>> {
match config.gateway_type.as_str() {
"telegram" => Ok(std::sync::Arc::new(telegram::TelegramGateway::new(config)?)),
other => anyhow::bail!("Unknown gateway type: {}", other),
}
}
+189
View File
@@ -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<String>,
state: PairingState,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct StoredPendingCode {
code: String,
gateway_type: String,
expires_at: i64,
}
pub struct PairingStore {
pairings: RwLock<HashMap<PlatformUser, PairingState>>,
}
impl PairingStore {
pub fn new() -> anyhow::Result<Self> {
let pairings = Self::load_pairings_from_config();
Ok(Self {
pairings: RwLock::new(pairings),
})
}
fn load_pairings_from_config() -> HashMap<PlatformUser, PairingState> {
let config = Config::global();
let entries: Vec<StoredPairing> = 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<PlatformUser, PairingState>,
) -> anyhow::Result<()> {
let entries: Vec<StoredPairing> = 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<StoredPendingCode> {
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<PairingState> {
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<Option<String>> {
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<usize> {
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<Vec<(PlatformUser, String, i64)>> {
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)));
}
}
+399
View File
@@ -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<TelegramMessage>,
}
#[derive(Debug, Deserialize)]
struct TelegramMessage {
message_id: i64,
from: Option<TelegramUser>,
chat: TelegramChat,
text: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TelegramUser {
first_name: String,
last_name: Option<String>,
#[allow(dead_code)]
username: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TelegramChat {
id: i64,
#[allow(dead_code)]
#[serde(rename = "type")]
chat_type: String,
}
#[derive(Debug, Deserialize)]
struct TelegramResponse<T> {
ok: bool,
result: Option<T>,
description: Option<String>,
}
impl TelegramGateway {
pub fn new(config: &GatewayConfig) -> anyhow::Result<Self> {
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<i64>) -> anyhow::Result<Vec<TelegramUpdate>> {
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<Vec<TelegramUpdate>> = self
.client
.post(self.api_url("getUpdates"))
.json(&params)
.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::<TelegramResponse<serde_json::Value>>().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<i64> = 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<serde_json::Value> = 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<String> {
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);
}
}
+279
View File
@@ -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 `<a>`. 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<u64> = None;
for event in parser {
match event {
Event::Start(tag) => match tag {
Tag::Paragraph => {}
Tag::Heading { .. } => output.push_str("<b>"),
Tag::Strong => output.push_str("<b>"),
Tag::Emphasis => output.push_str("<i>"),
Tag::Strikethrough => output.push_str("<s>"),
Tag::CodeBlock(_) => output.push_str("<pre><code>"),
Tag::Link { dest_url, .. } => {
output.push_str(&format!("<a href=\"{}\">", 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("<blockquote>"),
_ => {}
},
Event::End(tag_end) => match tag_end {
TagEnd::Paragraph => output.push('\n'),
TagEnd::Heading(_) => output.push_str("</b>\n"),
TagEnd::Strong => output.push_str("</b>"),
TagEnd::Emphasis => output.push_str("</i>"),
TagEnd::Strikethrough => output.push_str("</s>"),
TagEnd::CodeBlock => output.push_str("</code></pre>\n"),
TagEnd::Link => output.push_str("</a>"),
TagEnd::List(_) => {
list_number = None;
}
TagEnd::Item => output.push('\n'),
TagEnd::BlockQuote(_) => output.push_str("</blockquote>\n"),
_ => {}
},
Event::Text(text) => output.push_str(&escape_html(&text)),
Event::Code(code) => {
output.push_str("<code>");
output.push_str(&escape_html(&code));
output.push_str("</code>");
}
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 `<pre>` 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
#[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 <b>bold</b> and <i>italic</i>"
);
}
#[test]
fn inline_code() {
assert_eq!(
markdown_to_telegram_html("Use `cargo build` to compile"),
"Use <code>cargo build</code> 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("<pre><code>"));
assert!(html.contains("fn main() {}"));
assert!(html.contains("</code></pre>"));
}
#[test]
fn code_block_no_language() {
let html = markdown_to_telegram_html("```\nhello\n```");
assert!(html.contains("<pre><code>"));
assert!(html.contains("hello"));
}
#[test]
fn heading() {
assert_eq!(markdown_to_telegram_html("# Title"), "<b>Title</b>");
}
#[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("<a href=\"https://rust-lang.org\">Rust</a>"));
}
#[test]
fn html_entities_escaped() {
assert_eq!(
markdown_to_telegram_html("1 < 2 & 3 > 0"),
"1 &lt; 2 &amp; 3 &gt; 0"
);
}
#[test]
fn strikethrough() {
assert_eq!(markdown_to_telegram_html("~~deleted~~"), "<s>deleted</s>");
}
#[test]
fn blockquote() {
let html = markdown_to_telegram_html("> This is a quote");
assert!(html.contains("<blockquote>"));
assert!(html.contains("This is a quote"));
assert!(html.contains("</blockquote>"));
}
#[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("<b>Summary</b>"));
assert!(html.contains("<b>First item</b>"));
assert!(html.contains("1. "));
assert!(html.contains("2. "));
assert!(html.contains("<pre><code>"));
assert!(html.contains("print(&quot;hello&quot;)"));
assert!(html.contains("<a href="));
assert!(html.contains("<blockquote>"));
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}"
);
}
}
+1
View File
@@ -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;
@@ -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)),
}
}
+2 -1
View File
@@ -7434,7 +7434,8 @@
"scheduled",
"sub_agent",
"hidden",
"terminal"
"terminal",
"gateway"
]
},
"SessionsQuery": {
+32 -10
View File
@@ -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"
}
+1 -1
View File
@@ -1211,7 +1211,7 @@ export type SessionListResponse = {
sessions: Array<Session>;
};
export type SessionType = 'user' | 'scheduled' | 'sub_agent' | 'hidden' | 'terminal';
export type SessionType = 'user' | 'scheduled' | 'sub_agent' | 'hidden' | 'terminal' | 'gateway';
export type SessionsQuery = {
limit: number;
@@ -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"
>
<div className="space-y-8">
<div className="space-y-8 pb-8">
<SessionSharingSection />
<ExternalBackendSection />
{!tunnelDisabled && (
<div className="space-y-4">
<TunnelSection />
<GatewaySettingsSection />
</div>
)}
</div>
</TabsContent>
@@ -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 */}
<NavigationSettingsCard />
<TunnelSection />
<TelemetrySettings isWelcome={false} />
@@ -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<string, string>;
}
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<GatewayStatus[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pairingCode, setPairingCode] = useState<PairingCodeResponse | null>(null);
const [pairingGatewayType, setPairingGatewayType] = useState<string | null>(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 (
<div className="flex items-center gap-2 text-sm text-text-muted py-4">
<Loader2 className="h-4 w-4 animate-spin" />
Loading...
</div>
);
}
const telegram = gateways.find((g) => g.gateway_type === 'telegram');
return (
<>
{error && (
<div className="p-3 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded text-sm text-red-800 dark:text-red-200 mb-4">
{error}
</div>
)}
<TelegramGatewayCard
status={telegram}
onStart={(config) =>
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}
/>
<PairingCodeModal
open={pairingCode !== null}
onClose={() => {
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 (
<div className="space-y-1 mt-2">
<h4 className="text-xs text-text-muted font-medium">Paired Users</h4>
{users.map((user) => (
<div
key={`${user.platform}-${user.user_id}`}
className="flex items-center justify-between py-1.5 px-2 bg-background-muted rounded text-sm"
>
<div className="flex items-center gap-2 min-w-0">
<User className="h-3 w-3 text-text-muted flex-shrink-0" />
<span className="truncate">{user.display_name || user.user_id}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onUnpairUser(user.platform, user.user_id)}
className="h-6 w-6 p-0 text-text-muted hover:text-red-600 flex-shrink-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
);
}
function TelegramGatewayCard({
status,
onStart,
onRestart,
onStop,
onRemove,
onGenerateCode,
onUnpairUser,
}: {
status: GatewayStatus | undefined;
onStart: (config: Record<string, unknown>) => Promise<void>;
onRestart: () => Promise<void>;
onStop: () => Promise<void>;
onRemove: () => Promise<void>;
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<void>) => 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 (
<Card className="rounded-lg">
<CardHeader className="pb-0">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
Telegram
{running && (
<span className="inline-flex items-center text-xs text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900/30 px-2 py-0.5 rounded-full">
Running
</span>
)}
{!running && configured && (
<span className="inline-flex items-center text-xs text-yellow-700 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/30 px-2 py-0.5 rounded-full">
Stopped
</span>
)}
</CardTitle>
<div className="flex items-center gap-2">
{running && (
<>
<Button variant="outline" size="sm" onClick={onGenerateCode}>
Pair Device
</Button>
<Button variant="destructive" size="sm" disabled={busy} onClick={wrap(onStop)}>
<Square className="h-3 w-3 mr-1" />
Stop
</Button>
</>
)}
{!running && configured && (
<>
<Button size="sm" disabled={busy} onClick={wrap(onRestart)}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Start'}
</Button>
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={wrap(onRemove)}
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-900/20"
>
<Trash2 className="h-3 w-3 mr-1" />
Remove
</Button>
</>
)}
</div>
</div>
</CardHeader>
<CardContent className="pt-3 space-y-2">
{!running && !configured && (
<>
<div className="text-xs text-text-muted space-y-1.5 mb-2">
<p>
Open{' '}
<a
href="https://t.me/BotFather"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 text-blue-600 dark:text-blue-400 hover:underline"
>
@BotFather
<ExternalLink className="h-3 w-3" />
</a>
{' '}on your phone, send <code className="bg-background-muted px-1 py-0.5 rounded">/newbot</code>, and follow
the prompts to name your bot. BotFather will reply with an API token paste it below.
</p>
</div>
<div className="flex items-center gap-2">
<Input
type="password"
placeholder="Paste bot token here"
value={botToken}
onChange={(e) => setBotToken(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleFirstStart()}
className="text-sm"
/>
<Button size="sm" onClick={handleFirstStart} disabled={busy || !botToken.trim()}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Start'}
</Button>
</div>
</>
)}
{status && <PairedUsersList users={status.paired_users} onUnpairUser={onUnpairUser} />}
</CardContent>
</Card>
);
}
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 (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Pairing Code</DialogTitle>
</DialogHeader>
<div className="py-6 space-y-4">
<div className="flex justify-center">
<div className="flex items-center gap-2">
<code className="text-4xl font-mono font-bold tracking-[0.3em] select-all">
{code.code}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => onCopy(code.code)}
className="flex-shrink-0"
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<p className="text-center text-sm text-text-muted">
Send this code to your{' '}
<span className="capitalize font-medium">{gatewayType}</span> bot to pair.
</p>
<div className="text-center text-xs text-text-muted">
Expires in {minutes}:{seconds.toString().padStart(2, '0')}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -127,7 +127,7 @@ export default function TunnelSection() {
<>
<Card className="rounded-lg">
<CardHeader className="pb-0">
<CardTitle className="mb-1">Remote Access</CardTitle>
<CardTitle className="mb-1">Mobile App</CardTitle>
<CardDescription className="flex flex-col gap-2">
<div className="flex items-start gap-2 p-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded">
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
@@ -205,7 +205,7 @@ export default function TunnelSection() {
<Dialog open={showQRModal} onOpenChange={setShowQRModal}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Remote Access Connection</DialogTitle>
<DialogTitle>Mobile App Connection</DialogTitle>
</DialogHeader>
{tunnelInfo.state === 'running' && (