1use std::env;
24use std::sync::OnceLock;
25use teloxide::prelude::*;
26use teloxide::types::ChatId;
27
28const MAX_MESSAGE_LEN: usize = 4096;
30
31static BOT: OnceLock<Bot> = OnceLock::new();
32
33#[derive(Debug)]
35pub enum NotifyError {
36 MissingEnv(&'static str),
38 InvalidChatId,
40 EmptyMessage,
42 MessageTooLong,
44 Telegram(teloxide::RequestError),
46}
47
48impl std::fmt::Display for NotifyError {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match self {
51 Self::MissingEnv(name) => write!(f, "missing environment variable: {name}"),
52 Self::InvalidChatId => write!(f, "invalid TELEGRAM_CHAT_ID"),
53 Self::EmptyMessage => write!(f, "message is empty"),
54 Self::MessageTooLong => write!(f, "message exceeds 4096 characters"),
55 Self::Telegram(err) => write!(f, "telegram request failed: {err}"),
56 }
57 }
58}
59
60impl std::error::Error for NotifyError {}
61
62impl From<teloxide::RequestError> for NotifyError {
63 fn from(err: teloxide::RequestError) -> Self {
64 Self::Telegram(err)
65 }
66}
67
68fn validated_message(msg: &str) -> Result<&str, NotifyError> {
69 let msg = msg.trim();
70
71 if msg.is_empty() {
72 return Err(NotifyError::EmptyMessage);
73 }
74
75 if msg.chars().count() > MAX_MESSAGE_LEN {
76 return Err(NotifyError::MessageTooLong);
77 }
78
79 Ok(msg)
80}
81
82fn load_bot_token() -> Result<String, NotifyError> {
83 env::var("TELEGRAM_BOT_TOKEN").map_err(|_| NotifyError::MissingEnv("TELEGRAM_BOT_TOKEN"))
84}
85
86fn load_chat_id() -> Result<ChatId, NotifyError> {
87 let chat_id = env::var("TELEGRAM_CHAT_ID")
88 .map_err(|_| NotifyError::MissingEnv("TELEGRAM_CHAT_ID"))?
89 .parse::<i64>()
90 .map_err(|_| NotifyError::InvalidChatId)?;
91
92 Ok(ChatId(chat_id))
93}
94
95fn bot() -> Result<&'static Bot, NotifyError> {
96 if let Some(bot) = BOT.get() {
97 return Ok(bot);
98 }
99
100 let token = load_bot_token()?;
101 Ok(BOT.get_or_init(|| Bot::new(token)))
102}
103
104pub async fn send(msg: &str) -> Result<(), NotifyError> {
119 let msg = validated_message(msg)?;
120 let bot = bot()?;
121 let chat_id = load_chat_id()?;
122
123 bot.send_message(chat_id, msg).await?;
124
125 Ok(())
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use std::sync::{Mutex, OnceLock};
132
133 static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
134
135 fn env_lock() -> &'static Mutex<()> {
136 ENV_LOCK.get_or_init(|| Mutex::new(()))
137 }
138
139 #[test]
140 fn display_messages_exist() {
141 assert_eq!(
142 NotifyError::MissingEnv("X").to_string(),
143 "missing environment variable: X"
144 );
145 assert_eq!(
146 NotifyError::InvalidChatId.to_string(),
147 "invalid TELEGRAM_CHAT_ID"
148 );
149 assert_eq!(NotifyError::EmptyMessage.to_string(), "message is empty");
150 assert_eq!(
151 NotifyError::MessageTooLong.to_string(),
152 "message exceeds 4096 characters"
153 );
154 }
155
156 #[test]
157 fn validated_message_rejects_empty() {
158 let err = validated_message(" ").unwrap_err();
159 assert!(matches!(err, NotifyError::EmptyMessage));
160 }
161
162 #[test]
163 fn validated_message_rejects_too_long() {
164 let msg = "a".repeat(4097);
165 let err = validated_message(&msg).unwrap_err();
166 assert!(matches!(err, NotifyError::MessageTooLong));
167 }
168
169 #[test]
170 fn validated_message_trims_ok() {
171 let msg = validated_message(" hello ").unwrap();
172 assert_eq!(msg, "hello");
173 }
174
175 #[test]
176 fn load_bot_token_missing() {
177 let _guard = env_lock().lock().unwrap();
178
179 unsafe {
180 env::remove_var("TELEGRAM_BOT_TOKEN");
181 }
182
183 let err = load_bot_token().unwrap_err();
184 assert!(matches!(err, NotifyError::MissingEnv("TELEGRAM_BOT_TOKEN")));
185 }
186
187 #[test]
188 fn load_bot_token_ok() {
189 let _guard = env_lock().lock().unwrap();
190
191 unsafe {
192 env::set_var("TELEGRAM_BOT_TOKEN", "test_token");
193 }
194
195 let token = load_bot_token().unwrap();
196 assert_eq!(token, "test_token");
197 }
198
199 #[test]
200 fn load_chat_id_missing() {
201 let _guard = env_lock().lock().unwrap();
202
203 unsafe {
204 env::remove_var("TELEGRAM_CHAT_ID");
205 }
206
207 let err = load_chat_id().unwrap_err();
208 assert!(matches!(err, NotifyError::MissingEnv("TELEGRAM_CHAT_ID")));
209 }
210
211 #[test]
212 fn load_chat_id_invalid() {
213 let _guard = env_lock().lock().unwrap();
214
215 unsafe {
216 env::set_var("TELEGRAM_CHAT_ID", "not_a_number");
217 }
218
219 let err = load_chat_id().unwrap_err();
220 assert!(matches!(err, NotifyError::InvalidChatId));
221 }
222
223 #[test]
224 fn load_chat_id_ok() {
225 let _guard = env_lock().lock().unwrap();
226
227 unsafe {
228 env::set_var("TELEGRAM_CHAT_ID", "123");
229 }
230
231 let chat_id = load_chat_id().unwrap();
232 assert_eq!(chat_id, ChatId(123));
233 }
234}