Skip to main content

okbot_core/
notify.rs

1//! Where a notification goes (`[[notify]] to`, ADR 0010).
2
3use std::fmt;
4use std::str::FromStr;
5
6use crate::id::{ChannelId, ConversationKey, IdError, UserId};
7
8/// One destination of a `[[notify]]` target, written as text in `okbot.toml`.
9///
10/// | Text | Meaning |
11/// |---|---|
12/// | `room:<conversation>` | a room, chat, space or thread, as `/whoami` shows it |
13/// | `user:<platform>:<instance>/<user id>` | that account's direct chat |
14/// | `person:<id>` | one direct chat of a person from `[people]` (`owner` on a personal bot) |
15/// | `admins` | the direct chat of every `[bot] admins` account (and the owner's) |
16///
17/// A room is the exact [`ConversationKey`] text, so threads and workspaces work on every
18/// platform without a platform-specific address format.
19#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20#[non_exhaustive]
21pub enum NotifyTo {
22    /// A conversation the bot posts into.
23    Room(ConversationKey),
24    /// One account's direct chat.
25    User(ChannelId, UserId),
26    /// A linked person's direct chat on one of their platforms.
27    Person(String),
28    /// Every administrator's direct chat.
29    Admins,
30}
31
32impl NotifyTo {
33    /// The text that addresses the conversation `key` (`room:<key>`).
34    pub fn room_address(key: &ConversationKey) -> String {
35        format!("room:{key}")
36    }
37}
38
39impl FromStr for NotifyTo {
40    type Err = IdError;
41
42    fn from_str(value: &str) -> Result<Self, Self::Err> {
43        let invalid = |why: &str| IdError::NotifyTarget(format!("`{value}`: {why}"));
44        if value == "admins" {
45            return Ok(Self::Admins);
46        }
47        if let Some(room) = value.strip_prefix("room:") {
48            return room
49                .parse()
50                .map(Self::Room)
51                .map_err(|_| invalid("send /whoami in the room and copy its `room:` address"));
52        }
53        if let Some(account) = value.strip_prefix("user:") {
54            let (channel, user) = account
55                .split_once('/')
56                .filter(|(_, user)| !user.is_empty())
57                .ok_or_else(|| invalid("write `user:<platform>:<instance>/<user id>`"))?;
58            let channel = channel
59                .parse()
60                .map_err(|e: IdError| invalid(&e.to_string()))?;
61            return Ok(Self::User(channel, UserId::new(user)));
62        }
63        if let Some(person) = value.strip_prefix("person:") {
64            let slug = !person.is_empty()
65                && person.bytes().all(|b| {
66                    b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_'
67                });
68            return if slug {
69                Ok(Self::Person(person.to_string()))
70            } else {
71                Err(invalid("a person id uses a-z, 0-9, `-` and `_`"))
72            };
73        }
74        Err(invalid(
75            "use `room:<conversation>`, `user:<platform>:<instance>/<id>`, `person:<id>` or `admins`",
76        ))
77    }
78}
79
80impl fmt::Display for NotifyTo {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        match self {
83            Self::Room(key) => write!(f, "room:{key}"),
84            Self::User(channel, user) => write!(f, "user:{channel}/{user}"),
85            Self::Person(id) => write!(f, "person:{id}"),
86            Self::Admins => f.write_str("admins"),
87        }
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn targets_round_trip() {
97        for text in [
98            "admins",
99            "person:luan",
100            "user:zalo:shop/6ede9afa",
101            "room:discord:ops|1111|2222|-",
102            "room:gchat:corp|-|spaces/AAA|spaces/AAA/threads/T1",
103            "room:slack:corp|T1|C2|1700000000.000100",
104        ] {
105            let target: NotifyTo = text.parse().unwrap();
106            assert_eq!(target.to_string(), text);
107        }
108    }
109
110    #[test]
111    fn rooms_are_conversation_keys() {
112        let key = ConversationKey::room("telegram:main".parse().unwrap(), "-100123");
113        let address = NotifyTo::room_address(&key);
114        assert_eq!(address, "room:telegram:main|-|-100123|-");
115        assert_eq!(address.parse::<NotifyTo>().unwrap(), NotifyTo::Room(key));
116    }
117
118    #[test]
119    fn bad_targets_say_how_to_write_them() {
120        for text in [
121            "",
122            "everyone",
123            "room:discord:ops/123",
124            "user:zalo:shop",
125            "user:Zalo:shop/1",
126            "person:",
127            "person:Luan",
128        ] {
129            let error = text.parse::<NotifyTo>().unwrap_err().to_string();
130            assert!(error.contains(&format!("`{text}`")), "{error}");
131        }
132    }
133}