From c496e41c55a5931ae4f4ea785dda1ac32b4e61d7 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Thu, 13 Aug 2026 21:57:07 +0800 Subject: [PATCH] feat(handlers): debounce inline queries to stop fetch storms while typing Telegram fires an inline query on every keystroke and every prefix of a pasted URL (status/12, status/123, ...) matches the site patterns, so typing used to trigger a full 3-attempt fetch per keystroke. Answer only after the query has been stable for 800ms, dedupe repeats through Telegram's inline cache (explicit cache_time 300), and let a repeat of a query that produced no answer retry the fetch. --- crates/xmedia-bot/src/handlers.rs | 84 +++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 5 deletions(-) diff --git a/crates/xmedia-bot/src/handlers.rs b/crates/xmedia-bot/src/handlers.rs index e18630f..4af07ba 100644 --- a/crates/xmedia-bot/src/handlers.rs +++ b/crates/xmedia-bot/src/handlers.rs @@ -764,16 +764,85 @@ pub async fn message_handler(bot: Bot, message: Message) -> Result<(), RequestEr respond(()) } +/// Debounce window for inline queries: Telegram fires an inline query on +/// every keystroke, and each prefix of a pasted URL (e.g. `.../status/12`, +/// `.../status/123`, ...) already matches the site patterns. Without a +/// debounce every keystroke triggers a fetch (3 attempts!) of a half-typed +/// post id. Only answer once the query has been stable for this long. +const INLINE_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(800); + +/// Last seen inline query and whether it was already answered. Guards the +/// debounce timer: a repeat of an answered query is served by Telegram's +/// inline cache (see `cache_time`), not by another fetch. +struct InlineDebounceState { + query: String, + answered: bool, +} + +static INLINE_DEBOUNCE_STATE: LazyLock>> = + LazyLock::new(|| parking_lot::Mutex::new(None)); + pub async fn inline_query_handler(bot: Bot, query: InlineQuery) -> Result<(), RequestError> { if query.query.is_empty() { return respond(()); } - // Telegram fires an inline query on every keystroke; only run a fetch - // (3 attempts!) for something that is actually a supported post URL, so - // typing does not hammer the source sites. + // Only run a fetch for something that is actually a supported post URL. if x_media::site::cache_key(&query.query).is_none() { return respond(()); } + // Debounce: record the query and answer only after it has been stable for + // INLINE_DEBOUNCE (the timer below). An already-answered repeat of the + // same query is left to Telegram's inline cache instead of re-fetching. + { + let mut state = INLINE_DEBOUNCE_STATE.lock(); + if let Some(prev) = state.as_ref() + && prev.query == query.query + && prev.answered + { + return respond(()); + } + *state = Some(InlineDebounceState { + query: query.query.clone(), + answered: false, + }); + } + let query_text = query.query.clone(); + tokio::spawn(async move { + tokio::time::sleep(INLINE_DEBOUNCE).await; + // Only the last query of a typing burst survives: earlier timers see + // the query changed and give up without answering. + { + let mut state = INLINE_DEBOUNCE_STATE.lock(); + let Some(state) = state.as_mut() else { + return; + }; + if state.query != query_text || state.answered { + return; + } + // Claim the answer so a repeat of the same query cannot start a + // second fetch; reset below when no answer was produced. + state.answered = true; + } + match answer_inline_query(bot, query).await { + Ok(true) => {} + // No results produced (or nothing to answer): let a repeat of the + // same query retry the fetch. + Ok(false) | Err(_) => { + let mut state = INLINE_DEBOUNCE_STATE.lock(); + if let Some(state) = state.as_mut() + && state.query == query_text + { + state.answered = false; + } + } + } + }); + respond(()) +} + +/// Fetches the post behind an inline query and answers it. The caller has +/// already applied the debounce. Returns `true` when an answer was sent. +async fn answer_inline_query(bot: Bot, query: InlineQuery) -> Result { log::info!("inline query: {}", query.query); match x_media::site::fetch(&query.query).await { Ok(Some(fetched)) => { @@ -822,13 +891,18 @@ pub async fn inline_query_handler(bot: Bot, query: InlineQuery) -> Result<(), Re results.push(result); } if !results.is_empty() { - bot.answer_inline_query(query.id, results).await?; + // Explicit cache window: repeats of the same query within 5 + // minutes are served by Telegram without hitting the bot. + bot.answer_inline_query(query.id, results) + .cache_time(300) + .await?; + return Ok(true); } } Ok(None) => {} Err(e) => log::error!("inline fetch {}: {e}", query.query), } - respond(()) + Ok(false) } pub async fn callback_query_handler(bot: Bot, query: CallbackQuery) -> Result<(), RequestError> {