Skip to main content

okbot_core/
id.rs

1//! Identifiers.
2
3use std::fmt;
4use std::str::FromStr;
5
6use serde::{Deserialize, Serialize};
7
8/// An invalid identifier.
9#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
10#[non_exhaustive]
11pub enum IdError {
12    /// The value is empty.
13    #[error("{kind} is empty")]
14    Empty {
15        /// Which identifier.
16        kind: &'static str,
17    },
18    /// The value contains characters outside the allowed set.
19    #[error("{kind} `{value}` may only contain a-z, 0-9, `-` and `_`")]
20    Charset {
21        /// Which identifier.
22        kind: &'static str,
23        /// The rejected value.
24        value: String,
25    },
26    /// A channel id is not `platform:instance`.
27    #[error("channel id `{0}` must look like `platform:instance`, for example `zalo:shop`")]
28    ChannelFormat(String),
29    /// A conversation key is not in its escaped four-part storage format.
30    #[error("invalid conversation key `{0}`")]
31    ConversationFormat(String),
32    /// A `[[notify]]` destination is not one of the accepted forms.
33    #[error("notify target {0}")]
34    NotifyTarget(String),
35}
36
37fn check_slug(kind: &'static str, value: &str) -> Result<(), IdError> {
38    if value.is_empty() {
39        return Err(IdError::Empty { kind });
40    }
41    if !value
42        .bytes()
43        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
44    {
45        return Err(IdError::Charset {
46            kind,
47            value: value.to_string(),
48        });
49    }
50    Ok(())
51}
52
53/// One configured instance of a chat platform, written `platform:instance` (`zalo:shop`).
54///
55/// Both parts are lowercase slugs, so the id is safe in file paths, logs and okbase paths.
56#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
57#[serde(try_from = "String", into = "String")]
58pub struct ChannelId {
59    platform: String,
60    instance: String,
61}
62
63impl ChannelId {
64    /// Builds an id from its parts.
65    pub fn new(platform: &str, instance: &str) -> Result<Self, IdError> {
66        check_slug("platform", platform)?;
67        check_slug("instance", instance)?;
68        Ok(Self {
69            platform: platform.to_string(),
70            instance: instance.to_string(),
71        })
72    }
73
74    /// The platform part (`zalo`).
75    pub fn platform(&self) -> &str {
76        &self.platform
77    }
78
79    /// The instance part (`shop`).
80    pub fn instance(&self) -> &str {
81        &self.instance
82    }
83
84    /// `platform-instance`, for places where `:` is not allowed (okbase paths).
85    pub fn slug(&self) -> String {
86        format!("{}-{}", self.platform, self.instance)
87    }
88}
89
90impl fmt::Display for ChannelId {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        write!(f, "{}:{}", self.platform, self.instance)
93    }
94}
95
96impl FromStr for ChannelId {
97    type Err = IdError;
98
99    fn from_str(s: &str) -> Result<Self, Self::Err> {
100        let (platform, instance) = s
101            .split_once(':')
102            .ok_or_else(|| IdError::ChannelFormat(s.to_string()))?;
103        Self::new(platform, instance)
104    }
105}
106
107impl TryFrom<String> for ChannelId {
108    type Error = IdError;
109
110    fn try_from(value: String) -> Result<Self, Self::Error> {
111        value.parse()
112    }
113}
114
115impl From<ChannelId> for String {
116    fn from(value: ChannelId) -> Self {
117        value.to_string()
118    }
119}
120
121macro_rules! opaque_id {
122    ($(#[$doc:meta])* $name:ident) => {
123        $(#[$doc])*
124        #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
125        #[serde(transparent)]
126        pub struct $name(String);
127
128        impl $name {
129            /// Wraps a platform-provided value.
130            pub fn new(value: impl Into<String>) -> Self {
131                Self(value.into())
132            }
133
134            /// The raw value.
135            pub fn as_str(&self) -> &str {
136                &self.0
137            }
138        }
139
140        impl fmt::Display for $name {
141            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142                f.write_str(&self.0)
143            }
144        }
145
146        impl From<&str> for $name {
147            fn from(value: &str) -> Self {
148                Self(value.to_string())
149            }
150        }
151
152        impl From<String> for $name {
153            fn from(value: String) -> Self {
154                Self(value)
155            }
156        }
157    };
158}
159
160opaque_id!(
161    /// A user id as the platform reports it, unique within one [`ChannelId`].
162    UserId
163);
164opaque_id!(
165    /// A message id as the platform reports it.
166    MessageId
167);
168opaque_id!(
169    /// The id of one agent run, used in logs and the audit table.
170    RunId
171);
172
173/// Who wrote a message.
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175pub struct UserRef {
176    /// Platform user id.
177    pub id: UserId,
178    /// Name to show and to address the user by.
179    pub display_name: String,
180    /// Whether the author is a bot (okbot ignores bots by default).
181    pub is_bot: bool,
182}
183
184/// Identifies one conversation. Messages with the same key are handled in order and share history.
185#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
186pub struct ConversationKey {
187    /// The channel instance.
188    pub channel: ChannelId,
189    /// Workspace, guild or Google Chat space, when the platform has one above rooms.
190    pub space: Option<String>,
191    /// Chat, channel or direct-message id.
192    pub room: String,
193    /// Thread inside the room, when the platform has threads.
194    pub thread: Option<String>,
195}
196
197impl ConversationKey {
198    /// A key for a room without space or thread (Zalo chats, console).
199    pub fn room(channel: ChannelId, room: impl Into<String>) -> Self {
200        Self {
201            channel,
202            space: None,
203            room: room.into(),
204            thread: None,
205        }
206    }
207}
208
209impl FromStr for ConversationKey {
210    type Err = IdError;
211
212    fn from_str(value: &str) -> Result<Self, Self::Err> {
213        let invalid = || IdError::ConversationFormat(value.to_string());
214        let mut parts = Vec::new();
215        let mut part = String::new();
216        let mut chars = value.chars();
217        let mut escaped = false;
218        while let Some(ch) = chars.next() {
219            match ch {
220                '\\' => {
221                    let next = chars.next().ok_or_else(invalid)?;
222                    if !matches!(next, '\\' | '|' | '-') {
223                        return Err(invalid());
224                    }
225                    escaped = true;
226                    part.push(next);
227                }
228                '|' => {
229                    parts.push(if part == "-" && !escaped {
230                        None
231                    } else {
232                        Some(part)
233                    });
234                    part = String::new();
235                    escaped = false;
236                }
237                _ => part.push(ch),
238            }
239        }
240        parts.push(if part == "-" && !escaped {
241            None
242        } else {
243            Some(part)
244        });
245        if parts.len() != 4 {
246            return Err(invalid());
247        }
248        let key = Self {
249            channel: parts[0].as_deref().ok_or_else(invalid)?.parse()?,
250            space: parts[1].clone(),
251            room: parts[2].clone().ok_or_else(invalid)?,
252            thread: parts[3].clone(),
253        };
254        if key.to_string() != value {
255            return Err(invalid());
256        }
257        Ok(key)
258    }
259}
260
261/// Stable text form used as a storage key: parts joined by `|`, with `\` and `|` escaped and `-`
262/// standing for a missing part (a literal `-` is escaped as `\-`).
263impl fmt::Display for ConversationKey {
264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265        fn part(value: Option<&str>) -> String {
266            match value {
267                None => "-".to_string(),
268                Some(v) => {
269                    let escaped = v.replace('\\', "\\\\").replace('|', "\\|");
270                    if escaped == "-" {
271                        "\\-".to_string()
272                    } else {
273                        escaped
274                    }
275                }
276            }
277        }
278        write!(
279            f,
280            "{}|{}|{}|{}",
281            self.channel,
282            part(self.space.as_deref()),
283            part(Some(&self.room)),
284            part(self.thread.as_deref())
285        )
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use proptest::prelude::*;
293
294    #[test]
295    fn channel_id_round_trip() {
296        let id: ChannelId = "zalo:shop".parse().unwrap();
297        assert_eq!(id.platform(), "zalo");
298        assert_eq!(id.instance(), "shop");
299        assert_eq!(id.to_string(), "zalo:shop");
300        assert_eq!(id.slug(), "zalo-shop");
301        let json = serde_json::to_string(&id).unwrap();
302        assert_eq!(json, "\"zalo:shop\"");
303        assert_eq!(serde_json::from_str::<ChannelId>(&json).unwrap(), id);
304    }
305
306    #[test]
307    fn channel_id_rejects_bad_values() {
308        assert!("zalo".parse::<ChannelId>().is_err());
309        assert!("Zalo:shop".parse::<ChannelId>().is_err());
310        assert!("zalo:".parse::<ChannelId>().is_err());
311        assert!("zalo:a/b".parse::<ChannelId>().is_err());
312    }
313
314    #[test]
315    fn conversation_key_text_is_stable() {
316        let channel: ChannelId = "gchat:corp".parse().unwrap();
317        let key = ConversationKey {
318            channel,
319            space: Some("spaces/A".into()),
320            room: "r|1".into(),
321            thread: None,
322        };
323        assert_eq!(key.to_string(), r"gchat:corp|spaces/A|r\|1|-");
324    }
325
326    proptest! {
327        #[test]
328        fn conversation_key_round_trip(
329            space in proptest::option::of(".{0,20}"), room in ".{0,20}",
330            thread in proptest::option::of(".{0,20}"),
331        ) {
332            let key = ConversationKey { channel: "discord:main".parse().unwrap(), space, room, thread };
333            prop_assert_eq!(key.to_string().parse::<ConversationKey>().unwrap(), key);
334        }
335        #[test]
336        fn conversation_key_text_is_injective(
337            a in proptest::option::of(".{0,6}"), b in ".{0,6}", c in proptest::option::of(".{0,6}"),
338            d in proptest::option::of(".{0,6}"), e in ".{0,6}", g in proptest::option::of(".{0,6}"),
339        ) {
340            let channel: ChannelId = "x:y".parse().unwrap();
341            let k1 = ConversationKey { channel: channel.clone(), space: a, room: b, thread: c };
342            let k2 = ConversationKey { channel, space: d, room: e, thread: g };
343            prop_assert_eq!(k1 == k2, k1.to_string() == k2.to_string());
344        }
345    }
346
347    #[test]
348    fn conversation_parser_checks_canonical_escaping() {
349        for value in ["discord:main|-|a\\|b|\\-", "discord:main|\\-|\\\\|-"] {
350            assert_eq!(value.parse::<ConversationKey>().unwrap().to_string(), value);
351        }
352        for value in [
353            "bad",
354            "discord:main|-|-|-",
355            "discord:main|-|x|dangling\\",
356            "discord:main|-|a\\q|-",
357        ] {
358            assert!(value.parse::<ConversationKey>().is_err(), "{value}");
359        }
360    }
361}