Skip to main content

conversation_api/
surface.rs

1use base64::Engine;
2use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use std::fmt;
5use std::str::FromStr;
6
7const SURFACE_VERSION: &str = "cs1";
8
9/// Canonical identity and delivery route of one isolated Conversation surface.
10///
11/// The wire representation is always the canonical string returned by
12/// [`ConversationSurface::canonical_id`]. Trusted ingress owns construction;
13/// clients and provider payloads must not choose a surface directly.
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
15pub enum ConversationSurface {
16    Node {
17        node_type: String,
18        node_id: String,
19        endpoint_id: String,
20        user_id: String,
21    },
22    ClientPersonal {
23        user_id: String,
24    },
25    ClientGroup {
26        group_id: String,
27    },
28    MessagingPersonal {
29        provider: String,
30        account_id: String,
31        conversation_id: String,
32        lane_id: Option<String>,
33    },
34    MessagingGroup {
35        provider: String,
36        account_id: String,
37        conversation_id: String,
38        lane_id: Option<String>,
39    },
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct SurfaceParseError;
44
45impl fmt::Display for SurfaceParseError {
46    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
47        formatter.write_str("invalid canonical Conversation surface")
48    }
49}
50
51impl std::error::Error for SurfaceParseError {}
52
53impl ConversationSurface {
54    pub fn node(
55        node_type: impl Into<String>,
56        node_id: impl Into<String>,
57        endpoint_id: impl Into<String>,
58        user_id: impl Into<String>,
59    ) -> Result<Self, SurfaceParseError> {
60        Ok(Self::Node {
61            node_type: normalize_provider(node_type.into())?,
62            node_id: required(node_id.into())?,
63            endpoint_id: required(endpoint_id.into())?,
64            user_id: required(user_id.into())?,
65        })
66    }
67    pub fn client_personal(user_id: impl Into<String>) -> Result<Self, SurfaceParseError> {
68        Ok(Self::ClientPersonal {
69            user_id: required(user_id.into())?,
70        })
71    }
72
73    pub fn client_group(group_id: impl Into<String>) -> Result<Self, SurfaceParseError> {
74        Ok(Self::ClientGroup {
75            group_id: required(group_id.into())?,
76        })
77    }
78
79    /// One Client Group per tenant/Space. Device and enrollment IDs never partition it.
80    /// All storage and delivery must also retain the enclosing tenant and scope.
81    #[must_use]
82    pub fn space_clients() -> Self {
83        Self::ClientGroup {
84            group_id: "space-clients".to_owned(),
85        }
86    }
87
88    #[must_use]
89    pub fn is_space_clients(&self) -> bool {
90        self == &Self::space_clients()
91    }
92
93    pub fn messaging_personal(
94        provider: impl Into<String>,
95        account_id: impl Into<String>,
96        conversation_id: impl Into<String>,
97        lane_id: Option<String>,
98    ) -> Result<Self, SurfaceParseError> {
99        Ok(Self::MessagingPersonal {
100            provider: normalize_provider(provider.into())?,
101            account_id: required(account_id.into())?,
102            conversation_id: required(conversation_id.into())?,
103            lane_id: optional(lane_id)?,
104        })
105    }
106
107    pub fn messaging_group(
108        provider: impl Into<String>,
109        account_id: impl Into<String>,
110        conversation_id: impl Into<String>,
111        lane_id: Option<String>,
112    ) -> Result<Self, SurfaceParseError> {
113        Ok(Self::MessagingGroup {
114            provider: normalize_provider(provider.into())?,
115            account_id: required(account_id.into())?,
116            conversation_id: required(conversation_id.into())?,
117            lane_id: optional(lane_id)?,
118        })
119    }
120
121    #[must_use]
122    pub fn canonical_id(&self) -> String {
123        match self {
124            Self::Node {
125                node_type,
126                node_id,
127                endpoint_id,
128                user_id,
129            } => format!(
130                "{SURFACE_VERSION}:n:{node_type}:{}:{}:{}",
131                encode(node_id),
132                encode(endpoint_id),
133                encode(user_id)
134            ),
135            Self::ClientPersonal { user_id } => {
136                format!("{SURFACE_VERSION}:cp:{}", encode(user_id))
137            }
138            Self::ClientGroup { group_id } => {
139                format!("{SURFACE_VERSION}:cg:{}", encode(group_id))
140            }
141            Self::MessagingPersonal {
142                provider,
143                account_id,
144                conversation_id,
145                lane_id,
146            } => messaging_id(
147                "mp",
148                provider,
149                account_id,
150                conversation_id,
151                lane_id.as_deref(),
152            ),
153            Self::MessagingGroup {
154                provider,
155                account_id,
156                conversation_id,
157                lane_id,
158            } => messaging_id(
159                "mg",
160                provider,
161                account_id,
162                conversation_id,
163                lane_id.as_deref(),
164            ),
165        }
166    }
167
168    #[must_use]
169    pub fn is_personal(&self) -> bool {
170        matches!(
171            self,
172            Self::ClientPersonal { .. } | Self::MessagingPersonal { .. } | Self::Node { .. }
173        )
174    }
175
176    #[must_use]
177    pub fn is_group(&self) -> bool {
178        matches!(self, Self::ClientGroup { .. } | Self::MessagingGroup { .. })
179    }
180
181    #[must_use]
182    pub fn is_client(&self) -> bool {
183        matches!(self, Self::ClientPersonal { .. } | Self::ClientGroup { .. })
184    }
185
186    #[must_use]
187    pub fn is_messaging(&self) -> bool {
188        matches!(
189            self,
190            Self::MessagingPersonal { .. } | Self::MessagingGroup { .. }
191        )
192    }
193
194    #[must_use]
195    pub fn user_id(&self) -> Option<&str> {
196        match self {
197            Self::ClientPersonal { user_id } | Self::Node { user_id, .. } => Some(user_id),
198            _ => None,
199        }
200    }
201
202    #[must_use]
203    pub fn group_id(&self) -> Option<&str> {
204        match self {
205            Self::ClientGroup { group_id } => Some(group_id),
206            _ => None,
207        }
208    }
209
210    #[must_use]
211    pub fn messaging_route(&self) -> Option<MessagingSurfaceRoute<'_>> {
212        match self {
213            Self::MessagingPersonal {
214                provider,
215                account_id,
216                conversation_id,
217                lane_id,
218            }
219            | Self::MessagingGroup {
220                provider,
221                account_id,
222                conversation_id,
223                lane_id,
224            } => Some(MessagingSurfaceRoute {
225                provider,
226                account_id,
227                conversation_id,
228                lane_id: lane_id.as_deref(),
229                group: self.is_group(),
230            }),
231            _ => None,
232        }
233    }
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub struct MessagingSurfaceRoute<'a> {
238    pub provider: &'a str,
239    pub account_id: &'a str,
240    pub conversation_id: &'a str,
241    pub lane_id: Option<&'a str>,
242    pub group: bool,
243}
244
245impl fmt::Display for ConversationSurface {
246    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
247        formatter.write_str(&self.canonical_id())
248    }
249}
250
251impl FromStr for ConversationSurface {
252    type Err = SurfaceParseError;
253
254    fn from_str(value: &str) -> Result<Self, Self::Err> {
255        let parts = value.split(':').collect::<Vec<_>>();
256        if parts.first().copied() != Some(SURFACE_VERSION) {
257            return Err(SurfaceParseError);
258        }
259        let surface = match parts.as_slice() {
260            [_, "n", node_type, node_id, endpoint_id, user_id] => Self::node(
261                *node_type,
262                decode(node_id)?,
263                decode(endpoint_id)?,
264                decode(user_id)?,
265            ),
266            [_, "cp", user_id] => Self::client_personal(decode(user_id)?),
267            [_, "cg", group_id] => Self::client_group(decode(group_id)?),
268            [
269                _,
270                kind @ ("mp" | "mg"),
271                provider,
272                account_id,
273                conversation_id,
274            ] => messaging(
275                kind,
276                provider,
277                decode(account_id)?,
278                decode(conversation_id)?,
279                None,
280            ),
281            [
282                _,
283                kind @ ("mp" | "mg"),
284                provider,
285                account_id,
286                conversation_id,
287                lane_id,
288            ] => messaging(
289                kind,
290                provider,
291                decode(account_id)?,
292                decode(conversation_id)?,
293                Some(decode(lane_id)?),
294            ),
295            _ => Err(SurfaceParseError),
296        }?;
297        (surface.canonical_id() == value)
298            .then_some(surface)
299            .ok_or(SurfaceParseError)
300    }
301}
302
303impl Serialize for ConversationSurface {
304    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
305    where
306        S: Serializer,
307    {
308        serializer.serialize_str(&self.canonical_id())
309    }
310}
311
312impl<'de> Deserialize<'de> for ConversationSurface {
313    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
314    where
315        D: Deserializer<'de>,
316    {
317        let value = String::deserialize(deserializer)?;
318        value.parse().map_err(serde::de::Error::custom)
319    }
320}
321
322fn messaging(
323    kind: &str,
324    provider: &str,
325    account_id: String,
326    conversation_id: String,
327    lane_id: Option<String>,
328) -> Result<ConversationSurface, SurfaceParseError> {
329    match kind {
330        "mp" => {
331            ConversationSurface::messaging_personal(provider, account_id, conversation_id, lane_id)
332        }
333        "mg" => {
334            ConversationSurface::messaging_group(provider, account_id, conversation_id, lane_id)
335        }
336        _ => Err(SurfaceParseError),
337    }
338}
339
340fn messaging_id(
341    kind: &str,
342    provider: &str,
343    account_id: &str,
344    conversation_id: &str,
345    lane_id: Option<&str>,
346) -> String {
347    let mut value = format!(
348        "{SURFACE_VERSION}:{kind}:{provider}:{}:{}",
349        encode(account_id),
350        encode(conversation_id)
351    );
352    if let Some(lane_id) = lane_id {
353        value.push(':');
354        value.push_str(&encode(lane_id));
355    }
356    value
357}
358
359fn required(value: String) -> Result<String, SurfaceParseError> {
360    let trimmed = value.trim();
361    (!trimmed.is_empty() && trimmed == value)
362        .then_some(value)
363        .ok_or(SurfaceParseError)
364}
365
366fn optional(value: Option<String>) -> Result<Option<String>, SurfaceParseError> {
367    value.map(required).transpose()
368}
369
370fn normalize_provider(value: String) -> Result<String, SurfaceParseError> {
371    let normalized = value.trim().to_ascii_lowercase();
372    (!normalized.is_empty()
373        && normalized
374            .as_bytes()
375            .first()
376            .is_some_and(u8::is_ascii_lowercase)
377        && normalized
378            .bytes()
379            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'))
380    .then_some(normalized)
381    .ok_or(SurfaceParseError)
382}
383
384fn encode(value: &str) -> String {
385    URL_SAFE_NO_PAD.encode(value.as_bytes())
386}
387
388fn decode(value: &str) -> Result<String, SurfaceParseError> {
389    let decoded = URL_SAFE_NO_PAD
390        .decode(value)
391        .map_err(|_| SurfaceParseError)?;
392    if URL_SAFE_NO_PAD.encode(&decoded) != value {
393        return Err(SurfaceParseError);
394    }
395    required(String::from_utf8(decoded).map_err(|_| SurfaceParseError)?)
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn one_speaker_has_distinct_user_owned_surfaces() {
404        let alice = ConversationSurface::node("audioBridge", "node", "speaker", "alice").unwrap();
405        let bob = ConversationSurface::node("audioBridge", "node", "speaker", "bob").unwrap();
406        assert_ne!(alice.canonical_id(), bob.canonical_id());
407        assert_eq!(alice.user_id(), Some("alice"));
408        assert!(alice.is_personal());
409        assert!(
410            "cs1:n:audiobridge:bm9kZQ:c3BlYWtlcg"
411                .parse::<ConversationSurface>()
412                .is_err()
413        );
414        assert!(ConversationSurface::node("audioBridge", "node", "speaker", "").is_err());
415    }
416
417    #[test]
418    fn all_surface_variants_round_trip_canonically() {
419        let surfaces = [
420            ConversationSurface::node("audioBridge", "node:1", "speaker:1", "user:1").unwrap(),
421            ConversationSurface::client_personal("user:1").unwrap(),
422            ConversationSurface::client_group("group:1").unwrap(),
423            ConversationSurface::messaging_personal("Telegram", "bot:1", "chat:2", None).unwrap(),
424            ConversationSurface::messaging_group(
425                "feishu",
426                "bot:1",
427                "chat:2",
428                Some("topic:3".to_string()),
429            )
430            .unwrap(),
431        ];
432        for surface in surfaces {
433            let encoded = surface.canonical_id();
434            assert_eq!(encoded.parse::<ConversationSurface>().unwrap(), surface);
435            assert_eq!(
436                serde_json::from_str::<ConversationSurface>(
437                    &serde_json::to_string(&surface).unwrap()
438                )
439                .unwrap(),
440                surface
441            );
442        }
443    }
444
445    #[test]
446    fn surface_kind_and_routes_are_typed() {
447        let personal = ConversationSurface::client_personal("user").unwrap();
448        assert!(personal.is_personal());
449        assert!(personal.is_client());
450        assert_eq!(personal.user_id(), Some("user"));
451
452        let group = ConversationSurface::messaging_group(
453            "telegram",
454            "account",
455            "chat",
456            Some("topic".to_string()),
457        )
458        .unwrap();
459        assert!(group.is_group());
460        let route = group.messaging_route().unwrap();
461        assert_eq!(route.provider, "telegram");
462        assert_eq!(route.lane_id, Some("topic"));
463        assert!(route.group);
464    }
465
466    #[test]
467    fn noncanonical_or_ambiguous_surfaces_are_rejected() {
468        for value in [
469            "meow-link",
470            "cs1:cp:",
471            "cs1:cp:dXNlcg==",
472            "cs1:mp:Telegram:YQ:Yg",
473            "cs1:mg:telegram:YQ:Yg:",
474            "cs2:cp:dXNlcg",
475        ] {
476            assert!(value.parse::<ConversationSurface>().is_err(), "{value}");
477        }
478        assert!(ConversationSurface::messaging_personal("1bad", "a", "b", None).is_err());
479    }
480}