fix: bound the inline state map, test the 300s sweep, add a bot-wide send budget

Three gaps the last audit list named, all in the "resource growth, background
timers and limits nobody watches" class.

**Idle inline-query entries are pruned.** `DebounceStates` had no eviction at
all: one entry per user who ever used inline mode, forever, while the rate
limiter's buckets and the chat store both prune in the 300s sweep. Entries
now carry a `last_seen` stamp and `prune_idle_states()` drops the ones idle
past 300s — the window Telegram caches an inline answer for
(`cache_time(300)`), after which a repeat reaches the bot again and has to be
answered fresh, so the entry would only suppress a fetch the user is waiting
for. The boundary is tested through `prune_idle_at(now, idle_for)` so it does
not depend on ageing a monotonic clock.

**The 300s sweep is a function, and tested.** It was an inline `tokio::spawn`
block: the expiry edit (the only part that talks to Telegram) had no test at
all. It is now `periodic_sweep(sender, chat_store, link_cache, task_queue,
config, stop)`, which also prunes the inline entries, driven in a test with
`start_paused` — the loop's own timer fires the tick, exactly one expired
prompt is rewritten in place, a live one keeps its record and buttons. The
interval is pinned as a constant because no assertion on the edits can see it
(a shorter one produces the same single edit; the paused clock can jump past
the boundary while a tick's DB work is in flight). To make the edit reachable
at all, `edit_message_text` joined the `MediaSender` trait (Bot impl + mock
recording), which is also what keeps `main.rs`'s remaining `Bot` calls
unambiguous. `main.rs` leaves the "untested modules" list except for
startup/shutdown and the dispatcher tree.

**The bot-wide send budget exists.** Telegram throttles a bot in total
(~30 msg/s) as well as per chat; only the per-chat bucket existed, so a batch
forward fanned out over many chats was unguarded and earned 429s the queue
then retried. `acquire_global` charges the same spend against a single shared
bucket at the three paced sites (`send_media_group`, `send_animation`,
`copy_messages`). The unpaced ones (`send_message`, the edits, the toasts) stay
unpaced on purpose: they are one call per action, far below the ceiling, and
pacing a user-visible reply would delay it. Not covered: that the send paths
call it (they need a real `Bot`), which is the same structural gap as the
dispatcher tree.

Also: the startup token-exchange decision is now `startup_validation(result)`
instead of living inside the `Site::validate` future, so "a 5xx while the
container comes up must not disable pixiv" is asserted as a decision — the
message the admin gets plus `enabled()` unchanged. The rejected-credential
half is deliberately not exercised: it calls `disable()`, a process-wide flag
with no reset, and a test touching it would order-couple every other pixiv
test.

Verified: `cargo fmt`, `cargo clippy --workspace --all-targets --locked -- -D
warnings`, `cargo test --workspace --locked` (184 passed, 14 ignored) — plus
mutations, each confirmed to fail the relevant test: the sweep not being
driven on its timer, the interval shortened to 60s, and (earlier) the queue
sweep's missing wake-up. Dropped an empty leftover `crates/x-media/tests/`
directory while there (never tracked by git).
This commit is contained in:
2026-09-21 01:03:15 +08:00
parent 3828d5b483
commit 024dfd50b3
8 changed files with 384 additions and 84 deletions
+55
View File
@@ -68,6 +68,16 @@ pub trait MediaSender: Send + Sync {
text: Option<String>,
) -> BoxFuture<'_, Result<(), RequestError>>;
/// Rewrites a message's text and drops its inline keyboard: the
/// edit-expiry sweep rewriting a prompt whose record expired (a button left
/// behind could only answer "Expired").
fn edit_message_text(
&self,
chat_id: ChatId,
message_id: MessageId,
text: String,
) -> BoxFuture<'_, Result<(), RequestError>>;
/// Rewrites a message's caption, always with HTML parse mode (every caller
/// in this bot renders escaped HTML: templates and edit-before-forward
/// links).
@@ -106,6 +116,9 @@ impl MediaSender for Bot {
crate::rate_limit::limiter_for(chat_id.0)
.acquire(items.len() as f64)
.await;
// Same spend against the bot-wide budget: a fan-out over chats is
// invisible to the per-chat buckets.
crate::rate_limit::acquire_global(items.len() as f64).await;
// `<Bot as Requester>::` disambiguates from this trait's same-named
// method (teloxide's API lives in the `Requester` trait).
<Bot as Requester>::send_media_group(self, chat_id, items)
@@ -124,6 +137,7 @@ impl MediaSender for Bot {
) -> BoxFuture<'a, Result<Message, RequestError>> {
Box::pin(async move {
crate::rate_limit::limiter_for(chat_id.0).acquire(1.0).await;
crate::rate_limit::acquire_global(1.0).await;
let mut request = <Bot as Requester>::send_animation(self, chat_id, file)
.caption(caption)
.parse_mode(ParseMode::Html)
@@ -147,6 +161,7 @@ impl MediaSender for Bot {
crate::rate_limit::limiter_for(to.0)
.acquire(ids.len() as f64)
.await;
crate::rate_limit::acquire_global(ids.len() as f64).await;
<Bot as Requester>::copy_messages(self, to, from, ids).await
})
}
@@ -185,6 +200,20 @@ impl MediaSender for Bot {
})
}
fn edit_message_text(
&self,
chat_id: ChatId,
message_id: MessageId,
text: String,
) -> BoxFuture<'_, Result<(), RequestError>> {
Box::pin(async move {
<Bot as Requester>::edit_message_text(self, chat_id, message_id, text)
.reply_markup(InlineKeyboardMarkup::default())
.await
.map(|_| ())
})
}
fn edit_message_caption(
&self,
chat_id: ChatId,
@@ -260,6 +289,8 @@ pub(crate) mod test_support {
messages: Mutex<Vec<String>>,
captions: Mutex<Vec<String>>,
answers: Mutex<Vec<Option<String>>>,
/// `(chat, message, text)` of every text rewrite, in order.
edited_texts: Mutex<Vec<(i64, i64, String)>>,
/// Builds the error every `*Err` outcome returns (RequestError is not
/// cloneable, so the factory recreates it per call).
error: Box<dyn Fn() -> RequestError + Send + Sync>,
@@ -280,6 +311,7 @@ pub(crate) mod test_support {
messages: Mutex::new(Vec::new()),
captions: Mutex::new(Vec::new()),
answers: Mutex::new(Vec::new()),
edited_texts: Mutex::new(Vec::new()),
error: Box::new(error),
}
}
@@ -305,6 +337,11 @@ pub(crate) mod test_support {
self.answers.lock().clone()
}
/// `(chat, message, text)` of every `edit_message_text`, in order.
pub(crate) fn edited_texts(&self) -> Vec<(i64, i64, String)> {
self.edited_texts.lock().clone()
}
fn next(&self, kind: &'static str) -> Outcome {
self.calls.lock().push(kind);
let script = self.script.lock();
@@ -411,6 +448,24 @@ pub(crate) mod test_support {
})
}
fn edit_message_text(
&self,
chat_id: ChatId,
message_id: MessageId,
text: String,
) -> BoxFuture<'_, Result<(), RequestError>> {
// Always succeeds: the only caller is the expiry sweep, which
// tolerates a failure (a prompt the user already deleted), so the
// script stays free for the call the test is about.
Box::pin(async move {
self.calls.lock().push("edit_message_text");
self.edited_texts
.lock()
.push((chat_id.0, message_id.0 as i64, text));
Ok(())
})
}
fn edit_message_caption(
&self,
_chat_id: ChatId,