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(&crate::Envelope::new("request", request))
287            .await
288    }
289    /// Preserves unknown JSON properties when acknowledging the caller's exact request.
290    pub async fn submit_value(&self, request: &Value) -> Result<RelayAcknowledgement> {
291        self.submit_json(request).await
292    }
293    async fn submit_json<T: Serialize + ?Sized>(
294        &self,
295        request: &T,
296    ) -> Result<RelayAcknowledgement> {
297        let url = self
298            .base
299            .join("requests")
300            .map_err(|e| crate::Error::Configuration(e.to_string()))?;
301        Ok(crate::checked(
302            self.http
303                .post(url)
304                .bearer_auth(&self.token)
305                .json(request)
306                .send()
307                .await?,
308        )
309        .await?
310        .json::<crate::Envelope<RelayAcknowledgement>>()
311        .await?
312        .data)
313    }
314    pub async fn result(&self, id: Uuid) -> Result<RelayResult> {
315        let url = self
316            .base
317            .join(&format!("requests/{id}"))
318            .map_err(|e| crate::Error::Configuration(e.to_string()))?;
319        Ok(
320            crate::checked(self.http.get(url).bearer_auth(&self.token).send().await?)
321                .await?
322                .json::<crate::Envelope<RelayResult>>()
323                .await?
324                .data,
325        )
326    }
327    /// Reads progress without transferring the queued request or result body.
328    /// Daemons released before this route existed return HTTP 404; callers can
329    /// fall back to [`Self::result`] for those installations.
330    pub async fn request_status(&self, id: Uuid) -> Result<RelayRequestStatus> {
331        let url = self
332            .base
333            .join(&format!("requests/{id}/status"))
334            .map_err(|e| crate::Error::Configuration(e.to_string()))?;
335        Ok(
336            crate::checked(self.http.get(url).bearer_auth(&self.token).send().await?)
337                .await?
338                .json::<crate::Envelope<RelayRequestStatus>>()
339                .await?
340                .data,
341        )
342    }
343    pub async fn status(&self) -> Result<Value> {
344        let url = self
345            .base
346            .join("status")
347            .map_err(|e| crate::Error::Configuration(e.to_string()))?;
348        Ok(
349            crate::checked(self.http.get(url).bearer_auth(&self.token).send().await?)
350                .await?
351                .json::<crate::Envelope<Value>>()
352                .await?
353                .data,
354        )
355    }
356    pub async fn stop(&self) -> Result<()> {
357        let url = self
358            .base
359            .join("shutdown")
360            .map_err(|e| crate::Error::Configuration(e.to_string()))?;
361        crate::checked(self.http.post(url).bearer_auth(&self.token).send().await?).await?;
362        Ok(())
363    }
364}