mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
feat: gateway to chat to goose - telegram etc (#7199)
This commit is contained in:
Generated
+30
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
pub mod configure;
|
||||
pub mod gateway;
|
||||
pub mod info;
|
||||
pub mod project;
|
||||
pub mod recipe;
|
||||
|
||||
@@ -67,3 +67,4 @@ path = "src/bin/generate_schema.rs"
|
||||
tower = "0.5.2"
|
||||
env-lock = { workspace = true }
|
||||
wiremock = { workspace = true }
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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(),
|
||||
}))
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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(¶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::<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);
|
||||
}
|
||||
}
|
||||
@@ -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('&', "&")
|
||||
.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 <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 < 2 & 3 > 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("hello")"));
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7434,7 +7434,8 @@
|
||||
"scheduled",
|
||||
"sub_agent",
|
||||
"hidden",
|
||||
"terminal"
|
||||
"terminal",
|
||||
"gateway"
|
||||
]
|
||||
},
|
||||
"SessionsQuery": {
|
||||
|
||||
Generated
+32
-10
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
Reference in New Issue
Block a user