Skip to main content

telegram_notify/
lib.rs

1//! Tiny async Telegram notification crate.
2//!
3//! This crate provides a minimal `send` API for sending Telegram bot messages
4//! to a single configured chat.
5//!
6//! # Environment variables
7//!
8//! - `TELEGRAM_BOT_TOKEN`
9//! - `TELEGRAM_CHAT_ID`
10//!
11//! # Example
12//!
13//! ```no_run
14//! use telegram_notify::send;
15//!
16//! #[tokio::main]
17//! async fn main() -> Result<(), telegram_notify::NotifyError> {
18//!     send("trade executed").await?;
19//!     Ok(())
20//! }
21//! ```
22
23use std::env;
24use std::sync::OnceLock;
25use teloxide::prelude::*;
26use teloxide::types::ChatId;
27
28/// Maximum Telegram text message length.
29const MAX_MESSAGE_LEN: usize = 4096;
30
31static BOT: OnceLock<Bot> = OnceLock::new();
32
33/// Errors returned by this crate.
34#[derive(Debug)]
35pub enum NotifyError {
36    /// Required environment variable is missing.
37    MissingEnv(&'static str),
38    /// `TELEGRAM_CHAT_ID` could not be parsed as `i64`.
39    InvalidChatId,
40    /// Message is empty after trimming whitespace.
41    EmptyMessage,
42    /// Message exceeds Telegram's 4096 character limit.
43    MessageTooLong,
44    /// Telegram API request failed.
45    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
104/// Sends a plain text Telegram message to the configured chat.
105///
106/// The target chat is taken from the `TELEGRAM_CHAT_ID` environment variable,
107/// and the bot token is taken from `TELEGRAM_BOT_TOKEN`.
108///
109/// # Errors
110///
111/// Returns an error if:
112///
113/// - required environment variables are missing
114/// - `TELEGRAM_CHAT_ID` is invalid
115/// - the message is empty after trimming
116/// - the message exceeds Telegram's 4096 character limit
117/// - Telegram rejects the request
118pub 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}