mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-26 23:52:05 +00:00
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:
@@ -26,7 +26,7 @@ User-facing failure text is a function of the error class, never one generic sen
|
||||
|
||||
The `/test <url>` command runs the ordinary link pipeline (`urls::url_media`) with `PostSend::Suppressed`: the media is sent and cached like any other link, but the chat's `forward_channel_id`/`edit_before_forward` are ignored, so a test never forwards to the channel and never opens the edit prompt (retries and dead-letter notifications behave as usual). `/test`, `/debug`, `/set_format` and `/clear_cache` use the custom `parse_arg_remainder` parser (whole remainder, trimmed) because teloxide's built-in `split` parser takes exactly one space-separated token per field: `/set_format <site> <format>` never parsed with it (and `/clear_cache` without an argument did not either), and a command that fails to parse falls through to the URL flow in silence. `commands::tests::every_documented_invocation_parses` pins every documented form against exactly that.
|
||||
|
||||
The inline path (`handlers/inline.rs`) hands media URLs straight to Telegram, which fetches them itself and cannot send site-specific headers — so `x_media::site::needs_media_headers(url)` (true exactly where a site's `media_headers` is non-empty, i.e. pixiv's pximg.net) marks the media that must be skipped instead of shipped broken; locally produced media (ugoira MP4, bsky remux) fails `Url::parse` and is skipped the same way. Inline results are therefore URL-only by construction, and a query whose every item was skipped is answered *empty* (with a cache window) rather than left unanswered — an unanswered query keeps the client spinning and, through the debounce's release, re-runs the fetch on every keystroke.
|
||||
The inline path (`handlers/inline.rs`) answers from the **link cache** first: a post already sent somewhere answers with `InlineQueryResultCached*` built from its Telegram file ids, so no fetch happens and — unlike a URL result — media Telegram could never fetch itself still works (pixiv's pximg.net, a locally encoded ugoira/bsky MP4). Only a cache miss fetches (`fetch_once`), and then the media URLs go straight to Telegram, which fetches them itself and cannot send site-specific headers — so `x_media::site::needs_media_headers(url)` (true exactly where a site's `media_headers` is non-empty, i.e. pixiv's pximg.net) marks the media that must be skipped instead of shipped broken; locally produced media (ugoira MP4, bsky remux) fails `Url::parse` and is skipped the same way. A degraded cache entry keeps only URLs, so it takes the same URL path (a video with no poster is skipped there, Telegram has no thumbnail to show). A query whose every item was skipped is answered *empty* (with a cache window) rather than left unanswered — an unanswered query keeps the client spinning and, through the debounce's release, re-runs the fetch on every keystroke. The answer goes through `MediaSender::answer_inline_query` (the trait carries it so the path is mock-testable; `inline.rs`'s own tests cover the cache and degraded-entry answers offline).
|
||||
|
||||
`url_media` is a thin wrapper over `url_media_inner`: `run_with_chat_action` sends the chat action, then re-sends it every `ACTION_REFRESH` (4 s) while the pipeline future is pending, because Telegram drops an action after ~5 s and a fetch (ugoira encode, HLS remux) plus an upload routinely outlasts that. The pipeline flips the shared `ActionHint` from `Typing` to `UploadPhoto`/`UploadVideo` once the media kinds are known. The `select!` is `biased` on the pipeline branch so a finished pipeline never emits a stray action.
|
||||
|
||||
@@ -47,7 +47,7 @@ The `x-media` library: `site::fetch(url)` dispatches through the `SITES` registr
|
||||
| `crates/xmedia-bot/src/queue.rs` | `PersistentTaskQueue`: SQLite-backed queue (`tasks` table), `QUEUE_WORKERS = 4` concurrent workers (lease via `BEGIN IMMEDIATE` + `locked_until` TTL), retry→dead-letter, a `lease_token` fence: `lease_next` stamps a random token and every write-back (heartbeat, `delete`, `reschedule`, `mark_done`) is guarded by it, so a lease that expired and was re-leased cannot be written by its former holder — a lost lease stops the attempt instead; a finished row's `DELETE`/reschedule retried and a failed delete falling back to a `done` tombstone (the lease query and the sweep only look at `pending`/`in_progress`, so a task that already ran cannot be resurrected and re-run), `runnable_rows`/`replace_payload` (the startup repair's read/rewrite path: it runs before the workers exist, which is why it needs no lease token), `notify_one` worker wakeup plus a separate `Notify` for the 30 s lease-expiry sweep (a shared one let the sweep steal the workers' wakeup permit; the sweep does notify the workers after it actually recovered a row, since a recovered task is due immediately while every worker may be parked on `notify` with no pending row to sleep on), `busy_timeout` on all connections |
|
||||
| `crates/xmedia-bot/src/ctx.rs` | `AppContext`: the injected collaborators (`sender` + `ChatStore`/`PersistentTaskQueue`/`LinkCache`/`Config`), `from_statics` for production and the `CONTEXT` static the worker closures hold. `test_support::TestStores` backs handler tests with a tempdir store set, and the module also carries the fixtures those tests share — the canonical cached post (`cached_photo`), the edit-before-forward prompt (`seed_prompt` with its `PROMPT_ID`/`FORWARDED_ID`) and a scripted API error (`api_error`) — so no two test modules keep their own copies |
|
||||
| `crates/xmedia-bot/src/send/` | `send/mod.rs`: `Task`/`MediaItemPayload` payloads (`media: MediaRef`, i.e. `Source` URL-or-path vs `FileId` — one field used to carry both with a flag), `send_media_sequence`/`send_animation`/`forward_messages`; `send/error.rs`: the Bot API error policy (`SendError`/`Classification`, `classify_request_error`, the media-fetch/size markers); `send/input_media.rs`: payload → `InputFile`/`InputMedia` + `build_media_group` (caption on the first item only); `send/upload.rs`: the download-and-reupload fallback (`prepare_upload_item`/`send_batch_via_upload`, photo downscale handoff); `send/post_send.rs`: link-cache write, `KEEP_ALIVE` registry, `settle_task`, `post_send_actions`, `handle_task`/`dead_letter_notify` |
|
||||
| `crates/xmedia-bot/src/media_sender/{mod.rs,test_support.rs}` | `MediaSender` trait: the user-flow surface (`send_media_group`/`send_animation`/`copy_messages`/`send_message`/`answer_callback_query`/`edit_message_text`/`edit_message_caption`/`delete_message`/`send_chat_action`) implemented by teloxide `Bot` (per-chat rate-limited) and by a recording `MockSender` in tests. Admin/setup APIs (`get_chat`, `set_my_commands`, …) stay on the concrete `Bot`. `test_support.rs` (cfg(test)-only) holds the scripted `MockSender` and `fake_api` (the stand-in API the real-`Bot` tests drive) |
|
||||
| `crates/xmedia-bot/src/media_sender/{mod.rs,test_support.rs}` | `MediaSender` trait: the user-flow surface (`send_media_group`/`send_animation`/`copy_messages`/`send_message`/`answer_callback_query`/`edit_message_text`/`edit_message_caption`/`delete_message`/`send_chat_action`/`answer_inline_query`) implemented by teloxide `Bot` (per-chat rate-limited) and by a recording `MockSender` in tests. Admin/setup APIs (`get_chat`, `set_my_commands`, …) stay on the concrete `Bot`. `test_support.rs` (cfg(test)-only) holds the scripted `MockSender` and `fake_api` (the stand-in API the real-`Bot` tests drive) |
|
||||
| `crates/xmedia-bot/src/rate_limit.rs` | Two token buckets paced before sends reach the API so batch forwards don't trip flood control: one per chat (`CAPACITY = 20`, ~20 msg/min refill) and one bot-wide (`acquire_global`, 30/s — Telegram's per-bot ceiling, invisible to any per-chat bucket and only binding when a batch fans out over many chats). `prune_idle` drops the per-chat buckets that refilled while unheld |
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
//! inline cache instead of re-fetching.
|
||||
|
||||
use super::log_key;
|
||||
use crate::ctx::AppContext;
|
||||
use crate::link_cache::{CachedMediaKind, CachedPost};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use teloxide::RequestError;
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::types::{
|
||||
InlineQuery, InlineQueryResult, InlineQueryResultMpeg4Gif, InlineQueryResultPhoto,
|
||||
InlineQueryResultVideo, ParseMode,
|
||||
FileId, InlineQuery, InlineQueryResult, InlineQueryResultCachedMpeg4Gif,
|
||||
InlineQueryResultCachedPhoto, InlineQueryResultCachedVideo, InlineQueryResultMpeg4Gif,
|
||||
InlineQueryResultPhoto, InlineQueryResultVideo, ParseMode,
|
||||
};
|
||||
use x_media::media::Media;
|
||||
|
||||
@@ -133,7 +136,8 @@ pub async fn inline_query_handler(bot: Bot, query: InlineQuery) -> Result<(), Re
|
||||
if !INLINE_DEBOUNCE_STATE.lock().claim(user_id, &query_text) {
|
||||
return;
|
||||
}
|
||||
match answer_inline_query(bot, query).await {
|
||||
let ctx = AppContext::from_statics(&bot);
|
||||
match answer_inline_query(&ctx, query).await {
|
||||
Ok(true) => {}
|
||||
// The fetch or the answer call failed: release so a repeat of the
|
||||
// same query may retry it. An *empty* answer is a real answer
|
||||
@@ -145,26 +149,40 @@ pub async fn inline_query_handler(bot: Bot, query: InlineQuery) -> Result<(), Re
|
||||
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> {
|
||||
/// Answers the inline query behind a post URL. The caller has already applied
|
||||
/// the debounce. Returns `true` when an answer was sent.
|
||||
async fn answer_inline_query(
|
||||
ctx: &AppContext<'_>,
|
||||
query: InlineQuery,
|
||||
) -> Result<bool, RequestError> {
|
||||
// The query is user input: `debug` keeps only its normalized key, the
|
||||
// text itself is `trace` (same split as the message handler).
|
||||
log::debug!("inline query [key={}]", log_key(&query.query));
|
||||
log::trace!("inline query: {}", query.query);
|
||||
let Some(key) = x_media::site::cache_key(&query.query) else {
|
||||
return Ok(false);
|
||||
};
|
||||
// A post that was already sent to some chat is answered from the link
|
||||
// cache: its Telegram file ids make the answer instant, and — unlike a URL
|
||||
// result, which Telegram must fetch itself — they carry media that a
|
||||
// hotlink-protected host (pixiv's pximg.net) or a locally encoded file
|
||||
// (ugoira MP4, bsky remux) can never serve inline. That media used to be
|
||||
// skipped outright, so a pixiv link answered empty.
|
||||
if let Some(cached) = ctx.link_cache.get(&key, ctx.config.link_cache_ttl).await {
|
||||
let caption = inline_caption(&cached, ctx.config.caption_quote_text_chars);
|
||||
let results = cached_inline_results(&cached, &caption);
|
||||
answer(ctx.sender, query.id, results).await?;
|
||||
return Ok(true);
|
||||
}
|
||||
// No retries: the debounce plus a 1s/2s backoff would outlast the inline
|
||||
// query the answer belongs to.
|
||||
match x_media::site::fetch_once(&query.query).await {
|
||||
Ok(Some(fetched)) => {
|
||||
let mut results: Vec<InlineQueryResult> = Vec::new();
|
||||
// Inline results have the same 1024-char caption limit as regular
|
||||
// messages; truncate once here for all items, then apply the same
|
||||
// long-post quoting as the send paths. `answer_inline_query` has no
|
||||
// `AppContext` (the debounce spawns it), so the parsed config comes
|
||||
// from the process-wide static, and the text is the *escaped*
|
||||
// title/content the built-in caption embeds (the raw
|
||||
// `Fetched.title`/`content` differ whenever the post contains
|
||||
// `<`/`&`).
|
||||
// long-post quoting as the send paths. The built-in caption is what
|
||||
// an inline answer can use: there is no chat whose per-site format
|
||||
// could apply, so the render fields come from the fetch itself.
|
||||
let caption = x_media::site::truncate_caption(&fetched.caption);
|
||||
let text = fetched
|
||||
.render_fields()
|
||||
@@ -173,17 +191,17 @@ async fn answer_inline_query(bot: Bot, query: InlineQuery) -> Result<bool, Reque
|
||||
let caption = crate::send::quote_long_caption(
|
||||
&caption,
|
||||
&text,
|
||||
super::CONFIG.caption_quote_text_chars,
|
||||
ctx.config.caption_quote_text_chars,
|
||||
);
|
||||
let mut results: Vec<InlineQueryResult> = Vec::new();
|
||||
for (i, media) in fetched.media.iter().enumerate() {
|
||||
let id = format!("{i}");
|
||||
// Telegram fetches an inline result's URL itself and cannot
|
||||
// send site-specific headers, so hotlink-protected media
|
||||
// (pixiv's pximg.net) would render as a broken file there.
|
||||
// Locally produced media (ugoira MP4, bsky remux) is a local
|
||||
// path and does not parse as a URL at all — same skip.
|
||||
if x_media::site::needs_media_headers(media.url()) {
|
||||
log::debug!("inline: skipping hotlink-protected media {id}");
|
||||
log::debug!("inline: skipping hotlink-protected media {i}");
|
||||
continue;
|
||||
}
|
||||
let Some(url) = url::Url::parse(media.url()).ok() else {
|
||||
@@ -193,59 +211,26 @@ async fn answer_inline_query(bot: Bot, query: InlineQuery) -> Result<bool, Reque
|
||||
.thumbnail_url()
|
||||
.and_then(|t| url::Url::parse(t).ok())
|
||||
.unwrap_or_else(|| url.clone());
|
||||
let caption = caption.clone().into_owned();
|
||||
let result = match media {
|
||||
Media::Illustration { .. } => {
|
||||
// Inline photo results have their own (smaller) size
|
||||
// cap; use the reduced variant when one exists.
|
||||
let photo_url = media
|
||||
.smaller_url()
|
||||
.and_then(|u| url::Url::parse(u).ok())
|
||||
.unwrap_or_else(|| url.clone());
|
||||
InlineQueryResult::Photo(
|
||||
InlineQueryResultPhoto::new(id, photo_url, thumbnail)
|
||||
.caption(caption)
|
||||
.parse_mode(ParseMode::Html),
|
||||
)
|
||||
}
|
||||
Media::Video { .. } => InlineQueryResult::Video(
|
||||
InlineQueryResultVideo::new(
|
||||
id,
|
||||
url,
|
||||
"video/mp4".parse().expect("valid mime"),
|
||||
thumbnail,
|
||||
fetched.title.clone(),
|
||||
)
|
||||
.caption(caption)
|
||||
.parse_mode(ParseMode::Html),
|
||||
),
|
||||
Media::Animated { .. } => InlineQueryResult::Mpeg4Gif(
|
||||
InlineQueryResultMpeg4Gif::new(id, url, thumbnail)
|
||||
.caption(caption)
|
||||
.parse_mode(ParseMode::Html),
|
||||
),
|
||||
};
|
||||
results.push(result);
|
||||
// Inline photo results have their own (smaller) size cap; use
|
||||
// the reduced variant when one exists.
|
||||
let url = media
|
||||
.smaller_url()
|
||||
.and_then(|u| url::Url::parse(u).ok())
|
||||
.unwrap_or(url);
|
||||
results.push(url_result(
|
||||
i.to_string(),
|
||||
inline_kind(media),
|
||||
url,
|
||||
thumbnail,
|
||||
fetched.title.clone(),
|
||||
caption.clone().into_owned(),
|
||||
));
|
||||
}
|
||||
if !results.is_empty() {
|
||||
// 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);
|
||||
}
|
||||
// Every item was skipped: Telegram fetches an inline result's URL
|
||||
// itself, so pixiv's hotlink-protected media (and a local ugoira /
|
||||
// bsky MP4) can never be one. Answer *empty* — the client stops
|
||||
// spinning, and the same query is not re-fetched on every
|
||||
// keystroke: an unanswered query releases the debounce below
|
||||
// (`Ok(false)`), which is what made this re-run the fetch each
|
||||
// time, and the window lets Telegram serve the repeats itself.
|
||||
log::debug!("inline: nothing Telegram can fetch for the query; answering empty");
|
||||
bot.answer_inline_query(query.id, Vec::new())
|
||||
.cache_time(300)
|
||||
.await?;
|
||||
// Every item was skipped, or the post has no media at all: answer
|
||||
// *empty* rather than leaving the query unanswered (a client keeps
|
||||
// spinning on that, and the debounce's release re-runs the fetch on
|
||||
// every keystroke).
|
||||
answer(ctx.sender, query.id, results).await?;
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(None) => {}
|
||||
@@ -254,13 +239,274 @@ async fn answer_inline_query(bot: Bot, query: InlineQuery) -> Result<bool, Reque
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// The caption of an inline answer, from a cached post: the caption that was
|
||||
/// sent (the site's built-in one, truncated) plus the long-post quoting the
|
||||
/// send paths apply.
|
||||
fn inline_caption(cached: &CachedPost, quote_chars: usize) -> String {
|
||||
let text = x_media::site::compose_text(&cached.title, &cached.content);
|
||||
crate::send::quote_long_caption(
|
||||
&x_media::site::truncate_caption(&cached.caption),
|
||||
&text,
|
||||
quote_chars,
|
||||
)
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
/// Which inline result kind a payload maps to.
|
||||
fn inline_kind(media: &Media) -> InlineKind {
|
||||
match media {
|
||||
Media::Illustration { .. } => InlineKind::Photo,
|
||||
Media::Video { .. } => InlineKind::Video,
|
||||
Media::Animated { .. } => InlineKind::Gif,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum InlineKind {
|
||||
Photo,
|
||||
Video,
|
||||
Gif,
|
||||
}
|
||||
|
||||
/// One inline result pointing Telegram at a URL it fetches itself.
|
||||
fn url_result(
|
||||
id: String,
|
||||
kind: InlineKind,
|
||||
url: url::Url,
|
||||
thumbnail: url::Url,
|
||||
title: String,
|
||||
caption: String,
|
||||
) -> InlineQueryResult {
|
||||
let parse_mode = ParseMode::Html;
|
||||
match kind {
|
||||
InlineKind::Photo => InlineQueryResult::Photo(
|
||||
InlineQueryResultPhoto::new(id, url, thumbnail)
|
||||
.caption(caption)
|
||||
.parse_mode(parse_mode),
|
||||
),
|
||||
InlineKind::Video => InlineQueryResult::Video(
|
||||
InlineQueryResultVideo::new(
|
||||
id,
|
||||
url,
|
||||
"video/mp4".parse().expect("valid mime"),
|
||||
thumbnail,
|
||||
title,
|
||||
)
|
||||
.caption(caption)
|
||||
.parse_mode(parse_mode),
|
||||
),
|
||||
InlineKind::Gif => InlineQueryResult::Mpeg4Gif(
|
||||
InlineQueryResultMpeg4Gif::new(id, url, thumbnail)
|
||||
.caption(caption)
|
||||
.parse_mode(parse_mode),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// One inline result served from a Telegram file id.
|
||||
fn cached_result(
|
||||
id: String,
|
||||
kind: CachedMediaKind,
|
||||
file_id: String,
|
||||
title: String,
|
||||
caption: String,
|
||||
) -> InlineQueryResult {
|
||||
let parse_mode = ParseMode::Html;
|
||||
let file_id = FileId(file_id);
|
||||
match kind {
|
||||
CachedMediaKind::Photo => InlineQueryResult::CachedPhoto(
|
||||
InlineQueryResultCachedPhoto::new(id, file_id)
|
||||
.caption(caption)
|
||||
.parse_mode(parse_mode),
|
||||
),
|
||||
CachedMediaKind::Video => InlineQueryResult::CachedVideo(
|
||||
InlineQueryResultCachedVideo::new(id, file_id, title)
|
||||
.caption(caption)
|
||||
.parse_mode(parse_mode),
|
||||
),
|
||||
CachedMediaKind::Animation => InlineQueryResult::CachedMpeg4Gif(
|
||||
InlineQueryResultCachedMpeg4Gif::new(id, file_id)
|
||||
.caption(caption)
|
||||
.parse_mode(parse_mode),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The inline results a cached post answers with, one per media item: from the
|
||||
/// file id when the entry has one, else from the source URL (a degraded entry
|
||||
/// keeps only URLs). A URL item that needs site headers is skipped as in the
|
||||
/// fetch path; a *file id* needs no headers, which is what makes a pixiv post
|
||||
/// answerable inline.
|
||||
fn cached_inline_results(cached: &CachedPost, caption: &str) -> Vec<InlineQueryResult> {
|
||||
cached
|
||||
.media
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, media)| {
|
||||
let id = i.to_string();
|
||||
let caption = || caption.to_string();
|
||||
if !media.file_id.is_empty() {
|
||||
return Some(cached_result(
|
||||
id,
|
||||
media.kind,
|
||||
media.file_id.clone(),
|
||||
cached.title.clone(),
|
||||
caption(),
|
||||
));
|
||||
}
|
||||
// A degraded entry: no file id, so Telegram must fetch the URL.
|
||||
if x_media::site::needs_media_headers(&media.url) {
|
||||
log::debug!("inline: skipping hotlink-protected cached media {i}");
|
||||
return None;
|
||||
}
|
||||
let url = url::Url::parse(&media.url).ok()?;
|
||||
// A photo or gif is an image, so its own URL serves as the
|
||||
// thumbnail; a video needs a real poster, and a degraded entry has
|
||||
// none — Telegram would try to render the video as an image.
|
||||
let kind = match media.kind {
|
||||
CachedMediaKind::Photo => InlineKind::Photo,
|
||||
CachedMediaKind::Video => {
|
||||
log::debug!("inline: skipping a cached video with no thumbnail {i}");
|
||||
return None;
|
||||
}
|
||||
CachedMediaKind::Animation => InlineKind::Gif,
|
||||
};
|
||||
Some(url_result(
|
||||
id,
|
||||
kind,
|
||||
url.clone(),
|
||||
url,
|
||||
cached.title.clone(),
|
||||
caption(),
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Answers with `results` (an empty vec is a real answer: it stops the client
|
||||
/// spinning and lets Telegram serve repeats itself) under the cache window
|
||||
/// [`INLINE_STATE_TTL`] mirrors.
|
||||
async fn answer(
|
||||
sender: &dyn crate::media_sender::MediaSender,
|
||||
id: teloxide::types::InlineQueryId,
|
||||
results: Vec<InlineQueryResult>,
|
||||
) -> Result<(), RequestError> {
|
||||
if results.is_empty() {
|
||||
log::debug!("inline: nothing Telegram can serve for the query; answering empty");
|
||||
}
|
||||
sender.answer_inline_query(id, results, 300).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DebounceStates, INLINE_STATE_TTL};
|
||||
use super::{DebounceStates, INLINE_STATE_TTL, answer_inline_query};
|
||||
use crate::ctx::test_support::{TestStores, api_error, cached_photo};
|
||||
use crate::link_cache::{CachedMedia, CachedMediaKind};
|
||||
use crate::media_sender::test_support::MockSender;
|
||||
use teloxide::types::InlineQuery;
|
||||
|
||||
const URL_A: &str = "https://x.com/a/status/1";
|
||||
const URL_B: &str = "https://x.com/b/status/2";
|
||||
|
||||
fn inline_query(url: &str) -> InlineQuery {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id": "42",
|
||||
"from": { "id": 5, "is_bot": false, "first_name": "u" },
|
||||
"query": url,
|
||||
"offset": "",
|
||||
}))
|
||||
.expect("a minimal inline query deserializes")
|
||||
}
|
||||
|
||||
/// A post already in the link cache is answered from its file ids: no
|
||||
/// fetch, and — unlike a URL result — media Telegram could never fetch
|
||||
/// itself (a pixiv pximg URL) can be served.
|
||||
#[tokio::test]
|
||||
async fn a_cached_post_answers_from_its_file_ids() {
|
||||
let sender = MockSender::scripted(vec![], || api_error("boom"));
|
||||
let stores = TestStores::new();
|
||||
let ctx = stores.ctx(&sender);
|
||||
let mut entry = cached_photo();
|
||||
entry.media = vec![
|
||||
CachedMedia {
|
||||
kind: CachedMediaKind::Photo,
|
||||
file_id: "AgAC-photo".into(),
|
||||
url: "https://i.pximg.net/img-original/img/1.jpg".into(),
|
||||
},
|
||||
CachedMedia {
|
||||
kind: CachedMediaKind::Animation,
|
||||
file_id: "AgAC-gif".into(),
|
||||
url: "https://i.pximg.net/img-original/img/1.gif".into(),
|
||||
},
|
||||
];
|
||||
stores.link_cache().put("twitter:1", &entry).await;
|
||||
|
||||
let answered = answer_inline_query(&ctx, inline_query("https://x.com/u/status/1"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(answered);
|
||||
assert_eq!(
|
||||
sender.inline_answers(),
|
||||
vec![vec!["cached_photo:AgAC-photo", "cached_gif:AgAC-gif"]],
|
||||
"every item goes out as its cached file id, hotlink protection and all"
|
||||
);
|
||||
}
|
||||
|
||||
/// A degraded entry has no file ids left, so its URLs are used — and an
|
||||
/// item Telegram must not fetch (needs site headers) or cannot render (a
|
||||
/// video with no poster) is skipped. Nothing left means an *empty* answer:
|
||||
/// leaving the query unanswered makes the client spin and re-fetch on every
|
||||
/// keystroke.
|
||||
#[tokio::test]
|
||||
async fn a_degraded_cached_post_answers_with_urls_or_empty() {
|
||||
let sender = MockSender::scripted(vec![], || api_error("boom"));
|
||||
let stores = TestStores::new();
|
||||
let ctx = stores.ctx(&sender);
|
||||
|
||||
let mut entry = cached_photo();
|
||||
entry.media = vec![
|
||||
CachedMedia {
|
||||
kind: CachedMediaKind::Photo,
|
||||
file_id: String::new(),
|
||||
url: "https://p/1.jpg".into(),
|
||||
},
|
||||
CachedMedia {
|
||||
kind: CachedMediaKind::Video,
|
||||
file_id: String::new(),
|
||||
url: "https://v/1.mp4".into(),
|
||||
},
|
||||
];
|
||||
stores.link_cache().put("twitter:1", &entry).await;
|
||||
answer_inline_query(&ctx, inline_query("https://x.com/u/status/1"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
sender.inline_answers(),
|
||||
vec![vec!["photo:https://p/1.jpg"]],
|
||||
"the degradable photo goes out by URL, the poster-less video is skipped"
|
||||
);
|
||||
|
||||
// Nothing servable: a pixiv original needs a Referer Telegram does not
|
||||
// send.
|
||||
stores.link_cache().remove("twitter:1").await;
|
||||
let mut entry = cached_photo();
|
||||
entry.media = vec![CachedMedia {
|
||||
kind: CachedMediaKind::Photo,
|
||||
file_id: String::new(),
|
||||
url: "https://i.pximg.net/img-original/img/1.jpg".into(),
|
||||
}];
|
||||
stores.link_cache().put("twitter:1", &entry).await;
|
||||
answer_inline_query(&ctx, inline_query("https://x.com/u/status/1"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
sender.inline_answers(),
|
||||
vec![vec!["photo:https://p/1.jpg".to_string()], Vec::new()],
|
||||
"a query with nothing servable is still answered, with no results"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debounce_state_is_per_user() {
|
||||
let mut states = DebounceStates::default();
|
||||
|
||||
@@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CachedMediaKind {
|
||||
Photo,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user