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#[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 #[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}