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
14const MAX_PHOTO_BYTES: usize = 10 * 1024 * 1024;
16
17const MAX_DOCUMENT_BYTES: usize = 50 * 1024 * 1024;
19
20const EMPTY_PLACEHOLDER: &str = "<no content>";
22
23pub trait Telegram: Send + Sync {
30 fn send(&self, chat_id: ChatId, message: Message);
36
37 fn send_text(&self, chat_id: ChatId, text: &str) {
41 self.send(chat_id, Message::text(text));
42 }
43}
44
45#[derive(Debug)]
47pub struct ReqwestTelegram {
48 worker: Arc<Worker>,
49 max_input_bytes: usize,
50 max_chunks: usize,
51}
52
53impl ReqwestTelegram {
54 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 pub async fn flush(&self, timeout: Duration) -> bool {
98 self.worker.flush(timeout).await
99 }
100
101 #[must_use]
103 pub fn queued(&self) -> usize {
104 self.worker.queued()
105 }
106
107 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 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 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 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 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 break;
215 }
216 }
217 }
218}
219
220#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct SentMessage {
223 pub chat_id: ChatId,
225 pub message: Message,
227}
228
229#[derive(Debug, Default)]
235pub struct MockTelegram {
236 sent: Mutex<Vec<SentMessage>>,
237}
238
239impl MockTelegram {
240 #[must_use]
242 pub fn new() -> Self {
243 Self::default()
244 }
245
246 fn lock(&self) -> MutexGuard<'_, Vec<SentMessage>> {
248 self.sent.lock().unwrap_or_else(PoisonError::into_inner)
249 }
250
251 #[must_use]
253 pub fn sent(&self) -> Vec<SentMessage> {
254 self.lock().clone()
255 }
256
257 #[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 #[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 #[must_use]
278 pub fn len(&self) -> usize {
279 self.lock().len()
280 }
281
282 #[must_use]
284 pub fn is_empty(&self) -> bool {
285 self.lock().is_empty()
286 }
287
288 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 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 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 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 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 .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 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 .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 assert!(notifier.queued() <= 3);
625 }
626}