feat(inline): answer from the link cache, and put the answer on the trait

Inline results were URL-only: Telegram fetches an inline result's URL itself
and cannot send site headers, so every pixiv item (and every locally encoded
ugoira/bsky MP4) was skipped and such a query answered empty. A post that is
already in the link cache now answers with InlineQueryResultCached* built
from its Telegram file ids — no fetch, no upload, and the hotlink-protected
case simply works. A degraded entry (file ids gone) falls back to URLs, and
there a video with no poster is skipped (Telegram would try to render the
mp4 as its own thumbnail).

The answer call moves onto MediaSender (answer_inline_query, mirroring the
other user-flow methods), which is what makes the path testable at all: the
two new tests drive the cache answer and the degraded/empty answer through
TestStores + MockSender, which recorded nothing about inline before.

The result builders are shared by both paths now (url_result/cached_result +
inline_kind), so the fetch path's behaviour is unchanged.
This commit is contained in:
2026-09-21 20:35:18 +08:00
parent 20a0cf3ea3
commit 3ebc1e4a8f
5 changed files with 390 additions and 74 deletions
+27 -2
View File
@@ -8,8 +8,8 @@ use teloxide::RequestError;
use teloxide::prelude::Requester;
use teloxide::prelude::*;
use teloxide::types::{
CallbackQueryId, ChatAction, ChatId, InlineKeyboardMarkup, InputFile, InputMedia, Message,
MessageId, ParseMode, ReplyParameters,
CallbackQueryId, ChatAction, ChatId, InlineKeyboardMarkup, InlineQueryId, InlineQueryResult,
InputFile, InputMedia, Message, MessageId, ParseMode, ReplyParameters,
};
/// Boxed, `Send` future returned by a [`MediaSender`] method (`async fn` in
@@ -60,6 +60,17 @@ pub trait MediaSender: Send + Sync {
reply_markup: Option<InlineKeyboardMarkup>,
) -> BoxFuture<'_, Result<i64, RequestError>>;
/// Answers an inline query with `results`, cached by Telegram for
/// `cache_time` seconds. An empty `results` answers *empty*, which is a
/// real answer: it stops the client spinning and lets Telegram serve a
/// repeat itself instead of the bot re-running the query.
fn answer_inline_query(
&self,
id: InlineQueryId,
results: Vec<InlineQueryResult>,
cache_time: u32,
) -> BoxFuture<'_, Result<(), RequestError>>;
/// Answers a callback query, optionally with a toast `text` shown to the
/// user who pressed the button.
fn answer_callback_query(
@@ -186,6 +197,20 @@ impl MediaSender for Bot {
})
}
fn answer_inline_query(
&self,
id: InlineQueryId,
results: Vec<InlineQueryResult>,
cache_time: u32,
) -> BoxFuture<'_, Result<(), RequestError>> {
Box::pin(async move {
<Bot as Requester>::answer_inline_query(self, id, results)
.cache_time(cache_time)
.await
.map(|_| ())
})
}
fn answer_callback_query(
&self,
id: CallbackQueryId,
@@ -5,6 +5,7 @@
use super::*;
use parking_lot::Mutex;
use teloxide::types::InlineQueryResult;
/// One scripted outcome, consumed front-to-back; the last entry repeats
/// for further calls of the same method kind.
@@ -233,11 +234,29 @@ pub(crate) struct MockSender {
/// What each `send_animation` handed Telegram: a URL or a file id as that
/// string, an upload as `attach://<id>`.
animation_files: Mutex<Vec<String>>,
/// What every `answer_inline_query` answered with, one entry per result:
/// `cached_photo:<file id>`, `photo:<url>`, and so on. An answer with no
/// results is recorded as an empty inner vec.
inline_answers: Mutex<Vec<Vec<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>,
}
/// A one-string description of an inline result: the kind plus the file id it
/// is served from, or the URL it points Telegram at.
fn inline_result_tag(result: &InlineQueryResult) -> String {
match result {
InlineQueryResult::CachedPhoto(r) => format!("cached_photo:{}", r.photo_file_id.0),
InlineQueryResult::CachedVideo(r) => format!("cached_video:{}", r.video_file_id.0),
InlineQueryResult::CachedMpeg4Gif(r) => format!("cached_gif:{}", r.mpeg4_file_id.0),
InlineQueryResult::Photo(r) => format!("photo:{}", r.photo_url),
InlineQueryResult::Video(r) => format!("video:{}", r.video_url),
InlineQueryResult::Mpeg4Gif(r) => format!("gif:{}", r.mpeg4_url),
other => format!("{other:?}"),
}
}
/// The smallest `Message` the send paths accept, for the outcomes that must
/// report one (`send_animation` reads its id, and its media for the cache).
pub(crate) fn mock_message(id: i64) -> Message {
@@ -266,6 +285,7 @@ impl MockSender {
answers: Mutex::new(Vec::new()),
edited_texts: Mutex::new(Vec::new()),
animation_files: Mutex::new(Vec::new()),
inline_answers: Mutex::new(Vec::new()),
error: Box::new(error),
}
}
@@ -301,6 +321,11 @@ impl MockSender {
self.animation_files.lock().clone()
}
/// What every `answer_inline_query` answered with, in call order.
pub(crate) fn inline_answers(&self) -> Vec<Vec<String>> {
self.inline_answers.lock().clone()
}
fn next(&self, kind: &'static str) -> Outcome {
self.calls.lock().push(kind);
let script = self.script.lock();
@@ -403,6 +428,26 @@ impl MediaSender for MockSender {
})
}
fn answer_inline_query(
&self,
_id: InlineQueryId,
results: Vec<InlineQueryResult>,
cache_time: u32,
) -> BoxFuture<'_, Result<(), RequestError>> {
// Records what the answer was made of, so a test can tell a cached
// (file-id) result from a URL one. Always succeeds: the debounce's
// release path is covered by `DebounceStates` directly.
assert_eq!(
cache_time, 300,
"the inline cache window is what the tests pin"
);
self.calls.lock().push("answer_inline_query");
self.inline_answers
.lock()
.push(results.iter().map(inline_result_tag).collect());
Box::pin(async move { Ok(()) })
}
fn answer_callback_query(
&self,
_id: CallbackQueryId,