Skip to main content

kcode_telegram_native_media/
lib.rs

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    // Telegram may also expose an animation through its compatibility document
148    // representation, so native animation must be tested before document.
149    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}