Skip to main content

conversation_api/
surface.rs

1use base64::engine::general_purpose::URL_SAFE_NO_PAD;
2use base64::Engine;
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            [_, kind @ ("mp" | "mg"), provider, account_id, conversation_id] => messaging(
255                kind,
256                provider,
257                decode(account_id)?,
258                decode(conversation_id)?,
259                None,
260            ),
261            [_, kind @ ("mp" | "mg"), provider, account_id, conversation_id, lane_id] => messaging(
262                kind,
263                provider,
264                decode(account_id)?,
265                decode(conversation_id)?,
266                Some(decode(lane_id)?),
267            ),
268            _ => Err(SurfaceParseError),
269        }?;
270        (surface.canonical_id() == value)
271            .then_some(surface)
272            .ok_or(SurfaceParseError)
273    }
274}
275
276impl Serialize for ConversationSurface {
277    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
278    where
279        S: Serializer,
280    {
281        serializer.serialize_str(&self.canonical_id())
282    }
283}
284
285impl<'de> Deserialize<'de> for ConversationSurface {
286    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
287    where
288        D: Deserializer<'de>,
289    {
290        let value = String::deserialize(deserializer)?;
291        value.parse().map_err(serde::de::Error::custom)
292    }
293}
294
295fn messaging(
296    kind: &str,
297    provider: &str,
298    account_id: String,
299    conversation_id: String,
300    lane_id: Option<String>,
301) -> Result<ConversationSurface, SurfaceParseError> {
302    match kind {
303        "mp" => {
304            ConversationSurface::messaging_personal(provider, account_id, conversation_id, lane_id)
305        }
306        "mg" => {
307            ConversationSurface::messaging_group(provider, account_id, conversation_id, lane_id)
308        }
309        _ => Err(SurfaceParseError),
310    }
311}
312
313fn messaging_id(
314    kind: &str,
315    provider: &str,
316    account_id: &str,
317    conversation_id: &str,
318    lane_id: Option<&str>,
319) -> String {
320    let mut value = format!(
321        "{SURFACE_VERSION}:{kind}:{provider}:{}:{}",
322        encode(account_id),
323        encode(conversation_id)
324    );
325    if let Some(lane_id) = lane_id {
326        value.push(':');
327        value.push_str(&encode(lane_id));
328    }
329    value
330}
331
332fn required(value: String) -> Result<String, SurfaceParseError> {
333    let trimmed = value.trim();
334    (!trimmed.is_empty() && trimmed == value)
335        .then_some(value)
336        .ok_or(SurfaceParseError)
337}
338
339fn optional(value: Option<String>) -> Result<Option<String>, SurfaceParseError> {
340    value.map(required).transpose()
341}
342
343fn normalize_provider(value: String) -> Result<String, SurfaceParseError> {
344    let normalized = value.trim().to_ascii_lowercase();
345    (!normalized.is_empty()
346        && normalized
347            .as_bytes()
348            .first()
349            .is_some_and(u8::is_ascii_lowercase)
350        && normalized
351            .bytes()
352            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'))
353    .then_some(normalized)
354    .ok_or(SurfaceParseError)
355}
356
357fn encode(value: &str) -> String {
358    URL_SAFE_NO_PAD.encode(value.as_bytes())
359}
360
361fn decode(value: &str) -> Result<String, SurfaceParseError> {
362    let decoded = URL_SAFE_NO_PAD
363        .decode(value)
364        .map_err(|_| SurfaceParseError)?;
365    if URL_SAFE_NO_PAD.encode(&decoded) != value {
366        return Err(SurfaceParseError);
367    }
368    required(String::from_utf8(decoded).map_err(|_| SurfaceParseError)?)
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn one_speaker_has_distinct_user_owned_surfaces() {
377        let alice = ConversationSurface::node("audioBridge", "node", "speaker", "alice").unwrap();
378        let bob = ConversationSurface::node("audioBridge", "node", "speaker", "bob").unwrap();
379        assert_ne!(alice.canonical_id(), bob.canonical_id());
380        assert_eq!(alice.user_id(), Some("alice"));
381        assert!(alice.is_personal());
382        assert!("cs1:n:audiobridge:bm9kZQ:c3BlYWtlcg"
383            .parse::<ConversationSurface>()
384            .is_err());
385        assert!(ConversationSurface::node("audioBridge", "node", "speaker", "").is_err());
386    }
387
388    #[test]
389    fn all_surface_variants_round_trip_canonically() {
390        let surfaces = [
391            ConversationSurface::node("audioBridge", "node:1", "speaker:1", "user:1").unwrap(),
392            ConversationSurface::client_personal("user:1").unwrap(),
393            ConversationSurface::client_group("group:1").unwrap(),
394            ConversationSurface::messaging_personal("Telegram", "bot:1", "chat:2", None).unwrap(),
395            ConversationSurface::messaging_group(
396                "feishu",
397                "bot:1",
398                "chat:2",
399                Some("topic:3".to_string()),
400            )
401            .unwrap(),
402        ];
403        for surface in surfaces {
404            let encoded = surface.canonical_id();
405            assert_eq!(encoded.parse::<ConversationSurface>().unwrap(), surface);
406            assert_eq!(
407                serde_json::from_str::<ConversationSurface>(
408                    &serde_json::to_string(&surface).unwrap()
409                )
410                .unwrap(),
411                surface
412            );
413        }
414    }
415
416    #[test]
417    fn surface_kind_and_routes_are_typed() {
418        let personal = ConversationSurface::client_personal("user").unwrap();
419        assert!(personal.is_personal());
420        assert!(personal.is_client());
421        assert_eq!(personal.user_id(), Some("user"));
422
423        let group = ConversationSurface::messaging_group(
424            "telegram",
425            "account",
426            "chat",
427            Some("topic".to_string()),
428        )
429        .unwrap();
430        assert!(group.is_group());
431        let route = group.messaging_route().unwrap();
432        assert_eq!(route.provider, "telegram");
433        assert_eq!(route.lane_id, Some("topic"));
434        assert!(route.group);
435    }
436
437    #[test]
438    fn noncanonical_or_ambiguous_surfaces_are_rejected() {
439        for value in [
440            "meow-link",
441            "cs1:cp:",
442            "cs1:cp:dXNlcg==",
443            "cs1:mp:Telegram:YQ:Yg",
444            "cs1:mg:telegram:YQ:Yg:",
445            "cs2:cp:dXNlcg",
446        ] {
447            assert!(value.parse::<ConversationSurface>().is_err(), "{value}");
448        }
449        assert!(ConversationSurface::messaging_personal("1bad", "a", "b", None).is_err());
450    }
451}