feat(commands): wrap the /test caption in a blockquote (HTML report)

Replaces the strip-tags plain-text rendering: the /test reply is now an
HTML message (reply_html helper with ParseMode::Html). Raw fields (url,
source_url, title, author_url, media urls) are escaped, the pre-escaped
render fields are embedded as-is, and the caption is wrapped in
<blockquote>...</blockquote> so the report shows it exactly as it will
render in the sent media caption — escaped text and clickable links
included, no literal &amp;/&lt;/&gt; and no raw markup.
This commit is contained in:
2026-08-16 17:31:54 +08:00
parent 12a065846c
commit 90a011e978
3 changed files with 79 additions and 73 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ Telegram update → Dispatcher (polling or axum webhook) → dptree branches
Message flow: `message_handler` extracts URLs (from `url`/`text_link` entities, text + caption, deduped) → `x_media::site::fetch(url)``Fetched` → builds a `Task``send::send_media_sequence` (media groups ≤ 9, caption on first item) or `send::send_animation`. On Telegram URL-fetch failure or size error (`send_batch_via_upload`): download via `x_media::site::download_media` to a temp file (≤ 10 MiB), sniff magic bytes (`sniff_ext`), upload via multipart; oversized items fall back to `fallback_url`. On failure: `enqueue_retry` persists resume-state `Task` into the SQLite queue → workers lease (120 s lock TTL) → retry with exponential backoff (≤ 30 s, `MAX_RETRIES = 2`) → dead-letter → `notify_failure`. Success → `post_send_actions`: edit-before-forward prompt with inline buttons, or `copy_messages` to the bound forward channel. Message flow: `message_handler` extracts URLs (from `url`/`text_link` entities, text + caption, deduped) → `x_media::site::fetch(url)``Fetched` → builds a `Task``send::send_media_sequence` (media groups ≤ 9, caption on first item) or `send::send_animation`. On Telegram URL-fetch failure or size error (`send_batch_via_upload`): download via `x_media::site::download_media` to a temp file (≤ 10 MiB), sniff magic bytes (`sniff_ext`), upload via multipart; oversized items fall back to `fallback_url`. On failure: `enqueue_retry` persists resume-state `Task` into the SQLite queue → workers lease (120 s lock TTL) → retry with exponential backoff (≤ 30 s, `MAX_RETRIES = 2`) → dead-letter → `notify_failure`. Success → `post_send_actions`: edit-before-forward prompt with inline buttons, or `copy_messages` to the bound forward channel.
Debug command: `/test <url>` runs the same `x_media::site::fetch` and replies with `test_parse_report` (`handlers/commands.rs`) — site id, normalized cache key, source URL, title/author/tags, sensitive flag, caption and the media list — nothing is sent, cached or forwarded; the report is capped at 4000 chars (Telegram's 4096 plain-text limit). The report is plain text, so the caption/author/tags lines are HTML-decoded for display (they are stored pre-escaped) — the output shows the rendered text, never literal `&amp;`/`&lt;`/`&gt;`. It uses a custom `parse_test_arg` parser (whole remainder, trimmed) because teloxide's built-in `split` parser takes exactly one space-separated token. Debug command: `/test <url>` runs the same `x_media::site::fetch` and replies with `test_parse_report` (`handlers/commands.rs`) — site id, normalized cache key, source URL, title/author/tags, sensitive flag, caption and the media list — nothing is sent, cached or forwarded; the report is capped at 4000 chars and sent with HTML parse mode: raw fields are escaped, and the caption is wrapped in a `<blockquote>` so it renders exactly like the sent media caption (escaped text and links included). It uses a custom `parse_test_arg` parser (whole remainder, trimmed) because teloxide's built-in `split` parser takes exactly one space-separated token.
The `x-media` library: `site::fetch(url)` dispatches through the `SITES` registry (per-site `impl Site`, in order twitter → bsky → pixiv) and returns `Ok(None)` for unmatched URLs. `Fetched { source_url, caption, title, media: Vec<Media>, sensitive, site_id, … }`; `caption_with(format)` substitutes `{url} {author} {author_url} {title} {tags}`. The `x-media` library: `site::fetch(url)` dispatches through the `SITES` registry (per-site `impl Site`, in order twitter → bsky → pixiv) and returns `Ok(None)` for unmatched URLs. `Fetched { source_url, caption, title, media: Vec<Media>, sensitive, site_id, … }`; `caption_with(format)` substitutes `{url} {author} {author_url} {title} {tags}`.
+60 -71
View File
@@ -1,7 +1,7 @@
//! Bot command parsing, the `/`-command executor and `setMyCommands` //! Bot command parsing, the `/`-command executor and `setMyCommands`
//! registration. URL/inline/callback flows live in their own modules. //! registration. URL/inline/callback flows live in their own modules.
use super::{CHAT_STORE, CONFIG, LINK_CACHE, log_key, reply}; use super::{CHAT_STORE, CONFIG, LINK_CACHE, log_key, reply, reply_html};
use teloxide::RequestError; use teloxide::RequestError;
use teloxide::prelude::*; use teloxide::prelude::*;
use teloxide::types::{ChatId, Message, Recipient}; use teloxide::types::{ChatId, Message, Recipient};
@@ -361,7 +361,9 @@ pub(crate) async fn execute_command(
&fetched.caption, &fetched.caption,
&fetched.media, &fetched.media,
); );
reply(bot, message.chat.id.0, message.id, report).await?; // HTML report: the caption renders inside a <blockquote>
// exactly as it will appear in the sent media message.
reply_html(bot, message.chat.id.0, message.id, report).await?;
} }
} }
} }
@@ -387,12 +389,15 @@ pub async fn register_commands(bot: &Bot) -> Result<(), RequestError> {
/// it even for very large threads (many media lines + a long caption). /// it even for very large threads (many media lines + a long caption).
const MAX_TEST_REPORT_CHARS: usize = 4000; const MAX_TEST_REPORT_CHARS: usize = 4000;
/// Builds the plain-text report for the `/test` command: what the parser /// Builds the HTML report for the `/test` command: what the parser produced
/// produced for a link (site, canonical URL, title/author/tags, caption and /// for a link (site, canonical URL, title/author/tags, caption and the media
/// the media list) — no media is sent and nothing is cached or forwarded. /// list) — no media is sent and nothing is cached or forwarded. Sent with
/// Fields are passed individually so the formatter stays a pure function /// HTML parse mode: raw fields are escaped, the pre-escaped render fields are
/// testable without constructing a `Fetched` (its render fields are /// embedded as-is, and the caption is wrapped in a `<blockquote>` so it shows
/// `pub(crate)` to the x-media crate). /// exactly as it will render in the sent media message. Fields are passed
/// individually so the formatter stays a pure function testable without
/// constructing a `Fetched` (its render fields are `pub(crate)` to the
/// x-media crate).
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn test_parse_report( fn test_parse_report(
url: &str, url: &str,
@@ -405,32 +410,38 @@ fn test_parse_report(
media: &[x_media::media::Media], media: &[x_media::media::Media],
) -> String { ) -> String {
let mut lines = vec![ let mut lines = vec![
format!("Parse result for {url}"), format!("Parse result for {}", html_escape::encode_text(url)),
format!("site: {site_id}"), format!("site: {site_id}"),
format!( format!(
"key: {}", "key: {}",
x_media::site::cache_key(url).unwrap_or_else(|| "<unsupported>".to_string()) html_escape::encode_text(
&x_media::site::cache_key(url).unwrap_or_else(|| "<unsupported>".to_string())
)
), ),
]; ];
lines.push(format!("source_url: {source_url}")); lines.push(format!(
lines.push(format!("title: {title}")); "source_url: {}",
html_escape::encode_text(source_url)
));
lines.push(format!("title: {}", html_escape::encode_text(title)));
if let Some((author, author_url, _title, tags)) = render { if let Some((author, author_url, _title, tags)) = render {
// The render fields are pre-escaped for HTML captions; decode them // The render fields are already pre-escaped for HTML captions; embed
// so the plain-text report shows the text as it will be rendered // them as-is so the report renders them exactly like the final
// (no visible &amp; / &lt; / &gt;). // caption. `author_url` is raw and gets escaped here.
lines.push(format!("author: {author}"));
lines.push(format!( lines.push(format!(
"author: {}", "author_url: {}",
html_escape::decode_html_entities(author) html_escape::encode_text(author_url)
)); ));
lines.push(format!("author_url: {author_url}")); lines.push(format!("tags: {tags}"));
lines.push(format!("tags: {}", html_escape::decode_html_entities(tags)));
} }
lines.push(format!("sensitive: {sensitive}")); lines.push(format!("sensitive: {sensitive}"));
// The caption is wrapped in a <blockquote> so the report (an HTML
// message) shows it exactly as it will render in the sent media caption
// — escaped text and links included.
lines.push(format!( lines.push(format!(
"caption: {}", "caption: <blockquote>{}</blockquote>",
x_media::site::truncate_caption(&html_escape::decode_html_entities(&strip_html_tags( x_media::site::truncate_caption(caption)
caption
)))
)); ));
lines.push(format!("media ({}):", media.len())); lines.push(format!("media ({}):", media.len()));
for (i, item) in media.iter().enumerate() { for (i, item) in media.iter().enumerate() {
@@ -439,7 +450,11 @@ fn test_parse_report(
x_media::media::Media::Video { .. } => "video", x_media::media::Media::Video { .. } => "video",
x_media::media::Media::Animated { .. } => "gif", x_media::media::Media::Animated { .. } => "gif",
}; };
lines.push(format!(" {}. {kind}: {}", i + 1, item.url())); lines.push(format!(
" {}. {kind}: {}",
i + 1,
html_escape::encode_text(item.url())
));
} }
let mut out = lines.join( let mut out = lines.join(
" "
@@ -452,31 +467,9 @@ fn test_parse_report(
out out
} }
/// Drops HTML tags from a caption for the plain-text `/test` report, keeping
/// the visible text (the links are reported separately via `source_url` /
/// `author_url`). Runs on the *escaped* caption: entity-encoded content
/// (`&lt;` `&amp;`) is not a tag and survives, then
/// [`html_escape::decode_html_entities`] renders the remaining text — so a
/// tweet text like `>^ω^<` stays intact instead of being eaten as markup.
/// Built-in captions are the only source of tags (custom formats are fully
/// escaped and contain none).
fn strip_html_tags(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_tag = false;
for ch in s.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => out.push(ch),
_ => {}
}
}
out
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{MAX_TEST_REPORT_CHARS, strip_html_tags, test_parse_report}; use super::{MAX_TEST_REPORT_CHARS, test_parse_report};
use x_media::media::Media; use x_media::media::Media;
#[test] #[test]
@@ -531,10 +524,11 @@ mod tests {
} }
#[test] #[test]
fn test_parse_report_renders_caption_as_plain_text() { fn test_parse_report_wraps_caption_in_blockquote() {
// The report is a plain-text message: pre-escaped caption fields and // The report is an HTML message: raw fields are escaped, pre-escaped
// the HTML caption must be shown as rendered — tags stripped, entities // render fields are embedded as-is, and the caption is wrapped in a
// decoded — never with visible `<a href>` markup or &amp; / &lt; / &gt;. // <blockquote> so it shows exactly as it will render in the sent
// media caption (escaped text and links included).
let report = test_parse_report( let report = test_parse_report(
"https://x.com/u/status/1", "https://x.com/u/status/1",
"twitter", "twitter",
@@ -550,27 +544,22 @@ mod tests {
"<a href=\"https://x.com/u\">A &amp; B</a>: C &lt;D&gt; &amp; E", "<a href=\"https://x.com/u\">A &amp; B</a>: C &lt;D&gt; &amp; E",
&[], &[],
); );
assert!(report.contains("title: A & B <C>"), "{report}"); // Raw fields escaped (they render back to the original text in HTML).
assert!(report.contains("author: A & B"), "{report}"); assert!(report.contains("title: A &amp; B &lt;C&gt;"), "{report}");
assert!(report.contains("tags: #a & #b"), "{report}"); assert!(
// Anchor markup gone, entity-encoded text preserved through the strip report.contains("source_url: https://x.com/u/status/1"),
// and then decoded. "{report}"
assert!(report.contains("caption: A & B: C <D> & E"), "{report}"); );
for entity in ["&amp;", "&lt;", "&gt;"] { // Pre-escaped render fields embedded as-is.
assert!(!report.contains(entity), "unexpected {entity} in: {report}"); assert!(report.contains("author: A &amp; B"), "{report}");
} assert!(report.contains("tags: #a &amp; #b"), "{report}");
assert!(!report.contains("<a href"), "raw markup in: {report}"); // Caption wrapped in a blockquote with its HTML preserved.
} assert!(
report.contains(
#[test] "caption: <blockquote><a href=\"https://x.com/u\">A &amp; B</a>: C &lt;D&gt; &amp; E</blockquote>"
fn strip_html_tags_keeps_entity_encoded_text() { ),
// The strip runs on the escaped caption: `&lt;` is an entity, not a "{report}"
// tag, and must survive so the subsequent decode renders it as `<`.
assert_eq!(
strip_html_tags("<a href=\"https://x.com/u\">A &amp; B</a>: &gt;^ω^&lt;"),
"A &amp; B: &gt;^ω^&lt;"
); );
assert_eq!(strip_html_tags("plain text"), "plain text");
} }
#[test] #[test]
+18 -1
View File
@@ -22,7 +22,7 @@ use crate::media_sender::MediaSender;
use commands::{Command, execute_command}; use commands::{Command, execute_command};
use teloxide::RequestError; use teloxide::RequestError;
use teloxide::prelude::*; use teloxide::prelude::*;
use teloxide::types::{ChatId, ChatKind, Message, MessageId, ParseMode}; use teloxide::types::{ChatId, ChatKind, Message, MessageId, ParseMode, ReplyParameters};
use teloxide::utils::command::BotCommands; use teloxide::utils::command::BotCommands;
use urls::{URL_JOBS, extract_urls}; use urls::{URL_JOBS, extract_urls};
@@ -42,6 +42,23 @@ where
.await .await
} }
/// Reply to a message by id with HTML parse mode (same reply decoration as
/// [`reply`]). Used by `/test`, whose report is an HTML message (the caption
/// is wrapped in a `<blockquote>` to show it exactly as it will render).
pub(crate) async fn reply_html(
bot: &Bot,
chat_id: i64,
reply_to: MessageId,
text: String,
) -> Result<Message, RequestError> {
// `<Bot as Requester>::` disambiguates from the MediaSender trait's
// same-named method (see media_sender.rs).
<Bot as Requester>::send_message(bot, ChatId(chat_id), text)
.parse_mode(ParseMode::Html)
.reply_parameters(ReplyParameters::new(reply_to).allow_sending_without_reply())
.await
}
/// Log prefix tying the whole lifecycle of one link (fetch → send → cache → /// Log prefix tying the whole lifecycle of one link (fetch → send → cache →
/// forward) together: the normalized cache key (`twitter:123…`, `pixiv:123`, /// forward) together: the normalized cache key (`twitter:123…`, `pixiv:123`,
/// `bsky:handle/rkey`) instead of the raw URL, so logs stay short and do not /// `bsky:handle/rkey`) instead of the raw URL, so logs stay short and do not