mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-23 23:32:05 +00:00
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:
@@ -24,39 +24,37 @@ pub struct Config {
|
||||
impl Config {
|
||||
pub fn load() -> Config {
|
||||
let admin_ids = env::var("BOT_ADMIN")
|
||||
.ok()
|
||||
.map(|s| {
|
||||
s.split(',')
|
||||
.filter_map(|part| part.trim().parse::<i64>().ok())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
.ok()
|
||||
.map(|s| {
|
||||
s.split(',')
|
||||
.filter_map(|part| part.trim().parse::<i64>().ok())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let edit_message_ttl = env::var("EDIT_MESSAGE_TTL_SECONDS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or(Duration::from_secs(86400));
|
||||
let edit_message_ttl = env::var("EDIT_MESSAGE_TTL_SECONDS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or(Duration::from_secs(86400));
|
||||
|
||||
let link_cache_ttl = env::var("LINK_CACHE_TTL_SECONDS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or(Duration::from_secs(7 * 24 * 3600));
|
||||
let link_cache_ttl = env::var("LINK_CACHE_TTL_SECONDS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or(Duration::from_secs(7 * 24 * 3600));
|
||||
|
||||
let webhook_enabled = env::var("WEBHOOK")
|
||||
.is_ok_and(|v| matches!(v.to_lowercase().as_str(), "true" | "yes" | "1"));
|
||||
let webhook_url = env::var("WEBHOOK_URL").ok().and_then(|s| s.parse().ok());
|
||||
let webhook_listen = env::var("WEBHOOK_LISTEN").ok().and_then(|s| s.parse().ok());
|
||||
let webhook_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
|
||||
// value that would otherwise come from `.env`).
|
||||
let webhook_cert = env::var("WEBHOOK_CERT")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty());
|
||||
let webhook_secret_token = env::var("WEBHOOK_SECRET_TOKEN")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty());
|
||||
let webhook_enabled = env::var("WEBHOOK")
|
||||
.is_ok_and(|v| matches!(v.to_lowercase().as_str(), "true" | "yes" | "1"));
|
||||
let webhook_url = env::var("WEBHOOK_URL").ok().and_then(|s| s.parse().ok());
|
||||
let webhook_listen = env::var("WEBHOOK_LISTEN").ok().and_then(|s| s.parse().ok());
|
||||
let webhook_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
|
||||
// value that would otherwise come from `.env`).
|
||||
let webhook_cert = env::var("WEBHOOK_CERT").ok().filter(|s| !s.is_empty());
|
||||
let webhook_secret_token = env::var("WEBHOOK_SECRET_TOKEN")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
Config {
|
||||
admin_ids,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use dotenv::dotenv;
|
||||
use teloxide::dptree::endpoint;
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::stop::StopToken;
|
||||
use teloxide::types::{ChatId, InputFile, MessageId};
|
||||
use teloxide::update_listeners::{self, webhooks, UpdateListener};
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::update_listeners::{self, UpdateListener, webhooks};
|
||||
use tokio::sync::watch;
|
||||
use x_media::site;
|
||||
|
||||
@@ -75,7 +75,10 @@ async fn main() {
|
||||
}
|
||||
|
||||
// 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 bot = bot.clone();
|
||||
@@ -96,7 +99,10 @@ async fn main() {
|
||||
// If the prompt was already deleted, this fails with a
|
||||
// 400 "message to edit not found" — log and ignore.
|
||||
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
|
||||
{
|
||||
log::info!("edit-expiry sweep: prompt message gone: {e}");
|
||||
@@ -118,10 +124,7 @@ async fn main() {
|
||||
|
||||
if CONFIG.webhook_enabled {
|
||||
log::info!("running in webhook mode");
|
||||
let url = CONFIG
|
||||
.webhook_url
|
||||
.clone()
|
||||
.expect("WEBHOOK_URL is not set");
|
||||
let url = CONFIG.webhook_url.clone().expect("WEBHOOK_URL is not set");
|
||||
// `webhooks::axum` calls set_webhook itself (with the full options,
|
||||
// secret token included) — no explicit registration here.
|
||||
let listen = CONFIG.webhook_listen.expect("WEBHOOK_LISTEN is not set");
|
||||
|
||||
@@ -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
|
||||
/// over the upload cap afterwards becomes JPEG.
|
||||
fn prepare_png(file: NamedTempFile, bytes: Vec<u8>) -> Result<PhotoPrep, String> {
|
||||
let (w, h, _bit_depth, color_type) =
|
||||
parse_png_header(&bytes).ok_or("invalid PNG header")?;
|
||||
let (w, h, _bit_depth, color_type) = parse_png_header(&bytes).ok_or("invalid PNG header")?;
|
||||
let size_over = bytes.len() as u64 > MAX_UPLOAD_BYTES;
|
||||
if w + h <= PHOTO_MAX_DIMENSION_SUM && !size_over {
|
||||
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);
|
||||
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));
|
||||
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_h = reader.info().height;
|
||||
let mut buf = vec![
|
||||
@@ -457,7 +461,9 @@ mod tests {
|
||||
let mut bytes = Vec::new();
|
||||
{
|
||||
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();
|
||||
std::io::Write::write_all(file.as_file_mut(), &bytes).unwrap();
|
||||
@@ -485,7 +491,9 @@ mod tests {
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
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 v = (base as i32 + n).clamp(0, 255) as u8;
|
||||
data.extend_from_slice(&[v, v, v]);
|
||||
@@ -499,7 +507,11 @@ mod tests {
|
||||
let mut writer = encoder.write_header().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();
|
||||
std::io::Write::write_all(file.as_file_mut(), &bytes).unwrap();
|
||||
|
||||
+186
-107
@@ -5,20 +5,19 @@
|
||||
|
||||
use crate::handlers::{CHAT_STORE, LINK_CACHE, TASK_QUEUE};
|
||||
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::state::{EditMessage, unix_now};
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tempfile::NamedTempFile;
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::types::{
|
||||
ChatId, InlineKeyboardButton, InlineKeyboardMarkup, InputFile, InputMedia,
|
||||
InputMediaAnimation, InputMediaPhoto, InputMediaVideo, Message, MessageId, ParseMode,
|
||||
ReplyParameters,
|
||||
ChatId, InlineKeyboardButton, InlineKeyboardMarkup, InputFile, InputMedia, InputMediaAnimation,
|
||||
InputMediaPhoto, InputMediaVideo, Message, MessageId, ParseMode, ReplyParameters,
|
||||
};
|
||||
use teloxide::{ApiError, RequestError};
|
||||
use tempfile::NamedTempFile;
|
||||
use x_media::site::FetchError;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
@@ -111,16 +110,18 @@ pub enum Task {
|
||||
impl Task {
|
||||
fn cache_data(&self) -> Option<&CachedPost> {
|
||||
match self {
|
||||
Task::SendMediaSequence { cache_data, .. }
|
||||
| Task::SendAnimation { cache_data, .. } => cache_data.as_ref(),
|
||||
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::SendMediaSequence { source_url, .. } | Task::SendAnimation { source_url, .. } => {
|
||||
Some(source_url)
|
||||
}
|
||||
Task::ForwardMessages { .. } => None,
|
||||
}
|
||||
}
|
||||
@@ -138,9 +139,10 @@ fn file_id_of_message(message: &Message, item: &MediaItemPayload) -> Option<Stri
|
||||
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::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()),
|
||||
}
|
||||
@@ -214,7 +216,10 @@ pub const MAX_MEDIA_GROUP: usize = 9;
|
||||
|
||||
/// Splits media into batches of at most [`MAX_MEDIA_GROUP`] items.
|
||||
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.
|
||||
@@ -250,31 +255,41 @@ pub fn is_size_error(e: &ApiError) -> bool {
|
||||
return true;
|
||||
}
|
||||
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
|
||||
/// the (updated) task when building a [`SendError`].
|
||||
pub enum Classification {
|
||||
Retryable { delay_seconds: f64 },
|
||||
Permanent { message: String },
|
||||
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::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::Api(api) => Classification::Permanent {
|
||||
message: api.to_string(),
|
||||
},
|
||||
RequestError::MigrateToChatId(_)
|
||||
| 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)| {
|
||||
let item_caption = if i == 0 { caption } else { None };
|
||||
Ok(match item {
|
||||
MediaItemPayload::Photo {
|
||||
has_spoiler, ..
|
||||
} => photo_media(item.input_file()?, item_caption, *has_spoiler),
|
||||
MediaItemPayload::Photo { has_spoiler, .. } => {
|
||||
photo_media(item.input_file()?, item_caption, *has_spoiler)
|
||||
}
|
||||
MediaItemPayload::Video {
|
||||
has_spoiler,
|
||||
thumbnail,
|
||||
@@ -404,9 +419,9 @@ fn build_media_group(
|
||||
}
|
||||
video
|
||||
}
|
||||
MediaItemPayload::Animation {
|
||||
has_spoiler, ..
|
||||
} => animation_media(item.input_file()?, item_caption, *has_spoiler),
|
||||
MediaItemPayload::Animation { has_spoiler, .. } => {
|
||||
animation_media(item.input_file()?, item_caption, *has_spoiler)
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -431,8 +446,12 @@ fn sniff_ext(bytes: &[u8]) -> &'static str {
|
||||
}
|
||||
|
||||
enum FallbackError {
|
||||
Retryable { delay_seconds: f64 },
|
||||
Permanent { message: String },
|
||||
Retryable {
|
||||
delay_seconds: f64,
|
||||
},
|
||||
Permanent {
|
||||
message: String,
|
||||
},
|
||||
/// The downloaded file exceeds the upload cap; the caller falls back to
|
||||
/// the item's smaller URL.
|
||||
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
|
||||
// downscale / transcode them; only videos/animations short-circuit.
|
||||
if !matches!(item, MediaItemPayload::Photo { .. })
|
||||
&& bytes.len() as u64 > MAX_UPLOAD_BYTES
|
||||
{
|
||||
if !matches!(item, MediaItemPayload::Photo { .. }) && bytes.len() as u64 > MAX_UPLOAD_BYTES {
|
||||
return Err(FallbackError::MediaTooLarge);
|
||||
}
|
||||
let ext = sniff_ext(&bytes);
|
||||
@@ -628,14 +645,16 @@ async fn send_batch_via_upload(
|
||||
}
|
||||
let result = bot
|
||||
.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;
|
||||
match result {
|
||||
Ok(messages) => Ok(messages),
|
||||
Err(e) => Err(match classify_request_error(&e) {
|
||||
Classification::Retryable { delay_seconds } => FallbackError::Retryable {
|
||||
delay_seconds,
|
||||
},
|
||||
Classification::Retryable { delay_seconds } => {
|
||||
FallbackError::Retryable { delay_seconds }
|
||||
}
|
||||
Classification::Permanent { message } => FallbackError::Permanent { message },
|
||||
Classification::MediaFetchFailure => FallbackError::Permanent {
|
||||
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();
|
||||
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 caption = if idx == 0 {
|
||||
Some(caption.as_str())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let items = match build_media_group(batch, caption) {
|
||||
Ok(items) => items,
|
||||
Err(message) => {
|
||||
@@ -714,7 +737,9 @@ pub async fn send_media_sequence(bot: &Bot, task: &Task) -> Result<Vec<i64>, Sen
|
||||
};
|
||||
match bot
|
||||
.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
|
||||
{
|
||||
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);
|
||||
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) =>
|
||||
{
|
||||
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).unwrap_or("?")
|
||||
@@ -779,7 +802,9 @@ async fn send_animation_inner(
|
||||
.send_animation(ChatId(chat_id), file)
|
||||
.caption(caption)
|
||||
.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 {
|
||||
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 (media_url, has_spoiler) = match animation {
|
||||
MediaItemPayload::Animation {
|
||||
media,
|
||||
has_spoiler,
|
||||
..
|
||||
media, has_spoiler, ..
|
||||
} => (media, *has_spoiler),
|
||||
MediaItemPayload::Photo { .. } | MediaItemPayload::Video { .. } => {
|
||||
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) {
|
||||
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)
|
||||
.await
|
||||
{
|
||||
match send_animation_inner(bot, chat_id, reply_to, caption, has_spoiler, url_file).await {
|
||||
Ok(message) => {
|
||||
let id = message.id.0 as i64;
|
||||
cache_animation_send(task, &message).await;
|
||||
Ok(vec![id])
|
||||
}
|
||||
Err(RequestError::Api(api))
|
||||
if is_media_fetch_failure(&api) || is_size_error(&api) =>
|
||||
{
|
||||
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: {}",
|
||||
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(message) => {
|
||||
Err(SendError::Permanent { message, task: task.clone() })
|
||||
}
|
||||
Err(message) => Err(SendError::Permanent {
|
||||
message,
|
||||
task: task.clone(),
|
||||
}),
|
||||
},
|
||||
None => Err(SendError::Permanent {
|
||||
message: "media too large".into(),
|
||||
task: task.clone(),
|
||||
}),
|
||||
},
|
||||
Err(FallbackError::Retryable { delay_seconds }) => {
|
||||
Err(SendError::Retryable { delay_seconds, task: task.clone() })
|
||||
}
|
||||
Err(FallbackError::Permanent { message }) => {
|
||||
Err(SendError::Permanent { message, task: task.clone() })
|
||||
}
|
||||
Err(FallbackError::Retryable { delay_seconds }) => Err(SendError::Retryable {
|
||||
delay_seconds,
|
||||
task: task.clone(),
|
||||
}),
|
||||
Err(FallbackError::Permanent { message }) => Err(SendError::Permanent {
|
||||
message,
|
||||
task: 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))
|
||||
.collect::<Vec<_>>();
|
||||
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
|
||||
{
|
||||
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
|
||||
/// 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 mut request = bot.send_message(ChatId(chat_id), message);
|
||||
if let Some(message_id) = message_id {
|
||||
request = request
|
||||
.reply_parameters(ReplyParameters::new(MessageId(message_id as i32)).allow_sending_without_reply());
|
||||
request = request.reply_parameters(
|
||||
ReplyParameters::new(MessageId(message_id as i32)).allow_sending_without_reply(),
|
||||
);
|
||||
}
|
||||
if let Err(e) = request.await {
|
||||
log::error!("failed to notify about failed task: {e}");
|
||||
@@ -959,38 +996,45 @@ 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
|
||||
/// forward to the configured channel (with retry/queue handling).
|
||||
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) =
|
||||
match task {
|
||||
Task::SendMediaSequence {
|
||||
chat_id,
|
||||
reply_to_message_id,
|
||||
source_url,
|
||||
edit_before_forward,
|
||||
forward_channel_id,
|
||||
notify_chat_id,
|
||||
notify_message_id,
|
||||
..
|
||||
}
|
||||
| Task::SendAnimation {
|
||||
chat_id,
|
||||
reply_to_message_id,
|
||||
source_url,
|
||||
edit_before_forward,
|
||||
forward_channel_id,
|
||||
notify_chat_id,
|
||||
notify_message_id,
|
||||
..
|
||||
} => (
|
||||
*chat_id,
|
||||
*reply_to_message_id,
|
||||
source_url.clone(),
|
||||
*edit_before_forward,
|
||||
*forward_channel_id,
|
||||
*notify_chat_id,
|
||||
*notify_message_id,
|
||||
),
|
||||
Task::ForwardMessages { .. } => return,
|
||||
};
|
||||
let (
|
||||
chat_id,
|
||||
reply_to,
|
||||
source_url,
|
||||
edit_before_forward,
|
||||
forward_channel_id,
|
||||
notify_chat_id,
|
||||
notify_message_id,
|
||||
) = match task {
|
||||
Task::SendMediaSequence {
|
||||
chat_id,
|
||||
reply_to_message_id,
|
||||
source_url,
|
||||
edit_before_forward,
|
||||
forward_channel_id,
|
||||
notify_chat_id,
|
||||
notify_message_id,
|
||||
..
|
||||
}
|
||||
| Task::SendAnimation {
|
||||
chat_id,
|
||||
reply_to_message_id,
|
||||
source_url,
|
||||
edit_before_forward,
|
||||
forward_channel_id,
|
||||
notify_chat_id,
|
||||
notify_message_id,
|
||||
..
|
||||
} => (
|
||||
*chat_id,
|
||||
*reply_to_message_id,
|
||||
source_url.clone(),
|
||||
*edit_before_forward,
|
||||
*forward_channel_id,
|
||||
*notify_chat_id,
|
||||
*notify_message_id,
|
||||
),
|
||||
Task::ForwardMessages { .. } => return,
|
||||
};
|
||||
|
||||
if edit_before_forward {
|
||||
let mut chat_data = CHAT_STORE.get(chat_id).await;
|
||||
@@ -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 {
|
||||
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 {
|
||||
from_chat_id: chat_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 {
|
||||
Ok(()) => {}
|
||||
Err(SendError::Retryable { delay_seconds, task }) => {
|
||||
Err(SendError::Retryable {
|
||||
delay_seconds,
|
||||
task,
|
||||
}) => {
|
||||
let payload = serde_json::to_value(task).expect("task serializes");
|
||||
let run_after = std::time::SystemTime::now()
|
||||
.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 { .. } => {
|
||||
let message_ids = match send_media_or_animation(&bot, &task).await {
|
||||
Ok(ids) => ids,
|
||||
Err(SendError::Retryable { delay_seconds, task }) => {
|
||||
Err(SendError::Retryable {
|
||||
delay_seconds,
|
||||
task,
|
||||
}) => {
|
||||
return Err(QueueError::Retryable {
|
||||
delay_seconds,
|
||||
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 {
|
||||
Ok(()) => Ok(()),
|
||||
Err(SendError::Retryable { delay_seconds, task }) => Err(QueueError::Retryable {
|
||||
Err(SendError::Retryable {
|
||||
delay_seconds,
|
||||
task,
|
||||
}) => Err(QueueError::Retryable {
|
||||
delay_seconds,
|
||||
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..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));
|
||||
assert!(
|
||||
chunk_media_items((0..25).collect())
|
||||
.iter()
|
||||
.all(|c| c.len() <= 9)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1175,7 +1235,10 @@ mod tests {
|
||||
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"] {
|
||||
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}");
|
||||
}
|
||||
@@ -1196,7 +1259,10 @@ mod tests {
|
||||
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"] {
|
||||
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}");
|
||||
}
|
||||
@@ -1205,9 +1271,16 @@ mod tests {
|
||||
#[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 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!(matches!(
|
||||
photo,
|
||||
MediaItemPayload::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!(forward_channel_id, Some(333));
|
||||
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:?}"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user