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