mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-26 23:52:05 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2801aaa39c
|
||
|
|
93d47752cf
|
||
|
|
6fd4edb3c5
|
||
|
|
7c5afce0b4
|
||
|
|
caf41183a4
|
||
|
|
b6ae88e869
|
||
|
|
3dff9a2473
|
@@ -0,0 +1,2 @@
|
|||||||
|
# Shell scripts must stay LF: CRLF breaks the shebang inside containers.
|
||||||
|
*.sh text eol=lf
|
||||||
@@ -12,12 +12,35 @@ env:
|
|||||||
DOCKERHUB_REPO: yoursfunny/telegram-twitter-media-bot
|
DOCKERHUB_REPO: yoursfunny/telegram-twitter-media-bot
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# A tag push and a branch push to the same commit fire two workflow runs;
|
||||||
|
# build only once. Tag runs always build; master runs build only when the
|
||||||
|
# pushed commit is not already tagged (the tag run covers it).
|
||||||
|
should-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
build: ${{ steps.check.outputs.build }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- id: check
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ "$GITHUB_REF_TYPE" = "branch" ] && git tag --points-at "$GITHUB_SHA" | grep -q .; then
|
||||||
|
echo "commit already tagged; the tag run builds the image"
|
||||||
|
echo "build=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "build=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
|
needs: should-build
|
||||||
|
if: needs.should-build.outputs.build == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ${{ env.DOCKERHUB_REPO }}
|
images: ${{ env.DOCKERHUB_REPO }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -28,22 +51,22 @@ jobs:
|
|||||||
type=sha
|
type=sha
|
||||||
-
|
-
|
||||||
name: Set up QEMU
|
name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
-
|
-
|
||||||
name: Set up Docker Buildx
|
name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
-
|
-
|
||||||
name: Login to Docker Hub
|
name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
APP_NAME=${{ env.APP_NAME }}
|
APP_NAME=${{ env.APP_NAME }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|||||||
Generated
+2
-2
@@ -3296,7 +3296,7 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "x-media"
|
name = "x-media"
|
||||||
version = "1.0.0"
|
version = "1.0.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
@@ -3314,7 +3314,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xmedia-bot"
|
name = "xmedia-bot"
|
||||||
version = "1.0.0"
|
version = "1.0.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dotenv",
|
"dotenv",
|
||||||
"html-escape",
|
"html-escape",
|
||||||
|
|||||||
+7
-2
@@ -29,9 +29,14 @@ RUN wget -q -O /tmp/ffmpeg.tar.xz "$FFMPEG_URL" \
|
|||||||
&& rm /tmp/ffmpeg.tar.xz \
|
&& rm /tmp/ffmpeg.tar.xz \
|
||||||
&& /usr/local/bin/ffmpeg -version >/dev/null
|
&& /usr/local/bin/ffmpeg -version >/dev/null
|
||||||
|
|
||||||
# 3. Real sources last: only our crates recompile on source changes.
|
# 3. Real sources last: only our crates recompile on source changes. The
|
||||||
|
# COPY preserves host mtimes, which predate the stub artifacts from step 1;
|
||||||
|
# cargo's mtime-based freshness check would otherwise treat the stub build
|
||||||
|
# as up-to-date and never compile the real sources. `touch` forces cargo to
|
||||||
|
# see the real files as newer.
|
||||||
COPY crates/ ./crates/
|
COPY crates/ ./crates/
|
||||||
RUN cargo build --release -p xmedia-bot
|
RUN find crates -type f -name '*.rs' -exec touch {} + \
|
||||||
|
&& cargo build --release -p xmedia-bot
|
||||||
|
|
||||||
# ---------- runtime stage ----------
|
# ---------- runtime stage ----------
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "x-media"
|
name = "x-media"
|
||||||
version = "1.0.0"
|
version = "1.0.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -14,6 +14,24 @@ impl Media {
|
|||||||
Media::Animated { thumbnail_url, .. } => Some(thumbnail_url),
|
Media::Animated { thumbnail_url, .. } => Some(thumbnail_url),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A smaller variant of this media's file (used as the fallback when the
|
||||||
|
/// primary URL or upload exceeds Telegram's size limits). None when no
|
||||||
|
/// smaller variant exists (videos, animated gifs).
|
||||||
|
pub fn smaller_url(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
Media::Illustration {
|
||||||
|
url,
|
||||||
|
fallback_url,
|
||||||
|
thumbnail_url,
|
||||||
|
..
|
||||||
|
} => fallback_url
|
||||||
|
.as_deref()
|
||||||
|
.or(thumbnail_url.as_deref())
|
||||||
|
.filter(|smaller| *smaller != url),
|
||||||
|
Media::Video { .. } | Media::Animated { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|||||||
@@ -195,6 +195,19 @@ async fn fetch_once(url: &str) -> Result<Option<Fetched>, FetchError> {
|
|||||||
/// fetch of a media URL is blocked (hotlink protection), the bot downloads
|
/// fetch of a media URL is blocked (hotlink protection), the bot downloads
|
||||||
/// the file itself and uploads it via multipart. Site-appropriate headers:
|
/// the file itself and uploads it via multipart. Site-appropriate headers:
|
||||||
/// pixiv image hosts need the `Referer` header.
|
/// pixiv image hosts need the `Referer` header.
|
||||||
|
/// Returns the Content-Length of a media URL, or `None` when the server does
|
||||||
|
/// not report one. Used to check whether a file fits Telegram's size limits
|
||||||
|
/// before downloading/uploading it.
|
||||||
|
pub async fn media_size(url: &str) -> Result<Option<u64>, FetchError> {
|
||||||
|
let mut request = CLIENT.get(url);
|
||||||
|
let lower = url.to_ascii_lowercase();
|
||||||
|
if lower.contains("pximg.net") {
|
||||||
|
request = request.header("Referer", "https://www.pixiv.net/");
|
||||||
|
}
|
||||||
|
let response = request.send().await?;
|
||||||
|
Ok(response.content_length())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn download_media(url: &str) -> Result<bytes::Bytes, FetchError> {
|
pub async fn download_media(url: &str) -> Result<bytes::Bytes, FetchError> {
|
||||||
let mut request = CLIENT.get(url);
|
let mut request = CLIENT.get(url);
|
||||||
let lower = url.to_ascii_lowercase();
|
let lower = url.to_ascii_lowercase();
|
||||||
|
|||||||
@@ -127,9 +127,11 @@ impl Tweet {
|
|||||||
match item.media_type.as_str() {
|
match item.media_type.as_str() {
|
||||||
"photo" => media.push(Media::Illustration {
|
"photo" => media.push(Media::Illustration {
|
||||||
title: None,
|
title: None,
|
||||||
url: item.media_url_https,
|
url: original_twimg_url(&item.media_url_https),
|
||||||
thumbnail_url: None,
|
thumbnail_url: None,
|
||||||
fallback_url: None,
|
// The param-less base URL is a reduced-size variant;
|
||||||
|
// used as the fallback when the original is too large.
|
||||||
|
fallback_url: Some(item.media_url_https.clone()),
|
||||||
}),
|
}),
|
||||||
"video" => media.push(Media::Video {
|
"video" => media.push(Media::Video {
|
||||||
title: None,
|
title: None,
|
||||||
@@ -198,6 +200,20 @@ fn expand_links(text: &str, urls: &[model::SyndicationEntityUrl]) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// pbs.twimg.com serves a reduced default size without size params; `name=orig`
|
||||||
|
/// returns the original file (fxtwitter used to hand out the original
|
||||||
|
/// directly, the syndication API does not). Non-twimg URLs pass through
|
||||||
|
/// unchanged.
|
||||||
|
fn original_twimg_url(url: &str) -> String {
|
||||||
|
if url.starts_with("https://pbs.twimg.com/")
|
||||||
|
&& (url.ends_with(".jpg") || url.ends_with(".png"))
|
||||||
|
{
|
||||||
|
format!("{url}?name=orig")
|
||||||
|
} else {
|
||||||
|
url.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn mp4_variant(item: &model::SyndicationMedia) -> String {
|
fn mp4_variant(item: &model::SyndicationMedia) -> String {
|
||||||
item.video_info
|
item.video_info
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -302,6 +318,16 @@ mod tests {
|
|||||||
assert_eq!(fetched.title, "a & b <c>");
|
assert_eq!(fetched.title, "a & b <c>");
|
||||||
assert!(fetched.sensitive);
|
assert!(fetched.sensitive);
|
||||||
assert_eq!(fetched.media.len(), 2);
|
assert_eq!(fetched.media.len(), 2);
|
||||||
|
match &fetched.media[0] {
|
||||||
|
Media::Illustration { url, .. } => {
|
||||||
|
// Photo URL is rewritten to request the original file.
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
"https://pbs.twimg.com/media/photo.jpg?name=orig"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected illustration, got {other:?}"),
|
||||||
|
}
|
||||||
match &fetched.media[1] {
|
match &fetched.media[1] {
|
||||||
Media::Video { url, thumbnail_url, .. } => {
|
Media::Video { url, thumbnail_url, .. } => {
|
||||||
assert_eq!(url, "https://video.twimg.com/v.mp4");
|
assert_eq!(url, "https://video.twimg.com/v.mp4");
|
||||||
@@ -435,6 +461,27 @@ mod tests {
|
|||||||
assert_eq!(tweet.text, text, "full text kept intact");
|
assert_eq!(tweet.text, text, "full text kept intact");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn original_twimg_url_rewrites_photo_urls() {
|
||||||
|
assert_eq!(
|
||||||
|
original_twimg_url("https://pbs.twimg.com/media/C_UdnvPUwAE3Dnn.jpg"),
|
||||||
|
"https://pbs.twimg.com/media/C_UdnvPUwAE3Dnn.jpg?name=orig"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
original_twimg_url("https://pbs.twimg.com/media/abc.png"),
|
||||||
|
"https://pbs.twimg.com/media/abc.png?name=orig"
|
||||||
|
);
|
||||||
|
// Non-twimg URLs (videos, animated gifs) pass through unchanged.
|
||||||
|
assert_eq!(
|
||||||
|
original_twimg_url("https://video.twimg.com/v.mp4"),
|
||||||
|
"https://video.twimg.com/v.mp4"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
original_twimg_url("https://pbs.twimg.com/media/abc.webp"),
|
||||||
|
"https://pbs.twimg.com/media/abc.webp"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn syndication_token_matches_js_formula() {
|
fn syndication_token_matches_js_formula() {
|
||||||
// JS: ((861627479294746624 / 1e15) * PI).toString(36) == "236.vrsocvda"
|
// JS: ((861627479294746624 / 1e15) * PI).toString(36) == "236.vrsocvda"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "xmedia-bot"
|
name = "xmedia-bot"
|
||||||
version = "1.0.0"
|
version = "1.0.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -41,8 +41,14 @@ impl Config {
|
|||||||
let webhook_url = env::var("WEBHOOK_URL").ok().and_then(|s| s.parse().ok());
|
let webhook_url = env::var("WEBHOOK_URL").ok().and_then(|s| s.parse().ok());
|
||||||
let webhook_listen = env::var("WEBHOOK_LISTEN").ok().and_then(|s| s.parse().ok());
|
let webhook_listen = env::var("WEBHOOK_LISTEN").ok().and_then(|s| s.parse().ok());
|
||||||
let webhook_port = env::var("WEBHOOK_PORT").ok().and_then(|s| s.parse().ok());
|
let webhook_port = env::var("WEBHOOK_PORT").ok().and_then(|s| s.parse().ok());
|
||||||
let webhook_cert = env::var("WEBHOOK_CERT").ok();
|
// Empty strings count as unset (e.g. `-e WEBHOOK_CERT=` to disable a
|
||||||
let webhook_secret_token = env::var("WEBHOOK_SECRET_TOKEN").ok();
|
// value that would otherwise come from `.env`).
|
||||||
|
let webhook_cert = env::var("WEBHOOK_CERT")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
let webhook_secret_token = env::var("WEBHOOK_SECRET_TOKEN")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
Config {
|
Config {
|
||||||
admin_ids,
|
admin_ids,
|
||||||
|
|||||||
@@ -322,22 +322,26 @@ fn thumbnail_for(media: &Media) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn media_to_payload(media: &Media, sensitive: bool) -> MediaItemPayload {
|
fn media_to_payload(media: &Media, sensitive: bool) -> MediaItemPayload {
|
||||||
|
let fallback_url = media.smaller_url().map(str::to_string);
|
||||||
match media {
|
match media {
|
||||||
// A gif inside a group becomes a video item; a lone gif takes the
|
// A gif inside a group becomes a video item; a lone gif takes the
|
||||||
// animation path (see url_media).
|
// animation path (see url_media).
|
||||||
Media::Illustration { .. } => MediaItemPayload::Photo {
|
Media::Illustration { .. } => MediaItemPayload::Photo {
|
||||||
media: media.url().to_string(),
|
media: media.url().to_string(),
|
||||||
has_spoiler: sensitive,
|
has_spoiler: sensitive,
|
||||||
|
fallback_url,
|
||||||
},
|
},
|
||||||
Media::Video { .. } => MediaItemPayload::Video {
|
Media::Video { .. } => MediaItemPayload::Video {
|
||||||
media: media.url().to_string(),
|
media: media.url().to_string(),
|
||||||
has_spoiler: sensitive,
|
has_spoiler: sensitive,
|
||||||
thumbnail: thumbnail_for(media),
|
thumbnail: thumbnail_for(media),
|
||||||
|
fallback_url,
|
||||||
},
|
},
|
||||||
Media::Animated { .. } => MediaItemPayload::Video {
|
Media::Animated { .. } => MediaItemPayload::Video {
|
||||||
media: media.url().to_string(),
|
media: media.url().to_string(),
|
||||||
has_spoiler: sensitive,
|
has_spoiler: sensitive,
|
||||||
thumbnail: thumbnail_for(media),
|
thumbnail: thumbnail_for(media),
|
||||||
|
fallback_url,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,11 +506,19 @@ pub async fn inline_query_handler(bot: Bot, query: InlineQuery) -> Result<(), Re
|
|||||||
.unwrap_or_else(|| url.clone());
|
.unwrap_or_else(|| url.clone());
|
||||||
let caption = fetched.caption.clone();
|
let caption = fetched.caption.clone();
|
||||||
let result = match media {
|
let result = match media {
|
||||||
Media::Illustration { .. } => InlineQueryResult::Photo(
|
Media::Illustration { .. } => {
|
||||||
InlineQueryResultPhoto::new(id, url, thumbnail)
|
// Inline photo results have their own (smaller) size
|
||||||
.caption(caption)
|
// cap; use the reduced variant when one exists.
|
||||||
.parse_mode(ParseMode::Html),
|
let photo_url = media
|
||||||
),
|
.smaller_url()
|
||||||
|
.and_then(|u| url::Url::parse(u).ok())
|
||||||
|
.unwrap_or_else(|| url.clone());
|
||||||
|
InlineQueryResult::Photo(
|
||||||
|
InlineQueryResultPhoto::new(id, photo_url, thumbnail)
|
||||||
|
.caption(caption)
|
||||||
|
.parse_mode(ParseMode::Html),
|
||||||
|
)
|
||||||
|
}
|
||||||
Media::Video { .. } => InlineQueryResult::Video(
|
Media::Video { .. } => InlineQueryResult::Video(
|
||||||
InlineQueryResultVideo::new(
|
InlineQueryResultVideo::new(
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ async fn main() {
|
|||||||
.webhook_url
|
.webhook_url
|
||||||
.clone()
|
.clone()
|
||||||
.expect("WEBHOOK_URL is not set");
|
.expect("WEBHOOK_URL is not set");
|
||||||
bot.set_webhook(url.clone()).await.unwrap();
|
// `webhooks::axum` calls set_webhook itself (with the full options,
|
||||||
|
// secret token included) — no explicit registration here.
|
||||||
let listen = CONFIG.webhook_listen.expect("WEBHOOK_LISTEN is not set");
|
let listen = CONFIG.webhook_listen.expect("WEBHOOK_LISTEN is not set");
|
||||||
let port = CONFIG.webhook_port.expect("WEBHOOK_PORT is not set");
|
let port = CONFIG.webhook_port.expect("WEBHOOK_PORT is not set");
|
||||||
let mut options = webhooks::Options::new((listen, port).into(), url);
|
let mut options = webhooks::Options::new((listen, port).into(), url);
|
||||||
|
|||||||
+206
-36
@@ -25,11 +25,17 @@ pub enum MediaItemPayload {
|
|||||||
Photo {
|
Photo {
|
||||||
media: String,
|
media: String,
|
||||||
has_spoiler: bool,
|
has_spoiler: bool,
|
||||||
|
/// Smaller variant used when the primary media exceeds Telegram's
|
||||||
|
/// size limits.
|
||||||
|
#[serde(default)]
|
||||||
|
fallback_url: Option<String>,
|
||||||
},
|
},
|
||||||
Video {
|
Video {
|
||||||
media: String,
|
media: String,
|
||||||
has_spoiler: bool,
|
has_spoiler: bool,
|
||||||
thumbnail: Option<String>,
|
thumbnail: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
fallback_url: Option<String>,
|
||||||
},
|
},
|
||||||
Animation {
|
Animation {
|
||||||
media: String,
|
media: String,
|
||||||
@@ -37,6 +43,16 @@ pub enum MediaItemPayload {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl MediaItemPayload {
|
||||||
|
fn fallback_url(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
MediaItemPayload::Photo { fallback_url, .. }
|
||||||
|
| MediaItemPayload::Video { fallback_url, .. } => fallback_url.as_deref(),
|
||||||
|
MediaItemPayload::Animation { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum Task {
|
pub enum Task {
|
||||||
@@ -74,7 +90,9 @@ pub enum Task {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub const MAX_MEDIA_GROUP: usize = 9;
|
pub const MAX_MEDIA_GROUP: usize = 9;
|
||||||
pub const MAX_UPLOAD_BYTES: u64 = 50 * 1024 * 1024; // Telegram Bot API upload cap
|
/// Upload cap (bytes): files above this are not uploaded; the bot falls back
|
||||||
|
/// to a smaller media URL instead.
|
||||||
|
pub const MAX_UPLOAD_BYTES: u64 = 10 * 1024 * 1024; // 10485760
|
||||||
|
|
||||||
/// Splits media into batches of at most [`MAX_MEDIA_GROUP`] items.
|
/// Splits media into batches of at most [`MAX_MEDIA_GROUP`] items.
|
||||||
pub fn chunk_media_items<T: Clone>(items: Vec<T>) -> Vec<Vec<T>> {
|
pub fn chunk_media_items<T: Clone>(items: Vec<T>) -> Vec<Vec<T>> {
|
||||||
@@ -102,6 +120,18 @@ pub fn is_media_fetch_failure(e: &ApiError) -> bool {
|
|||||||
MARKERS.iter().any(|marker| description.contains(marker))
|
MARKERS.iter().any(|marker| description.contains(marker))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Telegram reported the media file as too large (HTTP 413 on multipart
|
||||||
|
/// upload, or a "too large" message for URL-fetched media). These errors are
|
||||||
|
/// handled by the size-check fallback (use a smaller media URL), NOT by a
|
||||||
|
/// queue retry.
|
||||||
|
pub fn is_size_error(e: &ApiError) -> bool {
|
||||||
|
if matches!(e, ApiError::RequestEntityTooLarge) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let description = e.to_string().to_lowercase();
|
||||||
|
["too large", "too big"].iter().any(|marker| description.contains(marker))
|
||||||
|
}
|
||||||
|
|
||||||
/// Task-free classification of a Telegram request error. The callers attach
|
/// Task-free classification of a Telegram request error. The callers attach
|
||||||
/// the (updated) task when building a [`SendError`].
|
/// the (updated) task when building a [`SendError`].
|
||||||
pub enum Classification {
|
pub enum Classification {
|
||||||
@@ -216,11 +246,13 @@ fn build_media_group(
|
|||||||
MediaItemPayload::Photo {
|
MediaItemPayload::Photo {
|
||||||
media,
|
media,
|
||||||
has_spoiler,
|
has_spoiler,
|
||||||
|
..
|
||||||
} => photo_media(input_file_for(media)?, item_caption, *has_spoiler),
|
} => photo_media(input_file_for(media)?, item_caption, *has_spoiler),
|
||||||
MediaItemPayload::Video {
|
MediaItemPayload::Video {
|
||||||
media,
|
media,
|
||||||
has_spoiler,
|
has_spoiler,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
let mut video = video_media(input_file_for(media)?, item_caption, *has_spoiler);
|
let mut video = video_media(input_file_for(media)?, item_caption, *has_spoiler);
|
||||||
if let (Some(thumb), InputMedia::Video(v)) = (thumbnail, &mut video) {
|
if let (Some(thumb), InputMedia::Video(v)) = (thumbnail, &mut video) {
|
||||||
@@ -231,6 +263,7 @@ fn build_media_group(
|
|||||||
MediaItemPayload::Animation {
|
MediaItemPayload::Animation {
|
||||||
media,
|
media,
|
||||||
has_spoiler,
|
has_spoiler,
|
||||||
|
..
|
||||||
} => animation_media(input_file_for(media)?, item_caption, *has_spoiler),
|
} => animation_media(input_file_for(media)?, item_caption, *has_spoiler),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -258,6 +291,9 @@ fn sniff_ext(bytes: &[u8]) -> &'static str {
|
|||||||
enum FallbackError {
|
enum FallbackError {
|
||||||
Retryable { delay_seconds: f64 },
|
Retryable { delay_seconds: f64 },
|
||||||
Permanent { message: String },
|
Permanent { message: String },
|
||||||
|
/// The downloaded file exceeds the upload cap; the caller falls back to
|
||||||
|
/// the item's smaller URL.
|
||||||
|
MediaTooLarge,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Downloads one media item to a temp file (deleted on drop). Network errors
|
/// Downloads one media item to a temp file (deleted on drop). Network errors
|
||||||
@@ -282,9 +318,7 @@ async fn download_to_temp(item: &MediaItemPayload) -> Result<NamedTempFile, Fall
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if bytes.len() as u64 > MAX_UPLOAD_BYTES {
|
if bytes.len() as u64 > MAX_UPLOAD_BYTES {
|
||||||
return Err(FallbackError::Permanent {
|
return Err(FallbackError::MediaTooLarge);
|
||||||
message: "media too large".into(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
let ext = sniff_ext(&bytes);
|
let ext = sniff_ext(&bytes);
|
||||||
let mut file = tempfile::Builder::new()
|
let mut file = tempfile::Builder::new()
|
||||||
@@ -302,7 +336,48 @@ async fn download_to_temp(item: &MediaItemPayload) -> Result<NamedTempFile, Fall
|
|||||||
Ok(file)
|
Ok(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download-and-reupload fallback for one media batch.
|
/// Builds the media group item from an uploaded file.
|
||||||
|
fn media_from_file(
|
||||||
|
item: &MediaItemPayload,
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
caption: Option<&str>,
|
||||||
|
) -> InputMedia {
|
||||||
|
match item {
|
||||||
|
MediaItemPayload::Photo { has_spoiler, .. } => {
|
||||||
|
photo_media(InputFile::file(path), caption, *has_spoiler)
|
||||||
|
}
|
||||||
|
MediaItemPayload::Video { has_spoiler, .. } => {
|
||||||
|
video_media(InputFile::file(path), caption, *has_spoiler)
|
||||||
|
}
|
||||||
|
MediaItemPayload::Animation { has_spoiler, .. } => {
|
||||||
|
animation_media(InputFile::file(path), caption, *has_spoiler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the media group item from a (smaller) URL.
|
||||||
|
fn media_from_url(
|
||||||
|
item: &MediaItemPayload,
|
||||||
|
url: &str,
|
||||||
|
caption: Option<&str>,
|
||||||
|
) -> Result<InputMedia, String> {
|
||||||
|
Ok(match item {
|
||||||
|
MediaItemPayload::Photo { has_spoiler, .. } => {
|
||||||
|
photo_media(input_file_for(url)?, caption, *has_spoiler)
|
||||||
|
}
|
||||||
|
MediaItemPayload::Video { has_spoiler, .. } => {
|
||||||
|
video_media(input_file_for(url)?, caption, *has_spoiler)
|
||||||
|
}
|
||||||
|
MediaItemPayload::Animation { has_spoiler, .. } => {
|
||||||
|
animation_media(input_file_for(url)?, caption, *has_spoiler)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download-and-reupload fallback for one media batch. Files over the upload
|
||||||
|
/// cap are not downloaded/uploaded; the item falls back to its smaller URL
|
||||||
|
/// (which Telegram fetches itself). Returns the fallback-error without the
|
||||||
|
/// task attached; callers wrap it with the updated task state.
|
||||||
async fn send_batch_via_upload(
|
async fn send_batch_via_upload(
|
||||||
bot: &Bot,
|
bot: &Bot,
|
||||||
chat_id: i64,
|
chat_id: i64,
|
||||||
@@ -313,22 +388,51 @@ async fn send_batch_via_upload(
|
|||||||
let mut files = Vec::new();
|
let mut files = Vec::new();
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
for (i, item) in batch.iter().enumerate() {
|
for (i, item) in batch.iter().enumerate() {
|
||||||
let file = download_to_temp(item).await?;
|
|
||||||
let path = file.path().to_path_buf();
|
|
||||||
let item_caption = if i == 0 { caption } else { None };
|
let item_caption = if i == 0 { caption } else { None };
|
||||||
let media = match item {
|
// Size check before downloading/uploading: over the cap, use the
|
||||||
MediaItemPayload::Photo { has_spoiler, .. } => {
|
// smaller URL instead of the file.
|
||||||
photo_media(InputFile::file(path), item_caption, *has_spoiler)
|
let too_large = match x_media::site::media_size(item_url(item)).await {
|
||||||
|
Ok(Some(size)) => size > MAX_UPLOAD_BYTES,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
let media = if too_large {
|
||||||
|
match item.fallback_url() {
|
||||||
|
Some(url) => match media_from_url(item, url, item_caption) {
|
||||||
|
Ok(media) => media,
|
||||||
|
Err(message) => {
|
||||||
|
return Err(FallbackError::Permanent { message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
return Err(FallbackError::Permanent {
|
||||||
|
message: "media too large".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
MediaItemPayload::Video { has_spoiler, .. } => {
|
} else {
|
||||||
video_media(InputFile::file(path), item_caption, *has_spoiler)
|
match download_to_temp(item).await {
|
||||||
}
|
Ok(file) => {
|
||||||
MediaItemPayload::Animation { has_spoiler, .. } => {
|
let path = file.path().to_path_buf();
|
||||||
animation_media(InputFile::file(path), item_caption, *has_spoiler)
|
files.push(file);
|
||||||
|
media_from_file(item, path, item_caption)
|
||||||
|
}
|
||||||
|
Err(FallbackError::MediaTooLarge) => match item.fallback_url() {
|
||||||
|
Some(url) => match media_from_url(item, url, item_caption) {
|
||||||
|
Ok(media) => media,
|
||||||
|
Err(message) => {
|
||||||
|
return Err(FallbackError::Permanent { message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
return Err(FallbackError::Permanent {
|
||||||
|
message: "media too large".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => return Err(e),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
items.push(media);
|
items.push(media);
|
||||||
files.push(file);
|
|
||||||
}
|
}
|
||||||
let result = bot
|
let result = bot
|
||||||
.send_media_group(ChatId(chat_id), items)
|
.send_media_group(ChatId(chat_id), items)
|
||||||
@@ -422,7 +526,9 @@ pub async fn send_media_sequence(bot: &Bot, task: &Task) -> Result<Vec<i64>, Sen
|
|||||||
);
|
);
|
||||||
sent.extend(messages.into_iter().map(|m| m.id.0 as i64));
|
sent.extend(messages.into_iter().map(|m| m.id.0 as i64));
|
||||||
}
|
}
|
||||||
Err(RequestError::Api(api)) if is_media_fetch_failure(&api) => {
|
Err(RequestError::Api(api))
|
||||||
|
if is_media_fetch_failure(&api) || is_size_error(&api) =>
|
||||||
|
{
|
||||||
log::info!(
|
log::info!(
|
||||||
"Telegram could not fetch media for batch {idx} ({}), downloading and reuploading",
|
"Telegram could not fetch media for batch {idx} ({}), downloading and reuploading",
|
||||||
batch
|
batch
|
||||||
@@ -444,6 +550,7 @@ pub async fn send_media_sequence(bot: &Bot, task: &Task) -> Result<Vec<i64>, Sen
|
|||||||
task: updated_sequence_task(task, idx, sent),
|
task: updated_sequence_task(task, idx, sent),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Err(FallbackError::MediaTooLarge) => unreachable!("handled inside upload"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -507,33 +614,63 @@ pub async fn send_animation(bot: &Bot, task: &Task) -> Result<Vec<i64>, SendErro
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(message) => Ok(vec![message.id.0 as i64]),
|
Ok(message) => Ok(vec![message.id.0 as i64]),
|
||||||
Err(RequestError::Api(api)) if is_media_fetch_failure(&api) => {
|
Err(RequestError::Api(api))
|
||||||
|
if is_media_fetch_failure(&api) || is_size_error(&api) =>
|
||||||
|
{
|
||||||
log::info!(
|
log::info!(
|
||||||
"Telegram could not fetch animation URL, downloading and reuploading: {}",
|
"Telegram could not fetch animation URL, downloading and reuploading: {}",
|
||||||
media_url
|
media_url
|
||||||
);
|
);
|
||||||
let file = match download_to_temp(animation).await {
|
match download_to_temp(animation).await {
|
||||||
Ok(file) => file,
|
Ok(file) => {
|
||||||
|
let path = file.path().to_path_buf();
|
||||||
|
match send_animation_inner(
|
||||||
|
bot,
|
||||||
|
chat_id,
|
||||||
|
reply_to,
|
||||||
|
caption,
|
||||||
|
has_spoiler,
|
||||||
|
InputFile::file(path),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(message) => Ok(vec![message.id.0 as i64]),
|
||||||
|
Err(e) => Err(classify_to_send_error(&e, task.clone())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Over the upload cap: fall back to the smaller URL.
|
||||||
|
Err(FallbackError::MediaTooLarge) => match animation.fallback_url() {
|
||||||
|
Some(url) => match input_file_for(url) {
|
||||||
|
Ok(file) => {
|
||||||
|
match send_animation_inner(
|
||||||
|
bot,
|
||||||
|
chat_id,
|
||||||
|
reply_to,
|
||||||
|
caption,
|
||||||
|
has_spoiler,
|
||||||
|
file,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(message) => Ok(vec![message.id.0 as i64]),
|
||||||
|
Err(e) => Err(classify_to_send_error(&e, task.clone())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
Err(SendError::Permanent { message, task: task.clone() })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => Err(SendError::Permanent {
|
||||||
|
message: "media too large".into(),
|
||||||
|
task: task.clone(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
Err(FallbackError::Retryable { delay_seconds }) => {
|
Err(FallbackError::Retryable { delay_seconds }) => {
|
||||||
return Err(SendError::Retryable { delay_seconds, task: task.clone() });
|
Err(SendError::Retryable { delay_seconds, task: task.clone() })
|
||||||
}
|
}
|
||||||
Err(FallbackError::Permanent { message }) => {
|
Err(FallbackError::Permanent { message }) => {
|
||||||
return Err(SendError::Permanent { message, task: task.clone() });
|
Err(SendError::Permanent { message, task: task.clone() })
|
||||||
}
|
}
|
||||||
};
|
|
||||||
let path = file.path().to_path_buf();
|
|
||||||
match send_animation_inner(
|
|
||||||
bot,
|
|
||||||
chat_id,
|
|
||||||
reply_to,
|
|
||||||
caption,
|
|
||||||
has_spoiler,
|
|
||||||
InputFile::file(path),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(message) => Ok(vec![message.id.0 as i64]),
|
|
||||||
Err(e) => Err(classify_to_send_error(&e, task.clone())),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => Err(classify_to_send_error(&e, task.clone())),
|
Err(e) => Err(classify_to_send_error(&e, task.clone())),
|
||||||
@@ -819,6 +956,36 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_size_error_matches_known_errors() {
|
||||||
|
// 413 upload cap.
|
||||||
|
let e = ApiError::RequestEntityTooLarge;
|
||||||
|
assert!(is_size_error(&e), "{e:?}");
|
||||||
|
// Unknown descriptions with size wording.
|
||||||
|
for description in [
|
||||||
|
"Bad Request: file is too large",
|
||||||
|
"Bad Request: media is too big",
|
||||||
|
"Bad Request: url file size is too big",
|
||||||
|
] {
|
||||||
|
let api = ApiError::Unknown(description.to_string());
|
||||||
|
assert!(is_size_error(&api), "{description}");
|
||||||
|
}
|
||||||
|
// Unrelated errors must not match.
|
||||||
|
for description in ["Bad Request: WEBPAGE_MEDIA_EMPTY", "Bad Request: message is not modified"] {
|
||||||
|
let api = ApiError::Unknown(description.to_string());
|
||||||
|
assert!(!is_size_error(&api), "{description}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn media_item_payload_fallback_url_serde_default() {
|
||||||
|
// Old queued payloads without the field deserialize with None.
|
||||||
|
let json = serde_json::json!({"kind": "photo", "media": "https://a/b.jpg", "has_spoiler": false});
|
||||||
|
let photo: MediaItemPayload = serde_json::from_value(json).unwrap();
|
||||||
|
assert!(matches!(photo, MediaItemPayload::Photo { fallback_url: None, .. }));
|
||||||
|
assert_eq!(photo.fallback_url(), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classification_mapping() {
|
fn classification_mapping() {
|
||||||
use teloxide::types::Seconds;
|
use teloxide::types::Seconds;
|
||||||
@@ -858,11 +1025,13 @@ mod tests {
|
|||||||
vec![MediaItemPayload::Photo {
|
vec![MediaItemPayload::Photo {
|
||||||
media: "https://a/b.jpg".into(),
|
media: "https://a/b.jpg".into(),
|
||||||
has_spoiler: true,
|
has_spoiler: true,
|
||||||
|
fallback_url: Some("https://a/b_small.jpg".into()),
|
||||||
}],
|
}],
|
||||||
vec![MediaItemPayload::Video {
|
vec![MediaItemPayload::Video {
|
||||||
media: "https://a/v.mp4".into(),
|
media: "https://a/v.mp4".into(),
|
||||||
has_spoiler: false,
|
has_spoiler: false,
|
||||||
thumbnail: Some("https://a/t.jpg".into()),
|
thumbnail: Some("https://a/t.jpg".into()),
|
||||||
|
fallback_url: None,
|
||||||
}],
|
}],
|
||||||
],
|
],
|
||||||
batch_index: 1,
|
batch_index: 1,
|
||||||
@@ -900,6 +1069,7 @@ mod tests {
|
|||||||
let photo = MediaItemPayload::Photo {
|
let photo = MediaItemPayload::Photo {
|
||||||
media: "https://a/b.jpg".into(),
|
media: "https://a/b.jpg".into(),
|
||||||
has_spoiler: false,
|
has_spoiler: false,
|
||||||
|
fallback_url: None,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_value(&photo).unwrap();
|
let json = serde_json::to_value(&photo).unwrap();
|
||||||
assert_eq!(json["kind"], "photo");
|
assert_eq!(json["kind"], "photo");
|
||||||
|
|||||||
Reference in New Issue
Block a user