mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-23 23:32:05 +00:00
refactor: finish the phase-B seam for the post-send path, funnel settlement
docs/architecture-refactor.md §3 stopped half-done: `url_media` got an injected `AppContext`, but `send.rs`'s post-send half kept reaching for the process-wide `CHAT_STORE`/`TASK_QUEUE`/`LINK_CACHE` statics, so the whole shell after a successful send (edit-before-forward prompt, channel forward, retry enqueue, cache write) had no test and no way to get one. - `ctx.rs` now owns `AppContext` (sender + the three stores + config) with `from_statics` for production and a `CONTEXT` static for the spawned worker closures; `handlers/urls.rs` drops its private copy and the duplicated assembler, and the queue handler/dead-letter callbacks take the context (main wires them with `CONTEXT`). - `send_media_sequence`/`send_animation`/`forward_messages`/`post_send_actions` take `&AppContext`; the cache write goes through the injected cache. - New `settle_task(ctx, task, Sent|Failed)` is the single place that ends a task: release its keep-alive temp media, and drop the link-cache entry only on failure. All five former call sites funnel through it — the earlier keep-alive leak existed precisely because one of them had to remember. `invalidate_cache`/`invalidate_cache_with` (static + injected pair, the latter only existing because of the former) collapse into one private fn. - `ctx::test_support::TestStores` gives tests a tempdir store set + context; `handlers/urls.rs` tests use it instead of hand-rolled setup. Tests: +5 (post-send forward ok / queued / notified, settle Sent/Failed); the post-send and settle paths were previously untested. fmt/clippy clean, 60 + 69 tests pass.
This commit is contained in:
@@ -0,0 +1,114 @@
|
|||||||
|
//! Runtime context: the collaborators a handler needs, injected as one struct
|
||||||
|
//! so tests can substitute a scripted sender and tempdir-backed stores.
|
||||||
|
//!
|
||||||
|
//! The production context is assembled from the process-wide statics
|
||||||
|
//! ([`AppContext::from_statics`]); the spawned worker closures hold
|
||||||
|
//! [`CONTEXT`], which is `'static` for that reason.
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::handlers::{CHAT_STORE, CONFIG, LINK_CACHE, TASK_QUEUE};
|
||||||
|
use crate::link_cache::LinkCache;
|
||||||
|
use crate::media_sender::MediaSender;
|
||||||
|
use crate::queue::PersistentTaskQueue;
|
||||||
|
use crate::send::BOT;
|
||||||
|
use crate::state::ChatStore;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub struct AppContext<'a> {
|
||||||
|
pub sender: &'a dyn MediaSender,
|
||||||
|
pub chat_store: &'a ChatStore,
|
||||||
|
pub task_queue: &'a PersistentTaskQueue,
|
||||||
|
pub link_cache: &'a LinkCache,
|
||||||
|
pub config: &'a Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> AppContext<'a> {
|
||||||
|
/// The stores are the process-wide statics; `sender` is whatever the caller
|
||||||
|
/// was handed (the dispatcher's `Bot` clone for update handlers, the shared
|
||||||
|
/// queue `Bot` for the worker loops). Update handlers build their own
|
||||||
|
/// context from the `Bot` they received so the same code path works with an
|
||||||
|
/// injected mock in tests.
|
||||||
|
pub fn from_statics(sender: &'a dyn MediaSender) -> AppContext<'a> {
|
||||||
|
AppContext {
|
||||||
|
sender,
|
||||||
|
chat_store: &CHAT_STORE,
|
||||||
|
task_queue: &TASK_QUEUE,
|
||||||
|
link_cache: &LINK_CACHE,
|
||||||
|
config: &CONFIG,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The URL/queue workers' context: `'static` because `tokio::spawn`ed closures
|
||||||
|
/// and the queue's handler type require it.
|
||||||
|
pub static CONTEXT: LazyLock<AppContext<'static>> =
|
||||||
|
LazyLock::new(|| AppContext::from_statics(&*BOT));
|
||||||
|
|
||||||
|
/// Test support: a tempdir-backed set of stores plus the context borrowing
|
||||||
|
/// them, so a handler test needs one line of setup.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod test_support {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub(crate) struct TestStores {
|
||||||
|
_dir: tempfile::TempDir,
|
||||||
|
pool: Arc<crate::db::DbPool>,
|
||||||
|
chat_store: ChatStore,
|
||||||
|
task_queue: PersistentTaskQueue,
|
||||||
|
link_cache: LinkCache,
|
||||||
|
config: Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestStores {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let pool = crate::db::open_store(dir.path().join("ctx.db").to_str().unwrap()).unwrap();
|
||||||
|
TestStores {
|
||||||
|
_dir: dir,
|
||||||
|
chat_store: ChatStore::new(Arc::clone(&pool)),
|
||||||
|
task_queue: PersistentTaskQueue::new(Arc::clone(&pool)),
|
||||||
|
link_cache: LinkCache::new(Arc::clone(&pool)),
|
||||||
|
config: Config::load(),
|
||||||
|
pool,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn ctx<'a>(&'a self, sender: &'a dyn MediaSender) -> AppContext<'a> {
|
||||||
|
AppContext {
|
||||||
|
sender,
|
||||||
|
chat_store: &self.chat_store,
|
||||||
|
task_queue: &self.task_queue,
|
||||||
|
link_cache: &self.link_cache,
|
||||||
|
config: &self.config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn link_cache(&self) -> &LinkCache {
|
||||||
|
&self.link_cache
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rows persisted in the task queue: what "queued for retry" looks like
|
||||||
|
/// from the outside.
|
||||||
|
pub(crate) async fn queued_tasks(&self) -> i64 {
|
||||||
|
let pool = Arc::clone(&self.pool);
|
||||||
|
pool.with_conn(|conn| {
|
||||||
|
conn.query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get(0))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single queued task payload, for asserting what was rescheduled.
|
||||||
|
pub(crate) async fn queued_payload(&self) -> serde_json::Value {
|
||||||
|
let pool = Arc::clone(&self.pool);
|
||||||
|
let payload: String = pool
|
||||||
|
.with_conn(|conn| {
|
||||||
|
conn.query_row("SELECT payload FROM tasks LIMIT 1", [], |row| row.get(0))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
serde_json::from_str(&payload).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ pub async fn callback_query_handler(bot: Bot, query: CallbackQuery) -> Result<()
|
|||||||
chat_id
|
chat_id
|
||||||
);
|
);
|
||||||
if data == "forward" {
|
if data == "forward" {
|
||||||
|
let ctx = crate::ctx::AppContext::from_statics(&bot);
|
||||||
match chat_data.forward_channel_id {
|
match chat_data.forward_channel_id {
|
||||||
Some(channel_id) => {
|
Some(channel_id) => {
|
||||||
let forward_task = Task::ForwardMessages {
|
let forward_task = Task::ForwardMessages {
|
||||||
@@ -59,7 +60,7 @@ pub async fn callback_query_handler(bot: Bot, query: CallbackQuery) -> Result<()
|
|||||||
notify_chat_id: Some(chat_id),
|
notify_chat_id: Some(chat_id),
|
||||||
notify_message_id: Some(prompt_message_id),
|
notify_message_id: Some(prompt_message_id),
|
||||||
};
|
};
|
||||||
match send::forward_messages(&bot, &forward_task).await {
|
match send::forward_messages(&ctx, &forward_task).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
log::info!(
|
log::info!(
|
||||||
"forwarded {} message(s) to channel {channel_id}",
|
"forwarded {} message(s) to channel {channel_id}",
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
//! URL extraction and the per-URL media pipeline: bounded job channel +
|
//! URL extraction and the per-URL media pipeline: bounded job channel +
|
||||||
//! worker pool, link-cache fast path, fetch, task build and send dispatch.
|
//! worker pool, link-cache fast path, fetch, task build and send dispatch.
|
||||||
|
|
||||||
use super::{CHAT_STORE, CONFIG, LINK_CACHE, TASK_QUEUE, log_key, reply};
|
use super::{log_key, reply};
|
||||||
use crate::config::Config;
|
use crate::ctx::{AppContext, CONTEXT};
|
||||||
use crate::link_cache::{CachedMediaKind, CachedPost, LinkCache};
|
use crate::link_cache::{CachedMediaKind, CachedPost};
|
||||||
use crate::media_sender::MediaSender;
|
|
||||||
use crate::queue::PersistentTaskQueue;
|
|
||||||
use crate::send::{self, MediaItemPayload, Task};
|
use crate::send::{self, MediaItemPayload, Task};
|
||||||
use crate::state::{ChatData, ChatStore};
|
use crate::state::ChatData;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use teloxide::types::{ChatAction, ChatId, Message, MessageEntityKind, MessageId};
|
use teloxide::types::{ChatAction, ChatId, Message, MessageEntityKind, MessageId};
|
||||||
@@ -34,27 +32,6 @@ static URL_WORKER_HANDLES: LazyLock<parking_lot::Mutex<Option<Vec<tokio::task::J
|
|||||||
/// while bounding how many jobs can be queued at all.
|
/// while bounding how many jobs can be queued at all.
|
||||||
const URL_WORKERS: usize = 8;
|
const URL_WORKERS: usize = 8;
|
||||||
|
|
||||||
/// Dependencies of the per-URL pipeline, injected so tests can substitute a
|
|
||||||
/// mock sender and tempdir-backed stores.
|
|
||||||
pub(crate) struct AppContext<'a> {
|
|
||||||
pub sender: &'a dyn MediaSender,
|
|
||||||
pub chat_store: &'a ChatStore,
|
|
||||||
pub task_queue: &'a PersistentTaskQueue,
|
|
||||||
pub link_cache: &'a LinkCache,
|
|
||||||
pub config: &'a Config,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Assembles the production context from the process-wide statics.
|
|
||||||
fn app_context() -> AppContext<'static> {
|
|
||||||
AppContext {
|
|
||||||
sender: &*crate::send::BOT,
|
|
||||||
chat_store: &CHAT_STORE,
|
|
||||||
task_queue: &TASK_QUEUE,
|
|
||||||
link_cache: &LINK_CACHE,
|
|
||||||
config: &CONFIG,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Starts the URL job workers (called once from main after the queue starts).
|
/// Starts the URL job workers (called once from main after the queue starts).
|
||||||
/// teloxide dispatches updates to a per-chat worker that handles them
|
/// teloxide dispatches updates to a per-chat worker that handles them
|
||||||
/// sequentially, so a batch-forward of many messages would otherwise be
|
/// sequentially, so a batch-forward of many messages would otherwise be
|
||||||
@@ -69,12 +46,11 @@ pub async fn start_url_workers() {
|
|||||||
for _ in 0..URL_WORKERS {
|
for _ in 0..URL_WORKERS {
|
||||||
let rx = std::sync::Arc::clone(&rx);
|
let rx = std::sync::Arc::clone(&rx);
|
||||||
handles.push(tokio::spawn(async move {
|
handles.push(tokio::spawn(async move {
|
||||||
let ctx = app_context();
|
|
||||||
while !URL_STOP.load(std::sync::atomic::Ordering::Relaxed) {
|
while !URL_STOP.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
let job = rx.lock().await.recv().await;
|
let job = rx.lock().await.recv().await;
|
||||||
match job {
|
match job {
|
||||||
Some((message, url)) => {
|
Some((message, url)) => {
|
||||||
url_media(&ctx, message.chat.id.0, message.id.0 as i64, &url).await
|
url_media(&CONTEXT, message.chat.id.0, message.id.0 as i64, &url).await
|
||||||
}
|
}
|
||||||
None => break,
|
None => break,
|
||||||
}
|
}
|
||||||
@@ -184,8 +160,8 @@ async fn dispatch_send(
|
|||||||
url: &str,
|
url: &str,
|
||||||
) {
|
) {
|
||||||
let result = match task {
|
let result = match task {
|
||||||
Task::SendAnimation { .. } => send::send_animation(ctx.sender, task).await,
|
Task::SendAnimation { .. } => send::send_animation(ctx, task).await,
|
||||||
Task::SendMediaSequence { .. } => send::send_media_sequence(ctx.sender, task).await,
|
Task::SendMediaSequence { .. } => send::send_media_sequence(ctx, task).await,
|
||||||
Task::ForwardMessages { .. } => unreachable!(),
|
Task::ForwardMessages { .. } => unreachable!(),
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
@@ -195,9 +171,8 @@ async fn dispatch_send(
|
|||||||
message_ids.len(),
|
message_ids.len(),
|
||||||
log_key(url)
|
log_key(url)
|
||||||
);
|
);
|
||||||
send::post_send_actions(ctx.sender, task, message_ids).await;
|
send::post_send_actions(ctx, task, message_ids).await;
|
||||||
// The task settled: drop any keep-alive temp media.
|
send::settle_task(ctx, task, send::Settled::Sent).await;
|
||||||
send::release_keep_alive(task);
|
|
||||||
}
|
}
|
||||||
Err(send::SendError::Retryable {
|
Err(send::SendError::Retryable {
|
||||||
delay_seconds,
|
delay_seconds,
|
||||||
@@ -220,8 +195,7 @@ async fn dispatch_send(
|
|||||||
message: err_message,
|
message: err_message,
|
||||||
task,
|
task,
|
||||||
}) => {
|
}) => {
|
||||||
send::invalidate_cache_with(ctx.link_cache, &task).await;
|
send::settle_task(ctx, &task, send::Settled::Failed).await;
|
||||||
send::release_keep_alive(&task);
|
|
||||||
log::error!("send for {url} failed permanently: {err_message}");
|
log::error!("send for {url} failed permanently: {err_message}");
|
||||||
let _ = reply(
|
let _ = reply(
|
||||||
ctx.sender,
|
ctx.sender,
|
||||||
@@ -434,10 +408,9 @@ async fn url_media(ctx: &AppContext<'_>, chat_id: i64, reply_to_message_id: i64,
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db;
|
use crate::ctx::test_support::TestStores;
|
||||||
use crate::link_cache::CachedMedia;
|
use crate::link_cache::CachedMedia;
|
||||||
use crate::media_sender::test_support::{MockSender, Outcome};
|
use crate::media_sender::test_support::{MockSender, Outcome};
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use teloxide::{ApiError, RequestError};
|
use teloxide::{ApiError, RequestError};
|
||||||
|
|
||||||
@@ -465,24 +438,16 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cache_hit_sends_file_ids_and_invalidates_on_permanent_failure() {
|
async fn cache_hit_sends_file_ids_and_invalidates_on_permanent_failure() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let stores = TestStores::new();
|
||||||
let pool = db::open_store(dir.path().join("t.db").to_str().unwrap()).unwrap();
|
|
||||||
let chat_store = ChatStore::new(Arc::clone(&pool));
|
|
||||||
let task_queue = PersistentTaskQueue::new(Arc::clone(&pool));
|
|
||||||
let link_cache = LinkCache::new(Arc::clone(&pool));
|
|
||||||
let config = Config::load();
|
|
||||||
let sender = MockSender::scripted(
|
let sender = MockSender::scripted(
|
||||||
vec![Outcome::GroupErr, Outcome::MessageErr],
|
vec![Outcome::GroupErr, Outcome::MessageErr],
|
||||||
permanent_error,
|
permanent_error,
|
||||||
);
|
);
|
||||||
let ctx = AppContext {
|
let ctx = stores.ctx(&sender);
|
||||||
sender: &sender,
|
stores
|
||||||
chat_store: &chat_store,
|
.link_cache()
|
||||||
task_queue: &task_queue,
|
.put("twitter:1", &cached_photo_entry())
|
||||||
link_cache: &link_cache,
|
.await;
|
||||||
config: &config,
|
|
||||||
};
|
|
||||||
link_cache.put("twitter:1", &cached_photo_entry()).await;
|
|
||||||
|
|
||||||
url_media(&ctx, 1, 2, "https://x.com/u/status/1").await;
|
url_media(&ctx, 1, 2, "https://x.com/u/status/1").await;
|
||||||
|
|
||||||
@@ -494,7 +459,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
// The stale cache entry was invalidated so the next request re-fetches.
|
// The stale cache entry was invalidated so the next request re-fetches.
|
||||||
assert!(
|
assert!(
|
||||||
link_cache
|
stores
|
||||||
|
.link_cache()
|
||||||
.get("twitter:1", Duration::from_secs(3600))
|
.get("twitter:1", Duration::from_secs(3600))
|
||||||
.await
|
.await
|
||||||
.is_none()
|
.is_none()
|
||||||
@@ -503,28 +469,21 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cache_hit_success_keeps_the_cache_entry() {
|
async fn cache_hit_success_keeps_the_cache_entry() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let stores = TestStores::new();
|
||||||
let pool = db::open_store(dir.path().join("t.db").to_str().unwrap()).unwrap();
|
|
||||||
let chat_store = ChatStore::new(Arc::clone(&pool));
|
|
||||||
let task_queue = PersistentTaskQueue::new(Arc::clone(&pool));
|
|
||||||
let link_cache = LinkCache::new(Arc::clone(&pool));
|
|
||||||
let config = Config::load();
|
|
||||||
let sender = MockSender::scripted(vec![Outcome::GroupOk], permanent_error);
|
let sender = MockSender::scripted(vec![Outcome::GroupOk], permanent_error);
|
||||||
let ctx = AppContext {
|
let ctx = stores.ctx(&sender);
|
||||||
sender: &sender,
|
stores
|
||||||
chat_store: &chat_store,
|
.link_cache()
|
||||||
task_queue: &task_queue,
|
.put("twitter:1", &cached_photo_entry())
|
||||||
link_cache: &link_cache,
|
.await;
|
||||||
config: &config,
|
|
||||||
};
|
|
||||||
link_cache.put("twitter:1", &cached_photo_entry()).await;
|
|
||||||
|
|
||||||
url_media(&ctx, 1, 2, "https://x.com/u/status/1").await;
|
url_media(&ctx, 1, 2, "https://x.com/u/status/1").await;
|
||||||
|
|
||||||
assert_eq!(sender.calls(), vec!["send_chat_action", "send_media_group"]);
|
assert_eq!(sender.calls(), vec!["send_chat_action", "send_media_group"]);
|
||||||
// Success must not evict the entry.
|
// Success must not evict the entry.
|
||||||
assert!(
|
assert!(
|
||||||
link_cache
|
stores
|
||||||
|
.link_cache()
|
||||||
.get("twitter:1", Duration::from_secs(3600))
|
.get("twitter:1", Duration::from_secs(3600))
|
||||||
.await
|
.await
|
||||||
.is_some()
|
.is_some()
|
||||||
@@ -533,20 +492,9 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn unsupported_url_is_ignored_silently() {
|
async fn unsupported_url_is_ignored_silently() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let stores = TestStores::new();
|
||||||
let pool = db::open_store(dir.path().join("t.db").to_str().unwrap()).unwrap();
|
|
||||||
let chat_store = ChatStore::new(Arc::clone(&pool));
|
|
||||||
let task_queue = PersistentTaskQueue::new(Arc::clone(&pool));
|
|
||||||
let link_cache = LinkCache::new(Arc::clone(&pool));
|
|
||||||
let config = Config::load();
|
|
||||||
let sender = MockSender::scripted(vec![], permanent_error);
|
let sender = MockSender::scripted(vec![], permanent_error);
|
||||||
let ctx = AppContext {
|
let ctx = stores.ctx(&sender);
|
||||||
sender: &sender,
|
|
||||||
chat_store: &chat_store,
|
|
||||||
task_queue: &task_queue,
|
|
||||||
link_cache: &link_cache,
|
|
||||||
config: &config,
|
|
||||||
};
|
|
||||||
|
|
||||||
// No cache key → the fetch dispatcher returns Ok(None) without any
|
// No cache key → the fetch dispatcher returns Ok(None) without any
|
||||||
// network; nothing is sent or replied.
|
// network; nothing is sent or replied.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use tokio::sync::watch;
|
|||||||
use x_media::site;
|
use x_media::site;
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
|
mod ctx;
|
||||||
mod db;
|
mod db;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod link_cache;
|
mod link_cache;
|
||||||
@@ -18,6 +19,7 @@ mod rate_limit;
|
|||||||
mod send;
|
mod send;
|
||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
|
use ctx::CONTEXT;
|
||||||
use handlers::{CHAT_STORE, CONFIG, LINK_CACHE, TASK_QUEUE};
|
use handlers::{CHAT_STORE, CONFIG, LINK_CACHE, TASK_QUEUE};
|
||||||
|
|
||||||
/// Docker `stop` / `compose down` delivers SIGTERM, which teloxide's ctrlc
|
/// Docker `stop` / `compose down` delivers SIGTERM, which teloxide's ctrlc
|
||||||
@@ -61,9 +63,13 @@ async fn main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Queue worker: handles typed tasks, dead-letters failed sends to the
|
// Queue worker: handles typed tasks, dead-letters failed sends to the
|
||||||
// task's chat.
|
// task's chat. Both closures use the shared context (the queue requires
|
||||||
|
// 'static handlers, and the statics are process-wide anyway).
|
||||||
TASK_QUEUE
|
TASK_QUEUE
|
||||||
.start(send::handle_task, send::dead_letter_notify)
|
.start(
|
||||||
|
|payload| send::handle_task(&CONTEXT, payload),
|
||||||
|
|payload, message| send::dead_letter_notify(&CONTEXT, payload, message),
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
log::info!("task queue worker started");
|
log::info!("task queue worker started");
|
||||||
|
|
||||||
|
|||||||
+271
-66
@@ -3,8 +3,9 @@
|
|||||||
//! URL is blocked by hotlink protection; the bot downloads the file itself
|
//! URL is blocked by hotlink protection; the bot downloads the file itself
|
||||||
//! and uploads it via multipart).
|
//! and uploads it via multipart).
|
||||||
|
|
||||||
|
use crate::ctx::AppContext;
|
||||||
use crate::db::{now_f64, unix_now};
|
use crate::db::{now_f64, unix_now};
|
||||||
use crate::handlers::{CHAT_STORE, LINK_CACHE, TASK_QUEUE, log_key};
|
use crate::handlers::log_key;
|
||||||
use crate::link_cache::{CachedMedia, CachedMediaKind, CachedPost, LinkCache};
|
use crate::link_cache::{CachedMedia, CachedMediaKind, CachedPost, LinkCache};
|
||||||
use crate::media_sender::MediaSender;
|
use crate::media_sender::MediaSender;
|
||||||
use crate::photo::{self, MAX_UPLOAD_BYTES, PhotoPrep};
|
use crate::photo::{self, MAX_UPLOAD_BYTES, PhotoPrep};
|
||||||
@@ -223,7 +224,7 @@ fn collect_file_ids(messages: &[Message], batch: &[MediaItemPayload], out: &mut
|
|||||||
|
|
||||||
/// Persists a successful send under the post's cache key. Only runs for a
|
/// Persists a successful send under the post's cache key. Only runs for a
|
||||||
/// fresh (non-resumed) task that carried raw cache data with no file ids yet.
|
/// fresh (non-resumed) task that carried raw cache data with no file ids yet.
|
||||||
async fn cache_sent_task(task: &Task, media: Vec<CachedMedia>) {
|
async fn cache_sent_task(ctx: &AppContext<'_>, task: &Task, media: Vec<CachedMedia>) {
|
||||||
let Some(cache_data) = task.cache_data() else {
|
let Some(cache_data) = task.cache_data() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -233,15 +234,16 @@ async fn cache_sent_task(task: &Task, media: Vec<CachedMedia>) {
|
|||||||
let mut post = cache_data.clone();
|
let mut post = cache_data.clone();
|
||||||
post.media = media;
|
post.media = media;
|
||||||
if let Some(key) = x_media::site::cache_key(&post.url) {
|
if let Some(key) = x_media::site::cache_key(&post.url) {
|
||||||
LINK_CACHE.put(&key, &post).await;
|
ctx.link_cache.put(&key, &post).await;
|
||||||
log::debug!("cached send for [key={}]", log_key(&post.url));
|
log::debug!("cached send for [key={}]", log_key(&post.url));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persists a lone animation send under the post's cache key.
|
/// Persists a lone animation send under the post's cache key.
|
||||||
async fn cache_animation_send(task: &Task, message: &Message) {
|
async fn cache_animation_send(ctx: &AppContext<'_>, task: &Task, message: &Message) {
|
||||||
if let Some(file_id) = message.animation().map(|a| a.file.id.to_string()) {
|
if let Some(file_id) = message.animation().map(|a| a.file.id.to_string()) {
|
||||||
cache_sent_task(
|
cache_sent_task(
|
||||||
|
ctx,
|
||||||
task,
|
task,
|
||||||
vec![CachedMedia {
|
vec![CachedMedia {
|
||||||
kind: CachedMediaKind::Animation,
|
kind: CachedMediaKind::Animation,
|
||||||
@@ -252,14 +254,28 @@ async fn cache_animation_send(task: &Task, message: &Message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A cached Telegram file id failed permanently (stale/expired); drop the
|
/// How a task ended. The two states differ only in whether a link-cache entry
|
||||||
/// cache entry so the next request re-fetches instead of repeating it.
|
/// may still be holding the (now unusable) media.
|
||||||
pub async fn invalidate_cache(task: &Task) {
|
pub enum Settled {
|
||||||
invalidate_cache_with(&LINK_CACHE, task).await;
|
Sent,
|
||||||
|
Failed,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`invalidate_cache`] against an injected cache (tests pass a tempdir one).
|
/// Every path that ends a task's life — sent, permanently failed, or
|
||||||
pub async fn invalidate_cache_with(cache: &LinkCache, task: &Task) {
|
/// dead-lettered after the last retry — funnels through here, so the cleanup a
|
||||||
|
/// settled task owes cannot be forgotten by a new path: release the keep-alive
|
||||||
|
/// temp media (retryable tasks keep it, they will be resent) and drop the
|
||||||
|
/// link-cache entry that a failed send's stale file ids would keep poisoning.
|
||||||
|
pub async fn settle_task(ctx: &AppContext<'_>, task: &Task, outcome: Settled) {
|
||||||
|
if matches!(outcome, Settled::Failed) {
|
||||||
|
invalidate_cache(ctx.link_cache, task).await;
|
||||||
|
}
|
||||||
|
release_keep_alive(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cached Telegram file id failed permanently (stale/expired); drop the
|
||||||
|
/// cache entry so the next request re-fetches instead of repeating it.
|
||||||
|
async fn invalidate_cache(cache: &LinkCache, task: &Task) {
|
||||||
if task.is_cached_send()
|
if task.is_cached_send()
|
||||||
&& let Some(url) = task.source_url()
|
&& let Some(url) = task.source_url()
|
||||||
&& let Some(key) = x_media::site::cache_key(url)
|
&& let Some(key) = x_media::site::cache_key(url)
|
||||||
@@ -909,10 +925,7 @@ fn updated_sequence_task(task: &Task, batch_index: usize, sent_message_ids: Vec<
|
|||||||
/// Sends the media batches starting at `task.batch_index`, extending
|
/// Sends the media batches starting at `task.batch_index`, extending
|
||||||
/// `sent_message_ids`. Returns all sent message ids on full success; on
|
/// `sent_message_ids`. Returns all sent message ids on full success; on
|
||||||
/// failure returns a [`SendError`] whose task carries the resumed state.
|
/// failure returns a [`SendError`] whose task carries the resumed state.
|
||||||
pub async fn send_media_sequence(
|
pub async fn send_media_sequence(ctx: &AppContext<'_>, task: &Task) -> Result<Vec<i64>, SendError> {
|
||||||
sender: &dyn MediaSender,
|
|
||||||
task: &Task,
|
|
||||||
) -> Result<Vec<i64>, SendError> {
|
|
||||||
let Task::SendMediaSequence {
|
let Task::SendMediaSequence {
|
||||||
chat_id,
|
chat_id,
|
||||||
reply_to_message_id,
|
reply_to_message_id,
|
||||||
@@ -948,7 +961,8 @@ pub async fn send_media_sequence(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match sender
|
match ctx
|
||||||
|
.sender
|
||||||
.send_media_group(ChatId(chat_id), MessageId(reply_to as i32), items)
|
.send_media_group(ChatId(chat_id), MessageId(reply_to as i32), items)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -971,7 +985,7 @@ pub async fn send_media_sequence(
|
|||||||
.unwrap_or_else(|| "?".into())
|
.unwrap_or_else(|| "?".into())
|
||||||
);
|
);
|
||||||
match send_batch_via_upload(
|
match send_batch_via_upload(
|
||||||
sender,
|
ctx.sender,
|
||||||
chat_id,
|
chat_id,
|
||||||
reply_to,
|
reply_to,
|
||||||
batch,
|
batch,
|
||||||
@@ -997,7 +1011,7 @@ pub async fn send_media_sequence(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if fresh_send {
|
if fresh_send {
|
||||||
cache_sent_task(task, cached_media).await;
|
cache_sent_task(ctx, task, cached_media).await;
|
||||||
}
|
}
|
||||||
Ok(sent)
|
Ok(sent)
|
||||||
}
|
}
|
||||||
@@ -1022,7 +1036,7 @@ async fn send_animation_inner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sends a lone animation (gif), URL first with the download fallback.
|
/// Sends a lone animation (gif), URL first with the download fallback.
|
||||||
pub async fn send_animation(sender: &dyn MediaSender, task: &Task) -> Result<Vec<i64>, SendError> {
|
pub async fn send_animation(ctx: &AppContext<'_>, task: &Task) -> Result<Vec<i64>, SendError> {
|
||||||
let Task::SendAnimation {
|
let Task::SendAnimation {
|
||||||
chat_id,
|
chat_id,
|
||||||
reply_to_message_id,
|
reply_to_message_id,
|
||||||
@@ -1052,10 +1066,19 @@ pub async fn send_animation(sender: &dyn MediaSender, task: &Task) -> Result<Vec
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match send_animation_inner(sender, chat_id, reply_to, caption, has_spoiler, url_file).await {
|
match send_animation_inner(
|
||||||
|
ctx.sender,
|
||||||
|
chat_id,
|
||||||
|
reply_to,
|
||||||
|
caption,
|
||||||
|
has_spoiler,
|
||||||
|
url_file,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(message) => {
|
Ok(message) => {
|
||||||
let id = message.id.0 as i64;
|
let id = message.id.0 as i64;
|
||||||
cache_animation_send(task, &message).await;
|
cache_animation_send(ctx, task, &message).await;
|
||||||
Ok(vec![id])
|
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) => {
|
||||||
@@ -1079,7 +1102,7 @@ pub async fn send_animation(sender: &dyn MediaSender, task: &Task) -> Result<Vec
|
|||||||
// Hold the temp file until the request completes.
|
// Hold the temp file until the request completes.
|
||||||
let _keep_alive = keep_alive;
|
let _keep_alive = keep_alive;
|
||||||
match send_animation_inner(
|
match send_animation_inner(
|
||||||
sender,
|
ctx.sender,
|
||||||
chat_id,
|
chat_id,
|
||||||
reply_to,
|
reply_to,
|
||||||
caption,
|
caption,
|
||||||
@@ -1090,7 +1113,7 @@ pub async fn send_animation(sender: &dyn MediaSender, task: &Task) -> Result<Vec
|
|||||||
{
|
{
|
||||||
Ok(message) => {
|
Ok(message) => {
|
||||||
let id = message.id.0 as i64;
|
let id = message.id.0 as i64;
|
||||||
cache_animation_send(task, &message).await;
|
cache_animation_send(ctx, task, &message).await;
|
||||||
Ok(vec![id])
|
Ok(vec![id])
|
||||||
}
|
}
|
||||||
Err(e) => Err(classify_to_send_error(
|
Err(e) => Err(classify_to_send_error(
|
||||||
@@ -1113,7 +1136,7 @@ pub async fn send_animation(sender: &dyn MediaSender, task: &Task) -> Result<Vec
|
|||||||
|
|
||||||
/// Copies already-sent messages to the forward channel. No download fallback:
|
/// Copies already-sent messages to the forward channel. No download fallback:
|
||||||
/// the files are already on Telegram's servers.
|
/// the files are already on Telegram's servers.
|
||||||
pub async fn forward_messages(sender: &dyn MediaSender, task: &Task) -> Result<(), SendError> {
|
pub async fn forward_messages(ctx: &AppContext<'_>, task: &Task) -> Result<(), SendError> {
|
||||||
let Task::ForwardMessages {
|
let Task::ForwardMessages {
|
||||||
from_chat_id,
|
from_chat_id,
|
||||||
to_chat_id,
|
to_chat_id,
|
||||||
@@ -1127,7 +1150,8 @@ pub async fn forward_messages(sender: &dyn MediaSender, task: &Task) -> Result<(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|id| MessageId(*id as i32))
|
.map(|id| MessageId(*id as i32))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
match sender
|
match ctx
|
||||||
|
.sender
|
||||||
.copy_messages(
|
.copy_messages(
|
||||||
ChatId(*to_chat_id),
|
ChatId(*to_chat_id),
|
||||||
ChatId(*from_chat_id),
|
ChatId(*from_chat_id),
|
||||||
@@ -1192,7 +1216,7 @@ pub async fn notify_failure(
|
|||||||
|
|
||||||
/// After a successful send: either open the edit-before-forward prompt or
|
/// After a successful send: either open the edit-before-forward prompt or
|
||||||
/// forward to the configured channel (with retry/queue handling).
|
/// forward to the configured channel (with retry/queue handling).
|
||||||
pub async fn post_send_actions(sender: &dyn MediaSender, task: &Task, message_ids: Vec<i64>) {
|
pub async fn post_send_actions(ctx: &AppContext<'_>, task: &Task, message_ids: Vec<i64>) {
|
||||||
let (
|
let (
|
||||||
chat_id,
|
chat_id,
|
||||||
reply_to,
|
reply_to,
|
||||||
@@ -1234,8 +1258,9 @@ pub async fn post_send_actions(sender: &dyn MediaSender, task: &Task, message_id
|
|||||||
};
|
};
|
||||||
|
|
||||||
if edit_before_forward {
|
if edit_before_forward {
|
||||||
let keyboard = build_edit_markup(&CHAT_STORE.get(chat_id).await.template);
|
let keyboard = build_edit_markup(&ctx.chat_store.get(chat_id).await.template);
|
||||||
let prompt = sender
|
let prompt = ctx
|
||||||
|
.sender
|
||||||
.send_message(
|
.send_message(
|
||||||
ChatId(chat_id),
|
ChatId(chat_id),
|
||||||
"Reply to edit message.".to_string(),
|
"Reply to edit message.".to_string(),
|
||||||
@@ -1252,7 +1277,7 @@ pub async fn post_send_actions(sender: &dyn MediaSender, task: &Task, message_id
|
|||||||
);
|
);
|
||||||
let prompt_id = prompt.id.0 as i64;
|
let prompt_id = prompt.id.0 as i64;
|
||||||
let source_url = source_url.clone();
|
let source_url = source_url.clone();
|
||||||
CHAT_STORE
|
ctx.chat_store
|
||||||
.update(chat_id, move |data| {
|
.update(chat_id, move |data| {
|
||||||
data.edit_message.insert(
|
data.edit_message.insert(
|
||||||
prompt_id,
|
prompt_id,
|
||||||
@@ -1284,17 +1309,17 @@ pub async fn post_send_actions(sender: &dyn MediaSender, task: &Task, message_id
|
|||||||
notify_chat_id,
|
notify_chat_id,
|
||||||
notify_message_id,
|
notify_message_id,
|
||||||
};
|
};
|
||||||
match forward_messages(sender, &forward_task).await {
|
match forward_messages(ctx, &forward_task).await {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(SendError::Retryable {
|
Err(SendError::Retryable {
|
||||||
delay_seconds,
|
delay_seconds,
|
||||||
task,
|
task,
|
||||||
}) => {
|
}) => {
|
||||||
enqueue_retry(&TASK_QUEUE, *task, delay_seconds).await;
|
enqueue_retry(ctx.task_queue, *task, delay_seconds).await;
|
||||||
}
|
}
|
||||||
Err(SendError::Permanent { message, .. }) => {
|
Err(SendError::Permanent { message, .. }) => {
|
||||||
notify_failure(
|
notify_failure(
|
||||||
sender,
|
ctx.sender,
|
||||||
notify_chat_id,
|
notify_chat_id,
|
||||||
notify_message_id,
|
notify_message_id,
|
||||||
&format!("Task failed after retries: {message}"),
|
&format!("Task failed after retries: {message}"),
|
||||||
@@ -1318,7 +1343,10 @@ pub async fn enqueue_retry(queue: &PersistentTaskQueue, task: Task, delay_second
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Queue entry point: parses the stored task and dispatches.
|
/// Queue entry point: parses the stored task and dispatches.
|
||||||
pub async fn handle_task(payload: serde_json::Value) -> Result<(), QueueError> {
|
pub async fn handle_task(
|
||||||
|
ctx: &AppContext<'_>,
|
||||||
|
payload: serde_json::Value,
|
||||||
|
) -> Result<(), QueueError> {
|
||||||
let task: Task = match serde_json::from_value(payload.clone()) {
|
let task: Task = match serde_json::from_value(payload.clone()) {
|
||||||
Ok(task) => task,
|
Ok(task) => task,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1328,10 +1356,9 @@ pub async fn handle_task(payload: serde_json::Value) -> Result<(), QueueError> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let bot = BOT.clone();
|
|
||||||
match task {
|
match task {
|
||||||
Task::SendMediaSequence { .. } | Task::SendAnimation { .. } => {
|
Task::SendMediaSequence { .. } | Task::SendAnimation { .. } => {
|
||||||
let message_ids = match send_media_or_animation(&bot, &task).await {
|
let message_ids = match send_media_or_animation(ctx, &task).await {
|
||||||
Ok(ids) => ids,
|
Ok(ids) => ids,
|
||||||
Err(SendError::Retryable {
|
Err(SendError::Retryable {
|
||||||
delay_seconds,
|
delay_seconds,
|
||||||
@@ -1343,9 +1370,7 @@ pub async fn handle_task(payload: serde_json::Value) -> Result<(), QueueError> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(SendError::Permanent { message, task }) => {
|
Err(SendError::Permanent { message, task }) => {
|
||||||
invalidate_cache(&task).await;
|
settle_task(ctx, &task, Settled::Failed).await;
|
||||||
// The task settles here: drop any keep-alive temp media.
|
|
||||||
release_keep_alive(&task);
|
|
||||||
return Err(QueueError::Permanent {
|
return Err(QueueError::Permanent {
|
||||||
message,
|
message,
|
||||||
payload: serde_json::to_value(task).expect("task serializes"),
|
payload: serde_json::to_value(task).expect("task serializes"),
|
||||||
@@ -1359,11 +1384,11 @@ pub async fn handle_task(payload: serde_json::Value) -> Result<(), QueueError> {
|
|||||||
// whole sequence (every batch) completed, so the channel forward
|
// whole sequence (every batch) completed, so the channel forward
|
||||||
// and the edit-before-forward prompt must not be lost just
|
// and the edit-before-forward prompt must not be lost just
|
||||||
// because the send needed a retry.
|
// because the send needed a retry.
|
||||||
post_send_actions(&bot, &task, message_ids).await;
|
post_send_actions(ctx, &task, message_ids).await;
|
||||||
release_keep_alive(&task);
|
settle_task(ctx, &task, Settled::Sent).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Task::ForwardMessages { .. } => match forward_messages(&bot, &task).await {
|
Task::ForwardMessages { .. } => match forward_messages(ctx, &task).await {
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
Err(SendError::Retryable {
|
Err(SendError::Retryable {
|
||||||
delay_seconds,
|
delay_seconds,
|
||||||
@@ -1373,7 +1398,7 @@ pub async fn handle_task(payload: serde_json::Value) -> Result<(), QueueError> {
|
|||||||
payload: serde_json::to_value(task).expect("task serializes"),
|
payload: serde_json::to_value(task).expect("task serializes"),
|
||||||
}),
|
}),
|
||||||
Err(SendError::Permanent { message, task }) => {
|
Err(SendError::Permanent { message, task }) => {
|
||||||
release_keep_alive(&task);
|
settle_task(ctx, &task, Settled::Failed).await;
|
||||||
Err(QueueError::Permanent {
|
Err(QueueError::Permanent {
|
||||||
message,
|
message,
|
||||||
payload: serde_json::to_value(task).expect("task serializes"),
|
payload: serde_json::to_value(task).expect("task serializes"),
|
||||||
@@ -1383,43 +1408,39 @@ pub async fn handle_task(payload: serde_json::Value) -> Result<(), QueueError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_media_or_animation(
|
async fn send_media_or_animation(ctx: &AppContext<'_>, task: &Task) -> Result<Vec<i64>, SendError> {
|
||||||
sender: &dyn MediaSender,
|
|
||||||
task: &Task,
|
|
||||||
) -> Result<Vec<i64>, SendError> {
|
|
||||||
match task {
|
match task {
|
||||||
Task::SendMediaSequence { .. } => send_media_sequence(sender, task).await,
|
Task::SendMediaSequence { .. } => send_media_sequence(ctx, task).await,
|
||||||
Task::SendAnimation { .. } => send_animation(sender, task).await,
|
Task::SendAnimation { .. } => send_animation(ctx, task).await,
|
||||||
Task::ForwardMessages { .. } => unreachable!(),
|
Task::ForwardMessages { .. } => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dead-letter callback wired to the queue in main: notifies the task's chat.
|
/// Dead-letter callback wired to the queue in main: settles the task and
|
||||||
pub async fn dead_letter_notify(payload: serde_json::Value, message: String) {
|
/// notifies its chat.
|
||||||
// A dead-lettered task never runs again. The queue dead-letters retry
|
pub async fn dead_letter_notify(ctx: &AppContext<'_>, payload: serde_json::Value, message: String) {
|
||||||
// exhaustion itself (the handler is not called again), so this is the
|
// A dead-lettered task never runs again, and the queue dead-letters retry
|
||||||
// only place that sees the final payload — release the keep-alive temp
|
// exhaustion itself (the handler is not called again), so this is the only
|
||||||
// media the fetch pipeline handed over, or it lives until process exit.
|
// place that sees the final payload.
|
||||||
if let Ok(task) = serde_json::from_value::<Task>(payload.clone()) {
|
if let Ok(task) = serde_json::from_value::<Task>(payload.clone()) {
|
||||||
release_keep_alive(&task);
|
settle_task(ctx, &task, Settled::Failed).await;
|
||||||
}
|
}
|
||||||
let notify_chat_id = payload.get("notify_chat_id").and_then(|v| v.as_i64());
|
let notify_chat_id = payload.get("notify_chat_id").and_then(|v| v.as_i64());
|
||||||
let notify_message_id = payload.get("notify_message_id").and_then(|v| v.as_i64());
|
let notify_message_id = payload.get("notify_message_id").and_then(|v| v.as_i64());
|
||||||
if notify_chat_id.is_some() {
|
|
||||||
let bot = BOT.clone();
|
|
||||||
notify_failure(
|
notify_failure(
|
||||||
&bot,
|
ctx.sender,
|
||||||
notify_chat_id,
|
notify_chat_id,
|
||||||
notify_message_id,
|
notify_message_id,
|
||||||
&format!("Task failed after retries: {message}"),
|
&format!("Task failed after retries: {message}"),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::ctx::test_support::TestStores;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn oversized_photo_boundary() {
|
fn oversized_photo_boundary() {
|
||||||
@@ -1734,8 +1755,10 @@ mod tests {
|
|||||||
vec![Outcome::GroupErr, Outcome::GroupErr],
|
vec![Outcome::GroupErr, Outcome::GroupErr],
|
||||||
media_fetch_error,
|
media_fetch_error,
|
||||||
);
|
);
|
||||||
|
let stores = TestStores::new();
|
||||||
|
let ctx = stores.ctx(&sender);
|
||||||
let task = sequence_task(file.to_str().unwrap());
|
let task = sequence_task(file.to_str().unwrap());
|
||||||
let result = send_media_sequence(&sender, &task).await;
|
let result = send_media_sequence(&ctx, &task).await;
|
||||||
assert!(
|
assert!(
|
||||||
matches!(result, Err(SendError::Permanent { .. })),
|
matches!(result, Err(SendError::Permanent { .. })),
|
||||||
"got {result:?}"
|
"got {result:?}"
|
||||||
@@ -1755,8 +1778,10 @@ mod tests {
|
|||||||
let sender = MockSender::scripted(vec![Outcome::GroupErr], || {
|
let sender = MockSender::scripted(vec![Outcome::GroupErr], || {
|
||||||
RequestError::RetryAfter(Seconds::from_seconds(7))
|
RequestError::RetryAfter(Seconds::from_seconds(7))
|
||||||
});
|
});
|
||||||
|
let stores = TestStores::new();
|
||||||
|
let ctx = stores.ctx(&sender);
|
||||||
let task = sequence_task(file.to_str().unwrap());
|
let task = sequence_task(file.to_str().unwrap());
|
||||||
let result = send_media_sequence(&sender, &task).await;
|
let result = send_media_sequence(&ctx, &task).await;
|
||||||
match result {
|
match result {
|
||||||
Err(SendError::Retryable { delay_seconds, .. }) => {
|
Err(SendError::Retryable { delay_seconds, .. }) => {
|
||||||
assert_eq!(delay_seconds, 7.0)
|
assert_eq!(delay_seconds, 7.0)
|
||||||
@@ -1791,7 +1816,9 @@ mod tests {
|
|||||||
notify_message_id: Some(2),
|
notify_message_id: Some(2),
|
||||||
cache_data: None,
|
cache_data: None,
|
||||||
};
|
};
|
||||||
let result = send_animation(&sender, &task).await;
|
let stores = TestStores::new();
|
||||||
|
let ctx = stores.ctx(&sender);
|
||||||
|
let result = send_animation(&ctx, &task).await;
|
||||||
assert!(
|
assert!(
|
||||||
matches!(result, Err(SendError::Permanent { .. })),
|
matches!(result, Err(SendError::Permanent { .. })),
|
||||||
"got {result:?}"
|
"got {result:?}"
|
||||||
@@ -1807,11 +1834,14 @@ mod tests {
|
|||||||
let file = dir.path().join("media.jpg");
|
let file = dir.path().join("media.jpg");
|
||||||
std::fs::write(&file, b"not-a-real-jpeg").unwrap();
|
std::fs::write(&file, b"not-a-real-jpeg").unwrap();
|
||||||
let sender = MockSender::scripted(vec![Outcome::GroupOk], media_fetch_error);
|
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 task = sequence_task(file.to_str().unwrap());
|
||||||
let result = send_media_sequence(&sender, &task).await;
|
let result = send_media_sequence(&ctx, &task).await;
|
||||||
assert!(result.is_ok(), "got {result:?}");
|
assert!(result.is_ok(), "got {result:?}");
|
||||||
|
|
||||||
let sender = MockSender::scripted(vec![Outcome::CopyOk], media_fetch_error);
|
let sender = MockSender::scripted(vec![Outcome::CopyOk], media_fetch_error);
|
||||||
|
let ctx = stores.ctx(&sender);
|
||||||
let task = Task::ForwardMessages {
|
let task = Task::ForwardMessages {
|
||||||
from_chat_id: 1,
|
from_chat_id: 1,
|
||||||
to_chat_id: 2,
|
to_chat_id: 2,
|
||||||
@@ -1819,7 +1849,7 @@ mod tests {
|
|||||||
notify_chat_id: None,
|
notify_chat_id: None,
|
||||||
notify_message_id: None,
|
notify_message_id: None,
|
||||||
};
|
};
|
||||||
assert!(forward_messages(&sender, &task).await.is_ok());
|
assert!(forward_messages(&ctx, &task).await.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1836,7 +1866,9 @@ mod tests {
|
|||||||
let sender = MockSender::scripted(vec![Outcome::CopyErr], || {
|
let sender = MockSender::scripted(vec![Outcome::CopyErr], || {
|
||||||
RequestError::RetryAfter(Seconds::from_seconds(7))
|
RequestError::RetryAfter(Seconds::from_seconds(7))
|
||||||
});
|
});
|
||||||
match forward_messages(&sender, &task).await {
|
let stores = TestStores::new();
|
||||||
|
let ctx = stores.ctx(&sender);
|
||||||
|
match forward_messages(&ctx, &task).await {
|
||||||
Err(SendError::Retryable { delay_seconds, .. }) => {
|
Err(SendError::Retryable { delay_seconds, .. }) => {
|
||||||
assert_eq!(delay_seconds, 7.0)
|
assert_eq!(delay_seconds, 7.0)
|
||||||
}
|
}
|
||||||
@@ -1848,8 +1880,9 @@ mod tests {
|
|||||||
"Bad Request: message is not modified".into(),
|
"Bad Request: message is not modified".into(),
|
||||||
))
|
))
|
||||||
});
|
});
|
||||||
|
let ctx = stores.ctx(&sender);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
forward_messages(&sender, &task).await,
|
forward_messages(&ctx, &task).await,
|
||||||
Err(SendError::Permanent { .. })
|
Err(SendError::Permanent { .. })
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1876,12 +1909,184 @@ mod tests {
|
|||||||
let dir_path = dir.path().to_path_buf();
|
let dir_path = dir.path().to_path_buf();
|
||||||
KEEP_ALIVE.lock().push(dir);
|
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();
|
let payload = serde_json::to_value(&task).unwrap();
|
||||||
dead_letter_notify(payload, "task failed after 2 retries".into()).await;
|
dead_letter_notify(&ctx, payload, "task failed after 2 retries".into()).await;
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!KEEP_ALIVE.lock().iter().any(|dir| dir.path() == dir_path),
|
!KEEP_ALIVE.lock().iter().any(|dir| dir.path() == dir_path),
|
||||||
"dead-lettered task kept its temp media alive"
|
"dead-lettered task kept its temp media alive"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user