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

This commit is contained in:
Michael Neale
2026-02-25 17:49:55 +11:00
committed by GitHub
parent d1f4dc947d
commit 19dc192efe
25 changed files with 2794 additions and 18 deletions
+510
View File
@@ -0,0 +1,510 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use futures::StreamExt;
use tokio_util::sync::CancellationToken;
use crate::agents::{AgentEvent, ExtensionConfig, SessionConfig};
use crate::config::extensions::get_enabled_extensions;
use crate::config::paths::Paths;
use crate::config::Config;
use crate::conversation::message::{Message, MessageContent};
use crate::execution::manager::AgentManager;
use crate::model::ModelConfig;
use crate::session::SessionType;
use crate::session::{EnabledExtensionsState, ExtensionState, Session};
use super::pairing::PairingStore;
use super::{Gateway, GatewayConfig, IncomingMessage, OutgoingMessage, PairingState, PlatformUser};
#[derive(Clone)]
pub struct GatewayHandler {
agent_manager: Arc<AgentManager>,
pairing_store: Arc<PairingStore>,
gateway: Arc<dyn Gateway>,
config: GatewayConfig,
}
impl GatewayHandler {
pub fn new(
agent_manager: Arc<AgentManager>,
pairing_store: Arc<PairingStore>,
gateway: Arc<dyn Gateway>,
config: GatewayConfig,
) -> Self {
Self {
agent_manager,
pairing_store,
gateway,
config,
}
}
pub async fn handle_message(&self, message: IncomingMessage) -> anyhow::Result<()> {
let pairing = self.pairing_store.get(&message.user).await?;
match pairing {
PairingState::Unpaired => {
if let Some(gateway_type) = self.try_consume_code(message.text.trim()).await? {
if gateway_type == self.config.gateway_type {
self.complete_pairing(&message.user).await?;
} else {
self.gateway
.send_message(
&message.user,
OutgoingMessage::Text {
body: "⚠️ That code is for a different gateway.".into(),
},
)
.await?;
}
} else {
self.gateway
.send_message(
&message.user,
OutgoingMessage::Text {
body: "Welcome! Enter your pairing code to connect to goose."
.into(),
},
)
.await?;
}
}
PairingState::PendingCode { code, expires_at } => {
let now = chrono::Utc::now().timestamp();
if now > expires_at {
self.pairing_store
.set(&message.user, PairingState::Unpaired)
.await?;
self.gateway
.send_message(
&message.user,
OutgoingMessage::Text {
body: "Your pairing code expired. Please request a new one.".into(),
},
)
.await?;
} else if message.text.trim().eq_ignore_ascii_case(&code) {
self.complete_pairing(&message.user).await?;
} else {
self.gateway
.send_message(
&message.user,
OutgoingMessage::Text {
body: "Invalid code. Please try again.".into(),
},
)
.await?;
}
}
PairingState::Paired { session_id, .. } => {
self.relay_to_session(&message, &session_id).await?;
}
}
Ok(())
}
async fn try_consume_code(&self, text: &str) -> anyhow::Result<Option<String>> {
let normalized = text.to_uppercase().replace(['-', ' '], "");
if normalized.len() == 6
&& normalized
.chars()
.all(|c| "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".contains(c))
{
return self.pairing_store.consume_pending_code(&normalized).await;
}
Ok(None)
}
async fn complete_pairing(&self, user: &PlatformUser) -> anyhow::Result<()> {
let working_dir = gateway_working_dir(&user.platform, &user.user_id);
std::fs::create_dir_all(&working_dir)?;
let session_name = format!(
"{}/{}",
user.platform,
user.display_name.as_deref().unwrap_or(&user.user_id)
);
let session = self
.agent_manager
.session_manager()
.create_session(working_dir, session_name, SessionType::Gateway)
.await?;
let manager = self.agent_manager.session_manager();
let config = Config::global();
// Store the current provider and model config on the session so the agent
// can be restored after LRU eviction, matching the start_agent flow.
let mut update = manager.update(&session.id);
if let Ok(provider) = config.get_goose_provider() {
update = update.provider_name(provider);
}
if let Ok(model_name) = config.get_goose_model() {
if let Ok(model_config) = ModelConfig::new(&model_name) {
update = update.model_config(model_config);
}
}
// Store default extensions so load_extensions_from_session works.
let extensions = get_enabled_extensions();
let extensions_state = EnabledExtensionsState::new(extensions);
let mut extension_data = session.extension_data.clone();
if let Err(e) = extensions_state.to_extension_data(&mut extension_data) {
tracing::warn!(error = %e, "failed to initialize gateway session extensions");
} else {
update = update.extension_data(extension_data);
}
update.apply().await?;
let now = chrono::Utc::now().timestamp();
self.pairing_store
.set(
user,
PairingState::Paired {
session_id: session.id.clone(),
paired_at: now,
},
)
.await?;
self.gateway
.send_message(
user,
OutgoingMessage::Text {
body: "Paired! You can now chat with goose.".into(),
},
)
.await?;
Ok(())
}
/// Sync the session's provider, model, and extensions with the current
/// global config so gateway sessions always reflect what the user has
/// configured in the desktop app. Returns `true` if extensions changed
/// (which means the caller must recreate the agent so stale extension
/// processes are torn down).
async fn sync_session_config(&self, session: &Session) -> anyhow::Result<bool> {
let config = Config::global();
let manager = self.agent_manager.session_manager();
// --- current global config ---
let current_provider = config.get_goose_provider().ok();
let current_model_name = config.get_goose_model().ok();
let current_extensions = get_enabled_extensions();
// --- what the session has ---
let session_extensions: Vec<ExtensionConfig> =
EnabledExtensionsState::from_extension_data(&session.extension_data)
.map(|s| s.extensions)
.unwrap_or_default();
let provider_changed = current_provider.as_deref() != session.provider_name.as_deref();
let model_changed = current_model_name.as_deref()
!= session.model_config.as_ref().map(|m| m.model_name.as_str());
let extensions_changed = current_extensions != session_extensions;
if !provider_changed && !model_changed && !extensions_changed {
return Ok(false);
}
tracing::info!(
session_id = %session.id,
provider_changed,
model_changed,
extensions_changed,
"syncing gateway session with current config"
);
let mut update = manager.update(&session.id);
if let Some(ref provider) = current_provider {
update = update.provider_name(provider);
}
if let Some(ref model_name) = current_model_name {
if let Ok(model_config) = ModelConfig::new(model_name) {
update = update.model_config(model_config);
}
}
if extensions_changed {
let extensions_state = EnabledExtensionsState::new(current_extensions);
let mut extension_data = session.extension_data.clone();
if let Err(e) = extensions_state.to_extension_data(&mut extension_data) {
tracing::warn!(error = %e, "failed to update gateway session extensions");
} else {
update = update.extension_data(extension_data);
}
}
update.apply().await?;
Ok(extensions_changed)
}
async fn relay_to_session(
&self,
message: &IncomingMessage,
session_id: &str,
) -> anyhow::Result<()> {
self.gateway
.send_message(&message.user, OutgoingMessage::Typing)
.await?;
let session = self
.agent_manager
.session_manager()
.get_session(session_id, false)
.await?;
// Sync provider/model/extensions with the user's current desktop config.
// If extensions changed we must tear down the old agent so stale
// extension processes don't linger.
let extensions_changed = self.sync_session_config(&session).await?;
if extensions_changed {
let _ = self.agent_manager.remove_session(session_id).await;
}
let agent = self
.agent_manager
.get_or_create_agent(session_id.to_string())
.await?;
// Re-read the session after sync so restore picks up the new values.
let session = self
.agent_manager
.session_manager()
.get_session(session_id, false)
.await?;
// Ensure provider is configured (handles first use and LRU eviction).
if let Err(e) = agent.restore_provider_from_session(&session).await {
self.gateway
.send_message(
&message.user,
OutgoingMessage::Text {
body: format!("⚠️ Failed to configure provider: {e}"),
},
)
.await?;
return Ok(());
}
// Load extensions (skips any already loaded on the agent).
agent.load_extensions_from_session(&session).await;
let cancel = CancellationToken::new();
let user_message = Message::user().with_text(&message.text);
// Cap tool-calling loops so the agent doesn't run away doing
// dozens of tool calls before responding. After this many
// LLM→tool round-trips the agent will stop and reply with
// whatever it has.
const GATEWAY_MAX_TURNS: u32 = 5;
let session_config = SessionConfig {
id: session_id.to_string(),
schedule_id: None,
max_turns: Some(GATEWAY_MAX_TURNS),
retry_config: None,
};
let mut stream = match agent
.reply(user_message, session_config, Some(cancel))
.await
{
Ok(s) => s,
Err(e) => {
self.gateway
.send_message(
&message.user,
OutgoingMessage::Text {
body: format!("⚠️ Failed to start agent: {e}"),
},
)
.await?;
return Ok(());
}
};
// Telegram stops showing "typing…" after ~5 seconds. Re-send the
// indicator every 4 s so the user always sees activity while the
// agent is working (tool calls, LLM round-trips, etc.).
let typing_cancel = CancellationToken::new();
let typing_gateway = self.gateway.clone();
let typing_user = message.user.clone();
let typing_handle = tokio::spawn({
let cancel = typing_cancel.clone();
async move {
let mut interval = tokio::time::interval(Duration::from_secs(4));
interval.tick().await; // first tick is immediate, skip it
loop {
tokio::select! {
_ = cancel.cancelled() => break,
_ = interval.tick() => {
if let Err(e) = typing_gateway
.send_message(&typing_user, OutgoingMessage::Typing)
.await
{
tracing::debug!(error = %e, "failed to re-send typing indicator");
break;
}
}
}
}
}
});
// Buffer text within a single assistant message so we send one
// Telegram message per LLM turn rather than per-chunk. When a
// ToolRequest appears in the same message we flush the buffer
// first — the user sees "Let me check…" immediately, then the
// typing indicator while the tool runs, then the next response.
let mut pending_text = String::new();
let mut sent_any = false;
let mut event_count: u64 = 0;
while let Some(event) = stream.next().await {
event_count += 1;
match event {
Ok(AgentEvent::Message(ref msg)) => {
tracing::debug!(
session_id,
role = ?msg.role,
content_items = msg.content.len(),
"gateway stream: message event #{event_count}"
);
if msg.role == rmcp::model::Role::Assistant {
for content in &msg.content {
match content {
MessageContent::Text(t) => {
if !t.text.is_empty() {
pending_text.push_str(&t.text);
}
}
MessageContent::ToolRequest(req) => {
// Flush any accumulated text before
// the tool runs — the user sees the
// assistant's intent immediately.
if !pending_text.is_empty() {
let _ = self
.gateway
.send_message(
&message.user,
OutgoingMessage::Text {
body: std::mem::take(&mut pending_text),
},
)
.await;
sent_any = true;
}
if let Ok(call) = &req.tool_call {
tracing::debug!(
session_id,
tool = %call.name,
"gateway stream: tool request"
);
let _ = self
.gateway
.send_message(&message.user, OutgoingMessage::Typing)
.await;
}
}
MessageContent::ToolResponse(resp) => {
tracing::debug!(
session_id,
id = %resp.id,
success = resp.tool_result.is_ok(),
"gateway stream: tool response"
);
}
_ => {}
}
}
}
}
Ok(AgentEvent::McpNotification(_)) => {
tracing::debug!(
session_id,
"gateway stream: mcp notification #{event_count}"
);
}
Ok(AgentEvent::ModelChange {
ref model,
ref mode,
}) => {
tracing::debug!(
session_id,
model,
mode,
"gateway stream: model change #{event_count}"
);
}
Ok(AgentEvent::HistoryReplaced(_)) => {
tracing::debug!(
session_id,
"gateway stream: history replaced #{event_count}"
);
}
Err(e) => {
tracing::error!(session_id, error = %e, "gateway stream: error at event #{event_count}");
// Stop typing indicator before sending error.
typing_cancel.cancel();
let _ = typing_handle.await;
self.gateway
.send_message(
&message.user,
OutgoingMessage::Text {
body: format!("⚠️ Agent error: {e}"),
},
)
.await?;
return Ok(());
}
}
}
// Stream finished — stop the typing indicator.
typing_cancel.cancel();
let _ = typing_handle.await;
tracing::debug!(
session_id,
event_count,
pending_text_len = pending_text.len(),
sent_any,
"gateway stream: finished"
);
// Send any remaining buffered text (this is typically the final
// assistant response after the last tool round-trip).
if !pending_text.is_empty() {
self.gateway
.send_message(&message.user, OutgoingMessage::Text { body: pending_text })
.await?;
} else if !sent_any {
// Nothing was ever sent — let the user know.
self.gateway
.send_message(
&message.user,
OutgoingMessage::Text {
body: "(No response)".to_string(),
},
)
.await?;
}
Ok(())
}
}
fn gateway_working_dir(platform: &str, user_id: &str) -> PathBuf {
Paths::config_dir()
.join("gateway")
.join(platform)
.join(user_id)
}
+416
View File
@@ -0,0 +1,416 @@
use std::collections::HashMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use utoipa::ToSchema;
use crate::config::Config;
use crate::execution::manager::AgentManager;
use super::handler::GatewayHandler;
use super::pairing::PairingStore;
use super::{Gateway, GatewayConfig, PairingState, PlatformUser};
const GATEWAY_CONFIGS_KEY: &str = "gateway_configs";
fn secret_key_for(gateway_type: &str) -> String {
format!("gateway_platform_config_{}", gateway_type)
}
/// Serialized form stored in goose config (no secrets).
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SavedGatewayEntry {
gateway_type: String,
max_sessions: usize,
}
#[allow(dead_code)]
pub struct GatewayInstance {
pub config: GatewayConfig,
pub gateway: Arc<dyn Gateway>,
pub cancel: CancellationToken,
pub handle: tokio::task::JoinHandle<()>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PairedUserInfo {
pub platform: String,
pub user_id: String,
pub display_name: Option<String>,
pub session_id: String,
pub paired_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct GatewayStatus {
pub gateway_type: String,
pub running: bool,
pub configured: bool,
pub paired_users: Vec<PairedUserInfo>,
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub info: HashMap<String, String>,
}
pub struct GatewayManager {
gateways: RwLock<HashMap<String, GatewayInstance>>,
pairing_store: Arc<PairingStore>,
agent_manager: Arc<AgentManager>,
}
impl GatewayManager {
pub fn new(agent_manager: Arc<AgentManager>) -> anyhow::Result<Self> {
let pairing_store = Arc::new(PairingStore::new()?);
Ok(Self {
gateways: RwLock::new(HashMap::new()),
pairing_store,
agent_manager,
})
}
#[allow(dead_code)]
pub fn pairing_store(&self) -> &Arc<PairingStore> {
&self.pairing_store
}
/// Load saved gateway configs and start them. Called once at server startup.
pub async fn check_auto_start(&self) {
let configs = match Self::load_saved_configs() {
Ok(configs) => configs,
Err(e) => {
tracing::warn!(error = %e, "failed to load saved gateway configs");
return;
}
};
for mut config in configs {
let gateway = match super::create_gateway(&mut config) {
Ok(gw) => gw,
Err(e) => {
tracing::error!(
gateway = %config.gateway_type,
error = %e,
"failed to create saved gateway"
);
continue;
}
};
if let Err(e) = self.start_gateway_internal(config.clone(), gateway).await {
tracing::error!(
gateway = %config.gateway_type,
error = %e,
"failed to auto-start gateway"
);
} else {
tracing::info!(gateway = %config.gateway_type, "gateway auto-started");
}
}
}
/// Start a gateway and persist its config for auto-start on next launch.
pub async fn start_gateway(
&self,
config: GatewayConfig,
gateway: Arc<dyn Gateway>,
) -> anyhow::Result<()> {
self.start_gateway_internal(config.clone(), gateway).await?;
Self::save_config(&config)?;
Ok(())
}
/// Stop a gateway and clear its pairings. Config is kept so it can be restarted.
pub async fn stop_gateway(&self, gateway_type: &str) -> anyhow::Result<()> {
let instance = self
.gateways
.write()
.await
.remove(gateway_type)
.ok_or_else(|| anyhow::anyhow!("Gateway '{}' is not running", gateway_type))?;
instance.cancel.cancel();
let _ = instance.handle.await;
match self
.pairing_store
.remove_all_for_platform(gateway_type)
.await
{
Ok(count) if count > 0 => {
tracing::info!(gateway = %gateway_type, count, "cleared pairings on stop");
}
Err(e) => {
tracing::warn!(gateway = %gateway_type, error = %e, "failed to clear pairings on stop");
}
_ => {}
}
tracing::info!(gateway = %gateway_type, "gateway stopped");
Ok(())
}
/// Stop a gateway (if running), clear pairings, and remove its saved config entirely.
pub async fn remove_gateway(&self, gateway_type: &str) -> anyhow::Result<()> {
// Stop if running (ignore error if not running).
if let Some(instance) = self.gateways.write().await.remove(gateway_type) {
instance.cancel.cancel();
let _ = instance.handle.await;
}
if let Err(e) = self
.pairing_store
.remove_all_for_platform(gateway_type)
.await
{
tracing::warn!(gateway = %gateway_type, error = %e, "failed to clear pairings on remove");
}
Self::remove_saved_config(gateway_type);
tracing::info!(gateway = %gateway_type, "gateway removed");
Ok(())
}
/// Restart a stopped gateway using its saved config.
pub async fn restart_gateway(&self, gateway_type: &str) -> anyhow::Result<()> {
if self.gateways.read().await.contains_key(gateway_type) {
anyhow::bail!("Gateway '{}' is already running", gateway_type);
}
let configs = Self::load_saved_configs()?;
let mut config = configs
.into_iter()
.find(|c| c.gateway_type == gateway_type)
.ok_or_else(|| anyhow::anyhow!("No saved config for gateway '{}'", gateway_type))?;
let gateway = super::create_gateway(&mut config)?;
self.start_gateway_internal(config, gateway).await?;
tracing::info!(gateway = %gateway_type, "gateway restarted");
Ok(())
}
async fn start_gateway_internal(
&self,
config: GatewayConfig,
gateway: Arc<dyn Gateway>,
) -> anyhow::Result<()> {
let gw_type = config.gateway_type.clone();
if self.gateways.read().await.contains_key(&gw_type) {
anyhow::bail!("Gateway '{}' is already running", gw_type);
}
gateway.validate_config().await?;
let cancel = CancellationToken::new();
let handler = GatewayHandler::new(
self.agent_manager.clone(),
self.pairing_store.clone(),
gateway.clone(),
config.clone(),
);
let gateway_clone = gateway.clone();
let cancel_clone = cancel.clone();
let gateway_type_for_task = gw_type.clone();
let handle = tokio::spawn(async move {
if let Err(e) = gateway_clone.start(handler, cancel_clone).await {
tracing::error!(gateway = %gateway_type_for_task, error = %e, "gateway stopped with error");
}
});
let instance = GatewayInstance {
config,
gateway,
cancel,
handle,
};
self.gateways.write().await.insert(gw_type, instance);
Ok(())
}
#[allow(dead_code)]
pub async fn stop_all(&self) {
let instances: Vec<(String, GatewayInstance)> =
self.gateways.write().await.drain().collect();
for (gateway_type, instance) in instances {
instance.cancel.cancel();
let _ = instance.handle.await;
if let Err(e) = self
.pairing_store
.remove_all_for_platform(&gateway_type)
.await
{
tracing::warn!(gateway = %gateway_type, error = %e, "failed to clear pairings on stop");
}
tracing::info!(gateway = %gateway_type, "gateway stopped");
}
}
#[allow(dead_code)]
pub async fn is_running(&self, gateway_type: &str) -> bool {
self.gateways.read().await.contains_key(gateway_type)
}
#[allow(dead_code)]
pub async fn list_running(&self) -> Vec<String> {
self.gateways.read().await.keys().cloned().collect()
}
pub async fn status(&self) -> Vec<GatewayStatus> {
let running = self.gateways.read().await;
let mut statuses = Vec::new();
let mut seen = std::collections::HashSet::new();
for (gw_type, instance) in running.iter() {
seen.insert(gw_type.clone());
let paired_users = self
.pairing_store
.list_paired_users(gw_type)
.await
.unwrap_or_default()
.into_iter()
.map(|(user, session_id, paired_at)| PairedUserInfo {
platform: user.platform,
user_id: user.user_id,
display_name: user.display_name,
session_id,
paired_at,
})
.collect();
statuses.push(GatewayStatus {
gateway_type: gw_type.clone(),
running: true,
configured: true,
paired_users,
info: instance.gateway.info(),
});
}
// Include configured-but-stopped gateways.
if let Ok(saved) = Self::load_saved_configs() {
for config in saved {
if seen.contains(&config.gateway_type) {
continue;
}
statuses.push(GatewayStatus {
gateway_type: config.gateway_type,
running: false,
configured: true,
paired_users: Vec::new(),
info: HashMap::new(),
});
}
}
statuses.sort_by(|a, b| a.gateway_type.cmp(&b.gateway_type));
statuses
}
pub async fn unpair_user(&self, platform: &str, user_id: &str) -> anyhow::Result<bool> {
let user = PlatformUser {
platform: platform.to_string(),
user_id: user_id.to_string(),
display_name: None,
};
let state = self.pairing_store.get(&user).await?;
if matches!(state, PairingState::Paired { .. }) {
self.pairing_store.remove(&user).await?;
Ok(true)
} else {
Ok(false)
}
}
pub async fn generate_pairing_code(&self, gateway_type: &str) -> anyhow::Result<(String, i64)> {
let code = PairingStore::generate_code();
let expires_at = chrono::Utc::now().timestamp() + 300;
self.pairing_store
.store_pending_code(&code, gateway_type, expires_at)
.await?;
Ok((code, expires_at))
}
// --- Config persistence ---
fn load_saved_configs() -> anyhow::Result<Vec<GatewayConfig>> {
let config = Config::global();
let entries: Vec<SavedGatewayEntry> = match config.get_param(GATEWAY_CONFIGS_KEY) {
Ok(entries) => entries,
Err(_) => return Ok(Vec::new()),
};
let mut configs = Vec::new();
for entry in entries {
let platform_config: serde_json::Value =
match config.get_secret(&secret_key_for(&entry.gateway_type)) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
gateway = %entry.gateway_type,
error = %e,
"skipping gateway with missing platform config secret"
);
continue;
}
};
configs.push(GatewayConfig {
gateway_type: entry.gateway_type,
platform_config,
max_sessions: entry.max_sessions,
});
}
Ok(configs)
}
fn save_config(gw_config: &GatewayConfig) -> anyhow::Result<()> {
let config = Config::global();
// Save platform_config (contains secrets like bot tokens) to the secret store.
config
.set_secret(
&secret_key_for(&gw_config.gateway_type),
&gw_config.platform_config,
)
.map_err(|e| anyhow::anyhow!("failed to save gateway secret: {}", e))?;
// Load existing entries, add/replace this one, save back.
let mut entries: Vec<SavedGatewayEntry> =
config.get_param(GATEWAY_CONFIGS_KEY).unwrap_or_default();
entries.retain(|e| e.gateway_type != gw_config.gateway_type);
entries.push(SavedGatewayEntry {
gateway_type: gw_config.gateway_type.clone(),
max_sessions: gw_config.max_sessions,
});
config
.set_param(GATEWAY_CONFIGS_KEY, &entries)
.map_err(|e| anyhow::anyhow!("failed to save gateway config: {}", e))?;
Ok(())
}
fn remove_saved_config(gateway_type: &str) {
let config = Config::global();
// Remove the secret.
if let Err(e) = config.delete_secret(&secret_key_for(gateway_type)) {
tracing::warn!(error = %e, "failed to remove gateway secret");
}
// Remove from the config entries list.
let mut entries: Vec<SavedGatewayEntry> =
config.get_param(GATEWAY_CONFIGS_KEY).unwrap_or_default();
entries.retain(|e| e.gateway_type != gateway_type);
if let Err(e) = config.set_param(GATEWAY_CONFIGS_KEY, &entries) {
tracing::warn!(error = %e, "failed to update gateway config list");
}
}
}
+102
View File
@@ -0,0 +1,102 @@
pub mod handler;
pub mod manager;
pub mod pairing;
pub mod telegram;
pub mod telegram_format;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tokio_util::sync::CancellationToken;
use utoipa::ToSchema;
use handler::GatewayHandler;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlatformUser {
pub platform: String,
pub user_id: String,
pub display_name: Option<String>,
}
impl PartialEq for PlatformUser {
fn eq(&self, other: &Self) -> bool {
self.platform == other.platform && self.user_id == other.user_id
}
}
impl Eq for PlatformUser {}
impl std::hash::Hash for PlatformUser {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.platform.hash(state);
self.user_id.hash(state);
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct IncomingMessage {
pub user: PlatformUser,
pub text: String,
pub platform_message_id: Option<String>,
pub attachments: Vec<Attachment>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Attachment {
pub filename: String,
pub mime_type: String,
pub data: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum OutgoingMessage {
Text { body: String },
Typing,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum PairingState {
Unpaired,
PendingCode { code: String, expires_at: i64 },
Paired { session_id: String, paired_at: i64 },
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct GatewayConfig {
pub gateway_type: String,
pub platform_config: serde_json::Value,
pub max_sessions: usize,
}
#[async_trait]
#[allow(dead_code)]
pub trait Gateway: Send + Sync + 'static {
fn gateway_type(&self) -> &str;
async fn start(&self, handler: GatewayHandler, cancel: CancellationToken)
-> anyhow::Result<()>;
async fn send_message(
&self,
user: &PlatformUser,
message: OutgoingMessage,
) -> anyhow::Result<()>;
async fn validate_config(&self) -> anyhow::Result<()>;
fn info(&self) -> HashMap<String, String> {
HashMap::new()
}
}
pub fn create_gateway(config: &mut GatewayConfig) -> anyhow::Result<std::sync::Arc<dyn Gateway>> {
match config.gateway_type.as_str() {
"telegram" => Ok(std::sync::Arc::new(telegram::TelegramGateway::new(config)?)),
other => anyhow::bail!("Unknown gateway type: {}", other),
}
}
+189
View File
@@ -0,0 +1,189 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use crate::config::Config;
use super::{PairingState, PlatformUser};
const PAIRINGS_CONFIG_KEY: &str = "gateway_pairings";
const PENDING_CODES_CONFIG_KEY: &str = "gateway_pending_codes";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct StoredPairing {
platform: String,
user_id: String,
display_name: Option<String>,
state: PairingState,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct StoredPendingCode {
code: String,
gateway_type: String,
expires_at: i64,
}
pub struct PairingStore {
pairings: RwLock<HashMap<PlatformUser, PairingState>>,
}
impl PairingStore {
pub fn new() -> anyhow::Result<Self> {
let pairings = Self::load_pairings_from_config();
Ok(Self {
pairings: RwLock::new(pairings),
})
}
fn load_pairings_from_config() -> HashMap<PlatformUser, PairingState> {
let config = Config::global();
let entries: Vec<StoredPairing> = config.get_param(PAIRINGS_CONFIG_KEY).unwrap_or_default();
let mut map = HashMap::new();
for entry in entries {
let user = PlatformUser {
platform: entry.platform,
user_id: entry.user_id,
display_name: entry.display_name,
};
map.insert(user, entry.state);
}
map
}
fn save_pairings_to_config(
pairings: &HashMap<PlatformUser, PairingState>,
) -> anyhow::Result<()> {
let entries: Vec<StoredPairing> = pairings
.iter()
.map(|(user, state)| StoredPairing {
platform: user.platform.clone(),
user_id: user.user_id.clone(),
display_name: user.display_name.clone(),
state: state.clone(),
})
.collect();
Config::global()
.set_param(PAIRINGS_CONFIG_KEY, &entries)
.map_err(|e| anyhow::anyhow!("failed to save gateway pairings: {}", e))
}
fn load_pending_codes() -> Vec<StoredPendingCode> {
Config::global()
.get_param(PENDING_CODES_CONFIG_KEY)
.unwrap_or_default()
}
fn save_pending_codes(codes: &[StoredPendingCode]) -> anyhow::Result<()> {
Config::global()
.set_param(PENDING_CODES_CONFIG_KEY, codes)
.map_err(|e| anyhow::anyhow!("failed to save pending codes: {}", e))
}
pub async fn get(&self, user: &PlatformUser) -> anyhow::Result<PairingState> {
let pairings = self.pairings.read().await;
Ok(pairings
.get(user)
.cloned()
.unwrap_or(PairingState::Unpaired))
}
pub async fn set(&self, user: &PlatformUser, state: PairingState) -> anyhow::Result<()> {
let mut pairings = self.pairings.write().await;
pairings.insert(user.clone(), state);
Self::save_pairings_to_config(&pairings)
}
pub async fn remove(&self, user: &PlatformUser) -> anyhow::Result<()> {
let mut pairings = self.pairings.write().await;
pairings.remove(user);
Self::save_pairings_to_config(&pairings)
}
pub async fn store_pending_code(
&self,
code: &str,
gateway_type: &str,
expires_at: i64,
) -> anyhow::Result<()> {
let mut codes = Self::load_pending_codes();
codes.retain(|c| c.code != code);
codes.push(StoredPendingCode {
code: code.to_string(),
gateway_type: gateway_type.to_string(),
expires_at,
});
Self::save_pending_codes(&codes)
}
pub async fn consume_pending_code(&self, code: &str) -> anyhow::Result<Option<String>> {
let mut codes = Self::load_pending_codes();
let pos = codes.iter().position(|c| c.code == code);
let Some(pos) = pos else {
return Ok(None);
};
let entry = codes.remove(pos);
Self::save_pending_codes(&codes)?;
let now = chrono::Utc::now().timestamp();
if now > entry.expires_at {
return Ok(None);
}
Ok(Some(entry.gateway_type))
}
pub fn generate_code() -> String {
use rand::Rng;
let chars: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let mut rng = rand::thread_rng();
(0..6)
.map(|_| chars[rng.gen_range(0..chars.len())] as char)
.collect()
}
pub async fn remove_all_for_platform(&self, platform: &str) -> anyhow::Result<usize> {
let mut pairings = self.pairings.write().await;
let before = pairings.len();
pairings.retain(|user, _| user.platform != platform);
let removed = before - pairings.len();
Self::save_pairings_to_config(&pairings)?;
Ok(removed)
}
pub async fn list_paired_users(
&self,
gateway_type: &str,
) -> anyhow::Result<Vec<(PlatformUser, String, i64)>> {
let pairings = self.pairings.read().await;
let mut result = Vec::new();
for (user, state) in pairings.iter() {
if user.platform == gateway_type {
if let PairingState::Paired {
session_id,
paired_at,
} = state
{
result.push((user.clone(), session_id.clone(), *paired_at));
}
}
}
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_code_generation() {
let code = PairingStore::generate_code();
assert_eq!(code.len(), 6);
assert!(code
.chars()
.all(|c| "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".contains(c)));
}
}
+399
View File
@@ -0,0 +1,399 @@
use super::{
Gateway, GatewayConfig, GatewayHandler, IncomingMessage, OutgoingMessage, PlatformUser,
};
use async_trait::async_trait;
use reqwest::Client;
use serde::Deserialize;
use tokio_util::sync::CancellationToken;
const TELEGRAM_API_BASE: &str = "https://api.telegram.org";
const POLL_TIMEOUT_SECS: u64 = 30;
const MAX_MESSAGE_LENGTH: usize = 4096;
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
pub struct TelegramGateway {
bot_token: String,
client: Client,
}
#[derive(Debug, Deserialize)]
struct TelegramUpdate {
update_id: i64,
message: Option<TelegramMessage>,
}
#[derive(Debug, Deserialize)]
struct TelegramMessage {
message_id: i64,
from: Option<TelegramUser>,
chat: TelegramChat,
text: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TelegramUser {
first_name: String,
last_name: Option<String>,
#[allow(dead_code)]
username: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TelegramChat {
id: i64,
#[allow(dead_code)]
#[serde(rename = "type")]
chat_type: String,
}
#[derive(Debug, Deserialize)]
struct TelegramResponse<T> {
ok: bool,
result: Option<T>,
description: Option<String>,
}
impl TelegramGateway {
pub fn new(config: &GatewayConfig) -> anyhow::Result<Self> {
let bot_token = config.platform_config["bot_token"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing bot_token in platform_config"))?
.to_string();
Ok(Self {
bot_token,
client: Client::new(),
})
}
fn api_url(&self, method: &str) -> String {
format!("{}/bot{}/{}", TELEGRAM_API_BASE, self.bot_token, method)
}
async fn get_updates(&self, offset: Option<i64>) -> anyhow::Result<Vec<TelegramUpdate>> {
let mut params = serde_json::json!({
"timeout": POLL_TIMEOUT_SECS,
"allowed_updates": ["message"],
});
if let Some(offset) = offset {
params["offset"] = serde_json::json!(offset);
}
let resp: TelegramResponse<Vec<TelegramUpdate>> = self
.client
.post(self.api_url("getUpdates"))
.json(&params)
.timeout(std::time::Duration::from_secs(POLL_TIMEOUT_SECS + 10))
.send()
.await?
.json()
.await?;
resp.result.ok_or_else(|| {
anyhow::anyhow!(
"Telegram API error: {}",
resp.description.unwrap_or_default()
)
})
}
async fn send_text(&self, chat_id: i64, text: &str) -> anyhow::Result<()> {
let html = super::telegram_format::markdown_to_telegram_html(text);
for chunk in split_message(&html, MAX_MESSAGE_LENGTH) {
let resp = self
.client
.post(self.api_url("sendMessage"))
.json(&serde_json::json!({
"chat_id": chat_id,
"text": chunk,
"parse_mode": "HTML",
}))
.send()
.await?;
if let Ok(body) = resp.json::<TelegramResponse<serde_json::Value>>().await {
if !body.ok {
tracing::warn!(
error = body.description.as_deref().unwrap_or("unknown"),
"Telegram rejected HTML, falling back to plain text"
);
for plain_chunk in split_message(text, MAX_MESSAGE_LENGTH) {
self.client
.post(self.api_url("sendMessage"))
.json(&serde_json::json!({
"chat_id": chat_id,
"text": plain_chunk,
}))
.send()
.await?;
}
return Ok(());
}
}
}
Ok(())
}
async fn send_chat_action(&self, chat_id: i64, action: &str) -> anyhow::Result<()> {
self.client
.post(self.api_url("sendChatAction"))
.json(&serde_json::json!({
"chat_id": chat_id,
"action": action,
}))
.send()
.await?;
Ok(())
}
fn to_platform_user(tg_msg: &TelegramMessage) -> PlatformUser {
PlatformUser {
platform: "telegram".to_string(),
user_id: tg_msg.chat.id.to_string(),
display_name: tg_msg.from.as_ref().map(|u| {
let mut name = u.first_name.clone();
if let Some(ref last) = u.last_name {
name.push(' ');
name.push_str(last);
}
name
}),
}
}
}
#[async_trait]
impl Gateway for TelegramGateway {
fn gateway_type(&self) -> &str {
"telegram"
}
async fn start(
&self,
handler: GatewayHandler,
cancel: CancellationToken,
) -> anyhow::Result<()> {
let mut offset: Option<i64> = None;
tracing::info!("Telegram gateway starting long-poll loop");
loop {
tokio::select! {
_ = cancel.cancelled() => {
tracing::info!("Telegram gateway shutting down");
break;
}
result = self.get_updates(offset) => {
match result {
Ok(updates) => {
for update in updates {
offset = Some(update.update_id + 1);
let Some(tg_msg) = update.message else {
continue;
};
let text = match tg_msg.text {
Some(ref t) => t.clone(),
None => continue,
};
let user = Self::to_platform_user(&tg_msg);
let incoming = IncomingMessage {
user,
text,
platform_message_id: Some(tg_msg.message_id.to_string()),
attachments: vec![],
};
let handler = handler.clone();
tokio::spawn(async move {
if let Err(e) = handler.handle_message(incoming).await {
tracing::error!(error = %e, "error handling Telegram message");
}
});
}
}
Err(e) => {
tracing::error!(error = %e, "Telegram poll error");
tokio::time::sleep(RETRY_DELAY).await;
}
}
}
}
}
Ok(())
}
async fn send_message(
&self,
user: &PlatformUser,
message: OutgoingMessage,
) -> anyhow::Result<()> {
let chat_id: i64 = user
.user_id
.parse()
.map_err(|_| anyhow::anyhow!("invalid chat_id: {}", user.user_id))?;
match message {
OutgoingMessage::Text { body } => {
self.send_text(chat_id, &body).await?;
}
OutgoingMessage::Typing => {
self.send_chat_action(chat_id, "typing").await?;
}
}
Ok(())
}
async fn validate_config(&self) -> anyhow::Result<()> {
let resp: TelegramResponse<serde_json::Value> = self
.client
.get(self.api_url("getMe"))
.send()
.await?
.json()
.await?;
if !resp.ok {
anyhow::bail!(
"invalid Telegram bot token: {}",
resp.description.unwrap_or_default()
);
}
if let Some(result) = &resp.result {
if let Some(username) = result.get("username").and_then(|v| v.as_str()) {
tracing::info!(bot = %username, "Telegram bot verified");
}
}
Ok(())
}
}
#[allow(clippy::string_slice)]
fn split_message(text: &str, max_len: usize) -> Vec<String> {
if text.len() <= max_len {
return vec![text.to_string()];
}
let mut chunks = Vec::new();
let mut remaining = text;
while !remaining.is_empty() {
if remaining.len() <= max_len {
chunks.push(remaining.to_string());
break;
}
let mut cut = max_len;
while cut > 0 && !remaining.is_char_boundary(cut) {
cut -= 1;
}
if cut == 0 {
cut = remaining
.char_indices()
.nth(1)
.map(|(i, _)| i)
.unwrap_or(remaining.len());
}
let split_at = remaining[..cut]
.rfind('\n')
.or_else(|| remaining[..cut].rfind(' '))
.map(|pos| pos + 1)
.unwrap_or(cut);
chunks.push(remaining[..split_at].to_string());
remaining = &remaining[split_at..];
}
chunks
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_short_message() {
let chunks = split_message("hello world", 4096);
assert_eq!(chunks, vec!["hello world"]);
}
#[test]
fn split_at_newline() {
let text = format!("{}\n{}", "a".repeat(4000), "b".repeat(200));
let chunks = split_message(&text, 4096);
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].len(), 4001);
assert_eq!(chunks[1].len(), 200);
}
#[test]
fn split_at_space() {
let text = format!("{} {}", "a".repeat(4000), "b".repeat(200));
let chunks = split_message(&text, 4096);
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].len(), 4001);
assert_eq!(chunks[1].len(), 200);
}
#[test]
fn split_no_boundary() {
let text = "a".repeat(5000);
let chunks = split_message(&text, 4096);
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].len(), 4096);
assert_eq!(chunks[1].len(), 904);
}
#[test]
fn split_exact_boundary() {
let text = "a".repeat(4096);
let chunks = split_message(&text, 4096);
assert_eq!(chunks.len(), 1);
}
#[test]
fn split_empty() {
let chunks = split_message("", 4096);
assert_eq!(chunks, vec![""]);
}
#[test]
fn split_multiple_chunks() {
let text = format!(
"{}\n{}\n{}",
"a".repeat(4000),
"b".repeat(4000),
"c".repeat(4000)
);
let chunks = split_message(&text, 4096);
assert_eq!(chunks.len(), 3);
}
#[test]
fn split_multibyte_chars() {
let text = "🦆".repeat(1025); // 4100 bytes
let chunks = split_message(&text, 4096);
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].chars().count(), 1024);
assert_eq!(chunks[1].chars().count(), 1);
}
#[test]
fn split_preserves_content() {
let text = format!(
"{} {} {}",
"a".repeat(3000),
"b".repeat(3000),
"c".repeat(3000)
);
let chunks = split_message(&text, 4096);
let reassembled: String = chunks.join("");
assert_eq!(reassembled, text);
}
}
+279
View File
@@ -0,0 +1,279 @@
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
/// Convert markdown into Telegram-compatible HTML.
///
/// Telegram supports only a minimal HTML subset with no attributes beyond
/// `href` on `<a>`. Unsupported tags or attributes (e.g. `class`) cause the
/// API to reject the message outright.
pub fn markdown_to_telegram_html(markdown: &str) -> String {
let options = Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES;
let parser = Parser::new_ext(markdown, options);
let mut output = String::with_capacity(markdown.len());
let mut list_number: Option<u64> = None;
for event in parser {
match event {
Event::Start(tag) => match tag {
Tag::Paragraph => {}
Tag::Heading { .. } => output.push_str("<b>"),
Tag::Strong => output.push_str("<b>"),
Tag::Emphasis => output.push_str("<i>"),
Tag::Strikethrough => output.push_str("<s>"),
Tag::CodeBlock(_) => output.push_str("<pre><code>"),
Tag::Link { dest_url, .. } => {
output.push_str(&format!("<a href=\"{}\">", escape_html(&dest_url)));
}
Tag::List(start) => {
list_number = start;
}
Tag::Item => {
if let Some(n) = list_number.as_mut() {
output.push_str(&format!("{}. ", n));
*n += 1;
} else {
output.push_str("");
}
}
Tag::BlockQuote(_) => output.push_str("<blockquote>"),
_ => {}
},
Event::End(tag_end) => match tag_end {
TagEnd::Paragraph => output.push('\n'),
TagEnd::Heading(_) => output.push_str("</b>\n"),
TagEnd::Strong => output.push_str("</b>"),
TagEnd::Emphasis => output.push_str("</i>"),
TagEnd::Strikethrough => output.push_str("</s>"),
TagEnd::CodeBlock => output.push_str("</code></pre>\n"),
TagEnd::Link => output.push_str("</a>"),
TagEnd::List(_) => {
list_number = None;
}
TagEnd::Item => output.push('\n'),
TagEnd::BlockQuote(_) => output.push_str("</blockquote>\n"),
_ => {}
},
Event::Text(text) => output.push_str(&escape_html(&text)),
Event::Code(code) => {
output.push_str("<code>");
output.push_str(&escape_html(&code));
output.push_str("</code>");
}
Event::SoftBreak | Event::HardBreak => output.push('\n'),
Event::Rule => output.push_str("———\n"),
_ => {}
}
}
let collapsed = collapse_newlines(&output);
collapsed.trim().to_string()
}
/// Collapse runs of 3+ newlines down to 2, preserving whitespace inside `<pre>` blocks.
fn collapse_newlines(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut in_pre = false;
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '<' {
let rest: String = chars.clone().take(4).collect();
if !in_pre && (rest.starts_with("pre>") || rest.starts_with("pre ")) {
in_pre = true;
}
if in_pre && rest.starts_with("/pre") {
in_pre = false;
}
result.push(ch);
} else if !in_pre && ch == '\n' {
let mut count = 1;
while chars.peek() == Some(&'\n') {
chars.next();
count += 1;
}
for _ in 0..count.min(2) {
result.push('\n');
}
} else {
result.push(ch);
}
}
result
}
fn escape_html(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_text_unchanged() {
assert_eq!(markdown_to_telegram_html("Hello world"), "Hello world");
}
#[test]
fn bold_and_italic() {
assert_eq!(
markdown_to_telegram_html("This is **bold** and *italic*"),
"This is <b>bold</b> and <i>italic</i>"
);
}
#[test]
fn inline_code() {
assert_eq!(
markdown_to_telegram_html("Use `cargo build` to compile"),
"Use <code>cargo build</code> to compile"
);
}
#[test]
fn code_block_no_class_attribute() {
let html = markdown_to_telegram_html("```rust\nfn main() {}\n```");
assert!(
!html.contains("class="),
"Telegram rejects class attributes: {html}"
);
assert!(html.contains("<pre><code>"));
assert!(html.contains("fn main() {}"));
assert!(html.contains("</code></pre>"));
}
#[test]
fn code_block_no_language() {
let html = markdown_to_telegram_html("```\nhello\n```");
assert!(html.contains("<pre><code>"));
assert!(html.contains("hello"));
}
#[test]
fn heading() {
assert_eq!(markdown_to_telegram_html("# Title"), "<b>Title</b>");
}
#[test]
fn unordered_list() {
let html = markdown_to_telegram_html("- one\n- two\n- three");
assert!(html.contains("• one"));
assert!(html.contains("• two"));
assert!(html.contains("• three"));
}
#[test]
fn ordered_list() {
let html = markdown_to_telegram_html("1. first\n2. second\n3. third");
assert!(html.contains("1. first"));
assert!(html.contains("2. second"));
assert!(html.contains("3. third"));
}
#[test]
fn link() {
let html = markdown_to_telegram_html("Visit [Rust](https://rust-lang.org) docs");
assert!(html.contains("<a href=\"https://rust-lang.org\">Rust</a>"));
}
#[test]
fn html_entities_escaped() {
assert_eq!(
markdown_to_telegram_html("1 < 2 & 3 > 0"),
"1 &lt; 2 &amp; 3 &gt; 0"
);
}
#[test]
fn strikethrough() {
assert_eq!(markdown_to_telegram_html("~~deleted~~"), "<s>deleted</s>");
}
#[test]
fn blockquote() {
let html = markdown_to_telegram_html("> This is a quote");
assert!(html.contains("<blockquote>"));
assert!(html.contains("This is a quote"));
assert!(html.contains("</blockquote>"));
}
#[test]
fn horizontal_rule() {
let html = markdown_to_telegram_html("above\n\n---\n\nbelow");
assert!(html.contains("———"));
}
#[test]
fn complex_llm_response() {
let md = r#"# Summary
Here's what I found:
1. **First item** - this is important
2. **Second item** - also relevant
```python
print("hello")
```
For more info, visit [docs](https://example.com).
> Note: this is a blockquote
That's all!"#;
let html = markdown_to_telegram_html(md);
assert!(!html.contains("class="), "no class attributes: {html}");
assert!(html.contains("<b>Summary</b>"));
assert!(html.contains("<b>First item</b>"));
assert!(html.contains("1. "));
assert!(html.contains("2. "));
assert!(html.contains("<pre><code>"));
assert!(html.contains("print(&quot;hello&quot;)"));
assert!(html.contains("<a href="));
assert!(html.contains("<blockquote>"));
assert!(html.contains("That's all!"));
}
#[test]
fn no_trailing_whitespace() {
let html = markdown_to_telegram_html("Hello\n\n");
assert!(!html.ends_with('\n'));
assert!(!html.ends_with(' '));
}
#[test]
fn no_excessive_newlines() {
let md = "Paragraph one.\n\n\n\nParagraph two.\n\n\n\n\nParagraph three.";
let html = markdown_to_telegram_html(md);
assert!(!html.contains("\n\n\n"));
}
#[test]
fn list_to_paragraph_spacing() {
let md = "- one\n- two\n\nNext paragraph.";
let html = markdown_to_telegram_html(md);
assert!(!html.contains("\n\n\n"));
}
#[test]
fn code_block_preserves_internal_newlines() {
let md = "```\nline1\n\n\nline2\n```";
let html = markdown_to_telegram_html(md);
assert!(html.contains("line1\n\n\nline2"));
}
#[test]
fn compact_output() {
let md = "Sure! Here's a quick summary:\n\n## Key Points\n\n- Point one\n- Point two\n\nThat's it!";
let html = markdown_to_telegram_html(md);
let newline_count = html.chars().filter(|c| *c == '\n').count();
assert!(
newline_count <= 7,
"too many newlines ({newline_count}): {html}"
);
}
}
+1
View File
@@ -7,6 +7,7 @@ pub mod conversation;
pub mod dictation;
pub mod download_manager;
pub mod execution;
pub mod gateway;
pub mod goose_apps;
pub mod hints;
pub mod logging;
@@ -31,6 +31,7 @@ pub enum SessionType {
SubAgent,
Hidden,
Terminal,
Gateway,
}
impl std::fmt::Display for SessionType {
@@ -41,6 +42,7 @@ impl std::fmt::Display for SessionType {
SessionType::Hidden => write!(f, "hidden"),
SessionType::Scheduled => write!(f, "scheduled"),
SessionType::Terminal => write!(f, "terminal"),
SessionType::Gateway => write!(f, "gateway"),
}
}
}
@@ -55,6 +57,7 @@ impl std::str::FromStr for SessionType {
"hidden" => Ok(SessionType::Hidden),
"scheduled" => Ok(SessionType::Scheduled),
"terminal" => Ok(SessionType::Terminal),
"gateway" => Ok(SessionType::Gateway),
_ => Err(anyhow::anyhow!("Invalid session type: {}", s)),
}
}