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