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}
30
31fn check_slug(kind: &'static str, value: &str) -> Result<(), IdError> {
32    if value.is_empty() {
33        return Err(IdError::Empty { kind });
34    }
35    if !value
36        .bytes()
37        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
38    {
39        return Err(IdError::Charset {
40            kind,
41            value: value.to_string(),
42        });
43    }
44    Ok(())
45}
46
47/// One configured instance of a chat platform, written `platform:instance` (`zalo:shop`).
48///
49/// Both parts are lowercase slugs, so the id is safe in file paths, logs and okbase paths.
50#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
51#[serde(try_from = "String", into = "String")]
52pub struct ChannelId {
53    platform: String,
54    instance: String,
55}
56
57impl ChannelId {
58    /// Builds an id from its parts.
59    pub fn new(platform: &str, instance: &str) -> Result<Self, IdError> {
60        check_slug("platform", platform)?;
61        check_slug("instance", instance)?;
62        Ok(Self {
63            platform: platform.to_string(),
64            instance: instance.to_string(),
65        })
66    }
67
68    /// The platform part (`zalo`).
69    pub fn platform(&self) -> &str {
70        &self.platform
71    }
72
73    /// The instance part (`shop`).
74    pub fn instance(&self) -> &str {
75        &self.instance
76    }
77
78    /// `platform-instance`, for places where `:` is not allowed (okbase paths).
79    pub fn slug(&self) -> String {
80        format!("{}-{}", self.platform, self.instance)
81    }
82}
83
84impl fmt::Display for ChannelId {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        write!(f, "{}:{}", self.platform, self.instance)
87    }
88}
89
90impl FromStr for ChannelId {
91    type Err = IdError;
92
93    fn from_str(s: &str) -> Result<Self, Self::Err> {
94        let (platform, instance) = s
95            .split_once(':')
96            .ok_or_else(|| IdError::ChannelFormat(s.to_string()))?;
97        Self::new(platform, instance)
98    }
99}
100
101impl TryFrom<String> for ChannelId {
102    type Error = IdError;
103
104    fn try_from(value: String) -> Result<Self, Self::Error> {
105        value.parse()
106    }
107}
108
109impl From<ChannelId> for String {
110    fn from(value: ChannelId) -> Self {
111        value.to_string()
112    }
113}
114
115macro_rules! opaque_id {
116    ($(#[$doc:meta])* $name:ident) => {
117        $(#[$doc])*
118        #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
119        #[serde(transparent)]
120        pub struct $name(String);
121
122        impl $name {
123            /// Wraps a platform-provided value.
124            pub fn new(value: impl Into<String>) -> Self {
125                Self(value.into())
126            }
127
128            /// The raw value.
129            pub fn as_str(&self) -> &str {
130                &self.0
131            }
132        }
133
134        impl fmt::Display for $name {
135            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136                f.write_str(&self.0)
137            }
138        }
139
140        impl From<&str> for $name {
141            fn from(value: &str) -> Self {
142                Self(value.to_string())
143            }
144        }
145
146        impl From<String> for $name {
147            fn from(value: String) -> Self {
148                Self(value)
149            }
150        }
151    };
152}
153
154opaque_id!(
155    /// A user id as the platform reports it, unique within one [`ChannelId`].
156    UserId
157);
158opaque_id!(
159    /// A message id as the platform reports it.
160    MessageId
161);
162opaque_id!(
163    /// The id of one agent run, used in logs and the audit table.
164    RunId
165);
166
167/// Who wrote a message.
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct UserRef {
170    /// Platform user id.
171    pub id: UserId,
172    /// Name to show and to address the user by.
173    pub display_name: String,
174    /// Whether the author is a bot (okbot ignores bots by default).
175    pub is_bot: bool,
176}
177
178/// Identifies one conversation. Messages with the same key are handled in order and share history.
179#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
180pub struct ConversationKey {
181    /// The channel instance.
182    pub channel: ChannelId,
183    /// Workspace, guild or Google Chat space, when the platform has one above rooms.
184    pub space: Option<String>,
185    /// Chat, channel or direct-message id.
186    pub room: String,
187    /// Thread inside the room, when the platform has threads.
188    pub thread: Option<String>,
189}
190
191impl ConversationKey {
192    /// A key for a room without space or thread (Zalo chats, console).
193    pub fn room(channel: ChannelId, room: impl Into<String>) -> Self {
194        Self {
195            channel,
196            space: None,
197            room: room.into(),
198            thread: None,
199        }
200    }
201}
202
203/// Stable text form used as a storage key: parts joined by `|`, with `\` and `|` escaped and `-`
204/// standing for a missing part (a literal `-` is escaped as `\-`).
205impl fmt::Display for ConversationKey {
206    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207        fn part(value: Option<&str>) -> String {
208            match value {
209                None => "-".to_string(),
210                Some(v) => {
211                    let escaped = v.replace('\\', "\\\\").replace('|', "\\|");
212                    if escaped == "-" {
213                        "\\-".to_string()
214                    } else {
215                        escaped
216                    }
217                }
218            }
219        }
220        write!(
221            f,
222            "{}|{}|{}|{}",
223            self.channel,
224            part(self.space.as_deref()),
225            part(Some(&self.room)),
226            part(self.thread.as_deref())
227        )
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use proptest::prelude::*;
235
236    #[test]
237    fn channel_id_round_trip() {
238        let id: ChannelId = "zalo:shop".parse().unwrap();
239        assert_eq!(id.platform(), "zalo");
240        assert_eq!(id.instance(), "shop");
241        assert_eq!(id.to_string(), "zalo:shop");
242        assert_eq!(id.slug(), "zalo-shop");
243        let json = serde_json::to_string(&id).unwrap();
244        assert_eq!(json, "\"zalo:shop\"");
245        assert_eq!(serde_json::from_str::<ChannelId>(&json).unwrap(), id);
246    }
247
248    #[test]
249    fn channel_id_rejects_bad_values() {
250        assert!("zalo".parse::<ChannelId>().is_err());
251        assert!("Zalo:shop".parse::<ChannelId>().is_err());
252        assert!("zalo:".parse::<ChannelId>().is_err());
253        assert!("zalo:a/b".parse::<ChannelId>().is_err());
254    }
255
256    #[test]
257    fn conversation_key_text_is_stable() {
258        let channel: ChannelId = "gchat:corp".parse().unwrap();
259        let key = ConversationKey {
260            channel,
261            space: Some("spaces/A".into()),
262            room: "r|1".into(),
263            thread: None,
264        };
265        assert_eq!(key.to_string(), r"gchat:corp|spaces/A|r\|1|-");
266    }
267
268    proptest! {
269        #[test]
270        fn conversation_key_text_is_injective(
271            a in proptest::option::of(".{0,6}"), b in ".{0,6}", c in proptest::option::of(".{0,6}"),
272            d in proptest::option::of(".{0,6}"), e in ".{0,6}", g in proptest::option::of(".{0,6}"),
273        ) {
274            let channel: ChannelId = "x:y".parse().unwrap();
275            let k1 = ConversationKey { channel: channel.clone(), space: a, room: b, thread: c };
276            let k2 = ConversationKey { channel, space: d, room: e, thread: g };
277            prop_assert_eq!(k1 == k2, k1.to_string() == k2.to_string());
278        }
279    }
280}