diff --git a/AGENTS.md b/AGENTS.md index 164463f..b090687 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ The `x-media` library: `site::fetch(url)` dispatches through the `SITES` registr | `crates/xmedia-bot/src/main.rs` | Entry point: env/log init, command registration (`register_commands` — `setMyCommands` plus the profile description texts), shared `send::BOT` force-init, startup sweep of this project's leftover temp files (`x_media::TEMP_FILE_PREFIX` + an age gate, since a killed process runs no destructors), startup repair of queued retries whose local media did not survive a restart (`handlers::repair_lost_local_media`, before any worker can lease: those rows are re-fetched from their `source_url`), queue worker start, site login validation (`site::validate_all`), `periodic_sweep` (`SWEEP_INTERVAL` 300 s): expired prompts are rewritten in place to `EDIT_PROMPT_EXPIRED_TEXT` with an empty keyboard — an edit, never a new message, so a background timer cannot wake a chat — plus the link-cache prune, the idle rate-limit buckets and the idle inline-query entries, and the queue backlog line (only when non-empty). Takes its collaborators rather than the statics so its loop is testable with a paused clock, dptree handler tree, webhook vs polling dispatch | | `crates/xmedia-bot/src/config.rs` | Manual env parsing into `Config` | | `crates/xmedia-bot/src/db.rs` | `DbPool`: one shared SQLite connection pool (`POOL_SIZE = 16`, WAL, busy_timeout) for all three tables over `$DATA_DIR/task_queue.db` (default `data/`) — the three stores share it; `open_store` creates file + schema and then applies the `PRAGMA user_version` migration chain (`MIGRATIONS` + `migrate` — append-only; `schema_init` is the version-0 baseline and must not gain columns an existing database would never receive — `db.rs`'s tests pin a pre-migration database upgrading intact, the shipped migration text frozen (appending is the only allowed change) and a fresh database landing at the latest version), `with_conn` runs all rusqlite I/O in `spawn_blocking` | -| `crates/xmedia-bot/src/handlers/` | Handler modules: `mod.rs` (message entry point, `reply`, `log_key`, the group-only `GROUP_LINK_HINT` for a supported link posted outside a private chat), `url_workers.rs`/`repair.rs` (worker pool; startup repair), `commands.rs` (teloxide `BotCommands` enum + command executor, incl. `/test ` (send-only) / `/debug ` (parse-only) and the admin-only `/bot_dict` state dump; `/set_format` rejects unknown `{…}` placeholders and resets with `-`), `urls.rs` (URL extraction + the per-URL pipeline; one *shared* in-flight fetch per cache key (`shared_fetch`+`IN_FLIGHT_FETCHES`: a second chat, a batch forward or a retry asking for the same post meanwhile waits for the first caller's result, the entry is dropped the moment the fetch settles so nothing is ever answered from an old fetch, and a waiter whose sharer was cancelled fetches for itself); plus the startup repair `repair_lost_local_media`, whose decision (`needs_refetch`) and rewrite (`apply_refresh`) are pure and tested while the fetch itself is a live test), `url_workers.rs` (the bounded job channel (256) and its `URL_WORKERS = 8` supervised workers, `start_url_workers`/`stop_url_workers` — backpressure instead of unbounded spawns; teloxide's per-chat workers are sequential, so batch-forwards need this concurrency), `repair.rs` (startup `repair_lost_local_media` with its `needs_refetch`/`apply_refresh`/`refetch`), `inline.rs`/`callback.rs` (inline queries / edit-before-forward buttons, incl. `skip`; a forward that fails retryably is both queued *and* settles the prompt — the queued row carries the message ids itself, and a prompt left live let a second Confirm copy the same messages twice and let Skip answer "nothing was forwarded" while the row still delivered), `statics.rs` (global statics) | +| `crates/xmedia-bot/src/handlers/` | Handler modules: `mod.rs` (message entry point, `reply`, `log_key`, the group-only `GROUP_LINK_HINT` for a supported link posted outside a private chat), `url_workers.rs`/`repair.rs` (worker pool; startup repair), `commands.rs` (teloxide `BotCommands` enum + command executor, incl. `/test ` (send-only) / `/debug ` (parse-only) and the admin-only `/bot_dict` state dump; `/set_format` rejects unknown `{…}` placeholders and resets with `-`), `urls.rs` (URL extraction + the per-URL pipeline; one *shared* in-flight fetch per cache key (`shared_fetch`+`IN_FLIGHT_FETCHES`: a second chat, a batch forward or a retry asking for the same post meanwhile waits for the first caller's result, the entry is dropped the moment the fetch settles so nothing is ever answered from an old fetch, and a waiter whose sharer was cancelled fetches for itself); plus the startup repair `repair_lost_local_media`, whose decision (`needs_refetch`) and rewrite (`apply_refresh`) are pure and tested while the fetch itself is a live test), `url_workers.rs` (the bounded job channel (256) and its `URL_WORKERS = 8` supervised workers, `start_url_workers`/`stop_url_workers` — backpressure instead of unbounded spawns; teloxide's per-chat workers are sequential, so batch-forwards need this concurrency), `repair.rs` (startup `repair_lost_local_media` with its `needs_refetch`/`apply_refresh`/`refetch`), `inline.rs`/`callback.rs` (inline queries / edit-before-forward buttons, incl. `skip`; a forward that fails retryably is both queued *and* settles the prompt — the queued row carries the message ids itself, and a prompt left live let a second Confirm copy the same messages twice and let Skip answer "nothing was forwarded" while the row still delivered; a Permanent failure that already copied a batch settles the prompt too, since a second Confirm would rebuild the task from offset 0 and re-copy those messages, while one that copied nothing keeps it live for a retry), `statics.rs` (global statics) | | `crates/xmedia-bot/src/state.rs` | `ChatStore`: parking_lot `Mutex` cache + SQLite write-through (`chat_state` table); the 300 s sweep's `prune_expired` evicts any chat with no live edit-before-forward prompt, so the cache (and the per-chat lock map) stays bounded to active prompts — durable settings reload from the DB on next use; `get` degrades a failed read to defaults for display/control flow, while `try_get` is the strict read the send pipeline uses — an unreadable row drops the send (and tells the user) instead of silently ignoring the chat's forward settings and persisting those defaults into the retry payload | | `crates/xmedia-bot/src/link_cache.rs` | `LinkCache`: SQLite-backed cache (`link_cache` table) of successfully sent posts — raw caption fields + the source media URLs + Telegram `file_id`s; repeat links re-send locally (no fetch/upload), TTL + prune; a permanent send failure *degrades* the entry instead of dropping it (the file ids go, the URLs stay, so the next request re-sends from those without a fetch), and a degraded entry that fails again is removed | | `crates/xmedia-bot/src/queue.rs` | `PersistentTaskQueue`: SQLite-backed queue (`tasks` table), `QUEUE_WORKERS = 4` concurrent workers (lease via `BEGIN IMMEDIATE` + `locked_until` TTL), retry→dead-letter, a `lease_token` fence: `lease_next` stamps a random token and every write-back (heartbeat, `delete`, `reschedule`, `mark_done`) is guarded by it, so a lease that expired and was re-leased cannot be written by its former holder — a lost lease stops the attempt instead; a finished row's `DELETE`/reschedule retried and a failed delete falling back to a `done` tombstone (the lease query and the sweep only look at `pending`/`in_progress`, so a task that already ran cannot be resurrected and re-run), `runnable_rows`/`replace_payload` (the startup repair's read/rewrite path: it runs before the workers exist, which is why it needs no lease token), `notify_one` worker wakeup plus a separate `Notify` for the 30 s lease-expiry sweep (a shared one let the sweep steal the workers' wakeup permit; the sweep does notify the workers after it actually recovered a row, since a recovered task is due immediately while every worker may be parked on `notify` with no pending row to sleep on), `busy_timeout` on all connections | diff --git a/crates/xmedia-bot/src/handlers/callback.rs b/crates/xmedia-bot/src/handlers/callback.rs index b4b7814..afaaa4d 100644 --- a/crates/xmedia-bot/src/handlers/callback.rs +++ b/crates/xmedia-bot/src/handlers/callback.rs @@ -142,9 +142,23 @@ async fn handle_callback( ) } } - Err(send::SendError::Permanent { message, .. }) => { + Err(send::SendError::Permanent { message, task }) => { log::error!("forward failed permanently: {message}"); - (format!("Forward failed: {message}"), false) + // `forward_offset` is where the failed batch began, so a + // non-zero offset means earlier messages already reached + // the channel. Keeping the prompt live there let a second + // Confirm rebuild the task from offset 0 and copy those + // messages a second time. + let partial = matches!( + *task, + Task::ForwardMessages { forward_offset, .. } if forward_offset > 0 + ); + let answer = if partial { + format!("Forward failed partway: {message}") + } else { + format!("Forward failed: {message}") + }; + (answer, partial) } }; if settled { @@ -231,7 +245,9 @@ async fn handle_callback( #[cfg(test)] mod tests { use super::*; - use crate::ctx::test_support::{FORWARDED_ID, PROMPT_ID, TestStores, api_error, seed_prompt}; + use crate::ctx::test_support::{ + FORWARDED_ID, PROMPT_ID, TestStores, api_error, permanent_error, seed_prompt, + }; use crate::media_sender::test_support::{MockSender, Outcome}; /// The Telegram wording the mocks answer with: a chat the bot cannot reach. @@ -430,6 +446,86 @@ mod tests { assert_eq!(stores.queued_tasks().await, 1, "no second forward row"); } + /// A Permanent forward failure that already copied some batches must settle + /// the prompt: leaving it live rebuilt the task from offset 0 on a second + /// Confirm and copied those messages to the channel a second time. + #[tokio::test] + async fn a_partial_permanent_forward_settles_the_prompt() { + let sender = MockSender::scripted(vec![Outcome::CopyOk, Outcome::CopyErr], permanent_error); + let stores = TestStores::new(); + let ctx = stores.ctx(&sender); + seed_prompt(&ctx, "", crate::db::unix_now()).await; + // Two copy batches: the first (100) succeeds, the second fails, so the + // failed task's offset is non-zero — a partial copy reached the channel. + ctx.chat_store + .update(1, |data| { + data.edit_message + .get_mut(&PROMPT_ID) + .unwrap() + .forward_message_ids = (1..=150).collect(); + }) + .await + .unwrap(); + + handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await; + + assert_eq!( + sender.calls(), + vec![ + "copy_messages", + "copy_messages", + "delete_message", + "answer_callback_query" + ] + ); + let answer = sender.answers()[0].clone().unwrap(); + assert!(answer.starts_with("Forward failed partway"), "{answer}"); + assert!( + !ctx.chat_store + .get(1) + .await + .edit_message + .contains_key(&PROMPT_ID), + "a partial copy must close the prompt so it cannot re-copy from 0" + ); + + // A second Confirm finds no record: it cannot copy the first batch again. + handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await; + let copies = sender + .calls() + .iter() + .filter(|c| **c == "copy_messages") + .count(); + assert_eq!(copies, 2, "the second Confirm must not copy again"); + } + + /// A Permanent failure on the first batch copied nothing, so the prompt + /// stays live for the user to retry. + #[tokio::test] + async fn a_permanent_forward_that_copied_nothing_keeps_the_prompt() { + let sender = MockSender::scripted(vec![Outcome::CopyErr], permanent_error); + let stores = TestStores::new(); + let ctx = stores.ctx(&sender); + seed_prompt(&ctx, "", crate::db::unix_now()).await; + + handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await; + + assert_eq!( + sender.calls(), + vec!["copy_messages", "answer_callback_query"] + ); + let answer = sender.answers()[0].clone().unwrap(); + assert!(answer.starts_with("Forward failed: "), "{answer}"); + assert!( + ctx.chat_store + .get(1) + .await + .edit_message + .contains_key(&PROMPT_ID), + "nothing was copied, so the prompt may be retried" + ); + } + #[tokio::test] async fn unknown_and_expired_prompts_answer_expired() { let sender = MockSender::scripted(vec![], || api_error(API_ERROR));