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.
This commit is contained in:
2026-08-13 21:57:07 +08:00
parent 68f026c990
commit c496e41c55
+79 -5
View File
@@ -764,16 +764,85 @@ pub async fn message_handler(bot: Bot, message: Message) -> Result<(), RequestEr
respond(()) 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<parking_lot::Mutex<Option<InlineDebounceState>>> =
LazyLock::new(|| parking_lot::Mutex::new(None));
pub async fn inline_query_handler(bot: Bot, query: InlineQuery) -> Result<(), RequestError> { pub async fn inline_query_handler(bot: Bot, query: InlineQuery) -> Result<(), RequestError> {
if query.query.is_empty() { if query.query.is_empty() {
return respond(()); return respond(());
} }
// Telegram fires an inline query on every keystroke; only run a fetch // Only run a fetch for something that is actually a supported post URL.
// (3 attempts!) for something that is actually a supported post URL, so
// typing does not hammer the source sites.
if x_media::site::cache_key(&query.query).is_none() { if x_media::site::cache_key(&query.query).is_none() {
return respond(()); 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<bool, RequestError> {
log::info!("inline query: {}", query.query); log::info!("inline query: {}", query.query);
match x_media::site::fetch(&query.query).await { match x_media::site::fetch(&query.query).await {
Ok(Some(fetched)) => { Ok(Some(fetched)) => {
@@ -822,13 +891,18 @@ pub async fn inline_query_handler(bot: Bot, query: InlineQuery) -> Result<(), Re
results.push(result); results.push(result);
} }
if !results.is_empty() { 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) => {} Ok(None) => {}
Err(e) => log::error!("inline fetch {}: {e}", query.query), Err(e) => log::error!("inline fetch {}: {e}", query.query),
} }
respond(()) Ok(false)
} }
pub async fn callback_query_handler(bot: Bot, query: CallbackQuery) -> Result<(), RequestError> { pub async fn callback_query_handler(bot: Bot, query: CallbackQuery) -> Result<(), RequestError> {