Skip to content

Commit 74722eb

Browse files
committed
[Telegram] Send image as document if aspect ratio exceeds 20
1 parent 3f1f25e commit 74722eb

File tree

3 files changed

+133
-30
lines changed

3 files changed

+133
-30
lines changed

src/platform/telegram/notify/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -677,7 +677,7 @@ impl Notifier {
677677
Media::Video(MediaVideo {
678678
input: MediaInput::Memory {
679679
data: playback.file.data.clone(),
680-
filename: Some(&playback.file.name),
680+
filename: Some(Cow::Borrowed(&playback.file.name)),
681681
},
682682
resolution: Some(playback.resolution),
683683
has_spoiler: false,
@@ -756,7 +756,7 @@ impl Notifier {
756756
MediaDocument {
757757
input: MediaInput::Memory {
758758
data: document.file.data.clone(),
759-
filename: Some(&document.file.name),
759+
filename: Some(Cow::Borrowed(&document.file.name)),
760760
},
761761
},
762762
)

src/platform/telegram/notify/request.rs

Lines changed: 128 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ use bytes::Bytes;
55
use http::Uri;
66
use image::{imageops::FilterType as ImageFilterType, DynamicImage, GenericImageView, ImageFormat};
77
use itertools::Itertools;
8-
use reqwest::multipart::{Form, Part};
8+
use reqwest::{
9+
multipart::{Form, Part},
10+
Url,
11+
};
912
use serde::{
1013
de::{DeserializeOwned, IgnoredAny},
1114
Deserialize,
@@ -17,6 +20,7 @@ use super::super::{ConfigApiServer, ConfigChat};
1720
use crate::{
1821
config::Config,
1922
helper::{self, VideoResolution},
23+
platform::twitter::source::TWITTER_IMAGE_URL_END_TAG,
2024
source::{PostAttachmentImage, PostAttachmentVideo, PostContent, PostContentPart},
2125
};
2226

@@ -483,24 +487,62 @@ impl<'a> Media<'a> {
483487
Self::Document(document) => document.input,
484488
}
485489
}
490+
491+
fn convert_to_document(&mut self) {
492+
let dropped = MediaInput::Url("* should never happen *");
493+
match self {
494+
Self::Document(_) => {}
495+
Self::Photo(photo) => {
496+
*self = Self::Document(MediaDocument {
497+
input: mem::replace(&mut photo.input, dropped),
498+
});
499+
}
500+
Self::Video(video) => {
501+
*self = Self::Document(MediaDocument {
502+
input: mem::replace(&mut video.input, dropped),
503+
});
504+
}
505+
}
506+
}
486507
}
487508

488509
#[derive(Clone)]
489510
pub enum MediaInput<'a> {
490511
Url(&'a str),
491512
Memory {
492513
data: Bytes,
493-
filename: Option<&'a str>,
514+
filename: Option<Cow<'a, str>>,
494515
},
495516
}
496517

497-
impl MediaInput<'_> {
518+
impl<'a> MediaInput<'a> {
519+
fn as_memory(&self) -> Option<&Bytes> {
520+
match self {
521+
Self::Memory { data, .. } => Some(data),
522+
_ => None,
523+
}
524+
}
525+
498526
fn to_url(&self, index: usize) -> Cow<'_, str> {
499527
match self {
500528
Self::Url(url) => Cow::Borrowed(url),
501529
Self::Memory { .. } => Cow::Owned(format!("attach://{index}")),
502530
}
503531
}
532+
533+
fn extract_filename<'b>(&'a self) -> Option<Cow<'b, str>>
534+
where
535+
'a: 'b,
536+
{
537+
match self {
538+
Self::Url(url) => Url::parse(url).ok().and_then(|url| {
539+
url.path_segments()
540+
.and_then(|mut segments| segments.next_back())
541+
.map(|filename| Cow::Owned(filename.to_string()))
542+
}),
543+
Self::Memory { filename, .. } => filename.as_deref().map(Cow::Borrowed),
544+
}
545+
}
504546
}
505547

506548
impl fmt::Debug for MediaInput<'_> {
@@ -613,30 +655,53 @@ impl<'a> SendMedia<'a> {
613655
}
614656
}
615657

