From ef8a1dae156bbd977126e13d41a113c89897ac65 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Thu, 24 Sep 2026 15:23:02 +0800 Subject: [PATCH] fix(handlers): honour the TTL when a caption-edit reply arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A button press on an edit-before-forward prompt already dropped records past EDIT_MESSAGE_TTL lazily (before the 300 s sweep clears them); a text reply to the same prompt did not — it rewrote the caption from a record the sweep was about to treat as dead, so the two paths disagreed about what 'expired' means. The reply path now applies the same rule: drop the stale record, return false, and let the message flow on as if no prompt existed. The test seeds a prompt90000 s old (past the TTL under any config a parallel test can hold) and pins all three effects: no edit consumed, no API call, record gone. --- crates/xmedia-bot/src/handlers/mod.rs | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/xmedia-bot/src/handlers/mod.rs b/crates/xmedia-bot/src/handlers/mod.rs index d6eb37f..d39ba11 100644 --- a/crates/xmedia-bot/src/handlers/mod.rs +++ b/crates/xmedia-bot/src/handlers/mod.rs @@ -141,6 +141,17 @@ async fn edit_message_handler( let Some(edit) = chat_data.edit_message.get(&reply_to_message_id) else { return false; }; + // Lazy expiry, the same rule a button press gets: a record past the TTL + // (not yet swept) is dropped and the reply falls through to the normal + // message flow instead of rewriting a caption from a dead prompt. + if edit.created_at + ctx.config.edit_message_ttl.as_secs() as i64 <= crate::db::unix_now() { + ctx.chat_store + .update(chat_id, |data| { + data.edit_message.remove(&reply_to_message_id); + }) + .await; + return false; + } let Some(first_forward_id) = edit.forward_message_ids.first() else { return false; }; @@ -317,6 +328,27 @@ mod tests { )); } + #[tokio::test] + async fn a_reply_to_an_expired_prompt_is_not_edited() { + // 90 000 s ago: past the TTL under any config a test can hold. + let sender = MockSender::scripted(vec![], || api_error(API_ERROR)); + let stores = TestStores::new(); + let ctx = stores.ctx(&sender); + seed_prompt(&ctx, "tpl", crate::db::unix_now() - 90_000).await; + + let consumed = edit_message_handler(&ctx, 1, PROMPT_ID, "new caption").await; + + assert!(!consumed, "an expired prompt must not consume the reply"); + assert!( + sender.captions().is_empty(), + "no caption edit may reach a dead prompt" + ); + assert!( + stores.chat_store().get(1).await.edit_message.is_empty(), + "the stale record must be dropped for good" + ); + } + #[tokio::test] async fn reply_to_a_prompt_swaps_the_caption_through_its_template() { let sender = MockSender::scripted(vec![Outcome::EditOk], || api_error(API_ERROR));