@@ -53,6 +53,67 @@ fn extract_retry_after(body: &str, default: u64) -> u64 {
5353/// Telegram `parse_mode` for HTML formatting.
5454const PARSE_MODE_HTML : & str = "HTML" ;
5555
56+ /// Returns `true` when `url_str` points at a host that Telegram's public
57+ /// cloud cannot reach — loopback, RFC1918 private ranges, link-local,
58+ /// `localhost`, or an unparseable URL. Used to short-circuit URL-based
59+ /// Bot API calls and fall back to multipart upload from inside the
60+ /// container, where these addresses are actually routable.
61+ fn is_private_url ( url_str : & str ) -> bool {
62+ let Ok ( parsed) = url:: Url :: parse ( url_str) else {
63+ return false ;
64+ } ;
65+ match parsed. host ( ) {
66+ Some ( url:: Host :: Domain ( d) ) => d. eq_ignore_ascii_case ( "localhost" ) ,
67+ Some ( url:: Host :: Ipv4 ( addr) ) => {
68+ addr. is_loopback ( ) || addr. is_private ( ) || addr. is_link_local ( )
69+ }
70+ Some ( url:: Host :: Ipv6 ( addr) ) => {
71+ addr. is_loopback ( )
72+ || ( addr. segments ( ) [ 0 ] & 0xfe00 ) == 0xfc00 // fc00::/7 unique-local
73+ || ( addr. segments ( ) [ 0 ] & 0xffc0 ) == 0xfe80 // fe80::/10 link-local
74+ }
75+ None => false ,
76+ }
77+ }
78+
79+ /// Best-effort filename extracted from the last path segment of `url_str`,
80+ /// falling back to a generic name if the URL has no usable path.
81+ fn url_filename ( url_str : & str , fallback : & str ) -> String {
82+ url:: Url :: parse ( url_str)
83+ . ok ( )
84+ . and_then ( |u| {
85+ u. path_segments ( )
86+ . and_then ( |segs| segs. last ( ) . map ( |s| s. to_string ( ) ) )
87+ . filter ( |s| !s. is_empty ( ) )
88+ } )
89+ . unwrap_or_else ( || fallback. to_string ( ) )
90+ }
91+
92+ /// Fetch bytes from an internal-network URL for re-upload via multipart.
93+ /// Returns the body plus the `Content-Type` header (falling back to
94+ /// `application/octet-stream` when the origin doesn't announce one).
95+ async fn fetch_url_bytes (
96+ client : & reqwest:: Client ,
97+ url_str : & str ,
98+ ) -> Result < ( Vec < u8 > , String ) , Box < dyn std:: error:: Error + Send + Sync > > {
99+ let resp = client. get ( url_str) . send ( ) . await ?;
100+ if !resp. status ( ) . is_success ( ) {
101+ return Err ( format ! (
102+ "Failed to fetch {url_str} for multipart fallback: HTTP {}" ,
103+ resp. status( )
104+ )
105+ . into ( ) ) ;
106+ }
107+ let mime = resp
108+ . headers ( )
109+ . get ( reqwest:: header:: CONTENT_TYPE )
110+ . and_then ( |v| v. to_str ( ) . ok ( ) )
111+ . unwrap_or ( "application/octet-stream" )
112+ . to_string ( ) ;
113+ let bytes = resp. bytes ( ) . await ?. to_vec ( ) ;
114+ Ok ( ( bytes, mime) )
115+ }
116+
56117/// A Telegram bot command definition for the command menu.
57118#[ derive( Debug , Clone , serde:: Serialize , serde:: Deserialize ) ]
58119pub struct BotCommand {
@@ -477,6 +538,29 @@ impl TelegramAdapter {
477538 filename : & str ,
478539 thread_id : Option < i64 > ,
479540 ) -> Result < ( ) , Box < dyn std:: error:: Error + Send + Sync > > {
541+ // Private-network URLs can't be fetched by Telegram's cloud. We
542+ // download them from inside the container (where the address is
543+ // routable) and re-upload via multipart, so the agent doesn't need
544+ // a public tunnel just to deliver a locally-served file.
545+ if is_private_url ( document_url) {
546+ info ! (
547+ url = document_url,
548+ "Private URL detected on sendDocument, falling back to multipart upload"
549+ ) ;
550+ let ( bytes, mime) = fetch_url_bytes ( & self . client , document_url) . await ?;
551+ return self
552+ . api_send_media_upload (
553+ "sendDocument" ,
554+ "document" ,
555+ chat_id,
556+ bytes,
557+ filename,
558+ & mime,
559+ Some ( & [ ( "caption" , filename. to_string ( ) ) ] ) ,
560+ thread_id,
561+ )
562+ . await ;
563+ }
480564 let body = serde_json:: json!( {
481565 "document" : document_url,
482566 "caption" : filename,
@@ -497,64 +581,98 @@ impl TelegramAdapter {
497581 filename : & str ,
498582 mime_type : & str ,
499583 thread_id : Option < i64 > ,
584+ ) -> Result < ( ) , Box < dyn std:: error:: Error + Send + Sync > > {
585+ self . api_send_media_upload (
586+ "sendDocument" ,
587+ "document" ,
588+ chat_id,
589+ data,
590+ filename,
591+ mime_type,
592+ None ,
593+ thread_id,
594+ )
595+ . await
596+ }
597+
598+ /// Generic multipart upload for any Telegram `send{Media}` endpoint.
599+ ///
600+ /// `field_name` is the form field the API expects for the payload
601+ /// (`document`, `voice`, `audio`, `photo`, `video`, …). `extra` merges
602+ /// optional text fields (captions, title, performer) into the form.
603+ /// Retries once on HTTP 429 like the URL path.
604+ #[ allow( clippy:: too_many_arguments) ]
605+ async fn api_send_media_upload (
606+ & self ,
607+ endpoint : & ' static str ,
608+ field_name : & ' static str ,
609+ chat_id : i64 ,
610+ data : Vec < u8 > ,
611+ filename : & str ,
612+ mime_type : & str ,
613+ extra : Option < & [ ( & str , String ) ] > ,
614+ thread_id : Option < i64 > ,
500615 ) -> Result < ( ) , Box < dyn std:: error:: Error + Send + Sync > > {
501616 let url = format ! (
502- "{}/bot{}/sendDocument " ,
617+ "{}/bot{}/{endpoint} " ,
503618 self . api_base_url,
504619 self . token. as_str( )
505620 ) ;
506621
507- // Convert to ref-counted Bytes so cloning is O(1) (atomic ref-count bump)
508- // instead of O(n) Vec deep-copy. Part::stream() accepts Bytes directly
509- // (Into<Body>), unlike Part::bytes() which requires Into<Cow<'static, [u8]>>.
510622 let data_bytes = bytes:: Bytes :: from ( data) ;
511623
512- let file_part = reqwest:: multipart:: Part :: stream ( data_bytes. clone ( ) )
513- . file_name ( filename. to_string ( ) )
514- . mime_str ( mime_type) ?;
515-
516- let mut form = reqwest:: multipart:: Form :: new ( )
517- . text ( "chat_id" , chat_id. to_string ( ) )
518- . part ( "document" , file_part) ;
519-
520- if let Some ( tid) = thread_id {
521- form = form. text ( "message_thread_id" , tid. to_string ( ) ) ;
522- }
624+ let build_form =
625+ || -> Result < reqwest:: multipart:: Form , Box < dyn std:: error:: Error + Send + Sync > > {
626+ let part = reqwest:: multipart:: Part :: stream ( data_bytes. clone ( ) )
627+ . file_name ( filename. to_string ( ) )
628+ . mime_str ( mime_type) ?;
629+ let mut form = reqwest:: multipart:: Form :: new ( )
630+ . text ( "chat_id" , chat_id. to_string ( ) )
631+ . part ( field_name, part) ;
632+ if let Some ( tid) = thread_id {
633+ form = form. text ( "message_thread_id" , tid. to_string ( ) ) ;
634+ }
635+ if let Some ( kv) = extra {
636+ for ( k, v) in kv {
637+ form = form. text ( k. to_string ( ) , v. clone ( ) ) ;
638+ }
639+ }
640+ Ok ( form)
641+ } ;
523642
524- let resp = self . client . post ( & url) . multipart ( form) . send ( ) . await ?;
643+ let resp = self
644+ . client
645+ . post ( & url)
646+ . multipart ( build_form ( ) ?)
647+ . send ( )
648+ . await ?;
525649 if !resp. status ( ) . is_success ( ) {
526650 let status = resp. status ( ) ;
527651 let body_text = resp. text ( ) . await . unwrap_or_default ( ) ;
528652
529653 if status. as_u16 ( ) == 429 {
530654 let retry_after = extract_retry_after ( & body_text, RETRY_AFTER_DEFAULT_SECS ) ;
531- warn ! ( "Telegram sendDocument upload rate limited, retrying after {retry_after}s" ) ;
655+ warn ! ( "Telegram {endpoint} upload rate limited, retrying after {retry_after}s" ) ;
532656 tokio:: time:: sleep ( Duration :: from_secs ( retry_after) ) . await ;
533657
534- // Rebuild the multipart form — Bytes::clone() is O(1)
535- let file_part = reqwest:: multipart:: Part :: stream ( data_bytes. clone ( ) )
536- . file_name ( filename. to_string ( ) )
537- . mime_str ( mime_type) ?;
538- let mut retry_form = reqwest:: multipart:: Form :: new ( )
539- . text ( "chat_id" , chat_id. to_string ( ) )
540- . part ( "document" , file_part) ;
541- if let Some ( tid) = thread_id {
542- retry_form = retry_form. text ( "message_thread_id" , tid. to_string ( ) ) ;
543- }
544-
545- let resp2 = self . client . post ( & url) . multipart ( retry_form) . send ( ) . await ?;
658+ let resp2 = self
659+ . client
660+ . post ( & url)
661+ . multipart ( build_form ( ) ?)
662+ . send ( )
663+ . await ?;
546664 if !resp2. status ( ) . is_success ( ) {
547665 let body_text2 = resp2. text ( ) . await . unwrap_or_default ( ) ;
548666 return Err ( format ! (
549- "Telegram sendDocument upload failed after retry: {body_text2}"
667+ "Telegram {endpoint} upload failed after retry: {body_text2}"
550668 )
551669 . into ( ) ) ;
552670 }
553671 return Ok ( ( ) ) ;
554672 }
555673
556674 return Err (
557- format ! ( "Telegram sendDocument upload failed ({status}): {body_text}" ) . into ( ) ,
675+ format ! ( "Telegram {endpoint} upload failed ({status}): {body_text}" ) . into ( ) ,
558676 ) ;
559677 }
560678 Ok ( ( ) )
@@ -568,6 +686,31 @@ impl TelegramAdapter {
568686 caption : Option < & str > ,
569687 thread_id : Option < i64 > ,
570688 ) -> Result < ( ) , Box < dyn std:: error:: Error + Send + Sync > > {
689+ if is_private_url ( voice_url) {
690+ info ! (
691+ url = voice_url,
692+ "Private URL detected on sendVoice, falling back to multipart upload"
693+ ) ;
694+ let ( bytes, mime) = fetch_url_bytes ( & self . client , voice_url) . await ?;
695+ let filename = url_filename ( voice_url, "voice.ogg" ) ;
696+ let mut extra: Vec < ( & str , String ) > = Vec :: new ( ) ;
697+ if let Some ( cap) = caption {
698+ extra. push ( ( "caption" , cap. to_string ( ) ) ) ;
699+ extra. push ( ( "parse_mode" , PARSE_MODE_HTML . to_string ( ) ) ) ;
700+ }
701+ return self
702+ . api_send_media_upload (
703+ "sendVoice" ,
704+ "voice" ,
705+ chat_id,
706+ bytes,
707+ & filename,
708+ & mime,
709+ Some ( & extra) ,
710+ thread_id,
711+ )
712+ . await ;
713+ }
571714 let mut body = serde_json:: json!( { "voice" : voice_url } ) ;
572715 if let Some ( cap) = caption {
573716 body[ "caption" ] = serde_json:: Value :: String ( cap. to_string ( ) ) ;
@@ -590,6 +733,37 @@ impl TelegramAdapter {
590733 performer : Option < & str > ,
591734 thread_id : Option < i64 > ,
592735 ) -> Result < ( ) , Box < dyn std:: error:: Error + Send + Sync > > {
736+ if is_private_url ( audio_url) {
737+ info ! (
738+ url = audio_url,
739+ "Private URL detected on sendAudio, falling back to multipart upload"
740+ ) ;
741+ let ( bytes, mime) = fetch_url_bytes ( & self . client , audio_url) . await ?;
742+ let filename = url_filename ( audio_url, "audio.mp3" ) ;
743+ let mut extra: Vec < ( & str , String ) > = Vec :: new ( ) ;
744+ if let Some ( cap) = caption {
745+ extra. push ( ( "caption" , cap. to_string ( ) ) ) ;
746+ extra. push ( ( "parse_mode" , PARSE_MODE_HTML . to_string ( ) ) ) ;
747+ }
748+ if let Some ( t) = title {
749+ extra. push ( ( "title" , t. to_string ( ) ) ) ;
750+ }
751+ if let Some ( p) = performer {
752+ extra. push ( ( "performer" , p. to_string ( ) ) ) ;
753+ }
754+ return self
755+ . api_send_media_upload (
756+ "sendAudio" ,
757+ "audio" ,
758+ chat_id,
759+ bytes,
760+ & filename,
761+ & mime,
762+ Some ( & extra) ,
763+ thread_id,
764+ )
765+ . await ;
766+ }
593767 let mut body = serde_json:: json!( { "audio" : audio_url } ) ;
594768 if let Some ( cap) = caption {
595769 body[ "caption" ] = serde_json:: Value :: String ( cap. to_string ( ) ) ;
0 commit comments