Skip to main content

core_api/
recipient.rs

1//! Stable recipient identities, independent of placement and live connections.
2//! Conversation surfaces identify Agent conversations; these IDs address recipients.
3use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
4use conversation_api::ConversationSurface;
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6use std::{fmt, str::FromStr};
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash)]
9pub struct RecipientId(String);
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct RecipientIdError;
13
14impl fmt::Display for RecipientIdError {
15    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
16        f.write_str("invalid canonical recipient identity")
17    }
18}
19impl std::error::Error for RecipientIdError {}
20
21impl RecipientId {
22    /// Convert a validated Messaging surface without losing audience or lane.
23    pub fn messaging(surface: &ConversationSurface) -> Result<Self, RecipientIdError> {
24        let surface_id = surface.canonical_id();
25        let id = if let Some(address) = surface_id.strip_prefix("cs1:mp:") {
26            format!("m:p:{address}")
27        } else if let Some(address) = surface_id.strip_prefix("cs1:mg:") {
28            format!("m:g:{address}")
29        } else {
30            return Err(RecipientIdError);
31        };
32        id.parse()
33    }
34
35    pub fn node(node_type: &str, node_id: &str) -> Result<Self, RecipientIdError> {
36        if !valid_kind(node_type) || !valid_segment(node_id) {
37            return Err(RecipientIdError);
38        }
39        Ok(Self(format!(
40            "n:{node_type}:{}",
41            URL_SAFE_NO_PAD.encode(node_id)
42        )))
43    }
44
45    pub fn as_str(&self) -> &str {
46        &self.0
47    }
48
49    pub fn messaging_surface(&self) -> Option<ConversationSurface> {
50        surface_id(&self.0)?.parse().ok()
51    }
52
53    /// The base authorization route excludes a provider topic/lane.
54    /// This does not change the recipient used for the actual delivery.
55    pub fn messaging_base(&self) -> Option<Self> {
56        let surface = match self.messaging_surface()? {
57            ConversationSurface::MessagingPersonal {
58                provider,
59                account_id,
60                conversation_id,
61                ..
62            } => {
63                ConversationSurface::messaging_personal(provider, account_id, conversation_id, None)
64                    .ok()?
65            }
66            ConversationSurface::MessagingGroup {
67                provider,
68                account_id,
69                conversation_id,
70                ..
71            } => ConversationSurface::messaging_group(provider, account_id, conversation_id, None)
72                .ok()?,
73            _ => return None,
74        };
75        Self::messaging(&surface).ok()
76    }
77
78    pub fn node_address(&self) -> Option<(&str, String)> {
79        let tail = self.0.strip_prefix("n:")?;
80        let (kind, encoded) = tail.split_once(':')?;
81        Some((kind, decode(encoded).ok()?))
82    }
83}
84
85fn surface_id(id: &str) -> Option<String> {
86    if let Some(address) = id.strip_prefix("m:p:") {
87        Some(format!("cs1:mp:{address}"))
88    } else {
89        id.strip_prefix("m:g:")
90            .map(|address| format!("cs1:mg:{address}"))
91    }
92}
93
94fn valid_kind(value: &str) -> bool {
95    let mut bytes = value.bytes();
96    bytes.next().is_some_and(|first| first.is_ascii_lowercase())
97        && bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
98}
99fn valid_segment(value: &str) -> bool {
100    !value.is_empty() && value.trim() == value
101}
102fn decode(encoded: &str) -> Result<String, RecipientIdError> {
103    let value = String::from_utf8(
104        URL_SAFE_NO_PAD
105            .decode(encoded)
106            .map_err(|_| RecipientIdError)?,
107    )
108    .map_err(|_| RecipientIdError)?;
109    if !valid_segment(&value) || URL_SAFE_NO_PAD.encode(&value) != encoded {
110        return Err(RecipientIdError);
111    }
112    Ok(value)
113}
114
115impl FromStr for RecipientId {
116    type Err = RecipientIdError;
117    fn from_str(id: &str) -> Result<Self, Self::Err> {
118        if let Some(surface_id) = surface_id(id) {
119            let surface: ConversationSurface = surface_id.parse().map_err(|_| RecipientIdError)?;
120            if surface.canonical_id() != surface_id {
121                return Err(RecipientIdError);
122            }
123        } else if let Some(tail) = id.strip_prefix("n:") {
124            let (kind, encoded) = tail.split_once(':').ok_or(RecipientIdError)?;
125            if !valid_kind(kind) {
126                return Err(RecipientIdError);
127            }
128            decode(encoded)?;
129        } else {
130            return Err(RecipientIdError);
131        }
132        Ok(Self(id.to_owned()))
133    }
134}
135impl fmt::Display for RecipientId {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        f.write_str(&self.0)
138    }
139}
140impl Serialize for RecipientId {
141    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
142        serializer.serialize_str(&self.0)
143    }
144}
145impl<'de> Deserialize<'de> for RecipientId {
146    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
147        String::deserialize(deserializer)?
148            .parse()
149            .map_err(serde::de::Error::custom)
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    #[test]
157    fn shared_conformance_vectors() {
158        let fixtures: serde_json::Value =
159            serde_json::from_str(include_str!("../fixtures/recipient-id.conformance.json"))
160                .unwrap();
161        for case in fixtures["valid"].as_array().unwrap() {
162            let id: RecipientId = case["id"].as_str().unwrap().parse().unwrap();
163            assert_eq!(serde_json::to_value(&id).unwrap(), case["id"]);
164            if let Some(surface) = case.get("surface") {
165                let parsed: ConversationSurface = surface.as_str().unwrap().parse().unwrap();
166                assert_eq!(RecipientId::messaging(&parsed).unwrap(), id);
167                assert_eq!(id.messaging_surface().unwrap(), parsed);
168            } else {
169                let (kind, node) = id.node_address().unwrap();
170                assert_eq!(kind, case["nodeType"].as_str().unwrap());
171                assert_eq!(node, case["nodeId"].as_str().unwrap());
172                assert_eq!(RecipientId::node(kind, &node).unwrap(), id);
173            }
174        }
175        for case in fixtures["invalid"].as_array().unwrap() {
176            assert!(
177                case.as_str().unwrap().parse::<RecipientId>().is_err(),
178                "{case}"
179            );
180        }
181    }
182    #[test]
183    fn lane_delivery_and_base_authorization_are_distinct() {
184        let id: RecipientId = "m:g:telegram:Ym90:Y2hhdA:dG9waWM".parse().unwrap();
185        assert_eq!(
186            id.messaging_base().unwrap().as_str(),
187            "m:g:telegram:Ym90:Y2hhdA"
188        );
189        assert_eq!(id.as_str(), "m:g:telegram:Ym90:Y2hhdA:dG9waWM");
190    }
191}