Files
TelegramTwitterMediaBot/crates/xmedia-bot/src/send/mod.rs
T
YoursFunny dbda6ec1c2 refactor(send): split the 2100-line module by concern
send.rs had grown back into the shape handlers.rs was split out of: payload
types, error classification, the download-and-reupload fallback, the senders
and the whole post-send/queue shell in one file. Split by concern, leaving
call sites (`crate::send::x`) unchanged:

- `send/input_media.rs`: payload → `InputFile`/`InputMedia` selection and
  `build_media_group` (with its caption-on-first-item rule).
- `send/upload.rs`: the fallback pipeline (download with the upload cap,
  photo downscale handoff, smaller-URL fallback, multipart upload).
- `send/post_send.rs`: link-cache write, the `KEEP_ALIVE` registry for locally
  produced media, `settle_task`, the post-send actions and the queue entry
  points; the parts other modules call are re-exported.
- `send/mod.rs`: payloads, error classification, classification helpers and
  the senders themselves, plus the test module.

No behaviour change: 128 + 1326 + 366 + 345 lines, 70 tests still pass.
AGENTS.md updated for the new layout and for `ctx.rs`.
2026-09-17 01:41:28 +08:00

1331 lines
47 KiB
Rust

//! Task payloads and the send/forward executors, split by concern:
//! [`input_media`] builds Telegram input types from payloads, [`upload`] is
//! the download-and-reupload fallback (Telegram's own fetch of a media URL is
//! blocked by hotlink protection, so the bot downloads the file itself and
//! uploads it via multipart), [`post_send`] covers everything around a send
//! (cache write, keep-alive media, settlement, post-send actions, queue
//! entry points). This module keeps the payload types, the error
//! classification and the senders themselves.
mod input_media;
mod post_send;
mod upload;
use crate::ctx::AppContext;
use crate::handlers::log_key;
use crate::link_cache::{CachedMedia, CachedMediaKind, CachedPost};
use crate::media_sender::MediaSender;
use input_media::{build_media_group, input_file_for, item_url};
use post_send::{cache_animation_send, cache_sent_task};
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
use teloxide::prelude::*;
use teloxide::types::{ChatId, InputFile, InputMedia, MessageId};
use teloxide::{ApiError, RequestError};
use upload::{FallbackError, PreparedItem, prepare_upload_item, send_batch_via_upload};
// The crate-facing API of this module lives in its submodules; re-export the
// parts other modules use so call sites stay `send::x`.
pub(crate) use post_send::{
KEEP_ALIVE, Settled, dead_letter_notify, enqueue_retry, handle_task, post_send_actions,
settle_task,
};
/// One process-wide Bot for queue workers. Building a fresh Bot (and its HTTP
/// client) per queue task was pure waste; forced at startup in main so a
/// missing token fails fast instead of on the first task.
pub static BOT: LazyLock<Bot> = LazyLock::new(Bot::from_env);
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum MediaItemPayload {
Photo {
media: String,
has_spoiler: bool,
/// Smaller variant used when the primary media exceeds Telegram's
/// size limits.
#[serde(default)]
fallback_url: Option<String>,
/// `media` is a Telegram file id (link-cache hit), not a URL.
#[serde(default)]
file_id: bool,
},
Video {
media: String,
has_spoiler: bool,
thumbnail: Option<String>,
#[serde(default)]
fallback_url: Option<String>,
/// `media` is a Telegram file id (link-cache hit), not a URL.
#[serde(default)]
file_id: bool,
},
Animation {
media: String,
has_spoiler: bool,
/// `media` is a Telegram file id (link-cache hit), not a URL.
#[serde(default)]
file_id: bool,
},
}
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,
}
}
/// The cover-frame URL for videos (used by the upload fallback, which
/// otherwise drops the thumbnail the URL-send path applies).
fn thumbnail_url(&self) -> Option<&str> {
match self {
MediaItemPayload::Video { thumbnail, .. } => thumbnail.as_deref(),
MediaItemPayload::Photo { .. } | MediaItemPayload::Animation { .. } => None,
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Task {
SendMediaSequence {
chat_id: i64,
reply_to_message_id: i64,
caption: String,
media_batches: Vec<Vec<MediaItemPayload>>,
batch_index: usize,
sent_message_ids: Vec<i64>,
source_url: String,
edit_before_forward: bool,
forward_channel_id: Option<i64>,
notify_chat_id: Option<i64>,
notify_message_id: Option<i64>,
/// Raw render data captured on a cache miss; the send fills in the
/// Telegram file ids and persists the entry (see `link_cache`).
#[serde(default)]
cache_data: Option<CachedPost>,
},
SendAnimation {
chat_id: i64,
reply_to_message_id: i64,
caption: String,
animation: MediaItemPayload,
source_url: String,
edit_before_forward: bool,
forward_channel_id: Option<i64>,
notify_chat_id: Option<i64>,
notify_message_id: Option<i64>,
/// Raw render data captured on a cache miss; the send fills in the
/// Telegram file id and persists the entry (see `link_cache`).
#[serde(default)]
cache_data: Option<CachedPost>,
},
ForwardMessages {
from_chat_id: i64,
to_chat_id: i64,
message_ids: Vec<i64>,
notify_chat_id: Option<i64>,
notify_message_id: Option<i64>,
},
}
impl Task {
fn cache_data(&self) -> Option<&CachedPost> {
match self {
Task::SendMediaSequence { cache_data, .. } | Task::SendAnimation { cache_data, .. } => {
cache_data.as_ref()
}
Task::ForwardMessages { .. } => None,
}
}
fn source_url(&self) -> Option<&str> {
match self {
Task::SendMediaSequence { source_url, .. } | Task::SendAnimation { source_url, .. } => {
Some(source_url)
}
Task::ForwardMessages { .. } => None,
}
}
/// True when the media payloads are Telegram file ids from the link cache
/// (a cached file id that goes permanently bad should be dropped so the
/// next request re-fetches).
fn is_cached_send(&self) -> bool {
self.cache_data().is_some_and(|c| !c.media.is_empty())
}
/// All media payloads of this task (sequence batches flattened plus the
/// lone animation).
fn media_items(&self) -> Vec<&MediaItemPayload> {
match self {
Task::SendMediaSequence { media_batches, .. } => {
media_batches.iter().flatten().collect()
}
Task::SendAnimation { animation, .. } => {
std::slice::from_ref(animation).iter().collect()
}
Task::ForwardMessages { .. } => Vec::new(),
}
}
/// Local file paths referenced by this task's media (ugoira / bsky remux
/// MP4 and the like); empty for URL or Telegram file-id sends.
fn local_media_paths(&self) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
for item in self.media_items() {
let is_file_id = match item {
MediaItemPayload::Photo { file_id, .. }
| MediaItemPayload::Video { file_id, .. }
| MediaItemPayload::Animation { file_id, .. } => *file_id,
};
if is_file_id {
continue;
}
let media = item_url(item);
if !media.starts_with("http://") && !media.starts_with("https://") {
out.push(std::path::PathBuf::from(media));
}
}
out
}
}
/// Telegram file id of the message's media, matched to the payload kind.
fn file_id_of_message(message: &Message, item: &MediaItemPayload) -> Option<String> {
match item {
// `photo()` returns all sizes, smallest first — the largest carries
// the file id of the sent media.
MediaItemPayload::Photo { .. } => message
.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::Animation { .. } => message.animation().map(|a| a.file.id.to_string()),
}
}
fn kind_of_item(item: &MediaItemPayload) -> CachedMediaKind {
match item {
MediaItemPayload::Photo { .. } => CachedMediaKind::Photo,
MediaItemPayload::Video { .. } => CachedMediaKind::Video,
MediaItemPayload::Animation { .. } => CachedMediaKind::Animation,
}
}
/// Collects the Telegram file ids of a sent media group, aligned to the
/// batch's items.
fn collect_file_ids(messages: &[Message], batch: &[MediaItemPayload], out: &mut Vec<CachedMedia>) {
for (message, item) in messages.iter().zip(batch.iter()) {
if let Some(file_id) = file_id_of_message(message, item) {
out.push(CachedMedia {
kind: kind_of_item(item),
file_id,
});
}
}
}
pub const MAX_MEDIA_GROUP: usize = 9;
/// Splits media into batches of at most [`MAX_MEDIA_GROUP`] items, moving the
/// items out (no per-item clone).
pub fn chunk_media_items<T>(items: Vec<T>) -> Vec<Vec<T>> {
let mut items = items.into_iter();
let mut batches = Vec::new();
loop {
let batch: Vec<T> = items.by_ref().take(MAX_MEDIA_GROUP).collect();
if batch.is_empty() {
return batches;
}
batches.push(batch);
}
}
/// Orders media for a Telegram media group: when photos and videos are
/// mixed, the first item must be a photo (Telegram's sendMediaGroup rule).
/// Stable sort keeps the source order within each kind; a lone animation is
/// untouched (it takes the SendAnimation path before this runs).
pub fn photos_first(items: Vec<MediaItemPayload>) -> Vec<MediaItemPayload> {
let mut items = items;
items.sort_by_key(|item| match item {
MediaItemPayload::Photo { .. } => 0,
MediaItemPayload::Video { .. } | MediaItemPayload::Animation { .. } => 1,
});
items
}
/// Exponential backoff with jitter, capped at 30s.
pub fn retry_delay_seconds(attempts: u32) -> f64 {
let jitter: f64 = rand::thread_rng().gen_range(0.2..0.8);
(2f64.powi(attempts as i32) + jitter).min(30.0)
}
/// Telegram's servers failed to fetch a media URL (hotlink protection etc.):
/// these errors are handled by the download-and-reupload fallback, NOT by a
/// queue retry (resending the URL cannot succeed).
pub fn is_media_fetch_failure(e: &ApiError) -> bool {
const MARKERS: [&str; 6] = [
"webpage_media_empty",
"media_empty",
"empty_web_media",
"webpage_curl_failed",
"timeout",
// Oversized photos (width + height > 10000 px) are rejected on URL
// sends too; route them to the download-and-resize fallback.
"photo_invalid_dimensions",
];
let description = e.to_string().to_lowercase();
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
/// the (updated) task when building a [`SendError`].
pub enum Classification {
Retryable {
delay_seconds: f64,
},
Permanent {
message: String,
},
/// Handled by the download fallback, not a queue retry.
MediaFetchFailure,
}
pub fn classify_request_error(e: &RequestError) -> Classification {
match e {
RequestError::RetryAfter(seconds) => Classification::Retryable {
delay_seconds: seconds.seconds() as f64,
},
RequestError::Network(_) => Classification::Retryable {
delay_seconds: retry_delay_seconds(0),
},
RequestError::Api(api) if is_media_fetch_failure(api) => Classification::MediaFetchFailure,
RequestError::Api(api) => Classification::Permanent {
message: api.to_string(),
},
RequestError::MigrateToChatId(_)
| RequestError::InvalidJson { .. }
| RequestError::Io(_) => Classification::Permanent {
message: e.to_string(),
},
}
}
/// Task boxed to keep the error size within `result_large_err` limits.
#[derive(Debug)]
pub enum SendError {
Retryable { delay_seconds: f64, task: Box<Task> },
Permanent { message: String, task: Box<Task> },
}
fn classify_to_send_error(e: &RequestError, task: Task, fetch_failure_label: &str) -> SendError {
match classify_request_error(e) {
Classification::Retryable { delay_seconds } => SendError::Retryable {
delay_seconds,
task: Box::new(task),
},
Classification::Permanent { message } => SendError::Permanent {
message,
task: Box::new(task),
},
Classification::MediaFetchFailure => SendError::Permanent {
message: fetch_failure_label.into(),
task: Box::new(task),
},
}
}
impl SendError {
/// Attaches the (updated) task to a task-free [`FallbackError`] from the
/// download/upload pipeline. [`FallbackError::MediaTooLarge`] never
/// escapes the pipeline (it is handled by falling back to the smaller
/// URL), so it is unreachable here.
fn from_fallback(f: FallbackError, task: Task) -> SendError {
match f {
FallbackError::Retryable { delay_seconds } => SendError::Retryable {
delay_seconds,
task: Box::new(task),
},
FallbackError::Permanent { message } => SendError::Permanent {
message,
task: Box::new(task),
},
FallbackError::MediaTooLarge => unreachable!("handled inside the upload fallback"),
}
}
}
fn updated_sequence_task(task: &Task, batch_index: usize, sent_message_ids: Vec<i64>) -> Task {
let mut updated = task.clone();
match &mut updated {
Task::SendMediaSequence {
batch_index: index,
sent_message_ids: ids,
..
} => {
*index = batch_index;
*ids = sent_message_ids;
}
_ => unreachable!("updated_sequence_task requires a SendMediaSequence task"),
}
updated
}
/// Sends the media batches starting at `task.batch_index`, extending
/// `sent_message_ids`. Returns all sent message ids on full success; on
/// failure returns a [`SendError`] whose task carries the resumed state.
pub async fn send_media_sequence(ctx: &AppContext<'_>, task: &Task) -> Result<Vec<i64>, SendError> {
let Task::SendMediaSequence {
chat_id,
reply_to_message_id,
caption,
media_batches,
batch_index,
sent_message_ids,
..
} = task
else {
unreachable!("send_media_sequence requires a SendMediaSequence task")
};
let chat_id = *chat_id;
let reply_to = *reply_to_message_id;
let mut sent = sent_message_ids.clone();
// File ids accumulated across batches for the link cache. Only a fresh
// (non-resumed) full send populates the cache.
let mut cached_media: Vec<CachedMedia> = Vec::new();
let fresh_send = *batch_index == 0 && sent.is_empty();
for idx in *batch_index..media_batches.len() {
let batch = &media_batches[idx];
let caption = if idx == 0 {
Some(caption.as_str())
} else {
None
};
let items = match build_media_group(batch, caption) {
Ok(items) => items,
Err(message) => {
return Err(SendError::Permanent {
message,
task: Box::new(updated_sequence_task(task, idx, sent)),
});
}
};
match ctx
.sender
.send_media_group(ChatId(chat_id), MessageId(reply_to as i32), items)
.await
{
Ok(messages) => {
log::debug!(
"media group batch {idx}/{} sent ({} item(s))",
media_batches.len(),
batch.len()
);
collect_file_ids(&messages, batch, &mut cached_media);
sent.extend(messages.into_iter().map(|m| m.id.0 as i64));
}
Err(RequestError::Api(api)) if is_media_fetch_failure(&api) || is_size_error(&api) => {
log::info!(
"Telegram could not fetch media for batch {idx} ({}), downloading and reuploading",
batch
.first()
.map(item_url)
.map(log_key)
.unwrap_or_else(|| "?".into())
);
match send_batch_via_upload(
ctx.sender,
chat_id,
reply_to,
batch,
caption,
updated_sequence_task(task, idx, sent.clone()),
)
.await
{
Ok(messages) => {
collect_file_ids(&messages, batch, &mut cached_media);
sent.extend(messages.into_iter().map(|m| m.id.0 as i64));
}
Err(e) => return Err(e),
}
}
Err(e) => {
return Err(classify_to_send_error(
&e,
updated_sequence_task(task, idx, sent),
"media fetch failed",
));
}
}
}
if fresh_send {
cache_sent_task(ctx, task, cached_media).await;
}
Ok(sent)
}
async fn send_animation_inner(
sender: &dyn MediaSender,
chat_id: i64,
reply_to: i64,
caption: &str,
spoiler: bool,
file: InputFile,
) -> Result<Message, RequestError> {
sender
.send_animation(
ChatId(chat_id),
MessageId(reply_to as i32),
caption,
spoiler,
file,
)
.await
}
/// Sends a lone animation (gif), URL first with the download fallback.
pub async fn send_animation(ctx: &AppContext<'_>, task: &Task) -> Result<Vec<i64>, SendError> {
let Task::SendAnimation {
chat_id,
reply_to_message_id,
caption,
animation,
..
} = task
else {
unreachable!("send_animation requires a SendAnimation task")
};
let chat_id = *chat_id;
let reply_to = *reply_to_message_id;
let (media_url, has_spoiler) = match animation {
MediaItemPayload::Animation {
media, has_spoiler, ..
} => (media, *has_spoiler),
MediaItemPayload::Photo { .. } | MediaItemPayload::Video { .. } => {
unreachable!("SendAnimation carries an Animation payload")
}
};
let url_file = match input_file_for(media_url) {
Ok(file) => file,
Err(message) => {
return Err(SendError::Permanent {
message,
task: Box::new(task.clone()),
});
}
};
match send_animation_inner(
ctx.sender,
chat_id,
reply_to,
caption,
has_spoiler,
url_file,
)
.await
{
Ok(message) => {
let id = message.id.0 as i64;
cache_animation_send(ctx, task, &message).await;
Ok(vec![id])
}
Err(RequestError::Api(api)) if is_media_fetch_failure(&api) || is_size_error(&api) => {
log::info!(
"Telegram could not fetch animation URL, downloading and reuploading: [key={}]",
log_key(media_url)
);
// Single-item local preparation — the same pipeline the media
// group fallback uses (download with the upload-cap check,
// downscale/transcode photos, smaller-URL fallback). Animations
// have no smaller variant, so an oversized file surfaces as a
// permanent error here.
match prepare_upload_item(animation.clone(), 0, None).await {
Ok(prepared) => {
let PreparedItem {
media, keep_alive, ..
} = prepared;
let InputMedia::Animation(animation) = media else {
unreachable!("an Animation payload prepares to InputMedia::Animation")
};
// Hold the temp file until the request completes.
let _keep_alive = keep_alive;
match send_animation_inner(
ctx.sender,
chat_id,
reply_to,
caption,
has_spoiler,
animation.media,
)
.await
{
Ok(message) => {
let id = message.id.0 as i64;
cache_animation_send(ctx, task, &message).await;
Ok(vec![id])
}
Err(e) => Err(classify_to_send_error(
&e,
task.clone(),
"media fetch failed",
)),
}
}
Err(e) => Err(SendError::from_fallback(e, task.clone())),
}
}
Err(e) => Err(classify_to_send_error(
&e,
task.clone(),
"media fetch failed",
)),
}
}
/// Copies already-sent messages to the forward channel. No download fallback:
/// the files are already on Telegram's servers.
pub async fn forward_messages(ctx: &AppContext<'_>, task: &Task) -> Result<(), SendError> {
let Task::ForwardMessages {
from_chat_id,
to_chat_id,
message_ids,
..
} = task
else {
unreachable!("forward_messages requires a ForwardMessages task")
};
let message_ids = message_ids
.iter()
.map(|id| MessageId(*id as i32))
.collect::<Vec<_>>();
match ctx
.sender
.copy_messages(
ChatId(*to_chat_id),
ChatId(*from_chat_id),
message_ids.clone(),
)
.await
{
Ok(_) => {
log::info!(
"copied {} message(s) from {} to {}",
message_ids.len(),
from_chat_id,
to_chat_id
);
Ok(())
}
Err(e) => Err(classify_to_send_error(
&e,
task.clone(),
"media fetch failed",
)),
}
}
#[cfg(test)]
mod tests {
use super::post_send::build_edit_markup;
use super::upload::sniff_ext;
use super::*;
use crate::ctx::test_support::TestStores;
use std::collections::HashMap;
use std::time::Duration;
#[test]
fn oversized_photo_boundary() {
// The empirical Telegram limit: sum 10000 passes, 10001 fails.
// Const-block asserts so clippy's assertions_on_constants stays quiet.
const { assert!(crate::photo::PHOTO_MAX_DIMENSION_SUM == 10000) };
const { assert!(6100 + 3900 <= crate::photo::PHOTO_MAX_DIMENSION_SUM) };
const { assert!(6300 + 3730 > crate::photo::PHOTO_MAX_DIMENSION_SUM) };
}
#[test]
fn chunk_media_items_sizes() {
assert_eq!(chunk_media_items::<i32>(vec![]), Vec::<Vec<i32>>::new());
assert_eq!(chunk_media_items((0..9).collect()).len(), 1);
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())[2].len(), 7);
assert!(
chunk_media_items((0..25).collect())
.iter()
.all(|c| c.len() <= 9)
);
}
#[test]
fn photos_first_orders_photos_before_videos() {
use MediaItemPayload::{Animation, Photo, Video};
let photo = |u: &str| Photo {
media: u.into(),
has_spoiler: false,
fallback_url: None,
file_id: false,
};
let video = |u: &str| Video {
media: u.into(),
has_spoiler: false,
thumbnail: None,
fallback_url: None,
file_id: false,
};
let items = vec![
video("https://v/1.mp4"),
photo("https://p/1.jpg"),
video("https://v/2.mp4"),
photo("https://p/2.jpg"),
];
let ordered = photos_first(items);
// All photos first (stable: p1 before p2), then all videos in order.
let kinds: Vec<&str> = ordered
.iter()
.map(|i| match i {
Photo { media, .. } => media.as_str(),
Video { media, .. } => media.as_str(),
Animation { .. } => unreachable!(),
})
.collect();
assert_eq!(
kinds,
[
"https://p/1.jpg",
"https://p/2.jpg",
"https://v/1.mp4",
"https://v/2.mp4"
]
);
// Already-photos-first input is unchanged.
let items = vec![photo("https://p/1.jpg"), video("https://v/1.mp4")];
assert!(matches!(photos_first(items)[0], Photo { .. }));
}
#[test]
fn retry_delay_seconds_bounds() {
for attempts in 0..10 {
let delay = retry_delay_seconds(attempts);
assert!(delay >= 1.0, "attempts={attempts}: {delay}");
assert!(delay <= 30.0, "attempts={attempts}: {delay}");
}
}
#[test]
fn edit_markup_lists_templates_sorted_then_confirm() {
// Six names: a HashMap walk would land on this order by chance only
// 1 time in 720.
let templates: HashMap<String, String> = ["z", "a", "m", "q", "b", "y"]
.into_iter()
.map(|name| (name.to_string(), "[]".to_string()))
.collect();
let labels: Vec<String> = build_edit_markup(&templates)
.inline_keyboard
.iter()
.flatten()
.map(|button| button.text.clone())
.collect();
assert_eq!(labels, ["a", "b", "m", "q", "y", "z", "↩️ Confirm"]);
}
#[test]
fn is_media_fetch_failure_matches_markers() {
for description in [
"Bad Request: WEBPAGE_MEDIA_EMPTY",
"Bad Request: media_empty",
"Bad Request: EMPTY_WEB_MEDIA",
"Bad Request: webpage_curl_failed",
"Bad Request: request timeout",
// Telegram sends the code in upper case; the comparison is against
// the lower-cased description.
"Bad Request: PHOTO_INVALID_DIMENSIONS: width and height must be <= 10000",
] {
let api = ApiError::Unknown(description.to_string());
assert!(is_media_fetch_failure(&api), "{description}");
}
for description in [
"Bad Request: message is not modified",
"Forbidden: bot was blocked by the user",
] {
let api = ApiError::Unknown(description.to_string());
assert!(!is_media_fetch_failure(&api), "{description}");
}
}
#[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]
fn classification_mapping() {
use teloxide::types::Seconds;
// RetryAfter -> Retryable with its delay
let e = RequestError::RetryAfter(Seconds::from_seconds(7));
assert!(matches!(
classify_request_error(&e),
Classification::Retryable { delay_seconds } if delay_seconds == 7.0
));
// Api error -> Permanent
let e = RequestError::Api(ApiError::Unknown("Bad Request: something".into()));
assert!(matches!(
classify_request_error(&e),
Classification::Permanent { .. }
));
// Api media-fetch marker -> MediaFetchFailure
let e = RequestError::Api(ApiError::Unknown("Bad Request: WEBPAGE_MEDIA_EMPTY".into()));
assert!(matches!(
classify_request_error(&e),
Classification::MediaFetchFailure
));
// MigrateToChatId -> Permanent
let e = RequestError::MigrateToChatId(ChatId(123));
assert!(matches!(
classify_request_error(&e),
Classification::Permanent { .. }
));
}
#[test]
fn task_serde_round_trip_preserves_resume_state() {
let task = Task::SendMediaSequence {
chat_id: 111,
reply_to_message_id: 222,
caption: "cap".into(),
media_batches: vec![
vec![MediaItemPayload::Photo {
media: "https://a/b.jpg".into(),
has_spoiler: true,
fallback_url: Some("https://a/b_small.jpg".into()),
file_id: false,
}],
vec![MediaItemPayload::Video {
media: "https://a/v.mp4".into(),
has_spoiler: false,
thumbnail: Some("https://a/t.jpg".into()),
fallback_url: None,
file_id: false,
}],
],
batch_index: 1,
sent_message_ids: vec![11, 12],
source_url: "https://x.com/u/status/1".into(),
edit_before_forward: true,
forward_channel_id: Some(333),
notify_chat_id: Some(111),
notify_message_id: Some(222),
cache_data: None,
};
let json = serde_json::to_value(&task).unwrap();
assert_eq!(json["type"], "send_media_sequence");
assert_eq!(json["batch_index"], 1);
let decoded: Task = serde_json::from_value(json).unwrap();
match decoded {
Task::SendMediaSequence {
batch_index,
sent_message_ids,
forward_channel_id,
media_batches,
..
} => {
assert_eq!(batch_index, 1);
assert_eq!(sent_message_ids, vec![11, 12]);
assert_eq!(forward_channel_id, Some(333));
assert_eq!(media_batches.len(), 2);
assert!(matches!(
media_batches[0][0],
MediaItemPayload::Photo {
has_spoiler: true,
..
}
));
}
other => panic!("expected SendMediaSequence, got {other:?}"),
}
}
#[test]
fn media_item_payload_serde_tags() {
let photo = MediaItemPayload::Photo {
media: "https://a/b.jpg".into(),
has_spoiler: false,
fallback_url: None,
file_id: false,
};
let json = serde_json::to_value(&photo).unwrap();
assert_eq!(json["kind"], "photo");
}
#[test]
fn sniff_ext_detects_formats() {
assert_eq!(sniff_ext(&[0xFF, 0xD8, 0xFF, 0xE0]), "jpg");
assert_eq!(sniff_ext(b"\x89PNG\r\n\x1a\n"), "png");
assert_eq!(sniff_ext(b"RIFF\x00\x00\x00\x00WEBPVP8 "), "webp");
assert_eq!(sniff_ext(b"GIF89a"), "gif");
assert_eq!(sniff_ext(b"\x00\x00\x00\x18ftypisom"), "mp4");
assert_eq!(sniff_ext(b"something else"), "bin");
}
// ── MediaSender-mock tests: fallback trigger + error classification ──
use crate::media_sender::test_support::{MockSender, Outcome};
/// Telegram's "I could not fetch this URL" error, which triggers the
/// download-and-reupload fallback.
fn media_fetch_error() -> RequestError {
RequestError::Api(ApiError::Unknown("Bad Request: WEBPAGE_MEDIA_EMPTY".into()))
}
fn sequence_task(media: &str) -> Task {
Task::SendMediaSequence {
chat_id: 1,
reply_to_message_id: 2,
caption: "cap".into(),
media_batches: vec![vec![MediaItemPayload::Photo {
media: media.to_string(),
has_spoiler: false,
fallback_url: None,
file_id: false,
}]],
batch_index: 0,
sent_message_ids: vec![],
source_url: "https://x.com/u/status/1".into(),
edit_before_forward: false,
forward_channel_id: None,
notify_chat_id: Some(1),
notify_message_id: Some(2),
cache_data: None,
}
}
#[tokio::test]
async fn media_group_fetch_failure_falls_back_then_permanent() {
// A local file avoids any network in the fallback (the prep pipeline
// uploads local paths directly). The first group send fails with a
// media-fetch error → the download-reupload fallback runs → the
// reupload also fails → Permanent.
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("media.jpg");
std::fs::write(&file, b"not-a-real-jpeg").unwrap();
let sender = MockSender::scripted(
vec![Outcome::GroupErr, Outcome::GroupErr],
media_fetch_error,
);
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
let task = sequence_task(file.to_str().unwrap());
let result = send_media_sequence(&ctx, &task).await;
assert!(
matches!(result, Err(SendError::Permanent { .. })),
"got {result:?}"
);
// Two group sends: the original + the fallback reupload.
assert_eq!(sender.calls(), vec!["send_media_group", "send_media_group"]);
}
#[tokio::test]
async fn media_group_retry_after_classifies_retryable_without_fallback() {
use teloxide::types::Seconds;
// RetryAfter is not a media-fetch failure: no fallback, straight to a
// retryable error carrying the Telegram delay.
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("media.jpg");
std::fs::write(&file, b"not-a-real-jpeg").unwrap();
let sender = MockSender::scripted(vec![Outcome::GroupErr], || {
RequestError::RetryAfter(Seconds::from_seconds(7))
});
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
let task = sequence_task(file.to_str().unwrap());
let result = send_media_sequence(&ctx, &task).await;
match result {
Err(SendError::Retryable { delay_seconds, .. }) => {
assert_eq!(delay_seconds, 7.0)
}
other => panic!("expected Retryable, got {other:?}"),
}
assert_eq!(sender.calls(), vec!["send_media_group"]);
}
#[tokio::test]
async fn animation_fetch_failure_falls_back_then_permanent() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("gif.mp4");
std::fs::write(&file, b"not-a-real-mp4").unwrap();
let sender = MockSender::scripted(
vec![Outcome::AnimationErr, Outcome::AnimationErr],
media_fetch_error,
);
let task = Task::SendAnimation {
chat_id: 1,
reply_to_message_id: 2,
caption: "cap".into(),
animation: MediaItemPayload::Animation {
media: file.to_string_lossy().into_owned(),
has_spoiler: false,
file_id: false,
},
source_url: "https://x.com/u/status/1".into(),
edit_before_forward: false,
forward_channel_id: None,
notify_chat_id: Some(1),
notify_message_id: Some(2),
cache_data: None,
};
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
let result = send_animation(&ctx, &task).await;
assert!(
matches!(result, Err(SendError::Permanent { .. })),
"got {result:?}"
);
assert_eq!(sender.calls(), vec!["send_animation", "send_animation"]);
}
#[tokio::test]
async fn media_group_success_and_forward_ok() {
// GroupOk: the group send succeeds (empty message list → no file ids
// collected, the batch counts as sent). CopyOk: the forward succeeds.
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("media.jpg");
std::fs::write(&file, b"not-a-real-jpeg").unwrap();
let sender = MockSender::scripted(vec![Outcome::GroupOk], media_fetch_error);
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
let task = sequence_task(file.to_str().unwrap());
let result = send_media_sequence(&ctx, &task).await;
assert!(result.is_ok(), "got {result:?}");
let sender = MockSender::scripted(vec![Outcome::CopyOk], media_fetch_error);
let ctx = stores.ctx(&sender);
let task = Task::ForwardMessages {
from_chat_id: 1,
to_chat_id: 2,
message_ids: vec![3],
notify_chat_id: None,
notify_message_id: None,
};
assert!(forward_messages(&ctx, &task).await.is_ok());
}
#[tokio::test]
async fn forward_classifies_retry_after_and_permanent() {
use teloxide::types::Seconds;
let task = Task::ForwardMessages {
from_chat_id: 1,
to_chat_id: 2,
message_ids: vec![3],
notify_chat_id: None,
notify_message_id: None,
};
// RetryAfter → Retryable with the Telegram delay.
let sender = MockSender::scripted(vec![Outcome::CopyErr], || {
RequestError::RetryAfter(Seconds::from_seconds(7))
});
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
match forward_messages(&ctx, &task).await {
Err(SendError::Retryable { delay_seconds, .. }) => {
assert_eq!(delay_seconds, 7.0)
}
other => panic!("expected Retryable, got {other:?}"),
}
// A generic API error → Permanent.
let sender = MockSender::scripted(vec![Outcome::CopyErr], || {
RequestError::Api(ApiError::Unknown(
"Bad Request: message is not modified".into(),
))
});
let ctx = stores.ctx(&sender);
assert!(matches!(
forward_messages(&ctx, &task).await,
Err(SendError::Permanent { .. })
));
}
#[tokio::test]
async fn dead_letter_releases_keep_alive_temp_media() {
// A task that exhausts its retries is dead-lettered by the queue
// without the handler running again: the keep-alive temp dir the
// fetch pipeline handed over must not outlive the task.
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("ugoira.mp4");
std::fs::write(&file, b"not-a-real-mp4").unwrap();
let mut task = sequence_task(file.to_str().unwrap());
if let Task::SendMediaSequence {
notify_chat_id,
notify_message_id,
..
} = &mut task
{
// No chat to notify → no Bot is built by the notify path.
*notify_chat_id = None;
*notify_message_id = None;
}
let dir_path = dir.path().to_path_buf();
KEEP_ALIVE.lock().push(dir);
// No chat to notify → the notify path sends nothing (its mock would
// have no scripted outcome left).
let sender = MockSender::scripted(vec![Outcome::MessageErr], media_fetch_error);
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
let payload = serde_json::to_value(&task).unwrap();
dead_letter_notify(&ctx, payload, "task failed after 2 retries".into()).await;
assert!(
!KEEP_ALIVE.lock().iter().any(|dir| dir.path() == dir_path),
"dead-lettered task kept its temp media alive"
);
}
#[tokio::test]
async fn post_send_opens_and_records_the_edit_prompt() {
let sender = MockSender::scripted(vec![Outcome::MessageOk], media_fetch_error);
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
let mut task = sent_task(None, false);
if let Task::SendMediaSequence {
edit_before_forward,
..
} = &mut task
{
*edit_before_forward = true;
}
post_send_actions(&ctx, &task, vec![10, 11]).await;
assert_eq!(sender.calls(), vec!["send_message"]);
assert_eq!(sender.messages(), vec!["Reply to edit message."]);
// The prompt's own message id keys the record the reply will edit.
let data = stores.chat_store().get(1).await;
let record = data
.edit_message
.get(&MockSender::SENT_ID)
.expect("the prompt record must be stored");
assert_eq!(record.url, "https://x.com/u/status/1");
assert_eq!(record.forward_message_ids, vec![10, 11]);
}
#[tokio::test]
async fn post_send_forwards_immediately_when_configured() {
let sender = MockSender::scripted(vec![Outcome::CopyOk], media_fetch_error);
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
let task = sent_task(Some(2), false);
post_send_actions(&ctx, &task, vec![10, 11]).await;
assert_eq!(sender.calls(), vec!["copy_messages"]);
assert_eq!(stores.queued_tasks().await, 0);
}
#[tokio::test]
async fn post_send_queues_a_retryable_forward() {
use teloxide::types::Seconds;
let sender = MockSender::scripted(vec![Outcome::CopyErr], || {
RequestError::RetryAfter(Seconds::from_seconds(7))
});
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
let task = sent_task(Some(2), false);
post_send_actions(&ctx, &task, vec![10, 11]).await;
assert_eq!(sender.calls(), vec!["copy_messages"]);
assert_eq!(stores.queued_tasks().await, 1, "forward retry not queued");
let payload = stores.queued_payload().await;
assert_eq!(payload["type"], "forward_messages");
assert_eq!(payload["to_chat_id"], 2);
assert_eq!(payload["message_ids"], serde_json::json!([10, 11]));
}
#[tokio::test]
async fn post_send_notifies_a_permanent_forward_failure() {
let sender = MockSender::scripted(vec![Outcome::CopyErr, Outcome::MessageErr], || {
RequestError::Api(ApiError::Unknown("Bad Request: chat not found".into()))
});
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
let task = sent_task(Some(2), true);
post_send_actions(&ctx, &task, vec![10, 11]).await;
// The copy failed permanently → the chat is told, nothing is queued.
assert_eq!(sender.calls(), vec!["copy_messages", "send_message"]);
assert_eq!(stores.queued_tasks().await, 0);
}
// ── Settlement: the invariant every terminal path owes ──────────────
/// An already-sent sequence task with the post-send knobs set: the state
/// `post_send_actions` branches on.
fn sent_task(forward_channel_id: Option<i64>, notify: bool) -> Task {
Task::SendMediaSequence {
chat_id: 1,
reply_to_message_id: 2,
caption: "cap".into(),
media_batches: vec![vec![MediaItemPayload::Photo {
media: "https://p/1.jpg".into(),
has_spoiler: false,
fallback_url: None,
file_id: false,
}]],
batch_index: 0,
sent_message_ids: vec![],
source_url: "https://x.com/u/status/1".into(),
edit_before_forward: false,
forward_channel_id,
notify_chat_id: notify.then_some(1),
notify_message_id: notify.then_some(2),
cache_data: None,
}
}
/// A task whose media are cached Telegram file ids (the only kind that can
/// hold a link-cache entry).
fn cached_sequence_task() -> Task {
Task::SendMediaSequence {
chat_id: 1,
reply_to_message_id: 2,
caption: "cap".into(),
media_batches: vec![vec![MediaItemPayload::Photo {
media: "AgAC-file-id".into(),
has_spoiler: false,
fallback_url: None,
file_id: true,
}]],
batch_index: 0,
sent_message_ids: vec![],
source_url: "https://x.com/u/status/1".into(),
edit_before_forward: false,
forward_channel_id: None,
notify_chat_id: None,
notify_message_id: None,
cache_data: Some(CachedPost {
url: "https://x.com/u/status/1".into(),
caption: "cap".into(),
title: "t".into(),
author: "a".into(),
author_url: "au".into(),
tags: String::new(),
sensitive: false,
media: vec![CachedMedia {
kind: CachedMediaKind::Photo,
file_id: "AgAC-file-id".into(),
}],
}),
}
}
#[tokio::test]
async fn settled_sent_keeps_the_cache_entry() {
let sender = MockSender::scripted(vec![], media_fetch_error);
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
let task = cached_sequence_task();
stores
.link_cache()
.put("twitter:1", &cached_sequence_cache_data())
.await;
settle_task(&ctx, &task, Settled::Sent).await;
assert!(
stores
.link_cache()
.get("twitter:1", Duration::from_secs(3600))
.await
.is_some(),
"a successful send must not drop its own cache entry"
);
}
#[tokio::test]
async fn settled_failed_drops_the_cache_entry() {
let sender = MockSender::scripted(vec![], media_fetch_error);
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
let task = cached_sequence_task();
stores
.link_cache()
.put("twitter:1", &cached_sequence_cache_data())
.await;
settle_task(&ctx, &task, Settled::Failed).await;
assert!(
stores
.link_cache()
.get("twitter:1", Duration::from_secs(3600))
.await
.is_none(),
"a permanently failed cached send must drop the entry"
);
}
fn cached_sequence_cache_data() -> CachedPost {
match cached_sequence_task() {
Task::SendMediaSequence {
cache_data: Some(post),
..
} => post,
other => panic!("expected a cached sequence task, got {other:?}"),
}
}
}