1#![doc = include_str!("../Documentation.md")]
2
3use std::cmp::Reverse;
4
5use teloxide::{
6 Bot, RequestError,
7 payloads::{
8 SendAnimationSetters, SendAudioSetters, SendPhotoSetters, SendStickerSetters,
9 SendVideoNoteSetters, SendVideoSetters,
10 },
11 prelude::Requester,
12 requests::Request,
13 types::{FileId, InputFile, Message, ReplyParameters, StickerFormat},
14};
15
16pub const NATIVE_MEDIA_KINDS: [NativeMediaKind; 6] = [
17 NativeMediaKind::Photo,
18 NativeMediaKind::Video,
19 NativeMediaKind::Animation,
20 NativeMediaKind::Audio,
21 NativeMediaKind::VideoNote,
22 NativeMediaKind::Sticker,
23];
24
25pub const INBOUND_MEDIA_KINDS: [&str; 8] = [
26 "voice",
27 "document",
28 "photo",
29 "video",
30 "animation",
31 "audio",
32 "video_note",
33 "sticker",
34];
35
36pub const OUTBOUND_MEDIA_KINDS: [&str; 7] = [
37 "document",
38 "photo",
39 "video",
40 "animation",
41 "audio",
42 "video_note",
43 "sticker",
44];
45
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum NativeMediaKind {
48 Photo,
49 Video,
50 Animation,
51 Audio,
52 VideoNote,
53 Sticker,
54}
55
56impl NativeMediaKind {
57 pub fn parse(value: &str) -> Option<Self> {
58 match value {
59 "photo" => Some(Self::Photo),
60 "video" => Some(Self::Video),
61 "animation" => Some(Self::Animation),
62 "audio" => Some(Self::Audio),
63 "video_note" => Some(Self::VideoNote),
64 "sticker" => Some(Self::Sticker),
65 _ => None,
66 }
67 }
68
69 pub const fn as_str(self) -> &'static str {
70 match self {
71 Self::Photo => "photo",
72 Self::Video => "video",
73 Self::Animation => "animation",
74 Self::Audio => "audio",
75 Self::VideoNote => "video_note",
76 Self::Sticker => "sticker",
77 }
78 }
79
80 pub const fn accepts_caption(self) -> bool {
81 matches!(
82 self,
83 Self::Photo | Self::Video | Self::Animation | Self::Audio
84 )
85 }
86
87 pub fn fallback_mime(self, file_name: Option<&str>) -> &'static str {
88 match self {
89 Self::Photo => "image/jpeg",
90 Self::Video | Self::VideoNote => "video/mp4",
91 Self::Sticker => sticker_mime_from_file_name(file_name),
92 Self::Animation => file_name
93 .and_then(mime_from_animation_extension)
94 .unwrap_or("application/octet-stream"),
95 Self::Audio => file_name
96 .and_then(mime_from_audio_extension)
97 .unwrap_or("application/octet-stream"),
98 }
99 }
100
101 pub fn default_file_name(self, message_id: i64, mime_type: &str) -> String {
102 let extension = extension_for_mime(mime_type).unwrap_or(match self {
103 Self::Photo => "jpg",
104 Self::Video | Self::VideoNote => "mp4",
105 Self::Sticker => "webp",
106 Self::Animation | Self::Audio => "bin",
107 });
108 format!(
109 "telegram-{}-{message_id}.{extension}",
110 self.as_str().replace('_', "-")
111 )
112 }
113}
114
115#[derive(Clone, Debug)]
116pub struct InboundMedia {
117 pub kind: &'static str,
118 pub file_id: FileId,
119 pub declared_size: Option<u32>,
120 pub text: Option<String>,
121 pub mime_type: Option<String>,
122 pub file_name: Option<String>,
123 pub duration_seconds: Option<i64>,
124 pub label: &'static str,
125}
126
127pub fn classify_message(message: &Message) -> Option<InboundMedia> {
128 if let Some(voice) = message.voice() {
129 return Some(InboundMedia {
130 kind: "voice",
131 file_id: voice.file.id.clone(),
132 declared_size: declared_file_size(voice.file.size),
133 text: message.caption().map(ToOwned::to_owned),
134 mime_type: Some(
135 voice
136 .mime_type
137 .as_ref()
138 .map(ToString::to_string)
139 .unwrap_or_else(|| "audio/ogg".into()),
140 ),
141 file_name: None,
142 duration_seconds: Some(i64::from(voice.duration.seconds())),
143 label: "voice note",
144 });
145 }
146
147 if let Some(animation) = message.animation() {
150 let provider_file_name = animation.file_name.clone();
151 let mime_type = animation
152 .mime_type
153 .as_ref()
154 .map(ToString::to_string)
155 .or_else(|| {
156 provider_file_name
157 .as_deref()
158 .and_then(mime_from_animation_extension)
159 .map(str::to_owned)
160 })
161 .unwrap_or_else(|| "application/octet-stream".into());
162 let kind = NativeMediaKind::Animation;
163 return Some(InboundMedia {
164 kind: kind.as_str(),
165 file_id: animation.file.id.clone(),
166 declared_size: declared_file_size(animation.file.size),
167 text: message.caption().map(ToOwned::to_owned),
168 file_name: Some(
169 provider_file_name
170 .unwrap_or_else(|| kind.default_file_name(i64::from(message.id.0), &mime_type)),
171 ),
172 mime_type: Some(mime_type),
173 duration_seconds: Some(i64::from(animation.duration.seconds())),
174 label: "animation",
175 });
176 }
177
178 if let Some(photo) = message.photo().and_then(select_photo) {
179 let kind = NativeMediaKind::Photo;
180 let mime_type = kind.fallback_mime(None);
181 return Some(InboundMedia {
182 kind: kind.as_str(),
183 file_id: photo.file.id.clone(),
184 declared_size: declared_file_size(photo.file.size),
185 text: message.caption().map(ToOwned::to_owned),
186 mime_type: Some(mime_type.into()),
187 file_name: Some(kind.default_file_name(i64::from(message.id.0), mime_type)),
188 duration_seconds: None,
189 label: "photo",
190 });
191 }
192
193 if let Some(video) = message.video() {
194 let kind = NativeMediaKind::Video;
195 let mime_type = video
196 .mime_type
197 .as_ref()
198 .map(ToString::to_string)
199 .unwrap_or_else(|| kind.fallback_mime(video.file_name.as_deref()).into());
200 return Some(InboundMedia {
201 kind: kind.as_str(),
202 file_id: video.file.id.clone(),
203 declared_size: declared_file_size(video.file.size),
204 text: message.caption().map(ToOwned::to_owned),
205 file_name: Some(
206 video
207 .file_name
208 .clone()
209 .unwrap_or_else(|| kind.default_file_name(i64::from(message.id.0), &mime_type)),
210 ),
211 mime_type: Some(mime_type),
212 duration_seconds: Some(i64::from(video.duration.seconds())),
213 label: "video",
214 });
215 }
216
217 if let Some(audio) = message.audio() {
218 let kind = NativeMediaKind::Audio;
219 let provider_file_name = audio.file_name.clone();
220 let mime_type = audio
221 .mime_type
222 .as_ref()
223 .map(ToString::to_string)
224 .or_else(|| {
225 provider_file_name
226 .as_deref()
227 .and_then(mime_from_audio_extension)
228 .map(str::to_owned)
229 })
230 .unwrap_or_else(|| "application/octet-stream".into());
231 return Some(InboundMedia {
232 kind: kind.as_str(),
233 file_id: audio.file.id.clone(),
234 declared_size: declared_file_size(audio.file.size),
235 text: message.caption().map(ToOwned::to_owned),
236 file_name: Some(
237 provider_file_name
238 .unwrap_or_else(|| kind.default_file_name(i64::from(message.id.0), &mime_type)),
239 ),
240 mime_type: Some(mime_type),
241 duration_seconds: Some(i64::from(audio.duration.seconds())),
242 label: "audio",
243 });
244 }
245
246 if let Some(video_note) = message.video_note() {
247 let kind = NativeMediaKind::VideoNote;
248 let mime_type = kind.fallback_mime(None);
249 return Some(InboundMedia {
250 kind: kind.as_str(),
251 file_id: video_note.file.id.clone(),
252 declared_size: declared_file_size(video_note.file.size),
253 text: None,
254 mime_type: Some(mime_type.into()),
255 file_name: Some(kind.default_file_name(i64::from(message.id.0), mime_type)),
256 duration_seconds: Some(i64::from(video_note.duration.seconds())),
257 label: "video note",
258 });
259 }
260
261 if let Some(sticker) = message.sticker() {
262 let kind = NativeMediaKind::Sticker;
263 let mime_type = match sticker.format() {
264 StickerFormat::Static => "image/webp",
265 StickerFormat::Animated => "application/x-tgsticker",
266 StickerFormat::Video => "video/webm",
267 };
268 return Some(InboundMedia {
269 kind: kind.as_str(),
270 file_id: sticker.file.id.clone(),
271 declared_size: declared_file_size(sticker.file.size),
272 text: sticker.emoji.clone(),
273 mime_type: Some(mime_type.into()),
274 file_name: Some(kind.default_file_name(i64::from(message.id.0), mime_type)),
275 duration_seconds: None,
276 label: "sticker",
277 });
278 }
279
280 if let Some(document) = message.document() {
281 return Some(InboundMedia {
282 kind: "document",
283 file_id: document.file.id.clone(),
284 declared_size: declared_file_size(document.file.size),
285 text: message.caption().map(ToOwned::to_owned),
286 mime_type: document.mime_type.as_ref().map(ToString::to_string),
287 file_name: Some(
288 document
289 .file_name
290 .clone()
291 .unwrap_or_else(|| "telegram-file".into()),
292 ),
293 duration_seconds: None,
294 label: "file",
295 });
296 }
297
298 None
299}
300
301fn select_photo(photos: &[teloxide::types::PhotoSize]) -> Option<&teloxide::types::PhotoSize> {
302 photos
303 .iter()
304 .enumerate()
305 .max_by_key(|(index, photo)| {
306 let area = u64::from(photo.width).saturating_mul(u64::from(photo.height));
307 (
308 area,
309 declared_file_size(photo.file.size).unwrap_or(0),
310 Reverse(*index),
311 )
312 })
313 .map(|(_, photo)| photo)
314}
315
316fn declared_file_size(size: u32) -> Option<u32> {
317 (size != u32::MAX).then_some(size)
318}
319
320pub fn is_retained_media_kind(kind: &str) -> bool {
321 matches!(kind, "voice" | "document") || NativeMediaKind::parse(kind).is_some()
322}
323
324pub fn is_audio_oriented(kind: &str) -> bool {
325 matches!(kind, "voice" | "audio" | "video_note")
326}
327
328pub fn message_duration(message: &Message, kind: NativeMediaKind) -> Option<i64> {
329 match kind {
330 NativeMediaKind::Video => message
331 .video()
332 .map(|media| i64::from(media.duration.seconds())),
333 NativeMediaKind::Animation => message
334 .animation()
335 .map(|media| i64::from(media.duration.seconds())),
336 NativeMediaKind::Audio => message
337 .audio()
338 .map(|media| i64::from(media.duration.seconds())),
339 NativeMediaKind::VideoNote => message
340 .video_note()
341 .map(|media| i64::from(media.duration.seconds())),
342 NativeMediaKind::Photo | NativeMediaKind::Sticker => None,
343 }
344}
345
346pub async fn send_native_media(
347 bot: &Bot,
348 chat_id: i64,
349 kind: NativeMediaKind,
350 bytes: &[u8],
351 file_name: &str,
352 caption: Option<&str>,
353 reply_parameters: Option<ReplyParameters>,
354) -> Result<Message, RequestError> {
355 let input_file = InputFile::memory(bytes.to_vec()).file_name(file_name.to_owned());
356 match kind {
357 NativeMediaKind::Photo => {
358 let mut request = bot.send_photo(teloxide::types::ChatId(chat_id), input_file);
359 if let Some(caption) = caption {
360 request = request.caption(caption.to_owned());
361 }
362 if let Some(reply_parameters) = reply_parameters {
363 request = request.reply_parameters(reply_parameters);
364 }
365 kcode_telegram_request_policy::retry_request("send_photo", || request.clone().send())
366 .await
367 }
368 NativeMediaKind::Video => {
369 let mut request = bot.send_video(teloxide::types::ChatId(chat_id), input_file);
370 if let Some(caption) = caption {
371 request = request.caption(caption.to_owned());
372 }
373 if let Some(reply_parameters) = reply_parameters {
374 request = request.reply_parameters(reply_parameters);
375 }
376 kcode_telegram_request_policy::retry_request("send_video", || request.clone().send())
377 .await
378 }
379 NativeMediaKind::Animation => {
380 let mut request = bot.send_animation(teloxide::types::ChatId(chat_id), input_file);
381 if let Some(caption) = caption {
382 request = request.caption(caption.to_owned());
383 }
384 if let Some(reply_parameters) = reply_parameters {
385 request = request.reply_parameters(reply_parameters);
386 }
387 kcode_telegram_request_policy::retry_request("send_animation", || {
388 request.clone().send()
389 })
390 .await
391 }
392 NativeMediaKind::Audio => {
393 let mut request = bot.send_audio(teloxide::types::ChatId(chat_id), input_file);
394 if let Some(caption) = caption {
395 request = request.caption(caption.to_owned());
396 }
397 if let Some(reply_parameters) = reply_parameters {
398 request = request.reply_parameters(reply_parameters);
399 }
400 kcode_telegram_request_policy::retry_request("send_audio", || request.clone().send())
401 .await
402 }
403 NativeMediaKind::VideoNote => {
404 let mut request = bot.send_video_note(teloxide::types::ChatId(chat_id), input_file);
405 if let Some(reply_parameters) = reply_parameters {
406 request = request.reply_parameters(reply_parameters);
407 }
408 kcode_telegram_request_policy::retry_request("send_video_note", || {
409 request.clone().send()
410 })
411 .await
412 }
413 NativeMediaKind::Sticker => {
414 let mut request = bot.send_sticker(teloxide::types::ChatId(chat_id), input_file);
415 if let Some(reply_parameters) = reply_parameters {
416 request = request.reply_parameters(reply_parameters);
417 }
418 kcode_telegram_request_policy::retry_request("send_sticker", || request.clone().send())
419 .await
420 }
421 }
422}
423
424fn lower_extension(file_name: &str) -> Option<String> {
425 let (_, extension) = file_name.rsplit_once('.')?;
426 (!extension.is_empty()).then(|| extension.to_ascii_lowercase())
427}
428
429fn mime_from_animation_extension(file_name: &str) -> Option<&'static str> {
430 match lower_extension(file_name)?.as_str() {
431 "gif" => Some("image/gif"),
432 "mp4" => Some("video/mp4"),
433 _ => None,
434 }
435}
436
437fn mime_from_audio_extension(file_name: &str) -> Option<&'static str> {
438 match lower_extension(file_name)?.as_str() {
439 "mp3" => Some("audio/mpeg"),
440 "m4a" | "mp4" => Some("audio/mp4"),
441 "ogg" | "oga" | "opus" => Some("audio/ogg"),
442 "wav" => Some("audio/wav"),
443 "flac" => Some("audio/flac"),
444 _ => None,
445 }
446}
447
448fn sticker_mime_from_file_name(file_name: Option<&str>) -> &'static str {
449 match file_name.and_then(lower_extension).as_deref() {
450 Some("tgs") => "application/x-tgsticker",
451 Some("webm") => "video/webm",
452 _ => "image/webp",
453 }
454}
455
456fn extension_for_mime(mime_type: &str) -> Option<&'static str> {
457 match mime_type.to_ascii_lowercase().as_str() {
458 "image/jpeg" => Some("jpg"),
459 "image/gif" => Some("gif"),
460 "image/webp" => Some("webp"),
461 "video/mp4" => Some("mp4"),
462 "video/webm" => Some("webm"),
463 "audio/mpeg" => Some("mp3"),
464 "audio/mp4" => Some("m4a"),
465 "audio/ogg" => Some("ogg"),
466 "audio/wav" => Some("wav"),
467 "audio/flac" => Some("flac"),
468 "application/x-tgsticker" => Some("tgs"),
469 _ => None,
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use std::sync::{Arc, Mutex};
476
477 use axum::{Json, Router, extract::State, routing::post};
478 use serde_json::Value;
479
480 use super::*;
481
482 fn message(value: serde_json::Value) -> Message {
483 serde_json::from_value(value).unwrap()
484 }
485
486 #[test]
487 fn native_kind_contract_is_cohesive() {
488 assert_eq!(
489 NATIVE_MEDIA_KINDS.map(NativeMediaKind::as_str),
490 [
491 "photo",
492 "video",
493 "animation",
494 "audio",
495 "video_note",
496 "sticker"
497 ]
498 );
499 assert!(NativeMediaKind::Photo.accepts_caption());
500 assert!(!NativeMediaKind::Sticker.accepts_caption());
501 assert!(is_audio_oriented("audio"));
502 assert!(!is_audio_oriented("video"));
503 }
504
505 #[test]
506 fn largest_photo_uses_area_then_size_then_stable_order() {
507 let message = message(serde_json::json!({
508 "message_id":123,
509 "date":1629404938,
510 "chat":{"id":42,"type":"private"},
511 "photo":[
512 {"file_id":"first","file_unique_id":"a","width":100,"height":100,"file_size":20},
513 {"file_id":"large-small","file_unique_id":"b","width":200,"height":100,"file_size":10},
514 {"file_id":"large-big","file_unique_id":"c","width":100,"height":200,"file_size":30},
515 {"file_id":"large-big-later","file_unique_id":"d","width":100,"height":200,"file_size":30},
516 {"file_id":"large-missing-size","file_unique_id":"e","width":100,"height":200}
517 ],
518 "caption":"exact caption"
519 }));
520 let media = classify_message(&message).unwrap();
521 assert_eq!(media.kind, "photo");
522 assert_eq!(media.file_id.0, "large-big");
523 assert_eq!(media.text.as_deref(), Some("exact caption"));
524 assert_eq!(media.file_name.as_deref(), Some("telegram-photo-123.jpg"));
525 }
526
527 #[test]
528 fn animation_precedes_compatibility_document_and_uncertain_mime_is_honest() {
529 let animation = serde_json::json!({
530 "file_id":"animation",
531 "file_unique_id":"a",
532 "width":320,
533 "height":240,
534 "duration":3,
535 "file_size":50,
536 "mime_type":null
537 });
538 let compatibility_document = serde_json::json!({
539 "file_id":"document",
540 "file_unique_id":"d",
541 "file_size":50
542 });
543 let direct_animation =
544 serde_json::from_value::<teloxide::types::MediaAnimation>(serde_json::json!({
545 "animation":animation.clone(),
546 "document":compatibility_document.clone()
547 }));
548 assert!(
549 direct_animation.is_ok(),
550 "animation fixture must be valid: {direct_animation:?}"
551 );
552 let provider_kind: teloxide::types::MediaKind = serde_json::from_value(serde_json::json!({
553 "animation":animation.clone(),
554 "document":compatibility_document
555 }))
556 .unwrap();
557 assert!(matches!(
558 provider_kind,
559 teloxide::types::MediaKind::Animation(_)
560 ));
561
562 let message = message(serde_json::json!({
563 "message_id":5,
564 "date":1629404938,
565 "chat":{"id":42,"type":"private"},
566 "animation":animation
567 }));
568 let media = classify_message(&message).unwrap();
569 assert_eq!(media.kind, "animation");
570 assert_eq!(media.mime_type.as_deref(), Some("application/octet-stream"));
571 assert_eq!(media.file_name.as_deref(), Some("telegram-animation-5.bin"));
572 }
573
574 #[test]
575 fn documents_keep_document_semantics_regardless_of_mime() {
576 let message = message(serde_json::json!({
577 "message_id":6,
578 "date":1629404938,
579 "chat":{"id":42,"type":"private"},
580 "document":{
581 "file_id":"document",
582 "file_unique_id":"d",
583 "file_size":50,
584 "file_name":"picture.png",
585 "mime_type":"image/png"
586 }
587 }));
588 let media = classify_message(&message).unwrap();
589 assert_eq!(media.kind, "document");
590 assert_eq!(media.mime_type.as_deref(), Some("image/png"));
591 }
592
593 #[test]
594 fn voice_video_audio_and_video_note_keep_essential_metadata() {
595 let cases = [
596 (
597 serde_json::json!({
598 "message_id":7,
599 "date":1629404938,
600 "chat":{"id":42,"type":"private"},
601 "voice":{
602 "file_id":"voice",
603 "file_unique_id":"voice-unique",
604 "file_size":50,
605 "duration":8,
606 "mime_type":"audio/ogg"
607 },
608 "caption":"voice caption"
609 }),
610 "voice",
611 Some("voice caption"),
612 None,
613 Some("audio/ogg"),
614 Some(8),
615 ),
616 (
617 serde_json::json!({
618 "message_id":8,
619 "date":1629404938,
620 "chat":{"id":42,"type":"private"},
621 "video":{
622 "file_id":"video",
623 "file_unique_id":"v",
624 "file_size":51,
625 "width":640,
626 "height":480,
627 "duration":9,
628 "file_name":"clip.mp4",
629 "mime_type":"video/mp4"
630 },
631 "caption":"video caption"
632 }),
633 "video",
634 Some("video caption"),
635 Some("clip.mp4"),
636 Some("video/mp4"),
637 Some(9),
638 ),
639 (
640 serde_json::json!({
641 "message_id":9,
642 "date":1629404938,
643 "chat":{"id":42,"type":"private"},
644 "audio":{
645 "file_id":"audio",
646 "file_unique_id":"a",
647 "file_size":52,
648 "duration":10,
649 "file_name":"track.ogg",
650 "mime_type":"audio/ogg"
651 },
652 "caption":"audio caption"
653 }),
654 "audio",
655 Some("audio caption"),
656 Some("track.ogg"),
657 Some("audio/ogg"),
658 Some(10),
659 ),
660 (
661 serde_json::json!({
662 "message_id":10,
663 "date":1629404938,
664 "chat":{"id":42,"type":"private"},
665 "video_note":{
666 "file_id":"note",
667 "file_unique_id":"n",
668 "file_size":53,
669 "length":240,
670 "duration":11
671 }
672 }),
673 "video_note",
674 None,
675 Some("telegram-video-note-10.mp4"),
676 Some("video/mp4"),
677 Some(11),
678 ),
679 ];
680 for (message_value, kind, text, file_name, mime_type, duration) in cases {
681 let media = classify_message(&message(message_value)).unwrap();
682 assert_eq!(media.kind, kind);
683 assert_eq!(media.text.as_deref(), text);
684 assert_eq!(media.file_name.as_deref(), file_name);
685 assert_eq!(media.mime_type.as_deref(), mime_type);
686 assert_eq!(media.duration_seconds, duration);
687 }
688 }
689
690 #[test]
691 fn sticker_fallbacks_follow_provider_format() {
692 for (flags, expected_mime, expected_suffix) in [
693 (
694 serde_json::json!({"is_animated":false,"is_video":false}),
695 "image/webp",
696 ".webp",
697 ),
698 (
699 serde_json::json!({"is_animated":true,"is_video":false}),
700 "application/x-tgsticker",
701 ".tgs",
702 ),
703 (
704 serde_json::json!({"is_animated":false,"is_video":true}),
705 "video/webm",
706 ".webm",
707 ),
708 ] {
709 let mut sticker = serde_json::json!({
710 "file_id":"sticker",
711 "file_unique_id":"s",
712 "file_size":25,
713 "width":512,
714 "height":512,
715 "type":"regular",
716 "emoji":"🙂"
717 });
718 sticker
719 .as_object_mut()
720 .unwrap()
721 .extend(flags.as_object().unwrap().clone());
722 let message = message(serde_json::json!({
723 "message_id":7,
724 "date":1629404938,
725 "chat":{"id":42,"type":"private"},
726 "sticker":sticker
727 }));
728 let media = classify_message(&message).unwrap();
729 assert_eq!(media.mime_type.as_deref(), Some(expected_mime));
730 assert!(media.file_name.unwrap().ends_with(expected_suffix));
731 assert_eq!(media.text.as_deref(), Some("🙂"));
732 }
733 }
734
735 #[tokio::test]
736 async fn every_native_kind_uses_its_native_telegram_method() {
737 async fn accept(
738 State(paths): State<Arc<Mutex<Vec<String>>>>,
739 uri: axum::http::Uri,
740 ) -> Json<Value> {
741 paths.lock().unwrap().push(uri.path().to_ascii_lowercase());
742 Json(serde_json::json!({
743 "ok":true,
744 "result":{
745 "message_id":900,
746 "date":1629404938,
747 "from":{
748 "id":999,
749 "is_bot":true,
750 "first_name":"Kennedy",
751 "username":"KennedyBot"
752 },
753 "chat":{"id":42,"first_name":"David","type":"private"},
754 "text":"accepted"
755 }
756 }))
757 }
758
759 let paths = Arc::new(Mutex::new(Vec::new()));
760 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
761 let address = listener.local_addr().unwrap();
762 let app = Router::new()
763 .fallback(post(accept))
764 .with_state(paths.clone());
765 let server = tokio::spawn(async move {
766 axum::serve(listener, app).await.unwrap();
767 });
768 let bot = Bot::new("test-token").set_api_url(format!("http://{address}").parse().unwrap());
769
770 for kind in NATIVE_MEDIA_KINDS {
771 let caption = kind.accepts_caption().then_some("caption");
772 send_native_media(
773 &bot,
774 42,
775 kind,
776 b"media",
777 &kind.default_file_name(1, kind.fallback_mime(None)),
778 caption,
779 None,
780 )
781 .await
782 .unwrap();
783 }
784
785 assert_eq!(
786 *paths.lock().unwrap(),
787 [
788 "/bottest-token/sendphoto",
789 "/bottest-token/sendvideo",
790 "/bottest-token/sendanimation",
791 "/bottest-token/sendaudio",
792 "/bottest-token/sendvideonote",
793 "/bottest-token/sendsticker",
794 ]
795 );
796 server.abort();
797 }
798}