fix(settings): drop a link send whose chat settings could not be read

The pipeline read settings through get(), which degrades a failed read to
defaults. A send then went out with the chat's channel forward and
edit-before-forward silently ignored, and the task it built carried those
defaults into the retry payload. try_get() is the strict read; an
unreadable row now drops the send and tells the user.
This commit is contained in:
2026-10-14 22:10:36 +08:00
parent 3a944c1361
commit 2ffe647939
3 changed files with 62 additions and 3 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ The `x-media` library: `site::fetch(url)` dispatches through the `SITES` registr
| `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 <url>` (send-only) / `/debug <url>` (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/state.rs` | `ChatStore`: parking_lot `Mutex<HashMap>` 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 |
| `crates/xmedia-bot/src/state.rs` | `ChatStore`: parking_lot `Mutex<HashMap>` 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 |
| `crates/xmedia-bot/src/ctx.rs` | `AppContext`: the injected collaborators (`sender` + `ChatStore`/`PersistentTaskQueue`/`LinkCache`/`Config`), `from_statics` for production and the `CONTEXT` static the worker closures hold. `test_support::TestStores` backs handler tests with a tempdir store set, and the module also carries the fixtures those tests share — the canonical cached post (`cached_photo`), the edit-before-forward prompt (`seed_prompt` with its `PROMPT_ID`/`FORWARDED_ID`) and a scripted API error (`api_error`) — so no two test modules keep their own copies |
+53 -2
View File
@@ -543,6 +543,31 @@ fn site_title(site: &str) -> String {
}
}
/// The chat's settings, or `None` after telling the user the send was dropped
/// because they could not be read. Falling back to defaults silently dropped
/// the chat's channel forward / edit-before-forward, and wrote those defaults
/// into the retry payload the task carries.
async fn settings_or_report(
ctx: &AppContext<'_>,
chat_id: i64,
reply_to: MessageId,
) -> Option<ChatData> {
match ctx.chat_store.try_get(chat_id).await {
Ok(data) => Some(data),
Err(()) => {
log::error!("chat settings read failed for chat={chat_id}; not sending");
let _ = reply(
ctx.sender,
chat_id,
reply_to,
"Couldn't read this chat's settings. Please try again.",
)
.await;
None
}
}
}
#[allow(clippy::too_many_arguments)]
async fn url_media_inner(
ctx: &AppContext<'_>,
@@ -564,7 +589,9 @@ async fn url_media_inner(
&& let Some(cached) = ctx.link_cache.get(&key, ctx.config.link_cache_ttl).await
{
log::debug!("link cache hit for {key}");
let chat_data = ctx.chat_store.get(chat_id).await;
let Some(chat_data) = settings_or_report(ctx, chat_id, reply_to).await else {
return;
};
// Cache keys are prefixed with the site id ("twitter:…"), matching
// the value a fresh fetch would read from Fetched::site_id. The key
// came out of cache_key, so its prefix is a registered id by
@@ -633,7 +660,9 @@ async fn url_media_inner(
let _ = reply(ctx.sender, chat_id, reply_to, fetch_error_message(e)).await;
}
Ok(Some(fetched)) => {
let chat_data = ctx.chat_store.get(chat_id).await;
let Some(chat_data) = settings_or_report(ctx, chat_id, reply_to).await else {
return;
};
// Per-site caption format override (empty -> built-in caption).
let format = chat_data.format_for(fetched.site_id);
if fetched.media.is_empty() {
@@ -752,6 +781,28 @@ mod tests {
assert!(entry.media[0].file_id.is_empty());
}
/// A settings read failure must drop the send, not fall back to defaults:
/// sending anyway silently ignored the chat's channel forward and
/// edit-before-forward, and wrote those defaults into the retry payload.
#[tokio::test]
async fn an_unreadable_settings_row_drops_the_send_and_tells_the_user() {
let stores = TestStores::new();
let sender = MockSender::scripted(vec![Outcome::MessageOk], permanent_error);
let ctx = stores.ctx(&sender);
stores.link_cache().put("twitter:1", &cached_photo()).await;
// Force the read to fail (the missing-table shape `state.rs` uses).
let raw = rusqlite::Connection::open(stores.db_path()).unwrap();
raw.execute_batch("DROP TABLE chat_state").unwrap();
url_media(&ctx, 1, 2, "https://x.com/u/status/1", PostSend::FromChat).await;
assert_eq!(
sender.calls(),
vec!["send_chat_action", "send_message"],
"no media may go out without the chat's settings; the user is told instead"
);
}
/// A repeat request for a single-gif post re-sends the cached file id
/// instead of failing: the animation path used to hand the id to
/// `input_file_for`, which read it as a local path and answered "local
+8
View File
@@ -112,6 +112,14 @@ impl ChatStore {
self.load(chat_id).await.unwrap_or_default()
}
/// Strict read for callers whose *behavior* depends on the settings. A
/// send that reads them via [`Self::get`] would silently use the defaults
/// on a failed read — no channel forward, no edit prompt — and persist
/// those defaults into the retry payload it builds.
pub async fn try_get(&self, chat_id: i64) -> Result<ChatData, ()> {
self.load(chat_id).await
}
/// Write-through: update the cache and the DB. Returns whether the DB
/// write landed: the cache is updated either way, so `false` means the
/// change lives only until the next restart and the caller has to say so