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/// Loopback-only HTTP relay client; bearer is a local credential, not an IAM token.
251#[derive(Clone)]
252pub struct RelayClient {
253    http: reqwest::Client,
254    base: url::Url,
255    token: String,
256}
257impl RelayClient {
258    pub fn new(base: &str, token: impl Into<String>) -> Result<Self> {
259        let base = url::Url::parse(base).map_err(|e| crate::Error::Configuration(e.to_string()))?;
260        crate::validate_endpoint(&base)?;
261        if !matches!(
262            base.host_str(),
263            Some("127.0.0.1" | "localhost" | "dm.localhost" | "[::1]" | "::1")
264        ) {
265            return Err(crate::Error::Configuration(
266                "relay must use a loopback URL".into(),
267            ));
268        }
269        Ok(Self {
270            http: reqwest::Client::builder()
271                .timeout(std::time::Duration::from_secs(15))
272                .no_proxy()
273                .build()?,
274            base,
275            token: token.into(),
276        })
277    }
278    pub async fn submit(&self, request: &RelayRequest) -> Result<RelayAcknowledgement> {
279        self.submit_value(&serde_json::to_value(request)?).await
280    }
281    /// Preserves unknown JSON properties when acknowledging the caller's exact request.
282    pub async fn submit_value(&self, request: &Value) -> Result<RelayAcknowledgement> {
283        let url = self
284            .base
285            .join("requests")
286            .map_err(|e| crate::Error::Configuration(e.to_string()))?;
287        Ok(crate::checked(
288            self.http
289                .post(url)
290                .bearer_auth(&self.token)
291                .json(request)
292                .send()
293                .await?,
294        )
295        .await?
296        .json()
297        .await?)
298    }
299    pub async fn result(&self, id: Uuid) -> Result<RelayResult> {
300        let url = self
301            .base
302            .join(&format!("requests/{id}"))
303            .map_err(|e| crate::Error::Configuration(e.to_string()))?;
304        Ok(
305            crate::checked(self.http.get(url).bearer_auth(&self.token).send().await?)
306                .await?
307                .json()
308                .await?,
309        )
310    }
311    pub async fn status(&self) -> Result<Value> {
312        let url = self
313            .base
314            .join("status")
315            .map_err(|e| crate::Error::Configuration(e.to_string()))?;
316        Ok(
317            crate::checked(self.http.get(url).bearer_auth(&self.token).send().await?)
318                .await?
319                .json()
320                .await?,
321        )
322    }
323    pub async fn stop(&self) -> Result<()> {
324        let url = self
325            .base
326            .join("shutdown")
327            .map_err(|e| crate::Error::Configuration(e.to_string()))?;
328        crate::checked(self.http.post(url).bearer_auth(&self.token).send().await?).await?;
329        Ok(())
330    }
331}