Skip to main content

webserver_base/telegram/
notifier.rs

1use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
2use std::time::Duration;
3
4use reqwest::Client;
5use tracing::{error, instrument, warn};
6
7use super::chat_id::ChatId;
8use super::chunk::{Chunk, SplitOutcome, prepare};
9use super::error::TelegramError;
10use super::message::{FileSource, Media, Message, SendOptions};
11use super::queue::{Outgoing, Worker, spawn};
12use super::settings::{MAX_CAPTION_LENGTH, MAX_TEXT_LENGTH, TelegramSettings};
13
14/// Telegram's upload ceiling for a photo, in bytes.
15const MAX_PHOTO_BYTES: usize = 10 * 1024 * 1024;
16
17/// Telegram's upload ceiling for a document, in bytes.
18const MAX_DOCUMENT_BYTES: usize = 50 * 1024 * 1024;
19
20/// Substituted when a message would otherwise be empty, which Telegram rejects.
21const EMPTY_PLACEHOLDER: &str = "<no content>";
22
23/// Sends outbound Telegram notifications.
24///
25/// Sending is deliberately **synchronous and infallible**: a notification must
26/// never block a request handler, and there is rarely anything upstream which
27/// could act on a delivery failure anyway. Failures surface through `tracing`
28/// instead, which reaches Sentry through the usual subscriber.
29pub trait Telegram: Send + Sync {
30    /// Queues a message for delivery to a chat.
31    ///
32    /// Returns immediately. The message is chunked, paced against Telegram's
33    /// rate limits, retried on transient failure, and finally delivered on a
34    /// background task.
35    fn send(&self, chat_id: ChatId, message: Message);
36
37    /// Queues an unformatted message.
38    ///
39    /// Convenience for the common case where no formatting is needed.
40    fn send_text(&self, chat_id: ChatId, text: &str) {
41        self.send(chat_id, Message::text(text));
42    }
43}
44
45/// A [`Telegram`] which really talks to the Bot API over HTTP.
46#[derive(Debug)]
47pub struct ReqwestTelegram {
48    worker: Arc<Worker>,
49    max_input_bytes: usize,
50    max_chunks: usize,
51}
52
53impl ReqwestTelegram {
54    /// Creates a notifier and spawns its background delivery worker.
55    ///
56    /// If `client` is `None`, an HTTP client is built from the timeouts in
57    /// `settings`. Passing an existing client lets the notifier share the
58    /// application's connection pool.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`TelegramError::Http`] if a client must be built and cannot be.
63    ///
64    /// # Panics
65    ///
66    /// Panics if called outside a Tokio runtime, because the delivery worker is
67    /// spawned during construction.
68    pub fn new(settings: TelegramSettings, client: Option<Client>) -> Result<Self, TelegramError> {
69        let client: Client = match client {
70            Some(client) => client,
71            None => Client::builder()
72                .connect_timeout(settings.connect_timeout)
73                .timeout(settings.request_timeout)
74                .build()?,
75        };
76
77        let max_input_bytes: usize = settings.max_input_bytes;
78        let max_chunks: usize = settings.max_chunks;
79
80        let worker: Arc<Worker> = Arc::new(Worker::new(settings, client));
81        spawn(&worker);
82
83        Ok(Self {
84            worker,
85            max_input_bytes,
86            max_chunks,
87        })
88    }
89
90    /// Waits for every queued message to be delivered, up to `timeout`.
91    ///
92    /// Call this during shutdown. Without it, whatever is still queued when the
93    /// process exits is lost — including, typically, the notification about
94    /// whatever caused the shutdown.
95    ///
96    /// Returns `true` if the queue drained in time.
97    pub async fn flush(&self, timeout: Duration) -> bool {
98        self.worker.flush(timeout).await
99    }
100
101    /// How many messages are currently waiting to be delivered.
102    #[must_use]
103    pub fn queued(&self) -> usize {
104        self.worker.queued()
105    }
106
107    /// Removes control characters which Telegram rejects, keeping newlines and tabs.
108    fn sanitize(text: &str) -> String {
109        text.chars()
110            .filter(|character: &char| {
111                !character.is_control() || *character == '\n' || *character == '\t'
112            })
113            .collect()
114    }
115
116    /// Rejects an attachment which exceeds Telegram's upload ceiling.
117    fn media_is_sendable(media: &Media) -> bool {
118        let FileSource::Bytes { bytes, .. } = media.source() else {
119            return true;
120        };
121
122        let (limit, label): (usize, &str) = match media {
123            Media::Photo(_) => (MAX_PHOTO_BYTES, "photo"),
124            Media::Document(_) => (MAX_DOCUMENT_BYTES, "document"),
125        };
126
127        if bytes.len() > limit {
128            error!(
129                "telegram {label} is {} bytes, which exceeds the {limit} byte upload limit; dropping message",
130                bytes.len()
131            );
132            return false;
133        }
134
135        true
136    }
137
138    /// Strips the options which only make sense on the first chunk of a split.
139    fn continuation_options(options: &SendOptions) -> SendOptions {
140        SendOptions {
141            reply_to_message_id: None,
142            ..options.clone()
143        }
144    }
145}
146
147impl Telegram for ReqwestTelegram {
148    #[instrument(skip_all)]
149    fn send(&self, chat_id: ChatId, message: Message) {
150        if message.text.len() > self.max_input_bytes {
151            error!(
152                "{}",
153                TelegramError::MessageTooLarge {
154                    bytes: message.text.len(),
155                    max: self.max_input_bytes,
156                }
157            );
158            return;
159        }
160
161        if let Some(media) = message.media.as_ref()
162            && !Self::media_is_sendable(media)
163        {
164            return;
165        }
166
167        let has_media: bool = message.media.is_some();
168        let mut text: String = Self::sanitize(&message.text);
169
170        // Telegram rejects an empty `text`, though an empty caption is fine.
171        if text.trim().is_empty() && !has_media {
172            text = String::from(EMPTY_PLACEHOLDER);
173        }
174
175        let limit: usize = if has_media {
176            MAX_CAPTION_LENGTH
177        } else {
178            MAX_TEXT_LENGTH
179        };
180
181        let outcome: SplitOutcome = prepare(&text, &message.entities, limit, self.max_chunks);
182
183        if outcome.dropped_units > 0 {
184            warn!(
185                "telegram message exceeded {} chunk(s); {} character(s) were truncated",
186                self.max_chunks, outcome.dropped_units
187            );
188        }
189
190        for (index, chunk) in outcome.chunks.into_iter().enumerate() {
191            let chunk: Chunk = chunk;
192            let is_first: bool = index == 0;
193
194            let accepted: bool = self.worker.enqueue(Outgoing {
195                chat_id: chat_id.clone(),
196                text: chunk.text,
197                entities: chunk.entities,
198                // Only the first chunk carries the attachment; the rest are
199                // follow-up text messages continuing an overlong caption.
200                media: if is_first {
201                    message.media.clone()
202                } else {
203                    None
204                },
205                options: if is_first {
206                    message.options.clone()
207                } else {
208                    Self::continuation_options(&message.options)
209                },
210            });
211
212            if !accepted {
213                // Nothing more will fit either; stop rather than interleaving.
214                break;
215            }
216        }
217    }
218}
219
220/// One message captured by [`MockTelegram`].
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct SentMessage {
223    /// The chat the message was addressed to.
224    pub chat_id: ChatId,
225    /// The message itself, exactly as the caller built it.
226    pub message: Message,
227}
228
229/// A [`Telegram`] which records messages instead of sending them.
230///
231/// Used both as the test double and as the local-development implementation, so
232/// that neither tests nor a developer's machine can reach the real bot. It
233/// records every message and logs nothing.
234#[derive(Debug, Default)]
235pub struct MockTelegram {
236    sent: Mutex<Vec<SentMessage>>,
237}
238
239impl MockTelegram {
240    /// Creates an empty mock.
241    #[must_use]
242    pub fn new() -> Self {
243        Self::default()
244    }
245
246    /// Locks the recorded messages, recovering from a poisoned lock.
247    fn lock(&self) -> MutexGuard<'_, Vec<SentMessage>> {
248        self.sent.lock().unwrap_or_else(PoisonError::into_inner)
249    }
250
251    /// Every message recorded, in the order it was sent.
252    #[must_use]
253    pub fn sent(&self) -> Vec<SentMessage> {
254        self.lock().clone()
255    }
256
257    /// Every message recorded for one chat, in order.
258    #[must_use]
259    pub fn sent_to(&self, chat_id: &ChatId) -> Vec<Message> {
260        self.lock()
261            .iter()
262            .filter(|sent: &&SentMessage| &sent.chat_id == chat_id)
263            .map(|sent: &SentMessage| sent.message.clone())
264            .collect()
265    }
266
267    /// The text of every message recorded, in order.
268    #[must_use]
269    pub fn texts(&self) -> Vec<String> {
270        self.lock()
271            .iter()
272            .map(|sent: &SentMessage| sent.message.as_text().to_string())
273            .collect()
274    }
275
276    /// How many messages have been recorded.
277    #[must_use]
278    pub fn len(&self) -> usize {
279        self.lock().len()
280    }
281
282    /// Whether no messages have been recorded.
283    #[must_use]
284    pub fn is_empty(&self) -> bool {
285        self.lock().is_empty()
286    }
287
288    /// Discards every recorded message.
289    pub fn clear(&self) {
290        self.lock().clear();
291    }
292}
293
294impl Telegram for MockTelegram {
295    fn send(&self, chat_id: ChatId, message: Message) {
296        self.lock().push(SentMessage { chat_id, message });
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use std::sync::Arc;
303    use std::time::Duration;
304
305    use axum::Router;
306    use axum::extract::State;
307    use serde_json::Value;
308    use tokio::net::TcpListener;
309    use tokio::sync::Mutex as AsyncMutex;
310
311    use super::{MockTelegram, ReqwestTelegram, SentMessage, Telegram};
312    use crate::telegram::chat_id::ChatId;
313    use crate::telegram::message::Message;
314    use crate::telegram::settings::TelegramSettings;
315
316    const VALID: &str = "123456789:AAFNpHzr6wq4YimAMwIjqVrFU8TO5kcayEI";
317
318    type Captured = Arc<AsyncMutex<Vec<Value>>>;
319
320    /// Serves Telegram's success envelope and records every request body.
321    async fn capture(State(captured): State<Captured>, body: String) -> &'static str {
322        if let Ok(value) = serde_json::from_str::<Value>(&body) {
323            captured.lock().await.push(value);
324        }
325
326        r#"{"ok":true,"result":{}}"#
327    }
328
329    /// Starts a local stand-in for the Bot API, returning its base URL.
330    async fn mock_api() -> (String, Captured) {
331        let captured: Captured = Arc::new(AsyncMutex::new(Vec::new()));
332        let app: Router = Router::new()
333            .fallback(capture)
334            .with_state(Arc::clone(&captured));
335
336        let listener: TcpListener = TcpListener::bind("127.0.0.1:0")
337            .await
338            .expect("should bind an ephemeral port");
339        let address: std::net::SocketAddr = listener
340            .local_addr()
341            .expect("listener should have an address");
342
343        tokio::spawn(async move {
344            axum::serve(listener, app).await.expect("server should run");
345        });
346
347        (format!("http://{address}"), captured)
348    }
349
350    #[tokio::test]
351    async fn mock_records_what_it_was_given() {
352        let mock: MockTelegram = MockTelegram::new();
353        mock.send(ChatId::Id(1), Message::from("hello"));
354
355        let expected: Vec<SentMessage> = vec![SentMessage {
356            chat_id: ChatId::Id(1),
357            message: Message::from("hello"),
358        }];
359        let actual: Vec<SentMessage> = mock.sent();
360        assert_eq!(expected, actual);
361    }
362
363    #[tokio::test]
364    async fn mock_starts_empty() {
365        let mock: MockTelegram = MockTelegram::new();
366
367        assert!(mock.is_empty());
368
369        let expected: usize = 0;
370        let actual: usize = mock.len();
371        assert_eq!(expected, actual);
372    }
373
374    #[tokio::test]
375    async fn mock_filters_by_chat() {
376        let mock: MockTelegram = MockTelegram::new();
377        mock.send(ChatId::Id(1), Message::from("one"));
378        mock.send(ChatId::Id(2), Message::from("two"));
379        mock.send(ChatId::Id(1), Message::from("three"));
380
381        let expected: Vec<Message> = vec![Message::from("one"), Message::from("three")];
382        let actual: Vec<Message> = mock.sent_to(&ChatId::Id(1));
383        assert_eq!(expected, actual);
384    }
385
386    #[tokio::test]
387    async fn mock_clears() {
388        let mock: MockTelegram = MockTelegram::new();
389        mock.send(ChatId::Id(1), Message::from("hello"));
390        mock.clear();
391
392        assert!(mock.is_empty());
393    }
394
395    #[tokio::test]
396    async fn mock_is_usable_behind_a_trait_object() {
397        let mock: Arc<MockTelegram> = Arc::new(MockTelegram::new());
398        let notifier: Arc<dyn Telegram> = Arc::clone(&mock) as Arc<dyn Telegram>;
399
400        notifier.send_text(ChatId::Id(1), "via dyn");
401
402        let expected: Vec<String> = vec![String::from("via dyn")];
403        let actual: Vec<String> = mock.texts();
404        assert_eq!(expected, actual);
405    }
406
407    #[tokio::test]
408    async fn a_plain_message_reaches_the_api() {
409        let (base_url, captured): (String, Captured) = mock_api().await;
410        let settings: TelegramSettings = TelegramSettings::builder(VALID)
411            .base_url(base_url)
412            .build()
413            .expect("settings should build");
414        let notifier: ReqwestTelegram =
415            ReqwestTelegram::new(settings, None).expect("notifier should build");
416
417        notifier.send(ChatId::Id(42), Message::from("hello"));
418
419        let drained: bool = notifier.flush(Duration::from_secs(5)).await;
420        assert!(drained);
421
422        let bodies: Vec<Value> = captured.lock().await.clone();
423
424        let expected: usize = 1;
425        let actual: usize = bodies.len();
426        assert_eq!(expected, actual);
427
428        let expected_text: Value = Value::String(String::from("hello"));
429        let actual_text: Value = bodies[0]
430            .get("text")
431            .cloned()
432            .expect("body should carry text");
433        assert_eq!(expected_text, actual_text);
434
435        let expected_chat: Value = Value::String(String::from("42"));
436        let actual_chat: Value = bodies[0]
437            .get("chat_id")
438            .cloned()
439            .expect("body should carry chat_id");
440        assert_eq!(expected_chat, actual_chat);
441    }
442
443    #[tokio::test]
444    async fn entities_are_sent_instead_of_markup() {
445        let (base_url, captured): (String, Captured) = mock_api().await;
446        let settings: TelegramSettings = TelegramSettings::builder(VALID)
447            .base_url(base_url)
448            .build()
449            .expect("settings should build");
450        let notifier: ReqwestTelegram =
451            ReqwestTelegram::new(settings, None).expect("notifier should build");
452
453        notifier.send(
454            ChatId::Id(1),
455            Message::builder().text("hi ").bold("there").build(),
456        );
457
458        assert!(notifier.flush(Duration::from_secs(5)).await);
459
460        let bodies: Vec<Value> = captured.lock().await.clone();
461
462        // The text carries no markup at all.
463        let expected_text: Value = Value::String(String::from("hi there"));
464        let actual_text: Value = bodies[0].get("text").cloned().expect("text should exist");
465        assert_eq!(expected_text, actual_text);
466
467        // There is no parse_mode anywhere in the request.
468        assert!(bodies[0].get("parse_mode").is_none());
469
470        let entities: &Value = bodies[0].get("entities").expect("entities should exist");
471        let expected_entities: Value = serde_json::json!([
472            {"type": "bold", "offset": 3, "length": 5}
473        ]);
474        assert_eq!(&expected_entities, entities);
475    }
476
477    #[tokio::test]
478    async fn an_oversized_message_is_split_into_several_requests() {
479        let (base_url, captured): (String, Captured) = mock_api().await;
480        let settings: TelegramSettings = TelegramSettings::builder(VALID)
481            .base_url(base_url)
482            // Pace fast so the test does not wait a real second per chunk.
483            .per_chat_interval(Duration::from_millis(1))
484            .build()
485            .expect("settings should build");
486        let notifier: ReqwestTelegram =
487            ReqwestTelegram::new(settings, None).expect("notifier should build");
488
489        notifier.send(ChatId::Id(1), Message::from("a".repeat(10_000)));
490
491        assert!(notifier.flush(Duration::from_secs(10)).await);
492
493        let bodies: Vec<Value> = captured.lock().await.clone();
494
495        let expected: usize = 3;
496        let actual: usize = bodies.len();
497        assert_eq!(expected, actual);
498
499        // Each piece announces its position in the sequence.
500        for (index, body) in bodies.iter().enumerate() {
501            let text: &str = body
502                .get("text")
503                .and_then(Value::as_str)
504                .expect("text should exist");
505            assert!(text.starts_with(&format!("({}/3) ", index + 1)));
506        }
507    }
508
509    #[tokio::test]
510    async fn an_empty_message_is_replaced_rather_than_rejected() {
511        let (base_url, captured): (String, Captured) = mock_api().await;
512        let settings: TelegramSettings = TelegramSettings::builder(VALID)
513            .base_url(base_url)
514            .build()
515            .expect("settings should build");
516        let notifier: ReqwestTelegram =
517            ReqwestTelegram::new(settings, None).expect("notifier should build");
518
519        notifier.send(ChatId::Id(1), Message::from("   "));
520
521        assert!(notifier.flush(Duration::from_secs(5)).await);
522
523        let bodies: Vec<Value> = captured.lock().await.clone();
524
525        let expected: Value = Value::String(String::from("<no content>"));
526        let actual: Value = bodies[0].get("text").cloned().expect("text should exist");
527        assert_eq!(expected, actual);
528    }
529
530    #[tokio::test]
531    async fn control_characters_are_stripped() {
532        let (base_url, captured): (String, Captured) = mock_api().await;
533        let settings: TelegramSettings = TelegramSettings::builder(VALID)
534            .base_url(base_url)
535            .build()
536            .expect("settings should build");
537        let notifier: ReqwestTelegram =
538            ReqwestTelegram::new(settings, None).expect("notifier should build");
539
540        notifier.send(ChatId::Id(1), Message::from("a\u{0}b\nc\td"));
541
542        assert!(notifier.flush(Duration::from_secs(5)).await);
543
544        let bodies: Vec<Value> = captured.lock().await.clone();
545
546        let expected: Value = Value::String(String::from("ab\nc\td"));
547        let actual: Value = bodies[0].get("text").cloned().expect("text should exist");
548        assert_eq!(expected, actual);
549    }
550
551    #[tokio::test]
552    async fn an_absurdly_large_message_is_never_sent() {
553        let (base_url, captured): (String, Captured) = mock_api().await;
554        let settings: TelegramSettings = TelegramSettings::builder(VALID)
555            .base_url(base_url)
556            .max_input_bytes(64)
557            .build()
558            .expect("settings should build");
559        let notifier: ReqwestTelegram =
560            ReqwestTelegram::new(settings, None).expect("notifier should build");
561
562        notifier.send(ChatId::Id(1), Message::from("a".repeat(1000)));
563
564        assert!(notifier.flush(Duration::from_secs(1)).await);
565
566        let bodies: Vec<Value> = captured.lock().await.clone();
567
568        let expected: usize = 0;
569        let actual: usize = bodies.len();
570        assert_eq!(expected, actual);
571    }
572
573    #[tokio::test]
574    async fn media_is_sent_with_a_caption() {
575        let (base_url, captured): (String, Captured) = mock_api().await;
576        let settings: TelegramSettings = TelegramSettings::builder(VALID)
577            .base_url(base_url)
578            .build()
579            .expect("settings should build");
580        let notifier: ReqwestTelegram =
581            ReqwestTelegram::new(settings, None).expect("notifier should build");
582
583        notifier.send(
584            ChatId::Id(1),
585            Message::builder()
586                .bold("Pattern ready")
587                .photo(crate::telegram::message::FileSource::url(
588                    "https://example.com/p.png",
589                ))
590                .build(),
591        );
592
593        assert!(notifier.flush(Duration::from_secs(5)).await);
594
595        let bodies: Vec<Value> = captured.lock().await.clone();
596
597        let expected_caption: Value = Value::String(String::from("Pattern ready"));
598        let actual_caption: Value = bodies[0]
599            .get("caption")
600            .cloned()
601            .expect("caption should exist");
602        assert_eq!(expected_caption, actual_caption);
603
604        assert!(bodies[0].get("caption_entities").is_some());
605        assert!(bodies[0].get("text").is_none());
606    }
607
608    #[tokio::test]
609    async fn a_full_queue_drops_rather_than_growing() {
610        let settings: TelegramSettings = TelegramSettings::builder(VALID)
611            // Nothing is listening, so the queue cannot drain.
612            .base_url("http://127.0.0.1:1")
613            .queue_capacity(3)
614            .build()
615            .expect("settings should build");
616        let notifier: ReqwestTelegram =
617            ReqwestTelegram::new(settings, None).expect("notifier should build");
618
619        for index in 0..100 {
620            notifier.send(ChatId::Id(1), Message::from(format!("message {index}")));
621        }
622
623        // At most capacity is retained; one may already be in flight.
624        assert!(notifier.queued() <= 3);
625    }
626}