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