feat: gateway to chat to goose - telegram etc (#7199)
This commit is contained in:
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user