style: cargo fmt across the workspace

Apply rustfmt to the 11 files that had drifted (86 hunks): x-media
site modules (bsky/pixiv/twitter) and xmedia-bot (config/main/
photo/send). Formatting only - no semantic changes; full test suite
still green.
This commit is contained in:
2026-08-07 16:12:01 +08:00
parent 063e910473
commit 3d6f8548c3
11 changed files with 457 additions and 302 deletions
+18 -10
View File
@@ -5,9 +5,8 @@ use html_escape::encode_text;
use regex::Regex; use regex::Regex;
use std::sync::LazyLock; use std::sync::LazyLock;
pub static PATTERN: LazyLock<Regex> = LazyLock::new(|| { pub static PATTERN: LazyLock<Regex> =
Regex::new(r"bsky\.app/profile/([\w.\-:]+)/post/([\w.\-~]+)").unwrap() LazyLock::new(|| Regex::new(r"bsky\.app/profile/([\w.\-:]+)/post/([\w.\-~]+)").unwrap());
});
pub fn enabled() -> bool { pub fn enabled() -> bool {
true true
@@ -15,8 +14,14 @@ pub fn enabled() -> bool {
pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> { pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
let caps = PATTERN.captures(url).ok_or(FetchError::NotFound)?; let caps = PATTERN.captures(url).ok_or(FetchError::NotFound)?;
let handle = caps.get(1).map(|m| m.as_str()).ok_or(FetchError::NotFound)?; let handle = caps
let rkey = caps.get(2).map(|m| m.as_str()).ok_or(FetchError::NotFound)?; .get(1)
.map(|m| m.as_str())
.ok_or(FetchError::NotFound)?;
let rkey = caps
.get(2)
.map(|m| m.as_str())
.ok_or(FetchError::NotFound)?;
Ok(fetch(handle, rkey).await?.into()) Ok(fetch(handle, rkey).await?.into())
} }
@@ -197,7 +202,10 @@ mod tests {
})); }));
let post = Post::from_json(&raw.to_string(), "3xxxx".into()).unwrap(); let post = Post::from_json(&raw.to_string(), "3xxxx".into()).unwrap();
let fetched: Fetched = post.into(); let fetched: Fetched = post.into();
assert_eq!(fetched.source_url, "https://bsky.app/profile/user.bsky.social/post/3xxxx"); assert_eq!(
fetched.source_url,
"https://bsky.app/profile/user.bsky.social/post/3xxxx"
);
assert_eq!(fetched.title, "hello <world>"); assert_eq!(fetched.title, "hello <world>");
assert_eq!(fetched.media.len(), 1); assert_eq!(fetched.media.len(), 1);
assert!(!fetched.sensitive); assert!(!fetched.sensitive);
@@ -246,9 +254,8 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn live_fetch_with_photos() { async fn live_fetch_with_photos() {
let fetched = fetch_from_url( let fetched =
"https://bsky.app/profile/asagi0398.bsky.social/post/3mqkhrq5w6k2m", fetch_from_url("https://bsky.app/profile/asagi0398.bsky.social/post/3mqkhrq5w6k2m")
)
.await .await
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -260,7 +267,8 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn live_fetch_smoke() { async fn live_fetch_smoke() {
let fetched = fetch_from_url("https://bsky.app/profile/fu-futa.bsky.social/post/3laoveufjv224") let fetched =
fetch_from_url("https://bsky.app/profile/fu-futa.bsky.social/post/3laoveufjv224")
.await .await
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
-1
View File
@@ -179,7 +179,6 @@ impl From<reqwest::Error> for FetchError {
} }
} }
impl From<serde_json::Error> for FetchError { impl From<serde_json::Error> for FetchError {
fn from(e: serde_json::Error) -> Self { fn from(e: serde_json::Error) -> Self {
FetchError::Json(e) FetchError::Json(e)
+23 -18
View File
@@ -10,8 +10,8 @@ use crate::site::FetchError;
use std::env; use std::env;
use std::fmt; use std::fmt;
use std::io::{Cursor, Read}; use std::io::{Cursor, Read};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::LazyLock; use std::sync::LazyLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
const AUTH_TOKEN_URL: &str = "https://oauth.secure.pixiv.net/auth/token"; const AUTH_TOKEN_URL: &str = "https://oauth.secure.pixiv.net/auth/token";
@@ -127,7 +127,9 @@ impl PixivAPI {
pub async fn illust_detail(&self, illust_id: u64) -> Result<IllustrationModel, PixivError> { pub async fn illust_detail(&self, illust_id: u64) -> Result<IllustrationModel, PixivError> {
let access_token = self.get_access_token().await?; let access_token = self.get_access_token().await?;
let response = crate::site::CLIENT let response = crate::site::CLIENT
.get(format!("{APP_API_URL}/v1/illust/detail?illust_id={illust_id}")) .get(format!(
"{APP_API_URL}/v1/illust/detail?illust_id={illust_id}"
))
.header("app-os", "ios") .header("app-os", "ios")
.header("app-os-version", "14.6") .header("app-os-version", "14.6")
.header("User-Agent", APP_USER_AGENT) .header("User-Agent", APP_USER_AGENT)
@@ -175,7 +177,9 @@ impl PixivAPI {
pub async fn ugoira_metadata(&self, illust_id: u64) -> Result<UgoiraMetadataModel, PixivError> { pub async fn ugoira_metadata(&self, illust_id: u64) -> Result<UgoiraMetadataModel, PixivError> {
let access_token = self.get_access_token().await?; let access_token = self.get_access_token().await?;
let response = crate::site::CLIENT let response = crate::site::CLIENT
.get(format!("{APP_API_URL}/v1/ugoira/metadata?illust_id={illust_id}")) .get(format!(
"{APP_API_URL}/v1/ugoira/metadata?illust_id={illust_id}"
))
.header("app-os", "ios") .header("app-os", "ios")
.header("app-os-version", "14.6") .header("app-os-version", "14.6")
.header("User-Agent", APP_USER_AGENT) .header("User-Agent", APP_USER_AGENT)
@@ -218,13 +222,15 @@ impl PixivAPI {
let Some(zip_url) = zip_url else { let Some(zip_url) = zip_url else {
return Ok(None); return Ok(None);
}; };
let zip_bytes = crate::site::download_media(&zip_url).await.map_err(|e| match e { let zip_bytes = crate::site::download_media(&zip_url)
.await
.map_err(|e| match e {
FetchError::Http(e) => PixivError::Http(e), FetchError::Http(e) => PixivError::Http(e),
other => PixivError::Api(format!("frame zip download failed: {other}")), other => PixivError::Api(format!("frame zip download failed: {other}")),
})?; })?;
let frame_delays = metadata.frames.iter().map(|f| f.delay).collect::<Vec<_>>(); let frame_delays = metadata.frames.iter().map(|f| f.delay).collect::<Vec<_>>();
let result = tokio::task::spawn_blocking( let result =
move || -> Result<(String, tempfile::TempDir), String> { tokio::task::spawn_blocking(move || -> Result<(String, tempfile::TempDir), String> {
let frames_dir = tempfile::tempdir().map_err(|e| e.to_string())?; let frames_dir = tempfile::tempdir().map_err(|e| e.to_string())?;
let out_dir = tempfile::tempdir().map_err(|e| e.to_string())?; let out_dir = tempfile::tempdir().map_err(|e| e.to_string())?;
@@ -240,11 +246,7 @@ impl PixivAPI {
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.name() .name()
.to_string(); .to_string();
first_name first_name.rsplit('.').next().unwrap_or("jpg").to_string()
.rsplit('.')
.next()
.unwrap_or("jpg")
.to_string()
} else { } else {
"jpg".to_string() "jpg".to_string()
}; };
@@ -253,7 +255,9 @@ impl PixivAPI {
let mut entry = archive.by_index(i).map_err(|e| e.to_string())?; let mut entry = archive.by_index(i).map_err(|e| e.to_string())?;
let mut bytes = Vec::new(); let mut bytes = Vec::new();
entry.read_to_end(&mut bytes).map_err(|e| e.to_string())?; entry.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
let path = frames_dir.path().join(format!("img_{count:05}.{extension}")); let path = frames_dir
.path()
.join(format!("img_{count:05}.{extension}"));
std::fs::write(&path, bytes).map_err(|e| e.to_string())?; std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
count += 1; count += 1;
} }
@@ -274,7 +278,10 @@ impl PixivAPI {
"-framerate", "-framerate",
&framerate.to_string(), &framerate.to_string(),
"-i", "-i",
&frames_dir.path().join(format!("img_%05d.{extension}")).to_string_lossy(), &frames_dir
.path()
.join(format!("img_%05d.{extension}"))
.to_string_lossy(),
// libx264 needs even dimensions; pixiv ugoira frames can // libx264 needs even dimensions; pixiv ugoira frames can
// be odd-sized (e.g. 277x405). // be odd-sized (e.g. 277x405).
"-vf", "-vf",
@@ -295,8 +302,7 @@ impl PixivAPI {
return Err(format!("ffmpeg exited with {status}")); return Err(format!("ffmpeg exited with {status}"));
} }
Ok((output.to_string_lossy().into_owned(), out_dir)) Ok((output.to_string_lossy().into_owned(), out_dir))
}, })
)
.await .await
.expect("ugoira encode worker panicked"); .expect("ugoira encode worker panicked");
match result { match result {
@@ -332,9 +338,8 @@ fn log_once_ffmpeg_missing() {
} }
/// pixiv3-rs replacement: `None` when `PIXIV_REFRESH_TOKEN` is unset. /// pixiv3-rs replacement: `None` when `PIXIV_REFRESH_TOKEN` is unset.
static PIXIV_CLIENT: LazyLock<Option<PixivAPI>> = LazyLock::new(|| { static PIXIV_CLIENT: LazyLock<Option<PixivAPI>> =
env::var("PIXIV_REFRESH_TOKEN").ok().map(PixivAPI::new) LazyLock::new(|| env::var("PIXIV_REFRESH_TOKEN").ok().map(PixivAPI::new));
});
/// Set at startup when the login validation fails; pixiv stays disabled until /// Set at startup when the login validation fails; pixiv stays disabled until
/// the next process start. /// the next process start.
+65 -15
View File
@@ -82,7 +82,10 @@ impl Illustration {
// keeps media empty when encoding fails or ffmpeg is missing. // keeps media empty when encoding fails or ffmpeg is missing.
} else if model.page_count > 1 { } else if model.page_count > 1 {
media.extend(model.meta_pages.iter().filter_map(|page| { media.extend(model.meta_pages.iter().filter_map(|page| {
page.image_urls.original.clone().map(|original| Media::Illustration { page.image_urls
.original
.clone()
.map(|original| Media::Illustration {
title: None, title: None,
url: original, url: original,
thumbnail_url: Some(page.image_urls.medium.clone()), thumbnail_url: Some(page.image_urls.medium.clone()),
@@ -147,8 +150,8 @@ impl From<Illustration> for Fetched {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*;
use super::super::model::IllustrationModel; use super::super::model::IllustrationModel;
use super::*;
fn illust_json( fn illust_json(
type_: &str, type_: &str,
@@ -203,8 +206,14 @@ mod tests {
("https://pixiv.net/artworks/123456", "123456"), ("https://pixiv.net/artworks/123456", "123456"),
("https://www.pixiv.net/en/artworks/123456", "123456"), ("https://www.pixiv.net/en/artworks/123456", "123456"),
("https://www.pixiv.net/i/123456", "123456"), ("https://www.pixiv.net/i/123456", "123456"),
("https://www.pixiv.net/member_illust.php?mode=medium&illust_id=123456", "123456"), (
("https://www.pixiv.net/en/member_illust.php?illust_id=123456", "123456"), "https://www.pixiv.net/member_illust.php?mode=medium&illust_id=123456",
"123456",
),
(
"https://www.pixiv.net/en/member_illust.php?illust_id=123456",
"123456",
),
]; ];
for (url, id) in cases { for (url, id) in cases {
let caps = PATTERN.captures(url).unwrap_or_else(|| panic!("{url}")); let caps = PATTERN.captures(url).unwrap_or_else(|| panic!("{url}"));
@@ -225,7 +234,14 @@ mod tests {
#[test] #[test]
fn ugoira_yields_empty_media() { fn ugoira_yields_empty_media() {
let v = illust_json("ugoira", 1, Some("https://i.pximg.net/orig.jpg"), None, vec![], 0); let v = illust_json(
"ugoira",
1,
Some("https://i.pximg.net/orig.jpg"),
None,
vec![],
0,
);
let illustration = parse(v); let illustration = parse(v);
let fetched: Fetched = illustration.into(); let fetched: Fetched = illustration.into();
assert!(fetched.media.is_empty()); assert!(fetched.media.is_empty());
@@ -296,7 +312,12 @@ mod tests {
let fetched: Fetched = parse(v).into(); let fetched: Fetched = parse(v).into();
assert_eq!(fetched.media.len(), 1); assert_eq!(fetched.media.len(), 1);
match &fetched.media[0] { match &fetched.media[0] {
Media::Illustration { url, thumbnail_url, fallback_url, .. } => { Media::Illustration {
url,
thumbnail_url,
fallback_url,
..
} => {
assert_eq!(url, "https://i.pximg.net/p2.jpg"); assert_eq!(url, "https://i.pximg.net/p2.jpg");
assert_eq!(thumbnail_url.as_deref(), Some("m2.jpg")); assert_eq!(thumbnail_url.as_deref(), Some("m2.jpg"));
assert_eq!(fallback_url.as_deref(), Some("l2.jpg")); assert_eq!(fallback_url.as_deref(), Some("l2.jpg"));
@@ -307,7 +328,14 @@ mod tests {
#[test] #[test]
fn caption_with_escapes_format_and_substitutes() { fn caption_with_escapes_format_and_substitutes() {
let v = illust_json("illust", 1, Some("https://i.pximg.net/o.jpg"), None, vec![], 0); let v = illust_json(
"illust",
1,
Some("https://i.pximg.net/o.jpg"),
None,
vec![],
0,
);
let fetched: Fetched = parse(v).into(); let fetched: Fetched = parse(v).into();
// Format string is escaped in full, then placeholders substituted. // Format string is escaped in full, then placeholders substituted.
let out = fetched.caption_with("{title} by {author} <script> {tags}"); let out = fetched.caption_with("{title} by {author} <script> {tags}");
@@ -330,7 +358,14 @@ mod tests {
#[test] #[test]
fn ai_work_gets_leading_ai_tag() { fn ai_work_gets_leading_ai_tag() {
// illust_ai_type == 2 is the only AI marker. // illust_ai_type == 2 is the only AI marker.
let v = illust_json("illust", 1, Some("https://i.pximg.net/o.jpg"), None, vec![], 2); let v = illust_json(
"illust",
1,
Some("https://i.pximg.net/o.jpg"),
None,
vec![],
2,
);
let fetched: Fetched = parse(v).into(); let fetched: Fetched = parse(v).into();
assert!( assert!(
fetched.caption.contains("#AI #tag1 #tag2"), fetched.caption.contains("#AI #tag1 #tag2"),
@@ -338,14 +373,25 @@ mod tests {
fetched.caption fetched.caption
); );
// The {tags} placeholder reflects the tag array too. // The {tags} placeholder reflects the tag array too.
assert!(fetched.caption_with("{tags}").starts_with("#AI "), "got: {}", fetched.caption_with("{tags}")); assert!(
fetched.caption_with("{tags}").starts_with("#AI "),
"got: {}",
fetched.caption_with("{tags}")
);
} }
#[test] #[test]
fn non_ai_work_has_no_ai_tag() { fn non_ai_work_has_no_ai_tag() {
// 1 = explicitly not AI, 0 = undefined: neither gets the #AI tag. // 1 = explicitly not AI, 0 = undefined: neither gets the #AI tag.
for ai_type in [0, 1] { for ai_type in [0, 1] {
let v = illust_json("illust", 1, Some("https://i.pximg.net/o.jpg"), None, vec![], ai_type); let v = illust_json(
"illust",
1,
Some("https://i.pximg.net/o.jpg"),
None,
vec![],
ai_type,
);
let fetched: Fetched = parse(v).into(); let fetched: Fetched = parse(v).into();
assert!( assert!(
!fetched.caption.contains("#AI"), !fetched.caption.contains("#AI"),
@@ -357,7 +403,14 @@ mod tests {
#[test] #[test]
fn caption_escapes_and_links() { fn caption_escapes_and_links() {
let v = illust_json("illust", 1, Some("https://i.pximg.net/o.jpg"), None, vec![], 0); let v = illust_json(
"illust",
1,
Some("https://i.pximg.net/o.jpg"),
None,
vec![],
0,
);
let fetched: Fetched = parse(v).into(); let fetched: Fetched = parse(v).into();
assert!( assert!(
fetched fetched
@@ -367,9 +420,6 @@ mod tests {
fetched.caption fetched.caption
); );
assert!(fetched.caption.contains("#tag1 #tag2")); assert!(fetched.caption.contains("#tag1 #tag2"));
assert_eq!( assert_eq!(fetched.source_url, "https://www.pixiv.net/artworks/123");
fetched.source_url,
"https://www.pixiv.net/artworks/123"
);
} }
} }
+1 -1
View File
@@ -3,4 +3,4 @@ mod interface;
mod model; mod model;
pub use api::{PixivAPI, PixivError, disable, fetch, validate}; pub use api::{PixivAPI, PixivError, disable, fetch, validate};
pub use interface::{PATTERN, Illustration, enabled, fetch_from_url}; pub use interface::{Illustration, PATTERN, enabled, fetch_from_url};
+15 -11
View File
@@ -23,7 +23,7 @@
use std::sync::LazyLock; use std::sync::LazyLock;
use serde_json::{json, Value}; use serde_json::{Value, json};
use crate::site::FetchError; use crate::site::FetchError;
@@ -40,8 +40,7 @@ static AUTH_TOKEN: LazyLock<Option<String>> = LazyLock::new(|| {
}); });
/// Public "logged in" client token used by the x.com web app. /// Public "logged in" client token used by the x.com web app.
const LOGGED_IN_BEARER: &str = const LOGGED_IN_BEARER: &str = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
"Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
/// `TweetDetail` query id (from nazurin; still valid as of 2026-08, /// `TweetDetail` query id (from nazurin; still valid as of 2026-08,
/// corroborated by the current FxEmbed build — see module caveats). /// corroborated by the current FxEmbed build — see module caveats).
@@ -103,9 +102,7 @@ pub fn enabled() -> bool {
/// Fetches a tweet as the logged-in user via the private GraphQL API. /// Fetches a tweet as the logged-in user via the private GraphQL API.
/// Returns the syndication-shaped [`Tweet`] (media included for NSFW posts). /// Returns the syndication-shaped [`Tweet`] (media included for NSFW posts).
pub async fn fetch(id: &str) -> Result<Tweet, FetchError> { pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
let token = AUTH_TOKEN let token = AUTH_TOKEN.as_deref().ok_or(FetchError::Sensitive)?;
.as_deref()
.ok_or(FetchError::Sensitive)?;
// 16 random bytes as 32 hex chars: X rejects ct0 values of any other // 16 random bytes as 32 hex chars: X rejects ct0 values of any other
// length with 403 code 353 ("matching csrf cookie and header"). // length with 403 code 353 ("matching csrf cookie and header").
let ct0: String = (0..16) let ct0: String = (0..16)
@@ -136,11 +133,12 @@ pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
let text = response.text().await?; let text = response.text().await?;
let json: Value = serde_json::from_str(&text)?; let json: Value = serde_json::from_str(&text)?;
let result = parse_tweet_result(&json, id)?; let result = parse_tweet_result(&json, id)?;
let syndication_shape = to_syndication_shape(&result) let syndication_shape = to_syndication_shape(&result).ok_or_else(|| {
.ok_or_else(|| FetchError::Json(serde_json::Error::io(std::io::Error::new( FetchError::Json(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData, std::io::ErrorKind::InvalidData,
"missing tweet fields in GraphQL response", "missing tweet fields in GraphQL response",
))))?; )))
})?;
Tweet::from_syndication_json(&syndication_shape.to_string()).map_err(FetchError::Json) Tweet::from_syndication_json(&syndication_shape.to_string()).map_err(FetchError::Json)
} }
@@ -317,7 +315,10 @@ mod tests {
} }
other => panic!("expected video, got {other:?}"), other => panic!("expected video, got {other:?}"),
} }
assert_eq!(fetched.source_url, "https://x.com/nsfw_author/status/2083868672721039569"); assert_eq!(
fetched.source_url,
"https://x.com/nsfw_author/status/2083868672721039569"
);
// The appended media short link (no URL-entity mapping) is stripped. // The appended media short link (no URL-entity mapping) is stripped.
assert_eq!(fetched.title, "nsfw content"); assert_eq!(fetched.title, "nsfw content");
} }
@@ -330,7 +331,10 @@ mod tests {
let json = conversation(rt); let json = conversation(rt);
let result = parse_tweet_result(&json, "2083868672721039569").unwrap(); let result = parse_tweet_result(&json, "2083868672721039569").unwrap();
assert!(result.pointer("/legacy/retweeted_status_result").is_none()); assert!(result.pointer("/legacy/retweeted_status_result").is_none());
assert_eq!(result.pointer("/legacy/id_str").unwrap(), "2083868672721039569"); assert_eq!(
result.pointer("/legacy/id_str").unwrap(),
"2083868672721039569"
);
} }
#[test] #[test]
+17 -20
View File
@@ -35,9 +35,7 @@ pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
} }
} }
} else { } else {
log::info!( log::info!("tweet {id} is sensitive; set TWITTER_AUTH_TOKEN to fetch NSFW media");
"tweet {id} is sensitive; set TWITTER_AUTH_TOKEN to fetch NSFW media"
);
Ok(empty_fetched(url)) Ok(empty_fetched(url))
} }
} }
@@ -228,25 +226,22 @@ fn expand_links(text: &str, urls: &[model::SyndicationEntityUrl]) -> String {
/// Internal x.com page links (reply / quote plumbing) expand to /// Internal x.com page links (reply / quote plumbing) expand to
/// `x.com/i/web/status/<id>`; FxEmbed drops them — the tweet's own content /// `x.com/i/web/status/<id>`; FxEmbed drops them — the tweet's own content
/// already carries the information. /// already carries the information.
static WEB_STATUS_URL: LazyLock<Regex> = LazyLock::new(|| { static WEB_STATUS_URL: LazyLock<Regex> =
Regex::new(r"^https://(?:x\.com|twitter\.com)/i/web/status/\w+").unwrap() LazyLock::new(|| Regex::new(r"^https://(?:x\.com|twitter\.com)/i/web/status/\w+").unwrap());
});
/// A t.co short link, optionally preceded by a space. Any leftover /// A t.co short link, optionally preceded by a space. Any leftover
/// occurrence (unmapped — e.g. the appended media link) is removed, /// occurrence (unmapped — e.g. the appended media link) is removed,
/// mirroring FxEmbed. Real short-link codes are 10 alphanumerics; the /// mirroring FxEmbed. Real short-link codes are 10 alphanumerics; the
/// length-agnostic class keeps fixtures and hypothetical odd lengths safe. /// length-agnostic class keeps fixtures and hypothetical odd lengths safe.
static TCO_LINK: LazyLock<Regex> = LazyLock::new(|| { static TCO_LINK: LazyLock<Regex> =
Regex::new(r" ?https?://t\.co/[A-Za-z0-9]+").unwrap() LazyLock::new(|| Regex::new(r" ?https?://t\.co/[A-Za-z0-9]+").unwrap());
});
/// pbs.twimg.com serves a reduced default size without size params; `name=orig` /// pbs.twimg.com serves a reduced default size without size params; `name=orig`
/// returns the original file (fxtwitter used to hand out the original /// returns the original file (fxtwitter used to hand out the original
/// directly, the syndication API does not). Non-twimg URLs pass through /// directly, the syndication API does not). Non-twimg URLs pass through
/// unchanged. /// unchanged.
fn original_twimg_url(url: &str) -> String { fn original_twimg_url(url: &str) -> String {
if url.starts_with("https://pbs.twimg.com/") if url.starts_with("https://pbs.twimg.com/") && (url.ends_with(".jpg") || url.ends_with(".png"))
&& (url.ends_with(".jpg") || url.ends_with(".png"))
{ {
format!("{url}?name=orig") format!("{url}?name=orig")
} else { } else {
@@ -361,24 +356,23 @@ mod tests {
match &fetched.media[0] { match &fetched.media[0] {
Media::Illustration { url, .. } => { Media::Illustration { url, .. } => {
// Photo URL is rewritten to request the original file. // Photo URL is rewritten to request the original file.
assert_eq!( assert_eq!(url, "https://pbs.twimg.com/media/photo.jpg?name=orig");
url,
"https://pbs.twimg.com/media/photo.jpg?name=orig"
);
} }
other => panic!("expected illustration, got {other:?}"), 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");
assert_eq!(thumbnail_url, "https://pbs.twimg.com/thumb.jpg"); assert_eq!(thumbnail_url, "https://pbs.twimg.com/thumb.jpg");
} }
other => panic!("expected video, got {other:?}"), other => panic!("expected video, got {other:?}"),
} }
assert!( assert!(
fetched fetched.caption.contains(
.caption "<a href=\"https://x.com/author_handle\">Display Name</a>: a &amp; b &lt;c&gt;"
.contains("<a href=\"https://x.com/author_handle\">Display Name</a>: a &amp; b &lt;c&gt;"), ),
"caption: {}", "caption: {}",
fetched.caption fetched.caption
); );
@@ -587,6 +581,9 @@ mod tests {
async fn live_fetch_deleted_tweet_is_not_found() { async fn live_fetch_deleted_tweet_is_not_found() {
// Deleted tweet: the syndication endpoint answers with errors. // Deleted tweet: the syndication endpoint answers with errors.
let result = fetch("0").await; let result = fetch("0").await;
assert!(matches!(result, Err(FetchError::NotFound)), "got {result:?}"); assert!(
matches!(result, Err(FetchError::NotFound)),
"got {result:?}"
);
} }
} }
+1 -3
View File
@@ -51,9 +51,7 @@ impl Config {
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());
// Empty strings count as unset (e.g. `-e WEBHOOK_CERT=` to disable a // Empty strings count as unset (e.g. `-e WEBHOOK_CERT=` to disable a
// value that would otherwise come from `.env`). // value that would otherwise come from `.env`).
let webhook_cert = env::var("WEBHOOK_CERT") let webhook_cert = env::var("WEBHOOK_CERT").ok().filter(|s| !s.is_empty());
.ok()
.filter(|s| !s.is_empty());
let webhook_secret_token = env::var("WEBHOOK_SECRET_TOKEN") let webhook_secret_token = env::var("WEBHOOK_SECRET_TOKEN")
.ok() .ok()
.filter(|s| !s.is_empty()); .filter(|s| !s.is_empty());
+11 -8
View File
@@ -1,9 +1,9 @@
use dotenv::dotenv; use dotenv::dotenv;
use teloxide::dptree::endpoint; use teloxide::dptree::endpoint;
use teloxide::prelude::*;
use teloxide::stop::StopToken; use teloxide::stop::StopToken;
use teloxide::types::{ChatId, InputFile, MessageId}; use teloxide::types::{ChatId, InputFile, MessageId};
use teloxide::update_listeners::{self, webhooks, UpdateListener}; use teloxide::update_listeners::{self, UpdateListener, webhooks};
use teloxide::prelude::*;
use tokio::sync::watch; use tokio::sync::watch;
use x_media::site; use x_media::site;
@@ -75,7 +75,10 @@ async fn main() {
} }
// Edit-expiry sweep: clears the prompt's buttons once the record expires. // Edit-expiry sweep: clears the prompt's buttons once the record expires.
log::info!("edit-expiry sweep: every 300s, ttl {}", CONFIG.edit_message_ttl.as_secs()); log::info!(
"edit-expiry sweep: every 300s, ttl {}",
CONFIG.edit_message_ttl.as_secs()
);
let (stop_tx, stop_rx) = watch::channel(false); let (stop_tx, stop_rx) = watch::channel(false);
{ {
let bot = bot.clone(); let bot = bot.clone();
@@ -96,7 +99,10 @@ async fn main() {
// If the prompt was already deleted, this fails with a // If the prompt was already deleted, this fails with a
// 400 "message to edit not found" — log and ignore. // 400 "message to edit not found" — log and ignore.
if let Err(e) = bot if let Err(e) = bot
.edit_message_reply_markup(ChatId(chat_id), MessageId(prompt_message_id as i32)) .edit_message_reply_markup(
ChatId(chat_id),
MessageId(prompt_message_id as i32),
)
.await .await
{ {
log::info!("edit-expiry sweep: prompt message gone: {e}"); log::info!("edit-expiry sweep: prompt message gone: {e}");
@@ -118,10 +124,7 @@ async fn main() {
if CONFIG.webhook_enabled { if CONFIG.webhook_enabled {
log::info!("running in webhook mode"); log::info!("running in webhook mode");
let url = CONFIG let url = CONFIG.webhook_url.clone().expect("WEBHOOK_URL is not set");
.webhook_url
.clone()
.expect("WEBHOOK_URL is not set");
// `webhooks::axum` calls set_webhook itself (with the full options, // `webhooks::axum` calls set_webhook itself (with the full options,
// secret token included) — no explicit registration here. // 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");
+19 -7
View File
@@ -216,13 +216,15 @@ fn target_dims(w: u32, h: u32) -> (u32, u32) {
/// RGB, Lanczos-downscale beyond the dimension cap, encode PNG — a PNG still /// RGB, Lanczos-downscale beyond the dimension cap, encode PNG — a PNG still
/// over the upload cap afterwards becomes JPEG. /// over the upload cap afterwards becomes JPEG.
fn prepare_png(file: NamedTempFile, bytes: Vec<u8>) -> Result<PhotoPrep, String> { fn prepare_png(file: NamedTempFile, bytes: Vec<u8>) -> Result<PhotoPrep, String> {
let (w, h, _bit_depth, color_type) = let (w, h, _bit_depth, color_type) = parse_png_header(&bytes).ok_or("invalid PNG header")?;
parse_png_header(&bytes).ok_or("invalid PNG header")?;
let size_over = bytes.len() as u64 > MAX_UPLOAD_BYTES; let size_over = bytes.len() as u64 > MAX_UPLOAD_BYTES;
if w + h <= PHOTO_MAX_DIMENSION_SUM && !size_over { if w + h <= PHOTO_MAX_DIMENSION_SUM && !size_over {
return Ok(PhotoPrep::Upload(file)); return Ok(PhotoPrep::Upload(file));
} }
log::info!("photo {w}x{h} ({_bit_depth:?} {color_type:?}, {} bytes) needs processing", bytes.len()); log::info!(
"photo {w}x{h} ({_bit_depth:?} {color_type:?}, {} bytes) needs processing",
bytes.len()
);
let channels = output_channels(color_type); let channels = output_channels(color_type);
if (w as u64) * (h as u64) * channels as u64 > MAX_DECODE_BYTES { if (w as u64) * (h as u64) * channels as u64 > MAX_DECODE_BYTES {
@@ -238,7 +240,9 @@ fn prepare_png(file: NamedTempFile, bytes: Vec<u8>) -> Result<PhotoPrep, String>
}; };
let mut decoder = png::Decoder::new(std::io::Cursor::new(&bytes)); let mut decoder = png::Decoder::new(std::io::Cursor::new(&bytes));
decoder.set_transformations(transforms); decoder.set_transformations(transforms);
let mut reader = decoder.read_info().map_err(|e| format!("png decode: {e}"))?; let mut reader = decoder
.read_info()
.map_err(|e| format!("png decode: {e}"))?;
let out_w = reader.info().width; let out_w = reader.info().width;
let out_h = reader.info().height; let out_h = reader.info().height;
let mut buf = vec![ let mut buf = vec![
@@ -457,7 +461,9 @@ mod tests {
let mut bytes = Vec::new(); let mut bytes = Vec::new();
{ {
let encoder = jpeg_encoder::Encoder::new(&mut bytes, 90); let encoder = jpeg_encoder::Encoder::new(&mut bytes, 90);
encoder.encode(&rgb, w, h, jpeg_encoder::ColorType::Rgb).unwrap(); encoder
.encode(&rgb, w, h, jpeg_encoder::ColorType::Rgb)
.unwrap();
} }
let mut file = tempfile::Builder::new().suffix(".jpg").tempfile().unwrap(); let mut file = tempfile::Builder::new().suffix(".jpg").tempfile().unwrap();
std::io::Write::write_all(file.as_file_mut(), &bytes).unwrap(); std::io::Write::write_all(file.as_file_mut(), &bytes).unwrap();
@@ -485,7 +491,9 @@ mod tests {
for y in 0..h { for y in 0..h {
for x in 0..w { for x in 0..w {
let base = (x + y) * 255 / (w + h); let base = (x + y) * 255 / (w + h);
rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); rng = rng
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let n = ((rng >> 33) % 11) as i32 - 5; // noise in [-5, 5] let n = ((rng >> 33) % 11) as i32 - 5; // noise in [-5, 5]
let v = (base as i32 + n).clamp(0, 255) as u8; let v = (base as i32 + n).clamp(0, 255) as u8;
data.extend_from_slice(&[v, v, v]); data.extend_from_slice(&[v, v, v]);
@@ -499,7 +507,11 @@ mod tests {
let mut writer = encoder.write_header().unwrap(); let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&data).unwrap(); writer.write_image_data(&data).unwrap();
} }
assert!(bytes.len() as u64 > MAX_UPLOAD_BYTES, "test needs a >10MiB PNG, got {}", bytes.len()); assert!(
bytes.len() as u64 > MAX_UPLOAD_BYTES,
"test needs a >10MiB PNG, got {}",
bytes.len()
);
let mut file = tempfile::Builder::new().suffix(".png").tempfile().unwrap(); let mut file = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
std::io::Write::write_all(file.as_file_mut(), &bytes).unwrap(); std::io::Write::write_all(file.as_file_mut(), &bytes).unwrap();
+156 -77
View File
@@ -5,20 +5,19 @@
use crate::handlers::{CHAT_STORE, LINK_CACHE, TASK_QUEUE}; use crate::handlers::{CHAT_STORE, LINK_CACHE, TASK_QUEUE};
use crate::link_cache::{CachedMedia, CachedMediaKind, CachedPost}; use crate::link_cache::{CachedMedia, CachedMediaKind, CachedPost};
use crate::photo::{self, PhotoPrep, MAX_UPLOAD_BYTES}; use crate::photo::{self, MAX_UPLOAD_BYTES, PhotoPrep};
use crate::queue::QueueError; use crate::queue::QueueError;
use crate::state::{EditMessage, unix_now}; use crate::state::{EditMessage, unix_now};
use rand::Rng; use rand::Rng;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use tempfile::NamedTempFile;
use teloxide::prelude::*; use teloxide::prelude::*;
use teloxide::types::{ use teloxide::types::{
ChatId, InlineKeyboardButton, InlineKeyboardMarkup, InputFile, InputMedia, ChatId, InlineKeyboardButton, InlineKeyboardMarkup, InputFile, InputMedia, InputMediaAnimation,
InputMediaAnimation, InputMediaPhoto, InputMediaVideo, Message, MessageId, ParseMode, InputMediaPhoto, InputMediaVideo, Message, MessageId, ParseMode, ReplyParameters,
ReplyParameters,
}; };
use teloxide::{ApiError, RequestError}; use teloxide::{ApiError, RequestError};
use tempfile::NamedTempFile;
use x_media::site::FetchError; use x_media::site::FetchError;
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
@@ -111,16 +110,18 @@ pub enum Task {
impl Task { impl Task {
fn cache_data(&self) -> Option<&CachedPost> { fn cache_data(&self) -> Option<&CachedPost> {
match self { match self {
Task::SendMediaSequence { cache_data, .. } Task::SendMediaSequence { cache_data, .. } | Task::SendAnimation { cache_data, .. } => {
| Task::SendAnimation { cache_data, .. } => cache_data.as_ref(), cache_data.as_ref()
}
Task::ForwardMessages { .. } => None, Task::ForwardMessages { .. } => None,
} }
} }
fn source_url(&self) -> Option<&str> { fn source_url(&self) -> Option<&str> {
match self { match self {
Task::SendMediaSequence { source_url, .. } Task::SendMediaSequence { source_url, .. } | Task::SendAnimation { source_url, .. } => {
| Task::SendAnimation { source_url, .. } => Some(source_url), Some(source_url)
}
Task::ForwardMessages { .. } => None, Task::ForwardMessages { .. } => None,
} }
} }
@@ -138,9 +139,10 @@ fn file_id_of_message(message: &Message, item: &MediaItemPayload) -> Option<Stri
match item { match item {
// `photo()` returns all sizes, smallest first — the largest carries // `photo()` returns all sizes, smallest first — the largest carries
// the file id of the sent media. // the file id of the sent media.
MediaItemPayload::Photo { .. } => { MediaItemPayload::Photo { .. } => message
message.photo().and_then(|sizes| sizes.last()).map(|p| p.file.id.to_string()) .photo()
} .and_then(|sizes| sizes.last())
.map(|p| p.file.id.to_string()),
MediaItemPayload::Video { .. } => message.video().map(|v| v.file.id.to_string()), MediaItemPayload::Video { .. } => message.video().map(|v| v.file.id.to_string()),
MediaItemPayload::Animation { .. } => message.animation().map(|a| a.file.id.to_string()), MediaItemPayload::Animation { .. } => message.animation().map(|a| a.file.id.to_string()),
} }
@@ -214,7 +216,10 @@ pub const MAX_MEDIA_GROUP: usize = 9;
/// 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>> {
items.chunks(MAX_MEDIA_GROUP).map(|chunk| chunk.to_vec()).collect() items
.chunks(MAX_MEDIA_GROUP)
.map(|chunk| chunk.to_vec())
.collect()
} }
/// Exponential backoff with jitter, capped at 30s. /// Exponential backoff with jitter, capped at 30s.
@@ -250,31 +255,41 @@ pub fn is_size_error(e: &ApiError) -> bool {
return true; return true;
} }
let description = e.to_string().to_lowercase(); let description = e.to_string().to_lowercase();
["too large", "too big"].iter().any(|marker| description.contains(marker)) ["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 {
Retryable { delay_seconds: f64 }, Retryable {
Permanent { message: String }, delay_seconds: f64,
},
Permanent {
message: String,
},
/// Handled by the download fallback, not a queue retry. /// Handled by the download fallback, not a queue retry.
MediaFetchFailure, MediaFetchFailure,
} }
pub fn classify_request_error(e: &RequestError) -> Classification { pub fn classify_request_error(e: &RequestError) -> Classification {
match e { match e {
RequestError::RetryAfter(seconds) => { RequestError::RetryAfter(seconds) => Classification::Retryable {
Classification::Retryable { delay_seconds: seconds.seconds() as f64 } delay_seconds: seconds.seconds() as f64,
} },
RequestError::Network(_) => Classification::Retryable { RequestError::Network(_) => Classification::Retryable {
delay_seconds: retry_delay_seconds(0), delay_seconds: retry_delay_seconds(0),
}, },
RequestError::Api(api) if is_media_fetch_failure(api) => Classification::MediaFetchFailure, RequestError::Api(api) if is_media_fetch_failure(api) => Classification::MediaFetchFailure,
RequestError::Api(api) => Classification::Permanent { message: api.to_string() }, RequestError::Api(api) => Classification::Permanent {
message: api.to_string(),
},
RequestError::MigrateToChatId(_) RequestError::MigrateToChatId(_)
| RequestError::InvalidJson { .. } | RequestError::InvalidJson { .. }
| RequestError::Io(_) => Classification::Permanent { message: e.to_string() }, | RequestError::Io(_) => Classification::Permanent {
message: e.to_string(),
},
} }
} }
@@ -390,9 +405,9 @@ fn build_media_group(
.map(|(i, item)| { .map(|(i, item)| {
let item_caption = if i == 0 { caption } else { None }; let item_caption = if i == 0 { caption } else { None };
Ok(match item { Ok(match item {
MediaItemPayload::Photo { MediaItemPayload::Photo { has_spoiler, .. } => {
has_spoiler, .. photo_media(item.input_file()?, item_caption, *has_spoiler)
} => photo_media(item.input_file()?, item_caption, *has_spoiler), }
MediaItemPayload::Video { MediaItemPayload::Video {
has_spoiler, has_spoiler,
thumbnail, thumbnail,
@@ -404,9 +419,9 @@ fn build_media_group(
} }
video video
} }
MediaItemPayload::Animation { MediaItemPayload::Animation { has_spoiler, .. } => {
has_spoiler, .. animation_media(item.input_file()?, item_caption, *has_spoiler)
} => animation_media(item.input_file()?, item_caption, *has_spoiler), }
}) })
}) })
.collect() .collect()
@@ -431,8 +446,12 @@ fn sniff_ext(bytes: &[u8]) -> &'static str {
} }
enum FallbackError { enum FallbackError {
Retryable { delay_seconds: f64 }, Retryable {
Permanent { message: String }, delay_seconds: f64,
},
Permanent {
message: String,
},
/// The downloaded file exceeds the upload cap; the caller falls back to /// The downloaded file exceeds the upload cap; the caller falls back to
/// the item's smaller URL. /// the item's smaller URL.
MediaTooLarge, MediaTooLarge,
@@ -468,9 +487,7 @@ async fn download_to_temp(item: &MediaItemPayload) -> Result<NamedTempFile, Fall
}; };
// Photos are downloaded even over the cap so `prepare_photo` can // Photos are downloaded even over the cap so `prepare_photo` can
// downscale / transcode them; only videos/animations short-circuit. // downscale / transcode them; only videos/animations short-circuit.
if !matches!(item, MediaItemPayload::Photo { .. }) if !matches!(item, MediaItemPayload::Photo { .. }) && bytes.len() as u64 > MAX_UPLOAD_BYTES {
&& bytes.len() as u64 > MAX_UPLOAD_BYTES
{
return Err(FallbackError::MediaTooLarge); return Err(FallbackError::MediaTooLarge);
} }
let ext = sniff_ext(&bytes); let ext = sniff_ext(&bytes);
@@ -628,14 +645,16 @@ async fn send_batch_via_upload(
} }
let result = bot let result = bot
.send_media_group(ChatId(chat_id), items) .send_media_group(ChatId(chat_id), items)
.reply_parameters(ReplyParameters::new(MessageId(reply_to as i32)).allow_sending_without_reply()) .reply_parameters(
ReplyParameters::new(MessageId(reply_to as i32)).allow_sending_without_reply(),
)
.await; .await;
match result { match result {
Ok(messages) => Ok(messages), Ok(messages) => Ok(messages),
Err(e) => Err(match classify_request_error(&e) { Err(e) => Err(match classify_request_error(&e) {
Classification::Retryable { delay_seconds } => FallbackError::Retryable { Classification::Retryable { delay_seconds } => {
delay_seconds, FallbackError::Retryable { delay_seconds }
}, }
Classification::Permanent { message } => FallbackError::Permanent { message }, Classification::Permanent { message } => FallbackError::Permanent { message },
Classification::MediaFetchFailure => FallbackError::Permanent { Classification::MediaFetchFailure => FallbackError::Permanent {
message: "upload failed".into(), message: "upload failed".into(),
@@ -702,7 +721,11 @@ pub async fn send_media_sequence(bot: &Bot, task: &Task) -> Result<Vec<i64>, Sen
let fresh_send = *batch_index == 0 && sent.is_empty(); let fresh_send = *batch_index == 0 && sent.is_empty();
for idx in *batch_index..media_batches.len() { for idx in *batch_index..media_batches.len() {
let batch = &media_batches[idx]; let batch = &media_batches[idx];
let caption = if idx == 0 { Some(caption.as_str()) } else { None }; let caption = if idx == 0 {
Some(caption.as_str())
} else {
None
};
let items = match build_media_group(batch, caption) { let items = match build_media_group(batch, caption) {
Ok(items) => items, Ok(items) => items,
Err(message) => { Err(message) => {
@@ -714,7 +737,9 @@ pub async fn send_media_sequence(bot: &Bot, task: &Task) -> Result<Vec<i64>, Sen
}; };
match bot match bot
.send_media_group(ChatId(chat_id), items) .send_media_group(ChatId(chat_id), items)
.reply_parameters(ReplyParameters::new(MessageId(reply_to as i32)).allow_sending_without_reply()) .reply_parameters(
ReplyParameters::new(MessageId(reply_to as i32)).allow_sending_without_reply(),
)
.await .await
{ {
Ok(messages) => { Ok(messages) => {
@@ -726,9 +751,7 @@ pub async fn send_media_sequence(bot: &Bot, task: &Task) -> Result<Vec<i64>, Sen
collect_file_ids(&messages, batch, &mut cached_media); collect_file_ids(&messages, batch, &mut cached_media);
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)) Err(RequestError::Api(api)) if is_media_fetch_failure(&api) || is_size_error(&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.first().map(item_url).unwrap_or("?") batch.first().map(item_url).unwrap_or("?")
@@ -779,7 +802,9 @@ async fn send_animation_inner(
.send_animation(ChatId(chat_id), file) .send_animation(ChatId(chat_id), file)
.caption(caption) .caption(caption)
.parse_mode(ParseMode::Html) .parse_mode(ParseMode::Html)
.reply_parameters(ReplyParameters::new(MessageId(reply_to as i32)).allow_sending_without_reply()); .reply_parameters(
ReplyParameters::new(MessageId(reply_to as i32)).allow_sending_without_reply(),
);
if spoiler { if spoiler {
request = request.has_spoiler(true); request = request.has_spoiler(true);
} }
@@ -802,9 +827,7 @@ pub async fn send_animation(bot: &Bot, task: &Task) -> Result<Vec<i64>, SendErro
let reply_to = *reply_to_message_id; let reply_to = *reply_to_message_id;
let (media_url, has_spoiler) = match animation { let (media_url, has_spoiler) = match animation {
MediaItemPayload::Animation { MediaItemPayload::Animation {
media, media, has_spoiler, ..
has_spoiler,
..
} => (media, *has_spoiler), } => (media, *has_spoiler),
MediaItemPayload::Photo { .. } | MediaItemPayload::Video { .. } => { MediaItemPayload::Photo { .. } | MediaItemPayload::Video { .. } => {
unreachable!("SendAnimation carries an Animation payload") unreachable!("SendAnimation carries an Animation payload")
@@ -812,19 +835,20 @@ pub async fn send_animation(bot: &Bot, task: &Task) -> Result<Vec<i64>, SendErro
}; };
let url_file = match input_file_for(media_url) { let url_file = match input_file_for(media_url) {
Ok(file) => file, Ok(file) => file,
Err(message) => return Err(SendError::Permanent { message, task: task.clone() }), Err(message) => {
return Err(SendError::Permanent {
message,
task: task.clone(),
});
}
}; };
match send_animation_inner(bot, chat_id, reply_to, caption, has_spoiler, url_file) match send_animation_inner(bot, chat_id, reply_to, caption, has_spoiler, url_file).await {
.await
{
Ok(message) => { Ok(message) => {
let id = message.id.0 as i64; let id = message.id.0 as i64;
cache_animation_send(task, &message).await; cache_animation_send(task, &message).await;
Ok(vec![id]) Ok(vec![id])
} }
Err(RequestError::Api(api)) Err(RequestError::Api(api)) if is_media_fetch_failure(&api) || is_size_error(&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
@@ -872,21 +896,24 @@ pub async fn send_animation(bot: &Bot, task: &Task) -> Result<Vec<i64>, SendErro
Err(e) => Err(classify_to_send_error(&e, task.clone())), Err(e) => Err(classify_to_send_error(&e, task.clone())),
} }
} }
Err(message) => { Err(message) => Err(SendError::Permanent {
Err(SendError::Permanent { message, task: task.clone() }) message,
} task: task.clone(),
}),
}, },
None => Err(SendError::Permanent { None => Err(SendError::Permanent {
message: "media too large".into(), message: "media too large".into(),
task: task.clone(), task: task.clone(),
}), }),
}, },
Err(FallbackError::Retryable { delay_seconds }) => { Err(FallbackError::Retryable { delay_seconds }) => Err(SendError::Retryable {
Err(SendError::Retryable { delay_seconds, task: task.clone() }) delay_seconds,
} task: task.clone(),
Err(FallbackError::Permanent { message }) => { }),
Err(SendError::Permanent { message, task: task.clone() }) Err(FallbackError::Permanent { message }) => Err(SendError::Permanent {
} message,
task: task.clone(),
}),
} }
} }
Err(e) => Err(classify_to_send_error(&e, task.clone())), Err(e) => Err(classify_to_send_error(&e, task.clone())),
@@ -910,7 +937,11 @@ pub async fn forward_messages(bot: &Bot, task: &Task) -> Result<(), SendError> {
.map(|id| MessageId(*id as i32)) .map(|id| MessageId(*id as i32))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
match bot match bot
.copy_messages(ChatId(*to_chat_id), ChatId(*from_chat_id), message_ids.clone()) .copy_messages(
ChatId(*to_chat_id),
ChatId(*from_chat_id),
message_ids.clone(),
)
.await .await
{ {
Ok(_) => { Ok(_) => {
@@ -944,12 +975,18 @@ pub fn build_edit_markup(templates: &HashMap<String, String>) -> InlineKeyboardM
/// Notifies a chat about a dead-lettered task (skips when `notify_chat_id` is /// Notifies a chat about a dead-lettered task (skips when `notify_chat_id` is
/// absent). /// absent).
pub async fn notify_failure(bot: &Bot, chat_id: Option<i64>, message_id: Option<i64>, message: &str) { pub async fn notify_failure(
bot: &Bot,
chat_id: Option<i64>,
message_id: Option<i64>,
message: &str,
) {
let Some(chat_id) = chat_id else { return }; let Some(chat_id) = chat_id else { return };
let mut request = bot.send_message(ChatId(chat_id), message); let mut request = bot.send_message(ChatId(chat_id), message);
if let Some(message_id) = message_id { if let Some(message_id) = message_id {
request = request request = request.reply_parameters(
.reply_parameters(ReplyParameters::new(MessageId(message_id as i32)).allow_sending_without_reply()); ReplyParameters::new(MessageId(message_id as i32)).allow_sending_without_reply(),
);
} }
if let Err(e) = request.await { if let Err(e) = request.await {
log::error!("failed to notify about failed task: {e}"); log::error!("failed to notify about failed task: {e}");
@@ -959,8 +996,15 @@ pub async fn notify_failure(bot: &Bot, chat_id: Option<i64>, message_id: Option<
/// After a successful send: either open the edit-before-forward prompt or /// After a successful send: either open the edit-before-forward prompt or
/// forward to the configured channel (with retry/queue handling). /// forward to the configured channel (with retry/queue handling).
pub async fn post_send_actions(bot: &Bot, task: &Task, message_ids: Vec<i64>) { pub async fn post_send_actions(bot: &Bot, task: &Task, message_ids: Vec<i64>) {
let (chat_id, reply_to, source_url, edit_before_forward, forward_channel_id, notify_chat_id, notify_message_id) = let (
match task { chat_id,
reply_to,
source_url,
edit_before_forward,
forward_channel_id,
notify_chat_id,
notify_message_id,
) = match task {
Task::SendMediaSequence { Task::SendMediaSequence {
chat_id, chat_id,
reply_to_message_id, reply_to_message_id,
@@ -1027,7 +1071,10 @@ pub async fn post_send_actions(bot: &Bot, task: &Task, message_ids: Vec<i64>) {
} }
if let Some(channel_id) = forward_channel_id { if let Some(channel_id) = forward_channel_id {
log::info!("forwarding {} message(s) to channel {channel_id}", message_ids.len()); log::info!(
"forwarding {} message(s) to channel {channel_id}",
message_ids.len()
);
let forward_task = Task::ForwardMessages { let forward_task = Task::ForwardMessages {
from_chat_id: chat_id, from_chat_id: chat_id,
to_chat_id: channel_id, to_chat_id: channel_id,
@@ -1037,7 +1084,10 @@ pub async fn post_send_actions(bot: &Bot, task: &Task, message_ids: Vec<i64>) {
}; };
match forward_messages(bot, &forward_task).await { match forward_messages(bot, &forward_task).await {
Ok(()) => {} Ok(()) => {}
Err(SendError::Retryable { delay_seconds, task }) => { Err(SendError::Retryable {
delay_seconds,
task,
}) => {
let payload = serde_json::to_value(task).expect("task serializes"); let payload = serde_json::to_value(task).expect("task serializes");
let run_after = std::time::SystemTime::now() let run_after = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
@@ -1077,7 +1127,10 @@ pub async fn handle_task(payload: serde_json::Value) -> Result<(), QueueError> {
Task::SendMediaSequence { .. } | Task::SendAnimation { .. } => { Task::SendMediaSequence { .. } | Task::SendAnimation { .. } => {
let message_ids = match send_media_or_animation(&bot, &task).await { let message_ids = match send_media_or_animation(&bot, &task).await {
Ok(ids) => ids, Ok(ids) => ids,
Err(SendError::Retryable { delay_seconds, task }) => { Err(SendError::Retryable {
delay_seconds,
task,
}) => {
return Err(QueueError::Retryable { return Err(QueueError::Retryable {
delay_seconds, delay_seconds,
payload: serde_json::to_value(task).expect("task serializes"), payload: serde_json::to_value(task).expect("task serializes"),
@@ -1096,7 +1149,10 @@ pub async fn handle_task(payload: serde_json::Value) -> Result<(), QueueError> {
} }
Task::ForwardMessages { .. } => match forward_messages(&bot, &task).await { Task::ForwardMessages { .. } => match forward_messages(&bot, &task).await {
Ok(()) => Ok(()), Ok(()) => Ok(()),
Err(SendError::Retryable { delay_seconds, task }) => Err(QueueError::Retryable { Err(SendError::Retryable {
delay_seconds,
task,
}) => Err(QueueError::Retryable {
delay_seconds, delay_seconds,
payload: serde_json::to_value(task).expect("task serializes"), payload: serde_json::to_value(task).expect("task serializes"),
}), }),
@@ -1151,7 +1207,11 @@ mod tests {
assert_eq!(chunk_media_items((0..10).collect()).len(), 2); assert_eq!(chunk_media_items((0..10).collect()).len(), 2);
assert_eq!(chunk_media_items((0..25).collect()).len(), 3); assert_eq!(chunk_media_items((0..25).collect()).len(), 3);
assert_eq!(chunk_media_items((0..25).collect())[2].len(), 7); assert_eq!(chunk_media_items((0..25).collect())[2].len(), 7);
assert!(chunk_media_items((0..25).collect()).iter().all(|c| c.len() <= 9)); assert!(
chunk_media_items((0..25).collect())
.iter()
.all(|c| c.len() <= 9)
);
} }
#[test] #[test]
@@ -1175,7 +1235,10 @@ mod tests {
let api = ApiError::Unknown(description.to_string()); let api = ApiError::Unknown(description.to_string());
assert!(is_media_fetch_failure(&api), "{description}"); assert!(is_media_fetch_failure(&api), "{description}");
} }
for description in ["Bad Request: message is not modified", "Forbidden: bot was blocked by the user"] { for description in [
"Bad Request: message is not modified",
"Forbidden: bot was blocked by the user",
] {
let api = ApiError::Unknown(description.to_string()); let api = ApiError::Unknown(description.to_string());
assert!(!is_media_fetch_failure(&api), "{description}"); assert!(!is_media_fetch_failure(&api), "{description}");
} }
@@ -1196,7 +1259,10 @@ mod tests {
assert!(is_size_error(&api), "{description}"); assert!(is_size_error(&api), "{description}");
} }
// Unrelated errors must not match. // Unrelated errors must not match.
for description in ["Bad Request: WEBPAGE_MEDIA_EMPTY", "Bad Request: message is not modified"] { for description in [
"Bad Request: WEBPAGE_MEDIA_EMPTY",
"Bad Request: message is not modified",
] {
let api = ApiError::Unknown(description.to_string()); let api = ApiError::Unknown(description.to_string());
assert!(!is_size_error(&api), "{description}"); assert!(!is_size_error(&api), "{description}");
} }
@@ -1205,9 +1271,16 @@ mod tests {
#[test] #[test]
fn media_item_payload_fallback_url_serde_default() { fn media_item_payload_fallback_url_serde_default() {
// Old queued payloads without the field deserialize with None. // 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 json =
serde_json::json!({"kind": "photo", "media": "https://a/b.jpg", "has_spoiler": false});
let photo: MediaItemPayload = serde_json::from_value(json).unwrap(); let photo: MediaItemPayload = serde_json::from_value(json).unwrap();
assert!(matches!(photo, MediaItemPayload::Photo { fallback_url: None, .. })); assert!(matches!(
photo,
MediaItemPayload::Photo {
fallback_url: None,
..
}
));
assert_eq!(photo.fallback_url(), None); assert_eq!(photo.fallback_url(), None);
} }
@@ -1286,7 +1359,13 @@ mod tests {
assert_eq!(sent_message_ids, vec![11, 12]); assert_eq!(sent_message_ids, vec![11, 12]);
assert_eq!(forward_channel_id, Some(333)); assert_eq!(forward_channel_id, Some(333));
assert_eq!(media_batches.len(), 2); assert_eq!(media_batches.len(), 2);
assert!(matches!(media_batches[0][0], MediaItemPayload::Photo { has_spoiler: true, .. })); assert!(matches!(
media_batches[0][0],
MediaItemPayload::Photo {
has_spoiler: true,
..
}
));
} }
other => panic!("expected SendMediaSequence, got {other:?}"), other => panic!("expected SendMediaSequence, got {other:?}"),
} }