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    pub fn messaging_personal(
80        provider: impl Into<String>,
81        account_id: impl Into<String>,
82        conversation_id: impl Into<String>,
83        lane_id: Option<String>,
84    ) -> Result<Self, SurfaceParseError> {
85        Ok(Self::MessagingPersonal {
86            provider: normalize_provider(provider.into())?,
87            account_id: required(account_id.into())?,
88            conversation_id: required(conversation_id.into())?,
89            lane_id: optional(lane_id)?,
90        })
91    }
92
93    pub fn messaging_group(
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::MessagingGroup {
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    #[must_use]
108    pub fn canonical_id(&self) -> String {
109        match self {
110            Self::Node {
111                node_type,
112                node_id,
113                endpoint_id,
114                user_id,
115            } => format!(
116                "{SURFACE_VERSION}:n:{node_type}:{}:{}:{}",
117                encode(node_id),
118                encode(endpoint_id),
119                encode(user_id)
120            ),
121            Self::ClientPersonal { user_id } => {
122                format!("{SURFACE_VERSION}:cp:{}", encode(user_id))
123            }
124            Self::ClientGroup { group_id } => {
125                format!("{SURFACE_VERSION}:cg:{}", encode(group_id))
126            }
127            Self::MessagingPersonal {
128                provider,
129                account_id,
130                conversation_id,
131                lane_id,
132            } => messaging_id(
133                "mp",
134                provider,
135                account_id,
136                conversation_id,
137                lane_id.as_deref(),
138            ),
139            Self::MessagingGroup {
140                provider,
141                account_id,
142                conversation_id,
143                lane_id,
144            } => messaging_id(
145                "mg",
146                provider,
147                account_id,
148                conversation_id,
149                lane_id.as_deref(),
150            ),
151        }
152    }
153
154    #[must_use]
155    pub fn is_personal(&self) -> bool {
156        matches!(
157            self,
158            Self::ClientPersonal { .. } | Self::MessagingPersonal { .. } | Self::Node { .. }
159        )
160    }
161
162    #[must_use]
163    pub fn is_group(&self) -> bool {
164        matches!(self, Self::ClientGroup { .. } | Self::MessagingGroup { .. })
165    }
166
167    #[must_use]
168    pub fn is_client(&self) -> bool {
169        matches!(self, Self::ClientPersonal { .. } | Self::ClientGroup { .. })
170    }
171
172    #[must_use]
173    pub fn is_messaging(&self) -> bool {
174        matches!(
175            self,
176            Self::MessagingPersonal { .. } | Self::MessagingGroup { .. }
177        )
178    }
179
180    #[must_use]
181    pub fn user_id(&self) -> Option<&str> {
182        match self {
183            Self::ClientPersonal { user_id } | Self::Node { user_id, .. } => Some(user_id),
184            _ => None,
185        }
186    }
187
188    #[must_use]
189    pub fn group_id(&self) -> Option<&str> {
190        match self {
191            Self::ClientGroup { group_id } => Some(group_id),
192            _ => None,
193        }
194    }
195
196    #[must_use]
197    pub fn messaging_route(&self) -> Option<MessagingSurfaceRoute<'_>> {
198        match self {
199            Self::MessagingPersonal {
200                provider,
201                account_id,
202                conversation_id,
203                lane_id,
204            }
205            | Self::MessagingGroup {
206                provider,
207                account_id,
208                conversation_id,
209                lane_id,
210            } => Some(MessagingSurfaceRoute {
211                provider,
212                account_id,
213                conversation_id,
214                lane_id: lane_id.as_deref(),
215                group: self.is_group(),
216            }),
217            _ => None,
218        }
219    }
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub struct MessagingSurfaceRoute<'a> {
224    pub provider: &'a str,
225    pub account_id: &'a str,
226    pub conversation_id: &'a str,
227    pub lane_id: Option<&'a str>,
228    pub group: bool,
229}
230
231impl fmt::Display for ConversationSurface {
232    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
233        formatter.write_str(&self.canonical_id())
234    }
235}
236
237impl FromStr for ConversationSurface {
238    type Err = SurfaceParseError;
239
240    fn from_str(value: &str) -> Result<Self, Self::Err> {
241        let parts = value.split(':').collect::<Vec<_>>();
242        if parts.first().copied() != Some(SURFACE_VERSION) {
243            return Err(SurfaceParseError);
244        }
245        let surface = match parts.as_slice() {
246            [_, "n", node_type, node_id, endpoint_id, user_id] => Self::node(
247                *node_type,
248                decode(node_id)?,
249                decode(endpoint_id)?,
250                decode(user_id)?,
251            ),
252            [_, "cp", user_id] => Self::client_personal(decode(user_id)?),
253            [_, "cg", group_id] => Self::client_group(decode(group_id)?),
254            [
255                _,
256                kind @ ("mp" | "mg"),
257                provider,
258                account_id,
259                conversation_id,
260            ] => messaging(
261                kind,
262                provider,
263                decode(account_id)?,
264                decode(conversation_id)?,
265                None,
266            ),
267            [
268                _,
269                kind @ ("mp" | "mg"),
270                provider,
271                account_id,
272                conversation_id,
273                lane_id,
274            ] => messaging(
275                kind,
276                provider,
277                decode(account_id)?,
278                decode(conversation_id)?,
279                Some(decode(lane_id)?),
280            ),
281            _ => Err(SurfaceParseError),
282        }?;
283        (surface.canonical_id() == value)
284            .then_some(surface)
285            .ok_or(SurfaceParseError)
286    }
287}
288
289impl Serialize for ConversationSurface {
290    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
291    where
292        S: Serializer,
293    {
294        serializer.serialize_str(&self.canonical_id())
295    }
296}
297
298impl<'de> Deserialize<'de> for ConversationSurface {
299    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
300    where
301        D: Deserializer<'de>,
302    {
303        let value = String::deserialize(deserializer)?;
304        value.parse().map_err(serde::de::Error::custom)
305    }
306}
307
308fn messaging(
309    kind: &str,
310    provider: &str,
311    account_id: String,
312    conversation_id: String,
313    lane_id: Option<String>,
314) -> Result<ConversationSurface, SurfaceParseError> {
315    match kind {
316        "mp" => {
317            ConversationSurface::messaging_personal(provider, account_id, conversation_id, lane_id)
318        }
319        "mg" => {
320            ConversationSurface::messaging_group(provider, account_id, conversation_id, lane_id)
321        }
322        _ => Err(SurfaceParseError),
323    }
324}
325
326fn messaging_id(
327    kind: &str,
328    provider: &str,
329    account_id: &str,
330    conversation_id: &str,
331    lane_id: Option<&str>,
332) -> String {
333    let mut value = format!(
334        "{SURFACE_VERSION}:{kind}:{provider}:{}:{}",
335        encode(account_id),
336        encode(conversation_id)
337    );
338    if let Some(lane_id) = lane_id {
339        value.push(':');
340        value.push_str(&encode(lane_id));
341    }
342    value
343}
344
345fn required(value: String) -> Result<String, SurfaceParseError> {
346    let trimmed = value.trim();
347    (!trimmed.is_empty() && trimmed == value)
348        .then_some(value)
349        .ok_or(SurfaceParseError)
350}
351
352fn optional(value: Option<String>) -> Result<Option<String>, SurfaceParseError> {
353    value.map(required).transpose()
354}
355
356fn normalize_provider(value: String) -> Result<String, SurfaceParseError> {
357    let normalized = value.trim().to_ascii_lowercase();
358    (!normalized.is_empty()
359        && normalized
360            .as_bytes()
361            .first()
362            .is_some_and(u8::is_ascii_lowercase)
363        && normalized
364            .bytes()
365            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'))
366    .then_some(normalized)
367    .ok_or(SurfaceParseError)
368}
369
370fn encode(value: &str) -> String {
371    URL_SAFE_NO_PAD.encode(value.as_bytes())
372}
373
374fn decode(value: &str) -> Result<String, SurfaceParseError> {
375    let decoded = URL_SAFE_NO_PAD
376        .decode(value)
377        .map_err(|_| SurfaceParseError)?;
378    if URL_SAFE_NO_PAD.encode(&decoded) != value {
379        return Err(SurfaceParseError);
380    }
381    required(String::from_utf8(decoded).map_err(|_| SurfaceParseError)?)
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn one_speaker_has_distinct_user_owned_surfaces() {
390        let alice = ConversationSurface::node("audioBridge", "node", "speaker", "alice").unwrap();
391        let bob = ConversationSurface::node("audioBridge", "node", "speaker", "bob").unwrap();
392        assert_ne!(alice.canonical_id(), bob.canonical_id());
393        assert_eq!(alice.user_id(), Some("alice"));
394        assert!(alice.is_personal());
395        assert!(
396            "cs1:n:audiobridge:bm9kZQ:c3BlYWtlcg"
397                .parse::<ConversationSurface>()
398                .is_err()
399        );
400        assert!(ConversationSurface::node("audioBridge", "node", "speaker", "").is_err());
401    }
402
403    #[test]
404    fn all_surface_variants_round_trip_canonically() {
405        let surfaces = [
406            ConversationSurface::node("audioBridge", "node:1", "speaker:1", "user:1").unwrap(),
407            ConversationSurface::client_personal("user:1").unwrap(),
408            ConversationSurface::client_group("group:1").unwrap(),
409            ConversationSurface::messaging_personal("Telegram", "bot:1", "chat:2", None).unwrap(),
410            ConversationSurface::messaging_group(
411                "feishu",
412                "bot:1",
413                "chat:2",
414                Some("topic:3".to_string()),
415            )
416            .unwrap(),
417        ];
418        for surface in surfaces {
419            let encoded = surface.canonical_id();
420            assert_eq!(encoded.parse::<ConversationSurface>().unwrap(), surface);
421            assert_eq!(
422                serde_json::from_str::<ConversationSurface>(
423                    &serde_json::to_string(&surface).unwrap()
424                )
425                .unwrap(),
426                surface
427            );
428        }
429    }
430
431    #[test]
432    fn surface_kind_and_routes_are_typed() {
433        let personal = ConversationSurface::client_personal("user").unwrap();
434        assert!(personal.is_personal());
435        assert!(personal.is_client());
436        assert_eq!(personal.user_id(), Some("user"));
437
438        let group = ConversationSurface::messaging_group(
439            "telegram",
440            "account",
441            "chat",
442            Some("topic".to_string()),
443        )
444        .unwrap();
445        assert!(group.is_group());
446        let route = group.messaging_route().unwrap();
447        assert_eq!(route.provider, "telegram");
448        assert_eq!(route.lane_id, Some("topic"));
449        assert!(route.group);
450    }
451
452    #[test]
453    fn noncanonical_or_ambiguous_surfaces_are_rejected() {
454        for value in [
455            "meow-link",
456            "cs1:cp:",
457            "cs1:cp:dXNlcg==",
458            "cs1:mp:Telegram:YQ:Yg",
459            "cs1:mg:telegram:YQ:Yg:",
460            "cs2:cp:dXNlcg",
461        ] {
462            assert!(value.parse::<ConversationSurface>().is_err(), "{value}");
463        }
464        assert!(ConversationSurface::messaging_personal("1bad", "a", "b", None).is_err());
465    }
466}