1use std::fmt;
4use std::str::FromStr;
5
6use crate::id::{ChannelId, ConversationKey, IdError, UserId};
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20#[non_exhaustive]
21pub enum NotifyTo {
22 Room(ConversationKey),
24 User(ChannelId, UserId),
26 Person(String),
28 Admins,
30}
31
32impl NotifyTo {
33 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}