Skip to main content

astrid_telegram/
client.rs

1//! Daemon client for the Telegram bot.
2//!
3//! Adapted from `astrid-cli`'s `DaemonClient`. Key difference: **no daemon
4//! auto-start**. The bot is a long-lived service that connects to an
5//! already-running daemon.
6
7use std::path::PathBuf;
8use std::time::Duration;
9
10use astrid_core::{ApprovalDecision, ElicitationResponse, SessionId};
11use astrid_gateway::rpc::{AstridRpcClient, BudgetInfo, DaemonEvent, DaemonStatus, SessionInfo};
12use astrid_gateway::server::DaemonPaths;
13use jsonrpsee::ws_client::{WsClient, WsClientBuilder};
14
15use crate::error::TelegramBotError;
16
17/// A client that connects to the Astrid daemon via `WebSocket`.
18///
19/// Unlike the CLI client, this does **not** auto-start the daemon.
20/// The daemon must already be running.
21pub struct DaemonClient {
22    client: WsClient,
23}
24
25impl DaemonClient {
26    /// Connect to the daemon at the given URL.
27    pub async fn connect_url(url: &str) -> Result<Self, TelegramBotError> {
28        let client = WsClientBuilder::default()
29            .connection_timeout(Duration::from_secs(10))
30            .build(url)
31            .await
32            .map_err(|e| {
33                TelegramBotError::DaemonConnection(format!(
34                    "failed to connect to daemon at {url}: {e}"
35                ))
36            })?;
37
38        Ok(Self { client })
39    }
40
41    /// Connect to the daemon, auto-discovering the port from
42    /// `~/.astrid/daemon.port`.
43    pub async fn connect_discover() -> Result<Self, TelegramBotError> {
44        let paths = DaemonPaths::default_dir()
45            .map_err(|e| TelegramBotError::DaemonConnection(e.to_string()))?;
46
47        let port = astrid_gateway::DaemonServer::read_port(&paths).ok_or_else(|| {
48            TelegramBotError::DaemonConnection(
49                "daemon port file not found — is astridd running?".to_string(),
50            )
51        })?;
52
53        let url = format!("ws://127.0.0.1:{port}");
54        Self::connect_url(&url).await
55    }
56
57    /// Connect using an explicit URL or fall back to auto-discovery.
58    pub async fn connect(daemon_url: Option<&str>) -> Result<Self, TelegramBotError> {
59        match daemon_url {
60            Some(url) => Self::connect_url(url).await,
61            None => Self::connect_discover().await,
62        }
63    }
64
65    /// Create a new session.
66    pub async fn create_session(
67        &self,
68        workspace_path: Option<PathBuf>,
69    ) -> Result<SessionInfo, TelegramBotError> {
70        self.client
71            .create_session(workspace_path)
72            .await
73            .map_err(|e| TelegramBotError::DaemonRpc(e.to_string()))
74    }
75
76    /// End a session.
77    pub async fn end_session(&self, session_id: &SessionId) -> Result<(), TelegramBotError> {
78        self.client
79            .end_session(session_id.clone())
80            .await
81            .map_err(|e| TelegramBotError::DaemonRpc(e.to_string()))
82    }
83
84    /// Send user input to a session.
85    pub async fn send_input(
86        &self,
87        session_id: &SessionId,
88        input: &str,
89    ) -> Result<(), TelegramBotError> {
90        self.client
91            .send_input(session_id.clone(), input.to_string())
92            .await
93            .map_err(|e| TelegramBotError::DaemonRpc(e.to_string()))
94    }
95
96    /// Subscribe to session events.
97    pub async fn subscribe_events(
98        &self,
99        session_id: &SessionId,
100    ) -> Result<jsonrpsee::core::client::Subscription<DaemonEvent>, TelegramBotError> {
101        self.client
102            .subscribe_events(session_id.clone())
103            .await
104            .map_err(|e| TelegramBotError::DaemonRpc(e.to_string()))
105    }
106
107    /// Respond to an approval request.
108    pub async fn send_approval(
109        &self,
110        session_id: &SessionId,
111        request_id: &str,
112        decision: ApprovalDecision,
113    ) -> Result<(), TelegramBotError> {
114        self.client
115            .approval_response(session_id.clone(), request_id.to_string(), decision)
116            .await
117            .map_err(|e| TelegramBotError::DaemonRpc(e.to_string()))
118    }
119
120    /// Respond to an elicitation request.
121    pub async fn send_elicitation(
122        &self,
123        session_id: &SessionId,
124        request_id: &str,
125        response: ElicitationResponse,
126    ) -> Result<(), TelegramBotError> {
127        self.client
128            .elicitation_response(session_id.clone(), request_id.to_string(), response)
129            .await
130            .map_err(|e| TelegramBotError::DaemonRpc(e.to_string()))
131    }
132
133    /// Cancel the current turn.
134    pub async fn cancel_turn(&self, session_id: &SessionId) -> Result<(), TelegramBotError> {
135        self.client
136            .cancel_turn(session_id.clone())
137            .await
138            .map_err(|e| TelegramBotError::DaemonRpc(e.to_string()))
139    }
140
141    /// Get daemon status.
142    pub async fn status(&self) -> Result<DaemonStatus, TelegramBotError> {
143        self.client
144            .status()
145            .await
146            .map_err(|e| TelegramBotError::DaemonRpc(e.to_string()))
147    }
148
149    /// Get budget info for a session.
150    pub async fn session_budget(
151        &self,
152        session_id: &SessionId,
153    ) -> Result<BudgetInfo, TelegramBotError> {
154        self.client
155            .session_budget(session_id.clone())
156            .await
157            .map_err(|e| TelegramBotError::DaemonRpc(e.to_string()))
158    }
159}