mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-23 23:32:05 +00:00
P0 of the retry audit. The main finding: a Telegram 5xx was classified
Permanent, so one Telegram-side blip dead-lettered the post.
- `classify_request_error`: a server error is retryable again. teloxide sleeps
10s on a 5xx and then parses the body, so the HTTP status is gone by the
time the error arrives; it is recognised by shape instead — a JSON
server-error description, or an `InvalidJson` whose raw body is not JSON
(a proxy/error page). A JSON body of the wrong shape stays permanent, since
retrying a type mismatch cannot help. Reproduced end to end: with the old
classification a fake 502 (HTML body) logged "failed permanently" and
dead-lettered; now it logs "queued for retry" and the retry delivers.
- The same class of mistake elsewhere: `is_media_fetch_failure` was missing
`failed to get HTTP url content`, the description single-media URL sends
answer with, so hotlink-rejected media failed permanently instead of going
through the reupload fallback.
- `enqueue_retry` now reports whether the row was written, and the callers
only promise a retry when it was — a failed enqueue (DB write) used to tell
the user "retrying in Ns" and then deliver nothing, ever.
- A forward that fails retryably now settles the prompt instead of leaving it
live: the queued row carries the message ids itself, and a live prompt let
a second Confirm copy the same messages to the channel twice and let Skip
answer "nothing was forwarded" while the row still delivered.
- A prompt that could not be sent no longer swallows the gated forward
silently: the chat is told, since nothing would ever forward.
- `scaled_retry_delay` only scales up, so a server-asked `retry_after` above
the 300s cap is honoured instead of retried early (which earned another 429
and then dead-lettered the post).
- Download classification: a 4xx media download is permanent (the media is
gone or refused) while transport errors and 429/5xx retry — previously every
download error counted as retryable and burned the whole budget. A temp-file
*write* failure retries too (resource exhaustion clears; a temp dir that
cannot be created stays permanent).
- Site status mapping: 401/403 are `Blocked` (permanent) rather than
`Transient`, so a refusal is reported at once instead of after three
wasted attempts; and a twitter 200 that is not a tweet is no longer
reported as withheld content (the empty `{}` withheld shape keeps
`Sensitive`, which is what triggers the auth fallback).
834 lines
33 KiB
Rust
834 lines
33 KiB
Rust
//! Site fetching dispatcher and unified result types.
|
|
//!
|
|
//! Dispatch order: twitter → bsky → misskey → pixiv → bilibili. Each site
|
|
//! module exports a `PATTERN`, `enabled()` and `fetch_from_url()`; a future
|
|
//! site plugs in by adding one guarded entry in `SITES`.
|
|
|
|
use std::future::Future;
|
|
use std::pin::Pin;
|
|
use std::sync::LazyLock;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::time::Duration;
|
|
|
|
use regex::Regex;
|
|
use thiserror::Error;
|
|
|
|
pub mod bilibili;
|
|
pub mod bsky;
|
|
pub mod misskey;
|
|
pub mod pixiv;
|
|
pub mod twitter;
|
|
|
|
pub use pixiv::PixivError;
|
|
|
|
/// The result of fetching a post: canonical URL, HTML caption, the post's
|
|
/// title and body, media list and spoiler flag. Produced by [`fetch`].
|
|
#[derive(Debug)]
|
|
pub struct Fetched {
|
|
/// Canonical URL: `x.com/{author}/status/{id}` |
|
|
/// `https://www.pixiv.net/artworks/{id}` |
|
|
/// `https://bsky.app/profile/{handle}/post/{rkey}` |
|
|
/// `https://www.bilibili.com/opus/{id}`
|
|
pub source_url: String,
|
|
/// The exact HTML produced by the site's caption().
|
|
pub caption: String,
|
|
/// The post's own title, where the platform has one: a pixiv artwork's
|
|
/// title, the headline of a bilibili opus post or the title of the video
|
|
/// an AV dynamic attaches. Empty on the platforms whose posts are text
|
|
/// only (x/twitter, bsky, misskey) and on bilibili posts without a
|
|
/// headline.
|
|
pub title: String,
|
|
/// The post's body text, as the platform exposes it: a tweet, a bsky or
|
|
/// misskey post, a bilibili dynamic's text, a pixiv artwork's description
|
|
/// (HTML flattened). Empty when the post has no text at all.
|
|
pub content: String,
|
|
pub media: Vec<crate::media::Media>,
|
|
/// Spoiler flag for all media of this post.
|
|
pub sensitive: bool,
|
|
/// Site id (`"twitter"` / `"bsky"` / `"pixiv"` / `"bilibili"`): the single source of
|
|
/// truth for site identity — caption-format lookup, cache-key prefix and
|
|
/// the SetFormat whitelist all derive from it. Set by the producing site.
|
|
pub site_id: &'static str,
|
|
/// Raw values (pre-escaped) for user-customizable caption formats.
|
|
pub(crate) render_data: Option<RenderData>,
|
|
/// Keeps temp files (e.g. an encoded ugoira MP4) alive until the caller
|
|
/// finishes uploading; not part of the public contract.
|
|
pub(crate) _keep_alive: Option<tempfile::TempDir>,
|
|
}
|
|
|
|
/// Values for the `{url} {author} {author_url} {title} {content} {tags}`
|
|
/// placeholders in user-supplied caption formats, substituted by
|
|
/// [`caption_from_fields`] as HTML text (never as an attribute value).
|
|
///
|
|
/// `author`, `title`, `content` and `tags` come from the site API (post
|
|
/// text, display names, descriptions) and are HTML-escaped at construction.
|
|
/// `url` and `author_url` stay raw: they are canonical URLs the adapter
|
|
/// builds from numeric ids and API-constrained handles/DIDs, so they carry
|
|
/// no escapable character — the bot's `/test` report relies on that when it
|
|
/// embeds them.
|
|
#[derive(Debug)]
|
|
pub(crate) struct RenderData {
|
|
pub url: String,
|
|
pub author: String,
|
|
pub author_url: String,
|
|
pub title: String,
|
|
pub content: String,
|
|
pub tags: String,
|
|
}
|
|
|
|
/// The post's text as one string: title and content joined by a line break,
|
|
/// each only when it is non-empty. This is what the sites' built-in captions
|
|
/// show after the author line, and what the bot quotes when it is long.
|
|
pub fn compose_text(title: &str, content: &str) -> String {
|
|
match (title.is_empty(), content.is_empty()) {
|
|
(false, false) => format!("{title}\n{content}"),
|
|
(false, true) => title.to_string(),
|
|
(true, false) => content.to_string(),
|
|
(true, true) => String::new(),
|
|
}
|
|
}
|
|
|
|
impl Fetched {
|
|
/// The site this post came from (used for per-site format overrides).
|
|
/// A thin alias over [`Fetched::site_id`] kept for callers that read the
|
|
/// site off a fetched post.
|
|
pub fn site_name(&self) -> &'static str {
|
|
self.site_id
|
|
}
|
|
|
|
/// Renders a user-supplied caption format. The format string is
|
|
/// HTML-escaped in full, then the (already-escaped) placeholder values
|
|
/// are substituted — users can structure text but never inject raw HTML
|
|
/// or attributes. An empty/unknown format falls back to the built-in
|
|
/// caption. The result is truncated to [`MAX_CAPTION_CHARS`] (Telegram's
|
|
/// caption limit for HTML parse mode).
|
|
pub fn caption_with(&self, format: &str) -> String {
|
|
match (&self.render_data, format.is_empty()) {
|
|
(Some(data), false) => caption_from_fields(
|
|
format,
|
|
"",
|
|
&data.url,
|
|
&data.author,
|
|
&data.author_url,
|
|
&data.title,
|
|
&data.content,
|
|
&data.tags,
|
|
),
|
|
_ => truncate_caption(&self.caption),
|
|
}
|
|
}
|
|
|
|
/// The pre-escaped placeholder values (author, author_url, title,
|
|
/// content, tags) a caller needs to rebuild a caption later, e.g. for a
|
|
/// cached post where the [`Fetched`] is no longer available.
|
|
pub fn render_fields(&self) -> Option<(&str, &str, &str, &str, &str)> {
|
|
self.render_data.as_ref().map(|d| {
|
|
(
|
|
d.author.as_str(),
|
|
d.author_url.as_str(),
|
|
d.title.as_str(),
|
|
d.content.as_str(),
|
|
d.tags.as_str(),
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Hands over the temp dir keeping locally produced media (ugoira MP4,
|
|
/// bsky remux MP4) alive. The bot keeps it while its task may still be
|
|
/// retried by the queue, which runs after this [`Fetched`] is dropped and
|
|
/// its temp files would otherwise be gone. `None` when no such dir exists.
|
|
pub fn take_keep_alive(&mut self) -> Option<tempfile::TempDir> {
|
|
self._keep_alive.take()
|
|
}
|
|
}
|
|
|
|
/// Telegram's caption length limit (chars) for HTML parse mode; longer
|
|
/// captions are rejected with a 400.
|
|
pub const MAX_CAPTION_CHARS: usize = 1024;
|
|
|
|
/// Truncates a caption to at most [`MAX_CAPTION_CHARS`] chars, appending an
|
|
/// ellipsis when cut. Backs off to before an unclosed HTML entity (`&`
|
|
/// without its `;` would be malformed HTML and rejected by Telegram).
|
|
pub fn truncate_caption(caption: &str) -> String {
|
|
if caption.chars().count() <= MAX_CAPTION_CHARS {
|
|
return caption.to_string();
|
|
}
|
|
// Leave one char for the ellipsis; floor_char_boundary lands on a char
|
|
// edge (byte index ≤ MAX-1, so chars ≤ MAX-1).
|
|
let mut end = caption.floor_char_boundary(MAX_CAPTION_CHARS - 1);
|
|
// Don't split an entity: if the last '&' before `end` has no closing ';'
|
|
// inside the kept part, cut before it.
|
|
if let Some(amp) = caption[..end].rfind('&')
|
|
&& !caption[amp..end].contains(';')
|
|
{
|
|
end = amp;
|
|
}
|
|
let mut s = caption[..end].to_string();
|
|
s.push('…');
|
|
s
|
|
}
|
|
|
|
/// Renders a user-supplied caption format from raw (already-escaped) field
|
|
/// values with the same escaping/substitution rules as
|
|
/// [`Fetched::caption_with`]. An empty format returns `built_in` unchanged.
|
|
/// The result is truncated to [`MAX_CAPTION_CHARS`] (Telegram's caption
|
|
/// limit for HTML parse mode).
|
|
///
|
|
/// One flat argument per placeholder keeps the two callers (the fresh and the
|
|
/// cached caption path) mirroring each other; the same shape as the bot's
|
|
/// `debug_report`.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn caption_from_fields(
|
|
format: &str,
|
|
built_in: &str,
|
|
url: &str,
|
|
author: &str,
|
|
author_url: &str,
|
|
title: &str,
|
|
content: &str,
|
|
tags: &str,
|
|
) -> String {
|
|
if format.is_empty() {
|
|
return truncate_caption(built_in);
|
|
}
|
|
let escaped = html_escape::encode_text(format).into_owned();
|
|
truncate_caption(
|
|
&escaped
|
|
.replace("{url}", url)
|
|
.replace("{author}", author)
|
|
.replace("{author_url}", author_url)
|
|
.replace("{title}", title)
|
|
.replace("{content}", content)
|
|
.replace("{tags}", tags),
|
|
)
|
|
}
|
|
|
|
/// Stable per-post cache key derived from any supported URL, so variant
|
|
/// domains (x.com / twitter.com / fxtwitter.com, mobile, `/photo/N`
|
|
/// suffixes) map to the same post. Delegates to each registered site's
|
|
/// `cache_key` (in registry order).
|
|
pub fn cache_key(url: &str) -> Option<String> {
|
|
SITES.iter().find_map(|site| site.cache_key(url))
|
|
}
|
|
|
|
/// The site id carried by a cache key (`"twitter:123"` → `"twitter"`).
|
|
/// Unknown prefixes fall back to `"unknown"`. The bot uses this on the
|
|
/// link-cache hit path, where no [`Fetched`] is available — the same value
|
|
/// a fresh fetch would read from [`Fetched::site_id`].
|
|
pub fn site_id_from_key(key: &str) -> &'static str {
|
|
let prefix = key.split(':').next().unwrap_or("");
|
|
SITES
|
|
.iter()
|
|
.map(|site| site.id())
|
|
.find(|id| *id == prefix)
|
|
.unwrap_or("unknown")
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum FetchError {
|
|
#[error("http error: {0}")]
|
|
Http(#[from] reqwest::Error),
|
|
#[error("json error: {0}")]
|
|
Json(#[from] serde_json::Error),
|
|
#[error("pixiv error: {0}")]
|
|
Pixiv(#[from] PixivError),
|
|
/// A site-specific error from a site that keeps its own error type.
|
|
/// Permanent by default (sites that need retryable site errors convert
|
|
/// them to [`FetchError::Http`] / [`FetchError::Transient`] before
|
|
/// returning). Pixiv predates this and keeps the dedicated
|
|
/// [`FetchError::Pixiv`] variant.
|
|
#[error("{site} error: {error}")]
|
|
Site {
|
|
site: &'static str,
|
|
#[source]
|
|
error: Box<dyn std::error::Error + Send + Sync>,
|
|
},
|
|
#[error("not found")]
|
|
NotFound,
|
|
#[error("blocked")]
|
|
Blocked,
|
|
/// The URL matches a registered site that is disabled right now (pixiv
|
|
/// without `PIXIV_REFRESH_TOKEN`, or after a failed login). Distinct from
|
|
/// `Ok(None)` — an unsupported link — so the bot can tell the user why
|
|
/// the link was not handled instead of silently ignoring it.
|
|
#[error("{site} support is disabled")]
|
|
Disabled { site: &'static str },
|
|
/// The post exists but its content is withheld (twitter NSFW /
|
|
/// age-restricted tweets come back as an empty `{}` from syndication).
|
|
#[error("content withheld (sensitive)")]
|
|
Sensitive,
|
|
/// A download exceeded the caller's size cap (see [`download_media_limited`]).
|
|
#[error("media too large")]
|
|
TooLarge,
|
|
/// A transient server-side failure (429 / 5xx); [`fetch`] retries these.
|
|
#[error("transient: {0}")]
|
|
Transient(String),
|
|
/// A local I/O failure while streaming a download to disk
|
|
/// (see [`download_media_to_file`]).
|
|
#[error("io error: {0}")]
|
|
Io(std::io::Error),
|
|
}
|
|
|
|
/// Shared HTTP client (browser User-Agent) for twitter/bsky fetches and
|
|
/// [`download_media`].
|
|
pub(crate) static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
|
let mut builder = reqwest::Client::builder()
|
|
.user_agent("Mozilla/5.0")
|
|
// reqwest has no total timeout by default; a stalled connection
|
|
// would otherwise pin a fetch/handler forever.
|
|
.timeout(Duration::from_secs(30))
|
|
.connect_timeout(Duration::from_secs(10));
|
|
// Route site fetches through the same proxy the Bot API uses, so a
|
|
// network that needs TELOXIDE_PROXY (e.g. behind the GFW) does not
|
|
// leave site fetches dead while the bot itself works.
|
|
if let Some(proxy) = std::env::var("TELOXIDE_PROXY")
|
|
.ok()
|
|
.filter(|s| !s.is_empty())
|
|
&& let Ok(p) = reqwest::Proxy::all(&proxy)
|
|
{
|
|
builder = builder.proxy(p);
|
|
}
|
|
// Each `#[tokio::test]` runs on its own runtime; the connection pool is
|
|
// bound to the runtime that created it, so cross-runtime reuse of idle
|
|
// connections fails with DispatchGone. In test builds every request uses
|
|
// a fresh connection. Production runs on one runtime and keeps pooling.
|
|
#[cfg(test)]
|
|
let builder = builder.pool_max_idle_per_host(0);
|
|
builder.build().expect("failed to build HTTP client")
|
|
});
|
|
|
|
/// Whether a usable `ffmpeg` binary is on PATH (probed once). Shared by the
|
|
/// pixiv ugoira encoder and the bsky HLS remuxer.
|
|
static FFMPEG_AVAILABLE: LazyLock<bool> = LazyLock::new(|| {
|
|
std::process::Command::new("ffmpeg")
|
|
.arg("-version")
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.status()
|
|
.map(|s| s.success())
|
|
.unwrap_or(false)
|
|
});
|
|
|
|
static FFMPEG_MISSING_LOGGED: AtomicBool = AtomicBool::new(false);
|
|
|
|
pub(crate) fn ffmpeg_available() -> bool {
|
|
*FFMPEG_AVAILABLE
|
|
}
|
|
|
|
pub(crate) fn log_once_ffmpeg_missing() {
|
|
if !FFMPEG_MISSING_LOGGED.swap(true, Ordering::Relaxed) {
|
|
log::warn!("ffmpeg not found; ugoira and bsky video posts stay unsupported");
|
|
}
|
|
}
|
|
|
|
/// Site adapter: one impl per supported site (twitter / bsky / misskey /
|
|
/// pixiv / bilibili), registered in `SITES`. All site-specific knowledge — URL pattern,
|
|
/// cache-key format, fetch, retry policy, media-host headers, startup
|
|
/// validation — lives in the site module; the central dispatcher only
|
|
/// iterates the registry.
|
|
///
|
|
/// Async methods return a boxed future (see `SiteFuture`): `async fn` /
|
|
/// RPITIT in traits are not dyn-compatible (verified on rustc 1.95), and
|
|
/// `+ Send` is required since URL/queue workers spawn these futures. The
|
|
/// site structs are stateless unit structs, so the boxed futures never
|
|
/// borrow from `self` beyond the call's scope.
|
|
pub trait Site: Send + Sync {
|
|
/// Stable site id (`"twitter"` / `"bsky"` / `"misskey"` / `"pixiv"` /
|
|
/// `"bilibili"`): caption-format lookup, cache-key prefixes and the
|
|
/// SetFormat whitelist derive from it.
|
|
fn id(&self) -> &'static str;
|
|
/// URL pattern; the dispatcher's first match wins (dispatch order).
|
|
fn pattern(&self) -> &'static Regex;
|
|
/// Whether the site is usable (env token present, not disabled).
|
|
fn enabled(&self) -> bool {
|
|
true
|
|
}
|
|
/// Normalized cache key for a URL of this site (`None` when the URL does
|
|
/// not match this site).
|
|
fn cache_key(&self, url: &str) -> Option<String>;
|
|
/// Fetches and normalizes a post.
|
|
fn fetch_from_url<'a>(&'a self, url: &'a str) -> SiteFuture<'a, Fetched>;
|
|
/// Retry policy for fetch errors: transient classes only.
|
|
fn is_retryable(&self, err: &FetchError) -> bool {
|
|
matches!(err, FetchError::Http(_) | FetchError::Transient(_))
|
|
}
|
|
/// Extra headers for downloading this site's media (hotlink protection,
|
|
/// e.g. pixiv's Referer for pximg.net). Matched on the media URL, not
|
|
/// the site pattern.
|
|
fn media_headers(&self, _url: &str) -> Option<Vec<(&'static str, String)>> {
|
|
None
|
|
}
|
|
/// Startup validation (token check etc.); failures are surfaced by
|
|
/// [`validate_all`]. The default is a no-op.
|
|
fn validate(&self) -> SiteFuture<'static, (), String> {
|
|
Box::pin(async { Ok(()) })
|
|
}
|
|
}
|
|
|
|
/// A boxed, `Send` future produced by a [`Site`] async method. Boxed so the
|
|
/// trait stays dyn-compatible; `Send` because URL/queue workers `tokio::spawn`
|
|
/// these futures.
|
|
type SiteFuture<'a, T, E = FetchError> = Pin<Box<dyn Future<Output = Result<T, E>> + Send + 'a>>;
|
|
|
|
/// The one registry of supported sites, in dispatch order (twitter → bsky →
|
|
/// misskey → pixiv → bilibili). Adding a site = new module + one
|
|
/// `Box::new(...)` entry here; the bot crate never lists sites itself.
|
|
static SITES: LazyLock<Vec<Box<dyn Site>>> = LazyLock::new(|| {
|
|
vec![
|
|
Box::new(twitter::TwitterSite),
|
|
Box::new(bsky::BskySite),
|
|
Box::new(misskey::MisskeySite),
|
|
Box::new(pixiv::PixivSite),
|
|
Box::new(bilibili::BilibiliSite),
|
|
]
|
|
});
|
|
|
|
/// The first enabled site whose pattern matches `url`, in dispatch order.
|
|
fn find_site(url: &str) -> Option<&'static dyn Site> {
|
|
SITES
|
|
.iter()
|
|
.find(|site| site.enabled() && site.pattern().is_match(url))
|
|
.map(|site| site.as_ref())
|
|
}
|
|
|
|
/// The site whose pattern matches `url` but which is disabled right now.
|
|
/// `None` when no site matches the URL at all, or when the matching site is
|
|
/// enabled. Lets the dispatcher tell "unsupported link" (silently ignored)
|
|
/// apart from "this bot has that site switched off" (reported to the user).
|
|
fn disabled_site(url: &str) -> Option<&'static str> {
|
|
SITES
|
|
.iter()
|
|
.find(|site| !site.enabled() && site.pattern().is_match(url))
|
|
.map(|site| site.id())
|
|
}
|
|
|
|
/// Every supported site id, in dispatch order. The bot's SetFormat whitelist
|
|
/// derives from this list.
|
|
pub fn site_ids() -> Vec<&'static str> {
|
|
SITES.iter().map(|site| site.id()).collect()
|
|
}
|
|
|
|
/// Runs every enabled site's startup validation and returns the failures
|
|
/// (site id + message). The caller logs / notifies; failing sites disable
|
|
/// themselves (pixiv disables on a bad token).
|
|
pub async fn validate_all() -> Vec<(&'static str, String)> {
|
|
let mut failures = Vec::new();
|
|
for site in SITES.iter() {
|
|
if !site.enabled() {
|
|
continue;
|
|
}
|
|
if let Err(e) = site.validate().await {
|
|
failures.push((site.id(), e));
|
|
}
|
|
}
|
|
failures
|
|
}
|
|
|
|
/// Fetches a post from its URL. Returns `Ok(None)` when no site pattern
|
|
/// matches (unsupported links are silently ignored by the bot) and
|
|
/// [`FetchError::Disabled`] when the URL belongs to a registered site that is
|
|
/// switched off right now — the two are different answers for the user.
|
|
///
|
|
/// Transient failures are retried: 3 total attempts with 1s then 2s delays.
|
|
/// What counts as transient is the matched site's own policy (`is_retryable`
|
|
/// — e.g. pixiv retries only network errors and 429/5xx). Permanent classes
|
|
/// (not-found, blocked, sensitive, parse failures, pixiv 4xx/auth errors)
|
|
/// are returned immediately; retrying them only wastes attempts against the
|
|
/// source site.
|
|
pub async fn fetch(url: &str) -> Result<Option<Fetched>, FetchError> {
|
|
fetch_with_attempts(url, MAX_FETCH_ATTEMPTS).await
|
|
}
|
|
|
|
/// [`fetch`] without the retry backoff (one attempt). For callers with a
|
|
/// short deadline: an inline query's answer window is measured in seconds, so
|
|
/// the 1s + 2s retry sleeps would outlast the query the answer belongs to.
|
|
pub async fn fetch_once(url: &str) -> Result<Option<Fetched>, FetchError> {
|
|
fetch_with_attempts(url, 1).await
|
|
}
|
|
|
|
/// Total attempts of the retried [`fetch`] (3: the initial try plus two).
|
|
const MAX_FETCH_ATTEMPTS: u32 = 3;
|
|
|
|
async fn fetch_with_attempts(url: &str, attempts: u32) -> Result<Option<Fetched>, FetchError> {
|
|
// Wall time of the whole fetch, retry backoff included: the ugoira encode
|
|
// and the HLS remux live inside it, so this is where a slow fetch shows.
|
|
let started = std::time::Instant::now();
|
|
let Some(site) = find_site(url) else {
|
|
// A registered-but-disabled site (pixiv without a token) is not an
|
|
// unsupported link: report it, so the bot answers the user instead of
|
|
// ignoring the message.
|
|
return match disabled_site(url) {
|
|
Some(site) => Err(FetchError::Disabled { site }),
|
|
None => Ok(None),
|
|
};
|
|
};
|
|
for attempt in 0..attempts.max(1) {
|
|
match site.fetch_from_url(url).await {
|
|
Ok(fetched) => {
|
|
// Per-request detail: debug only, keyed by the post id.
|
|
log::debug!(
|
|
"fetched [key={}]: site {} returned {} media in {}ms",
|
|
cache_key(url).unwrap_or_else(|| "?".into()),
|
|
fetched.site_name(),
|
|
fetched.media.len(),
|
|
started.elapsed().as_millis()
|
|
);
|
|
return Ok(Some(fetched));
|
|
}
|
|
Err(err) => {
|
|
if site.is_retryable(&err) && attempt + 1 < attempts {
|
|
tokio::time::sleep(Duration::from_secs(1 << attempt)).await;
|
|
} else {
|
|
return Err(err);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
unreachable!("retry loop always returns")
|
|
}
|
|
|
|
/// Whether fetching `url` requires site-specific headers (pixiv's `Referer`
|
|
/// for `pximg.net` hotlink protection, see [`Site::media_headers`]). Telegram's
|
|
/// own fetch of a media URL sends none of them, so a URL that needs them fails
|
|
/// there — callers that hand a URL to Telegram (inline query results) must
|
|
/// skip such media instead of shipping a broken item.
|
|
pub fn needs_media_headers(url: &str) -> bool {
|
|
SITES.iter().any(|site| site.media_headers(url).is_some())
|
|
}
|
|
|
|
/// Applies every site's media-header rule to a download request (pixiv's
|
|
/// `Referer` for pximg.net hotlink protection). Sites contribute via their
|
|
/// `media_headers(url)` — the central download code carries no per-site logic.
|
|
fn apply_media_headers(mut request: reqwest::RequestBuilder, url: &str) -> reqwest::RequestBuilder {
|
|
for site in SITES.iter() {
|
|
if let Some(headers) = site.media_headers(url) {
|
|
for (name, value) in headers {
|
|
request = request.header(name, value);
|
|
}
|
|
}
|
|
}
|
|
request
|
|
}
|
|
|
|
/// Downloads media bytes for the bot's upload fallback: when Telegram's own
|
|
/// fetch of a media URL is blocked (hotlink protection), the bot downloads
|
|
/// the file itself and uploads it via multipart. Site-appropriate headers
|
|
/// come from each site's `media_headers` (pixiv image hosts need `Referer`).
|
|
/// 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 response = apply_media_headers(CLIENT.get(url), url)
|
|
.send()
|
|
.await?
|
|
.error_for_status()?;
|
|
Ok(response.content_length())
|
|
}
|
|
|
|
/// Maps a media download's HTTP status onto the same classes the site
|
|
/// adapters use, so callers can tell "try again" from "this URL is dead":
|
|
/// 4xx is a property of the media (gone, refused by the host), while 429/5xx
|
|
/// is a property of the moment. A transport error never reaches this — it
|
|
/// fails in `send()` and stays [`FetchError::Http`].
|
|
fn download_status_error(status: reqwest::StatusCode) -> FetchError {
|
|
match status.as_u16() {
|
|
401 | 403 => FetchError::Blocked,
|
|
404 | 410 => FetchError::NotFound,
|
|
_ => FetchError::Transient(format!("media status {status}")),
|
|
}
|
|
}
|
|
|
|
/// Downloads a media file with a hard size cap: the body is streamed and the
|
|
/// download aborts with [`FetchError::TooLarge`] the moment the cap is
|
|
/// crossed (or when a declared Content-Length already exceeds it). Keeps the
|
|
/// bot from buffering arbitrarily large bodies into memory.
|
|
pub async fn download_media_limited(url: &str, max_bytes: u64) -> Result<bytes::Bytes, FetchError> {
|
|
let response = apply_media_headers(CLIENT.get(url), url).send().await?;
|
|
let response = if response.status().is_success() {
|
|
response
|
|
} else {
|
|
return Err(download_status_error(response.status()));
|
|
};
|
|
if let Some(len) = response.content_length()
|
|
&& len > max_bytes
|
|
{
|
|
return Err(FetchError::TooLarge);
|
|
}
|
|
let mut response = response;
|
|
let mut buf = Vec::new();
|
|
while let Some(chunk) = response.chunk().await? {
|
|
buf.extend_from_slice(&chunk);
|
|
if buf.len() as u64 > max_bytes {
|
|
return Err(FetchError::TooLarge);
|
|
}
|
|
}
|
|
Ok(bytes::Bytes::from(buf))
|
|
}
|
|
|
|
pub async fn download_media(url: &str) -> Result<bytes::Bytes, FetchError> {
|
|
download_media_limited(url, u64::MAX).await
|
|
}
|
|
|
|
/// Streams a download to `out`, aborting with [`FetchError::TooLarge`] the
|
|
/// moment the body crosses `max_bytes` (or when a declared Content-Length
|
|
/// already exceeds it). Unlike [`download_media_limited`] the body is never
|
|
/// buffered in memory — used for large files (e.g. the pixiv ugoira frame
|
|
/// zip, which can be hundreds of MB) that would otherwise spike RAM.
|
|
/// Returns the number of bytes written.
|
|
pub async fn download_media_to_file(
|
|
url: &str,
|
|
max_bytes: u64,
|
|
out: &mut std::fs::File,
|
|
) -> Result<u64, FetchError> {
|
|
use std::io::Write;
|
|
let response = apply_media_headers(CLIENT.get(url), url).send().await?;
|
|
let response = if response.status().is_success() {
|
|
response
|
|
} else {
|
|
return Err(download_status_error(response.status()));
|
|
};
|
|
if let Some(len) = response.content_length()
|
|
&& len > max_bytes
|
|
{
|
|
return Err(FetchError::TooLarge);
|
|
}
|
|
let mut response = response;
|
|
let mut total: u64 = 0;
|
|
while let Some(chunk) = response.chunk().await? {
|
|
total += chunk.len() as u64;
|
|
if total > max_bytes {
|
|
return Err(FetchError::TooLarge);
|
|
}
|
|
out.write_all(&chunk).map_err(FetchError::Io)?;
|
|
}
|
|
Ok(total)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn cache_key_normalizes_domain_variants() {
|
|
assert_eq!(
|
|
cache_key("https://x.com/user/status/1234567890/photo/1"),
|
|
Some("twitter:1234567890".into())
|
|
);
|
|
assert_eq!(
|
|
cache_key("https://mobile.twitter.com/user/status/1234567890"),
|
|
Some("twitter:1234567890".into())
|
|
);
|
|
assert_eq!(
|
|
cache_key("https://fxtwitter.com/user/status/1234567890"),
|
|
Some("twitter:1234567890".into())
|
|
);
|
|
assert_eq!(
|
|
cache_key("https://www.pixiv.net/artworks/123456"),
|
|
Some("pixiv:123456".into())
|
|
);
|
|
assert_eq!(
|
|
cache_key("https://bsky.app/profile/handle.example/post/3lorem"),
|
|
Some("bsky:handle.example/3lorem".into())
|
|
);
|
|
assert_eq!(
|
|
cache_key("https://t.bilibili.com/1245284537985925159"),
|
|
Some("bilibili:1245284537985925159".into())
|
|
);
|
|
assert_eq!(cache_key("https://example.com/not-a-post"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn site_id_from_key_parses_prefix() {
|
|
assert_eq!(site_id_from_key("twitter:123"), "twitter");
|
|
assert_eq!(site_id_from_key("pixiv:123"), "pixiv");
|
|
assert_eq!(site_id_from_key("bsky:handle.example/3lorem"), "bsky");
|
|
assert_eq!(site_id_from_key("bilibili:123"), "bilibili");
|
|
assert_eq!(site_id_from_key("unknown:1"), "unknown");
|
|
assert_eq!(site_id_from_key("no-colon"), "unknown");
|
|
}
|
|
|
|
#[test]
|
|
fn registry_lists_all_sites_in_dispatch_order() {
|
|
assert_eq!(
|
|
site_ids(),
|
|
vec!["twitter", "bsky", "misskey", "pixiv", "bilibili"]
|
|
);
|
|
// Enabled sites dispatch; unsupported URLs never match.
|
|
assert!(find_site("https://x.com/u/status/1").is_some());
|
|
assert!(find_site("https://misskey.io/notes/abc").is_some());
|
|
assert!(find_site("https://t.bilibili.com/1245284537985925159").is_some());
|
|
assert!(find_site("https://example.com/x").is_none());
|
|
// Cache keys are pattern-driven, independent of the enabled() gate
|
|
// (pixiv is disabled in tests without PIXIV_REFRESH_TOKEN).
|
|
assert_eq!(
|
|
cache_key("https://www.pixiv.net/artworks/1"),
|
|
Some("pixiv:1".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn site_error_variant_displays_and_sources() {
|
|
use std::error::Error as _;
|
|
let err = FetchError::Site {
|
|
site: "example",
|
|
error: Box::new(std::io::Error::other("boom")),
|
|
};
|
|
assert_eq!(err.to_string(), "example error: boom");
|
|
assert!(err.source().is_some());
|
|
// Permanent by default: no site's is_retryable matches it.
|
|
assert!(!twitter::is_retryable(&err));
|
|
}
|
|
|
|
#[test]
|
|
fn caption_from_fields_substitutes_and_escapes() {
|
|
// The format string is escaped, the field values are substituted
|
|
// verbatim (callers pass the already-escaped render data).
|
|
let out = caption_from_fields(
|
|
"see {author} at {url} — {title}: {content}",
|
|
"",
|
|
"https://x.com/u/status/1",
|
|
"A & B",
|
|
"https://x.com/u",
|
|
"hello <world>",
|
|
"the body",
|
|
"",
|
|
);
|
|
assert_eq!(
|
|
out,
|
|
"see A & B at https://x.com/u/status/1 — hello <world>: the body"
|
|
);
|
|
// Empty format keeps the built-in caption untouched.
|
|
assert_eq!(
|
|
caption_from_fields("", "built-in", "u", "a", "au", "t", "c", "g"),
|
|
"built-in"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compose_text_joins_title_and_content() {
|
|
assert_eq!(compose_text("标题", "正文"), "标题\n正文");
|
|
assert_eq!(compose_text("标题", ""), "标题");
|
|
assert_eq!(compose_text("", "正文"), "正文");
|
|
assert_eq!(compose_text("", ""), "");
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_caption_keeps_short_text() {
|
|
assert_eq!(truncate_caption("short"), "short");
|
|
// Exactly at the limit: untouched.
|
|
let exact = "x".repeat(MAX_CAPTION_CHARS);
|
|
assert_eq!(truncate_caption(&exact), exact);
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_caption_cuts_long_text_with_ellipsis() {
|
|
let long = "x".repeat(MAX_CAPTION_CHARS + 100);
|
|
let out = truncate_caption(&long);
|
|
assert!(
|
|
out.chars().count() <= MAX_CAPTION_CHARS,
|
|
"len {}",
|
|
out.chars().count()
|
|
);
|
|
assert!(out.ends_with('…'));
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_caption_does_not_split_an_html_entity() {
|
|
// An entity crossing the cut must not be left half-open (& without ;).
|
|
let mut long = "a".repeat(MAX_CAPTION_CHARS - 4);
|
|
long.push_str("&bbbb");
|
|
let out = truncate_caption(&long);
|
|
assert!(out.chars().count() <= MAX_CAPTION_CHARS);
|
|
assert!(!out.contains("&"), "half entity left: {out:?}");
|
|
assert!(!out.ends_with('&'));
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_caption_handles_multibyte_boundary() {
|
|
// Multi-byte chars near the cut must not panic (char-boundary cut).
|
|
let long = "界".repeat(MAX_CAPTION_CHARS + 10);
|
|
let out = truncate_caption(&long);
|
|
assert!(out.chars().count() <= MAX_CAPTION_CHARS);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unsupported_url_returns_none() {
|
|
let result = fetch("https://example.com/some/article").await;
|
|
assert!(matches!(result, Ok(None)), "got {result:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_scheme_returns_none() {
|
|
let result = fetch("not a url at all").await;
|
|
assert!(matches!(result, Ok(None)), "got {result:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn media_headers_are_reported_only_where_telegram_would_fail() {
|
|
// pixiv's CDN needs a Referer, which only the bot can send: an inline
|
|
// result pointing at it renders broken, so callers skip it.
|
|
assert!(needs_media_headers(
|
|
"https://i.pximg.net/img-original/img/2024/01/01/00/00/00/1_p0.jpg"
|
|
));
|
|
// The rest serve direct requests (verified per site in their modules).
|
|
for url in [
|
|
"https://pbs.twimg.com/media/1.jpg",
|
|
"https://cdn.bsky.app/img/1.jpg",
|
|
"https://media.misskeyusercontent.jp/io/1.webp",
|
|
"https://i0.hdslb.com/bfs/1.jpg",
|
|
] {
|
|
assert!(!needs_media_headers(url), "{url}");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disabled_site_is_reported_not_ignored() {
|
|
// pixiv is the only token-gated site; with PIXIV_REFRESH_TOKEN set it
|
|
// is enabled and this link would hit the network, so skip then.
|
|
if std::env::var("PIXIV_REFRESH_TOKEN")
|
|
.ok()
|
|
.filter(|s| !s.is_empty())
|
|
.is_some()
|
|
{
|
|
eprintln!("skipping: PIXIV_REFRESH_TOKEN is set");
|
|
return;
|
|
}
|
|
let result = fetch("https://www.pixiv.net/artworks/1").await;
|
|
assert!(
|
|
matches!(result, Err(FetchError::Disabled { site: "pixiv" })),
|
|
"got {result:?}"
|
|
);
|
|
// The cache key still resolves: the bot keys the reply and the link
|
|
// cache off it even when the site is off.
|
|
assert_eq!(
|
|
cache_key("https://www.pixiv.net/artworks/1"),
|
|
Some("pixiv:1".into())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn download_media_pixiv_original_with_referer() {
|
|
// Proves the Referer header is attached for i.pximg.net: a header-less
|
|
// GET to a pixiv original URL is rejected with 403.
|
|
// Empty-string check too: an unset CI secret arrives as "" (GitHub
|
|
// Actions), which would otherwise run the test tokenless and fail.
|
|
if std::env::var("PIXIV_REFRESH_TOKEN")
|
|
.ok()
|
|
.filter(|s| !s.is_empty())
|
|
.is_none()
|
|
{
|
|
eprintln!("skipping: no PIXIV_REFRESH_TOKEN");
|
|
return;
|
|
}
|
|
let illustration = pixiv::fetch(126839080).await.unwrap();
|
|
let fetched: Fetched = illustration.into();
|
|
let url = match fetched.media.first() {
|
|
Some(crate::media::Media::Illustration { url, .. }) => url.clone(),
|
|
other => panic!("expected illustration media, got {other:?}"),
|
|
};
|
|
assert!(url.contains("i.pximg.net"));
|
|
let bytes = download_media(&url).await.unwrap();
|
|
assert!(!bytes.is_empty());
|
|
}
|
|
}
|