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 ClientPersonal {
17 user_id: String,
18 },
19 ClientGroup {
20 group_id: String,
21 },
22 MessagingPersonal {
23 provider: String,
24 account_id: String,
25 conversation_id: String,
26 lane_id: Option<String>,
27 },
28 MessagingGroup {
29 provider: String,
30 account_id: String,
31 conversation_id: String,
32 lane_id: Option<String>,
33 },
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct SurfaceParseError;
38
39impl fmt::Display for SurfaceParseError {
40 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
41 formatter.write_str("invalid canonical Conversation surface")
42 }
43}
44
45impl std::error::Error for SurfaceParseError {}
46
47impl ConversationSurface {
48 pub fn client_personal(user_id: impl Into<String>) -> Result<Self, SurfaceParseError> {
49 Ok(Self::ClientPersonal {
50 user_id: required(user_id.into())?,
51 })
52 }
53
54 pub fn client_group(group_id: impl Into<String>) -> Result<Self, SurfaceParseError> {
55 Ok(Self::ClientGroup {
56 group_id: required(group_id.into())?,
57 })
58 }
59
60 pub fn messaging_personal(
61 provider: impl Into<String>,
62 account_id: impl Into<String>,
63 conversation_id: impl Into<String>,
64 lane_id: Option<String>,
65 ) -> Result<Self, SurfaceParseError> {
66 Ok(Self::MessagingPersonal {
67 provider: normalize_provider(provider.into())?,
68 account_id: required(account_id.into())?,
69 conversation_id: required(conversation_id.into())?,
70 lane_id: optional(lane_id)?,
71 })
72 }
73
74 pub fn messaging_group(
75 provider: impl Into<String>,
76 account_id: impl Into<String>,
77 conversation_id: impl Into<String>,
78 lane_id: Option<String>,
79 ) -> Result<Self, SurfaceParseError> {
80 Ok(Self::MessagingGroup {
81 provider: normalize_provider(provider.into())?,
82 account_id: required(account_id.into())?,
83 conversation_id: required(conversation_id.into())?,
84 lane_id: optional(lane_id)?,
85 })
86 }
87
88 #[must_use]
89 pub fn canonical_id(&self) -> String {
90 match self {
91 Self::ClientPersonal { user_id } => {
92 format!("{SURFACE_VERSION}:cp:{}", encode(user_id))
93 }
94 Self::ClientGroup { group_id } => {
95 format!("{SURFACE_VERSION}:cg:{}", encode(group_id))
96 }
97 Self::MessagingPersonal {
98 provider,
99 account_id,
100 conversation_id,
101 lane_id,
102 } => messaging_id(
103 "mp",
104 provider,
105 account_id,
106 conversation_id,
107 lane_id.as_deref(),
108 ),
109 Self::MessagingGroup {
110 provider,
111 account_id,
112 conversation_id,
113 lane_id,
114 } => messaging_id(
115 "mg",
116 provider,
117 account_id,
118 conversation_id,
119 lane_id.as_deref(),
120 ),
121 }
122 }
123
124 #[must_use]
125 pub fn is_personal(&self) -> bool {
126 matches!(
127 self,
128 Self::ClientPersonal { .. } | Self::MessagingPersonal { .. }
129 )
130 }
131
132 #[must_use]
133 pub fn is_group(&self) -> bool {
134 !self.is_personal()
135 }
136
137 #[must_use]
138 pub fn is_client(&self) -> bool {
139 matches!(self, Self::ClientPersonal { .. } | Self::ClientGroup { .. })
140 }
141
142 #[must_use]
143 pub fn is_messaging(&self) -> bool {
144 !self.is_client()
145 }
146
147 #[must_use]
148 pub fn user_id(&self) -> Option<&str> {
149 match self {
150 Self::ClientPersonal { user_id } => Some(user_id),
151 _ => None,
152 }
153 }
154
155 #[must_use]
156 pub fn group_id(&self) -> Option<&str> {
157 match self {
158 Self::ClientGroup { group_id } => Some(group_id),
159 _ => None,
160 }
161 }
162
163 #[must_use]
164 pub fn messaging_route(&self) -> Option<MessagingSurfaceRoute<'_>> {
165 match self {
166 Self::MessagingPersonal {
167 provider,
168 account_id,
169 conversation_id,
170 lane_id,
171 }
172 | Self::MessagingGroup {
173 provider,
174 account_id,
175 conversation_id,
176 lane_id,
177 } => Some(MessagingSurfaceRoute {
178 provider,
179 account_id,
180 conversation_id,
181 lane_id: lane_id.as_deref(),
182 group: self.is_group(),
183 }),
184 _ => None,
185 }
186 }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub struct MessagingSurfaceRoute<'a> {
191 pub provider: &'a str,
192 pub account_id: &'a str,
193 pub conversation_id: &'a str,
194 pub lane_id: Option<&'a str>,
195 pub group: bool,
196}
197
198impl fmt::Display for ConversationSurface {
199 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
200 formatter.write_str(&self.canonical_id())
201 }
202}
203
204impl FromStr for ConversationSurface {
205 type Err = SurfaceParseError;
206
207 fn from_str(value: &str) -> Result<Self, Self::Err> {
208 let parts = value.split(':').collect::<Vec<_>>();
209 if parts.first().copied() != Some(SURFACE_VERSION) {
210 return Err(SurfaceParseError);
211 }
212 let surface = match parts.as_slice() {
213 [_, "cp", user_id] => Self::client_personal(decode(user_id)?),
214 [_, "cg", group_id] => Self::client_group(decode(group_id)?),
215 [_, kind @ ("mp" | "mg"), provider, account_id, conversation_id] => messaging(
216 kind,
217 provider,
218 decode(account_id)?,
219 decode(conversation_id)?,
220 None,
221 ),
222 [_, kind @ ("mp" | "mg"), provider, account_id, conversation_id, lane_id] => messaging(
223 kind,
224 provider,
225 decode(account_id)?,
226 decode(conversation_id)?,
227 Some(decode(lane_id)?),
228 ),
229 _ => Err(SurfaceParseError),
230 }?;
231 (surface.canonical_id() == value)
232 .then_some(surface)
233 .ok_or(SurfaceParseError)
234 }
235}
236
237impl Serialize for ConversationSurface {
238 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
239 where
240 S: Serializer,
241 {
242 serializer.serialize_str(&self.canonical_id())
243 }
244}
245
246impl<'de> Deserialize<'de> for ConversationSurface {
247 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
248 where
249 D: Deserializer<'de>,
250 {
251 let value = String::deserialize(deserializer)?;
252 value.parse().map_err(serde::de::Error::custom)
253 }
254}
255
256fn messaging(
257 kind: &str,
258 provider: &str,
259 account_id: String,
260 conversation_id: String,
261 lane_id: Option<String>,
262) -> Result<ConversationSurface, SurfaceParseError> {
263 match kind {
264 "mp" => {
265 ConversationSurface::messaging_personal(provider, account_id, conversation_id, lane_id)
266 }
267 "mg" => {
268 ConversationSurface::messaging_group(provider, account_id, conversation_id, lane_id)
269 }
270 _ => Err(SurfaceParseError),
271 }
272}
273
274fn messaging_id(
275 kind: &str,
276 provider: &str,
277 account_id: &str,
278 conversation_id: &str,
279 lane_id: Option<&str>,
280) -> String {
281 let mut value = format!(
282 "{SURFACE_VERSION}:{kind}:{provider}:{}:{}",
283 encode(account_id),
284 encode(conversation_id)
285 );
286 if let Some(lane_id) = lane_id {
287 value.push(':');
288 value.push_str(&encode(lane_id));
289 }
290 value
291}
292
293fn required(value: String) -> Result<String, SurfaceParseError> {
294 let trimmed = value.trim();
295 (!trimmed.is_empty() && trimmed == value)
296 .then_some(value)
297 .ok_or(SurfaceParseError)
298}
299
300fn optional(value: Option<String>) -> Result<Option<String>, SurfaceParseError> {
301 value.map(required).transpose()
302}
303
304fn normalize_provider(value: String) -> Result<String, SurfaceParseError> {
305 let normalized = value.trim().to_ascii_lowercase();
306 (!normalized.is_empty()
307 && normalized
308 .bytes()
309 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'))
310 .then_some(normalized)
311 .ok_or(SurfaceParseError)
312}
313
314fn encode(value: &str) -> String {
315 URL_SAFE_NO_PAD.encode(value.as_bytes())
316}
317
318fn decode(value: &str) -> Result<String, SurfaceParseError> {
319 let decoded = URL_SAFE_NO_PAD
320 .decode(value)
321 .map_err(|_| SurfaceParseError)?;
322 if URL_SAFE_NO_PAD.encode(&decoded) != value {
323 return Err(SurfaceParseError);
324 }
325 required(String::from_utf8(decoded).map_err(|_| SurfaceParseError)?)
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
333 fn all_surface_variants_round_trip_canonically() {
334 let surfaces = [
335 ConversationSurface::client_personal("user:1").unwrap(),
336 ConversationSurface::client_group("group:1").unwrap(),
337 ConversationSurface::messaging_personal("Telegram", "bot:1", "chat:2", None).unwrap(),
338 ConversationSurface::messaging_group(
339 "feishu",
340 "bot:1",
341 "chat:2",
342 Some("topic:3".to_string()),
343 )
344 .unwrap(),
345 ];
346 for surface in surfaces {
347 let encoded = surface.canonical_id();
348 assert_eq!(encoded.parse::<ConversationSurface>().unwrap(), surface);
349 assert_eq!(
350 serde_json::from_str::<ConversationSurface>(
351 &serde_json::to_string(&surface).unwrap()
352 )
353 .unwrap(),
354 surface
355 );
356 }
357 }
358
359 #[test]
360 fn surface_kind_and_routes_are_typed() {
361 let personal = ConversationSurface::client_personal("user").unwrap();
362 assert!(personal.is_personal());
363 assert!(personal.is_client());
364 assert_eq!(personal.user_id(), Some("user"));
365
366 let group = ConversationSurface::messaging_group(
367 "telegram",
368 "account",
369 "chat",
370 Some("topic".to_string()),
371 )
372 .unwrap();
373 assert!(group.is_group());
374 let route = group.messaging_route().unwrap();
375 assert_eq!(route.provider, "telegram");
376 assert_eq!(route.lane_id, Some("topic"));
377 assert!(route.group);
378 }
379
380 #[test]
381 fn noncanonical_or_ambiguous_surfaces_are_rejected() {
382 for value in [
383 "meow-link",
384 "cs1:cp:",
385 "cs1:cp:dXNlcg==",
386 "cs1:mp:Telegram:YQ:Yg",
387 "cs1:mg:telegram:YQ:Yg:",
388 "cs2:cp:dXNlcg",
389 ] {
390 assert!(value.parse::<ConversationSurface>().is_err(), "{value}");
391 }
392 }
393}