616-
pub async fn send(self) -> anyhow::Result<Response<ResultMessage>> {
658+
pub async fn send(mut self) -> anyhow::Result<Response<ResultMessage>> {
617659
let mut body = json!(
618660
{
619661
"chat_id": self.chat.to_json(),
620662
"message_thread_id": self.thread_id,
621663
"disable_notification": self.disable_notification
622664
}
623665
);
624-
let (method, url, retry_multipart) = match &self.media {
625-
Media::Photo(photo) => {
626-
let url = photo.input.to_url(0);
627-
body["photo"] = url.clone().into();
628-
body["has_spoiler"] = photo.has_spoiler.into();
629-
("sendPhoto", url, matches!(photo.input, MediaInput::Url(_)))
630-
}
666+
667+
let mut convert_to_document = false;
668+
let (mut method, url, retry_multipart) = match &mut self.media {
669+
Media::Photo(photo) => match &photo.input {
670+
MediaInput::Url(_) => {
671+
let url = photo.input.to_url(0).to_string();
672+
body["photo"] = url.clone().into();
673+
body["has_spoiler"] = photo.has_spoiler.into();
674+
("sendPhoto", url, true)
675+
}
676+
MediaInput::Memory { data, .. } => {
677+
// Width and height ratio must be at most 20.
678+
if check_image_aspect_radio(data) {
679+
let url = photo.input.to_url(0).to_string();
680+
body["photo"] = url.clone().into();
681+
body["has_spoiler"] = photo.has_spoiler.into();
682+
("sendPhoto", url, false)
683+
} else {
684+
// If the ratio is not satisfied, send as document (image without
685+
// compression)
686+
//
687+
// TODO: Do the same thing for send_media_group
688+
warn!("photo aspect ratio exceeds limit, sending as document instead @1");
689+
convert_to_document = true;
690+
let url = photo.input.to_url(0).to_string();
691+
body["document"] = url.clone().into();
692+
("sendDocument", url, false)
693+
}
694+
}
695+
},
631696
Media::Video(video) => {
632-
let url = video.input.to_url(0);
697+
let url = video.input.to_url(0).to_string();
633698
body["video"] = url.clone().into();
634699
body["supports_streaming"] = true.into();
635700
body["has_spoiler"] = video.has_spoiler.into();
636701
("sendVideo", url, matches!(video.input, MediaInput::Url(_)))
637702
}
638703
Media::Document(document) => {
639-
let url = document.input.to_url(0);
704+
let url = document.input.to_url(0).to_string();
640705
body["document"] = url.clone().into();
641706
(
642707
"sendDocument",
@@ -645,6 +710,9 @@ impl<'a> SendMedia<'a> {
645710
)
646711
}
647712
};
713+
if convert_to_document {
714+
self.media.convert_to_document();
715+
}
648716
if let Some(text) = self.text {
649717
let (text, entities) = text.into_json();
650718
let body = body.as_object_mut().unwrap();
@@ -662,10 +730,20 @@ impl<'a> SendMedia<'a> {
662730
if retry_multipart && is_media_failure(&resp) {
663731
warn!("failed to send media with URL, retrying with HTTP multipart. url '{url}', description '{}'", resp.description.as_deref().unwrap_or("*no description*"));
664732

665-
let downloaded = download_file(self.media).await?;
733+
let mut downloaded = download_file(self.media).await?;
666734
match &downloaded {
667735
Media::Photo(photo) => {
668-
body["photo"] = photo.input.to_url(0).into();
736+
if check_image_aspect_radio(photo.input.as_memory().unwrap()) {
737+
body["photo"] = photo.input.to_url(0).into();
738+
} else {
739+
warn!("photo aspect ratio exceeds limit, sending as document instead @2");
740+
let body = body.as_object_mut().unwrap();
741+
body.remove("photo");
742+
body.remove("has_spoiler");
743+
body.insert("document".into(), dbg!(&photo.input).to_url(0).into());
744+
method = "sendDocument";
745+
downloaded.convert_to_document();
746+
}
669747
}
670748
Media::Video(video) => {
671749
body["video"] = video.input.to_url(0).into();
@@ -1214,10 +1292,17 @@ async fn download_file<'a>(mut file: Media<'a>) -> anyhow::Result<Media<'a>> {
12141292
anyhow!("{rustfmt_bug}: {err}, status: {status} from url '{url}'")
12151293
})?;
12161294

1217-
*input = MediaInput::Memory {
1218-
data,
1219-
filename: None,
1220-
};
1295+
let filename = Url::parse(url).ok().and_then(|url| {
1296+
url.path_segments()
1297+
.and_then(|mut segments| segments.next_back())
1298+
.map(|filename| {
1299+
// Workaround for Twitter image URLs
1300+
let filename = filename.trim_end_matches(TWITTER_IMAGE_URL_END_TAG);
1301+
Cow::Owned(filename.to_string())
1302+
})
1303+
});
1304+
1305+
*input = MediaInput::Memory { data, filename };
12211306
// TODO: Replace failed image with a fallback image
12221307
Ok(file)
12231308
}
@@ -1233,20 +1318,25 @@ async fn download_files<'a>(
12331318
Ok(ret)
12341319
}
12351320

1321+
fn image(bytes: &Bytes) -> anyhow::Result<(DynamicImage, Option<ImageFormat>)> {
1322+
let image_reader = image::ImageReader::new(Cursor::new(&bytes))
1323+
.with_guessed_format()
1324+
.map_err(|err| anyhow!("failed to guess format for downloaded image: {err}"))?;
1325+
1326+
let format = image_reader.format();
1327+
let image = image_reader
1328+
.decode()
1329+
.map_err(|err| anyhow!("failed to decode downloaded image: {err}"))?;
1330+
Ok((image, format))
1331+
}
1332+
12361333
macro_rules! workaround_rustfmt_bug {
12371334
(trying) => {"photo #{} bytes size exceeds the limit, try scaling down with ratio {} from {},{} to {},{}, now the binary size is {}, attempt {}"};
12381335
(giving_up) => {"photo #{} bytes size still exceeds the limit after 10 iterations of scaling down, giving up"};
12391336
}
12401337
fn media_into_part(i: usize, bytes: Bytes, is_photo: bool) -> anyhow::Result<Part> {
12411338
let part = if is_photo {
1242-
let image_reader = image::ImageReader::new(Cursor::new(&bytes))
1243-
.with_guessed_format()
1244-
.map_err(|err| anyhow!("failed to guess format for downloaded image: {err}"))?;
1245-
1246-
let format = image_reader.format();
1247-
let image = image_reader
1248-
.decode()
1249-
.map_err(|err| anyhow!("failed to decode downloaded image: {err}"))?;
1339+
let (image, format) = image(&bytes)?;
12501340

12511341
fn adjust_dimensions(i: usize, mut image: DynamicImage) -> DynamicImage {
12521342
// Based on my testing, the actual limit is <=10001 :)
@@ -1332,6 +1422,17 @@ fn media_into_part(i: usize, bytes: Bytes, is_photo: bool) -> anyhow::Result<Par
13321422
Ok(part)
13331423
}
13341424

1425+
fn check_image_aspect_radio(bytes: &Bytes) -> bool {
1426+
fn check_image_radio_impl(bytes: &Bytes) -> anyhow::Result<bool> {
1427+
let (image, _) = image(bytes)?;
1428+
let (width, height) = image.dimensions();
1429+
Ok(width / height <= 20 && height / width <= 20)
1430+
}
1431+
check_image_radio_impl(bytes)
1432+
.inspect_err(|err| warn!("failed to check image aspect radio: {err}, assuming satisfied"))
1433+
.unwrap_or(true)
1434+
}
1435+
13351436
#[cfg(test)]
13361437
mod tests {
13371438
use super::*;

src/platform/twitter/source/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ use crate::{
2424
},
2525
};
2626

27+
pub(crate) const TWITTER_IMAGE_URL_END_TAG: &str = ":orig";
28+
2729
#[derive(Clone, Debug, PartialEq, Deserialize)]
2830
pub struct ConfigParams {
2931
pub username: String,
@@ -625,7 +627,7 @@ impl FetcherInner {
625627
.map(|media| match media.kind {
626628
data::TweetLegacyEntityMediaKind::Photo => {
627629
PostAttachment::Image(PostAttachmentImage {
628-
media_url: format!("{}:orig", media.media_url_https),
630+
media_url: format!("{}{TWITTER_IMAGE_URL_END_TAG}", media.media_url_https),
629631
has_spoiler: possibly_sensitive,
630632
})
631633
}

0 commit comments

Comments
 (0)