Skip to content

Commit 4b5d245

Browse files
author
Federico Liva
committed
feat(channels): multipart fallback for private URLs on sendDocument/Voice/Audio
Follow-up to d912f93 (silent-success fix). Even with proper error propagation, agents sending media via `file_url` pointing at an internal container IP still see deliveries fail because Telegram's cloud cannot reach private addresses — the agent just gets a clearer error instead. This commit makes that path actually work without requiring a public tunnel. - New `is_private_url(url)` helper detects loopback, RFC1918 private ranges, link-local (IPv4 + IPv6), and `localhost`. - New `fetch_url_bytes(client, url)` downloads the body + `Content-Type` from inside the container (where the private IP is routable). - New `url_filename(url, fallback)` derives a reasonable filename from the URL's last path segment. - `api_send_document_upload` is now a thin wrapper over a generalised `api_send_media_upload(endpoint, field_name, ..., extra, thread_id)` that supports any Telegram `send{Media}` endpoint. - `api_send_document`, `api_send_voice`, `api_send_audio` each check their URL argument: public URLs continue to pass through to `api_send_media_request` (Telegram's CDN fetches them); private URLs are downloaded locally and re-uploaded via multipart, preserving the caption / title / performer metadata. Result: an agent running inside the container can point `file_url` at its own HTTP server on `127.0.0.1` or a container-internal IP (`172.28.x.x`) and the file reaches the user, no external tunnel required. Scope still excludes: - Loosening the sandbox requirement on the `file_path` branch — that belongs to the manifest / config layer, not the adapter. - Photo / video / animation multipart fallback — trivial follow-up once the pattern is validated on document / voice / audio, which are the paths agents actually hit for voice notes and file delivery.
1 parent d912f93 commit 4b5d245

1 file changed

Lines changed: 205 additions & 31 deletions

File tree

crates/librefang-channels/src/telegram.rs

Lines changed: 205 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,67 @@ fn extract_retry_after(body: &str, default: u64) -> u64 {
5353
/// Telegram `parse_mode` for HTML formatting.
5454
const 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)]
58119
pub 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

Comments
 (0)