Skip to main content

silicon_dm_client/
relay.rs

1//! Typed commands for the local CLI relay. This module does not persist state.
2use crate::{Client, GifList, Result, models::*};
3use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5use uuid::Uuid;
6
7/// Public DM operations accepted by the local daemon, without auth secrets.
8/// Mutations include their retry key in the command so crash recovery preserves it.
9#[derive(Clone, Debug, Serialize, Deserialize)]
10#[serde(tag = "operation", rename_all = "snake_case")]
11pub enum Operation {
12    Me,
13    ListConversations {
14        #[serde(default)]
15        page: PageRequest,
16    },
17    CreateConversation {
18        participant_ids: Vec<String>,
19        idempotency_key: String,
20    },
21    ListMessages {
22        conversation_id: Uuid,
23        #[serde(default)]
24        page: PageRequest,
25        #[serde(default)]
26        include_bundled_members: bool,
27    },
28    GetMessage {
29        conversation_id: Uuid,
30        message_id: Uuid,
31    },
32    SendMessage {
33        conversation_id: Uuid,
34        message: MessageCreate,
35        idempotency_key: String,
36    },
37    EditMessage {
38        conversation_id: Uuid,
39        message_id: Uuid,
40        message: MessageCreate,
41        version: i64,
42        idempotency_key: String,
43    },
44    DeleteMessage {
45        conversation_id: Uuid,
46        message_id: Uuid,
47        version: i64,
48        idempotency_key: String,
49    },
50    Receipt {
51        conversation_id: Uuid,
52        message_id: Uuid,
53        status: ReceiptStatus,
54        device_id: String,
55    },
56    GetDraft {
57        conversation_id: Uuid,
58    },
59    PutDraft {
60        conversation_id: Uuid,
61        draft: DraftInput,
62        version: i64,
63    },
64    DeleteDraft {
65        conversation_id: Uuid,
66    },
67    CreateBundle {
68        conversation_id: Uuid,
69        bundle: BundleCreate,
70        idempotency_key: String,
71    },
72    GetBundle {
73        conversation_id: Uuid,
74        bundle_id: Uuid,
75    },
76    GetPresence {
77        actor_id: String,
78    },
79    /// Transient activity can only be sent by an active daemon WebSocket.
80    SetPresence {
81        activity: Option<Activity>,
82    },
83    TrendingGifs,
84    SearchGifs {
85        query: String,
86    },
87    RecentGifs,
88}
89impl Operation {
90    /// Whether execution changes backend state (presence is transient).
91    pub fn is_mutation(&self) -> bool {
92        matches!(
93            self,
94            Self::CreateConversation { .. }
95                | Self::SendMessage { .. }
96                | Self::EditMessage { .. }
97                | Self::DeleteMessage { .. }
98                | Self::Receipt { .. }
99                | Self::PutDraft { .. }
100                | Self::DeleteDraft { .. }
101                | Self::CreateBundle { .. }
102                | Self::SetPresence { .. }
103        )
104    }
105    /// Calls only the public DM client. Presence writes require a live socket.
106    pub async fn execute(&self, client: &Client) -> Result<Value> {
107        Ok(match self {
108            Self::Me => serde_json::to_value(client.me().await?)?,
109            Self::ListConversations { page } => {
110                serde_json::to_value(client.conversations(page).await?)?
111            }
112            Self::CreateConversation {
113                participant_ids,
114                idempotency_key,
115            } => serde_json::to_value(
116                client
117                    .create_conversation(participant_ids, idempotency_key)
118                    .await?,
119            )?,
120            Self::ListMessages {
121                conversation_id,
122                page,
123                include_bundled_members,
124            } => serde_json::to_value(
125                client
126                    .messages(*conversation_id, page, *include_bundled_members)
127                    .await?,
128            )?,
129            Self::GetMessage {
130                conversation_id,
131                message_id,
132            } => serde_json::to_value(client.message(*conversation_id, *message_id).await?)?,
133            Self::SendMessage {
134                conversation_id,
135                message,
136                idempotency_key,
137            } => serde_json::to_value(
138                client
139                    .send_message(*conversation_id, message, idempotency_key)
140                    .await?,
141            )?,
142            Self::EditMessage {
143                conversation_id,
144                message_id,
145                message,
146                version,
147                idempotency_key,
148            } => serde_json::to_value(
149                client
150                    .edit_message(
151                        *conversation_id,
152                        *message_id,
153                        message,
154                        *version,
155                        idempotency_key,
156                    )
157                    .await?,
158            )?,
159            Self::DeleteMessage {
160                conversation_id,
161                message_id,
162                version,
163                idempotency_key,
164            } => serde_json::to_value(
165                client
166                    .delete_message(*conversation_id, *message_id, *version, idempotency_key)
167                    .await?,
168            )?,
169            Self::Receipt {
170                conversation_id,
171                message_id,
172                status,
173                device_id,
174            } => serde_json::to_value(
175                client
176                    .record_receipt(*conversation_id, *message_id, *status, device_id)
177                    .await?,
178            )?,
179            Self::GetDraft { conversation_id } => {
180                serde_json::to_value(client.draft(*conversation_id).await?)?
181            }
182            Self::PutDraft {
183                conversation_id,
184                draft,
185                version,
186            } => serde_json::to_value(client.put_draft(*conversation_id, draft, *version).await?)?,
187            Self::DeleteDraft { conversation_id } => {
188                client.delete_draft(*conversation_id).await?;
189                json!({"deleted":true})
190            }
191            Self::CreateBundle {
192                conversation_id,
193                bundle,
194                idempotency_key,
195            } => serde_json::to_value(
196                client
197                    .create_bundle(*conversation_id, bundle, idempotency_key)
198                    .await?,
199            )?,
200            Self::GetBundle {
201                conversation_id,
202                bundle_id,
203            } => serde_json::to_value(client.bundle(*conversation_id, *bundle_id).await?)?,
204            Self::GetPresence { actor_id } => {
205                serde_json::to_value(client.presence(actor_id).await?)?
206            }
207            Self::SetPresence { .. } => {
208                return Err(crate::Error::Configuration(
209                    "presence changes require the local relay daemon".into(),
210                ));
211            }
212            Self::TrendingGifs => serde_json::to_value(client.gifs(GifList::Trending).await?)?,
213            Self::SearchGifs { query } => {
214                serde_json::to_value(client.gifs(GifList::Search(query)).await?)?
215            }
216            Self::RecentGifs => serde_json::to_value(client.gifs(GifList::Recent).await?)?,
217        })
218    }
219}
220
221#[derive(Clone, Debug, Serialize, Deserialize)]
222pub struct RelayRequest {
223    pub request_id: Uuid,
224    /// Local profile name, with optional testing environment UUID.
225    pub profile: String,
226    #[serde(default)]
227    pub testing_environment_id: Option<Uuid>,
228    /// Expected sandbox state; the daemon captures its known generation when omitted.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub testing_generation: Option<i64>,
231    pub request: Operation,
232}
233#[derive(Clone, Debug, Serialize, Deserialize)]
234pub struct RelayAcknowledgement {
235    pub acknowledged: bool,
236    pub request_id: Uuid,
237    /// Exact original JSON supplied to the local relay.
238    pub request: Value,
239}
240#[derive(Clone, Debug, Serialize, Deserialize)]
241pub struct RelayResult {
242    pub request_id: Uuid,
243    pub state: String,
244    #[serde(default)]
245    pub result: Option<Value>,
246    #[serde(default)]
247    pub error: Option<Value>,
248    pub request: Value,
249}
250/// Lightweight progress information; the exact request and response remain in
251/// [`RelayResult`] and need only be fetched after waiting finishes.
252#[derive(Clone, Debug, Serialize, Deserialize)]
253pub struct RelayRequestStatus {
254    pub request_id: Uuid,
255    pub state: String,
256}
257/// Loopback-only HTTP relay client; bearer is a local credential, not an IAM token.
258#[derive(Clone)]
259pub struct RelayClient {
260    http: reqwest::Client,
261    base: url::Url,
262    token: String,
263}
264impl RelayClient {
265    pub fn new(base: &str, token: impl Into<String>) -> Result<Self> {
266        let base = url::Url::parse(base).map_err(|e| crate::Error::Configuration(e.to_string()))?;
267        crate::validate_endpoint(&base)?;
268        if !matches!(
269            base.host_str(),
270            Some("127.0.0.1" | "localhost" | "dm.localhost" | "[::1]" | "::1")
271        ) {
272            return Err(crate::Error::Configuration(
273                "relay must use a loopback URL".into(),
274            ));
275        }
276        Ok(Self {
277            http: reqwest::Client::builder()
278                .timeout(std::time::Duration::from_secs(15))
279                .no_proxy()
280                .build()?,
281            base,
282            token: token.into(),
283        })
284    }
285    pub async fn submit(&self, request: &RelayRequest) -> Result<RelayAcknowledgement> {
286        self.submit_json(request).await
287    }
288    /// Preserves unknown JSON properties when acknowledging the caller's exact request.
289    pub async fn submit_value(&self, request: &Value) -> Result<RelayAcknowledgement> {
290        self.submit_json(request).await
291    }
292    async fn submit_json<T: Serialize + ?Sized>(
293        &self,
294        request: &T,
295    ) -> Result<RelayAcknowledgement> {
296        let url = self
297            .base
298            .join("requests")
299            .map_err(|e| crate::Error::Configuration(e.to_string()))?;
300        Ok(crate::checked(
301            self.http
302                .post(url)
303                .bearer_auth(&self.token)
304                .json(request)
305                .send()
306                .await?,
307        )
308        .await?
309        .json()
310        .await?)
311    }
312    pub async fn result(&self, id: Uuid) -> Result<RelayResult> {
313        let url = self
314            .base
315            .join(&format!("requests/{id}"))
316            .map_err(|e| crate::Error::Configuration(e.to_string()))?;
317        Ok(
318            crate::checked(self.http.get(url).bearer_auth(&self.token).send().await?)
319                .await?
320                .json()
321                .await?,
322        )
323    }
324    /// Reads progress without transferring the queued request or result body.
325    /// Daemons released before this route existed return HTTP 404; callers can
326    /// fall back to [`Self::result`] for those installations.
327    pub async fn request_status(&self, id: Uuid) -> Result<RelayRequestStatus> {
328        let url = self
329            .base
330            .join(&format!("requests/{id}/status"))
331            .map_err(|e| crate::Error::Configuration(e.to_string()))?;
332        Ok(
333            crate::checked(self.http.get(url).bearer_auth(&self.token).send().await?)
334                .await?
335                .json()
336                .await?,
337        )
338    }
339    pub async fn status(&self) -> Result<Value> {
340        let url = self
341            .base
342            .join("status")
343            .map_err(|e| crate::Error::Configuration(e.to_string()))?;
344        Ok(
345            crate::checked(self.http.get(url).bearer_auth(&self.token).send().await?)
346                .await?
347                .json()
348                .await?,
349        )
350    }
351    pub async fn stop(&self) -> Result<()> {
352        let url = self
353            .base
354            .join("shutdown")
355            .map_err(|e| crate::Error::Configuration(e.to_string()))?;
356        crate::checked(self.http.post(url).bearer_auth(&self.token).send().await?).await?;
357        Ok(())
358    }
359}