Skip to main content

webserver_base/telegram/
settings.rs

1use std::time::Duration;
2
3use super::error::TelegramError;
4use super::token::BotToken;
5
6/// The official Telegram Bot API host.
7pub const TELEGRAM_API_BASE_URL: &str = "https://api.telegram.org";
8
9/// Telegram's maximum message text length, in UTF-16 code units.
10pub const MAX_TEXT_LENGTH: usize = 4096;
11
12/// Telegram's maximum media caption length, in UTF-16 code units.
13///
14/// Note that this is a quarter of [`MAX_TEXT_LENGTH`]: a caption which fits in
15/// a text message may still overflow when attached to a photo.
16pub const MAX_CAPTION_LENGTH: usize = 1024;
17
18/// Default ceiling on how many messages one oversized message may become.
19pub const DEFAULT_MAX_CHUNKS: usize = 5;
20
21/// Default ceiling on the size of a single message, in bytes.
22///
23/// This guards against an upstream bug producing an enormous string; such a
24/// message is rejected outright rather than chunked.
25pub const DEFAULT_MAX_INPUT_BYTES: usize = 1024 * 1024;
26
27/// Default number of messages held per chat before new sends are dropped.
28pub const DEFAULT_QUEUE_CAPACITY: usize = 1024;
29
30/// Default minimum spacing between two messages to the same chat.
31///
32/// Telegram's Bot FAQ: "In a single chat, avoid sending more than one message
33/// per second."
34pub const DEFAULT_PER_CHAT_INTERVAL: Duration = Duration::from_secs(1);
35
36/// Default ceiling on messages per second across all chats.
37///
38/// Telegram's Bot FAQ: "bots are not able to broadcast more than about 30
39/// messages per second".
40pub const DEFAULT_GLOBAL_PER_SECOND: u32 = 30;
41
42/// Default number of retries for transient failures.
43pub const DEFAULT_MAX_RETRIES: u32 = 3;
44
45/// Default ceiling on an honored `retry_after`.
46///
47/// Telegram can ask for a very long wait. Because the queue is ordered per
48/// chat, obeying an hour-long `retry_after` would stall every later message to
49/// that chat, so beyond this ceiling the message is dropped instead.
50pub const DEFAULT_MAX_RETRY_AFTER: Duration = Duration::from_mins(1);
51
52/// Default timeout for establishing a connection.
53pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
54
55/// Default timeout for a complete request.
56pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
57
58/// Configuration for a [`ReqwestTelegram`](super::ReqwestTelegram).
59///
60/// This type deliberately reads nothing from the environment. Where the token
61/// comes from — an env var, a secrets manager, a config file — is the caller's
62/// decision, and every project already handles it differently.
63#[derive(Debug, Clone)]
64pub struct TelegramSettings {
65    pub(crate) token: BotToken,
66    pub(crate) base_url: String,
67    pub(crate) connect_timeout: Duration,
68    pub(crate) request_timeout: Duration,
69    pub(crate) queue_capacity: usize,
70    pub(crate) max_chunks: usize,
71    pub(crate) max_input_bytes: usize,
72    pub(crate) max_retries: u32,
73    pub(crate) max_retry_after: Duration,
74    pub(crate) per_chat_interval: Duration,
75    pub(crate) global_per_second: u32,
76}
77
78impl TelegramSettings {
79    /// Starts building settings for the given bot token.
80    ///
81    /// The token is the only required value; everything else defaults to the
82    /// constants in this module.
83    #[must_use]
84    pub fn builder(token: impl Into<String>) -> TelegramSettingsBuilder {
85        TelegramSettingsBuilder::new(token)
86    }
87
88    /// The configured API base URL.
89    #[must_use]
90    pub fn base_url(&self) -> &str {
91        &self.base_url
92    }
93}
94
95/// Builds a [`TelegramSettings`].
96#[derive(Debug, Clone)]
97pub struct TelegramSettingsBuilder {
98    token: String,
99    base_url: String,
100    connect_timeout: Duration,
101    request_timeout: Duration,
102    queue_capacity: usize,
103    max_chunks: usize,
104    max_input_bytes: usize,
105    max_retries: u32,
106    max_retry_after: Duration,
107    per_chat_interval: Duration,
108    global_per_second: u32,
109}
110
111impl TelegramSettingsBuilder {
112    /// Creates a builder for the given bot token.
113    #[must_use]
114    pub fn new(token: impl Into<String>) -> Self {
115        Self {
116            token: token.into(),
117            base_url: String::from(TELEGRAM_API_BASE_URL),
118            connect_timeout: DEFAULT_CONNECT_TIMEOUT,
119            request_timeout: DEFAULT_REQUEST_TIMEOUT,
120            queue_capacity: DEFAULT_QUEUE_CAPACITY,
121            max_chunks: DEFAULT_MAX_CHUNKS,
122            max_input_bytes: DEFAULT_MAX_INPUT_BYTES,
123            max_retries: DEFAULT_MAX_RETRIES,
124            max_retry_after: DEFAULT_MAX_RETRY_AFTER,
125            per_chat_interval: DEFAULT_PER_CHAT_INTERVAL,
126            global_per_second: DEFAULT_GLOBAL_PER_SECOND,
127        }
128    }
129
130    /// Overrides the API base URL.
131    ///
132    /// Intended for pointing tests at a local mock server, or for a self-hosted
133    /// Bot API server. Production should leave this at its default.
134    #[must_use]
135    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
136        self.base_url = base_url.into().trim_end_matches('/').to_string();
137        self
138    }
139
140    /// Overrides the connection timeout.
141    #[must_use]
142    pub const fn connect_timeout(mut self, timeout: Duration) -> Self {
143        self.connect_timeout = timeout;
144        self
145    }
146
147    /// Overrides the total request timeout.
148    #[must_use]
149    pub const fn request_timeout(mut self, timeout: Duration) -> Self {
150        self.request_timeout = timeout;
151        self
152    }
153
154    /// Overrides how many messages are held per chat before sends are dropped.
155    #[must_use]
156    pub const fn queue_capacity(mut self, capacity: usize) -> Self {
157        self.queue_capacity = capacity;
158        self
159    }
160
161    /// Overrides how many messages one oversized message may become.
162    #[must_use]
163    pub const fn max_chunks(mut self, max_chunks: usize) -> Self {
164        self.max_chunks = max_chunks;
165        self
166    }
167
168    /// Overrides the largest message accepted, in bytes.
169    #[must_use]
170    pub const fn max_input_bytes(mut self, max_bytes: usize) -> Self {
171        self.max_input_bytes = max_bytes;
172        self
173    }
174
175    /// Overrides how many times a transient failure is retried.
176    #[must_use]
177    pub const fn max_retries(mut self, retries: u32) -> Self {
178        self.max_retries = retries;
179        self
180    }
181
182    /// Overrides the longest `retry_after` which will be honored.
183    #[must_use]
184    pub const fn max_retry_after(mut self, max_retry_after: Duration) -> Self {
185        self.max_retry_after = max_retry_after;
186        self
187    }
188
189    /// Overrides the minimum spacing between messages to the same chat.
190    #[must_use]
191    pub const fn per_chat_interval(mut self, interval: Duration) -> Self {
192        self.per_chat_interval = interval;
193        self
194    }
195
196    /// Overrides the ceiling on messages per second across all chats.
197    #[must_use]
198    pub const fn global_per_second(mut self, per_second: u32) -> Self {
199        self.global_per_second = per_second;
200        self
201    }
202
203    /// Validates and finalizes the settings.
204    ///
205    /// # Errors
206    ///
207    /// Returns [`TelegramError::InvalidToken`] if the token is blank or
208    /// malformed. A blank-but-present token is treated as missing, because a
209    /// server which boots with one looks healthy while notifying nobody.
210    pub fn build(self) -> Result<TelegramSettings, TelegramError> {
211        Ok(TelegramSettings {
212            token: BotToken::new(self.token)?,
213            base_url: self.base_url,
214            connect_timeout: self.connect_timeout,
215            request_timeout: self.request_timeout,
216            queue_capacity: self.queue_capacity.max(1),
217            max_chunks: self.max_chunks.max(1),
218            max_input_bytes: self.max_input_bytes.max(1),
219            max_retries: self.max_retries,
220            max_retry_after: self.max_retry_after,
221            per_chat_interval: self.per_chat_interval,
222            global_per_second: self.global_per_second.max(1),
223        })
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use std::time::Duration;
230
231    use super::{
232        DEFAULT_GLOBAL_PER_SECOND, DEFAULT_MAX_CHUNKS, DEFAULT_QUEUE_CAPACITY,
233        TELEGRAM_API_BASE_URL, TelegramSettings,
234    };
235    use crate::telegram::error::TelegramError;
236
237    const VALID: &str = "123456789:AAFNpHzr6wq4YimAMwIjqVrFU8TO5kcayEI";
238
239    #[test]
240    fn defaults_match_telegrams_documented_limits() {
241        let settings: TelegramSettings = TelegramSettings::builder(VALID)
242            .build()
243            .expect("valid token should build");
244
245        let expected_base: String = String::from(TELEGRAM_API_BASE_URL);
246        let actual_base: String = settings.base_url().to_string();
247        assert_eq!(expected_base, actual_base);
248
249        let expected_global: u32 = DEFAULT_GLOBAL_PER_SECOND;
250        let actual_global: u32 = settings.global_per_second;
251        assert_eq!(expected_global, actual_global);
252
253        let expected_interval: Duration = Duration::from_secs(1);
254        let actual_interval: Duration = settings.per_chat_interval;
255        assert_eq!(expected_interval, actual_interval);
256
257        let expected_chunks: usize = DEFAULT_MAX_CHUNKS;
258        let actual_chunks: usize = settings.max_chunks;
259        assert_eq!(expected_chunks, actual_chunks);
260
261        let expected_capacity: usize = DEFAULT_QUEUE_CAPACITY;
262        let actual_capacity: usize = settings.queue_capacity;
263        assert_eq!(expected_capacity, actual_capacity);
264    }
265
266    #[test]
267    fn blank_token_is_rejected_at_build_time() {
268        let error: TelegramError = TelegramSettings::builder("   ")
269            .build()
270            .expect_err("blank token should be rejected");
271
272        assert!(matches!(error, TelegramError::InvalidToken(_)));
273    }
274
275    #[test]
276    fn overrides_are_applied() {
277        let settings: TelegramSettings = TelegramSettings::builder(VALID)
278            .base_url("http://127.0.0.1:8080/")
279            .queue_capacity(16)
280            .max_chunks(2)
281            .global_per_second(5)
282            .per_chat_interval(Duration::from_millis(10))
283            .build()
284            .expect("valid token should build");
285
286        let expected_base: String = String::from("http://127.0.0.1:8080");
287        let actual_base: String = settings.base_url().to_string();
288        assert_eq!(expected_base, actual_base);
289
290        let expected_capacity: usize = 16;
291        let actual_capacity: usize = settings.queue_capacity;
292        assert_eq!(expected_capacity, actual_capacity);
293
294        let expected_chunks: usize = 2;
295        let actual_chunks: usize = settings.max_chunks;
296        assert_eq!(expected_chunks, actual_chunks);
297    }
298
299    #[test]
300    fn trailing_slash_is_stripped_from_the_base_url() {
301        let settings: TelegramSettings = TelegramSettings::builder(VALID)
302            .base_url("https://example.com///")
303            .build()
304            .expect("valid token should build");
305
306        let expected: String = String::from("https://example.com");
307        let actual: String = settings.base_url().to_string();
308        assert_eq!(expected, actual);
309    }
310
311    #[test]
312    fn degenerate_values_are_clamped_to_something_workable() {
313        let settings: TelegramSettings = TelegramSettings::builder(VALID)
314            .queue_capacity(0)
315            .max_chunks(0)
316            .global_per_second(0)
317            .build()
318            .expect("valid token should build");
319
320        let expected_capacity: usize = 1;
321        let actual_capacity: usize = settings.queue_capacity;
322        assert_eq!(expected_capacity, actual_capacity);
323
324        let expected_chunks: usize = 1;
325        let actual_chunks: usize = settings.max_chunks;
326        assert_eq!(expected_chunks, actual_chunks);
327
328        let expected_global: u32 = 1;
329        let actual_global: u32 = settings.global_per_second;
330        assert_eq!(expected_global, actual_global);
331    }
332
333    #[test]
334    fn debug_output_does_not_leak_the_token() {
335        let settings: TelegramSettings = TelegramSettings::builder(VALID)
336            .build()
337            .expect("valid token should build");
338
339        let actual: String = format!("{settings:?}");
340        assert!(!actual.contains("AAFNpHzr"));
341        assert!(actual.contains("***"));
342    }
343}