From 90a011e9780ef1f70bd0f61d6c327dc2b803d67c Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 16 Aug 2026 17:31:54 +0800 Subject: [PATCH] feat(commands): wrap the /test caption in a blockquote (HTML report) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
...
so the report shows it exactly as it will render in the sent media caption — escaped text and clickable links included, no literal &/</> and no raw markup. --- AGENTS.md | 2 +- crates/xmedia-bot/src/handlers/commands.rs | 131 ++++++++++----------- crates/xmedia-bot/src/handlers/mod.rs | 19 ++- 3 files changed, 79 insertions(+), 73 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ba19782..48a2878 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. -Debug command: `/test ` 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 `&`/`<`/`>`. 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 ` 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 `
` 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, sensitive, site_id, … }`; `caption_with(format)` substitutes `{url} {author} {author_url} {title} {tags}`. diff --git a/crates/xmedia-bot/src/handlers/commands.rs b/crates/xmedia-bot/src/handlers/commands.rs index 5b0edfb..d40948d 100644 --- a/crates/xmedia-bot/src/handlers/commands.rs +++ b/crates/xmedia-bot/src/handlers/commands.rs @@ -1,7 +1,7 @@ //! Bot command parsing, the `/`-command executor and `setMyCommands` //! 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::prelude::*; use teloxide::types::{ChatId, Message, Recipient}; @@ -361,7 +361,9 @@ pub(crate) async fn execute_command( &fetched.caption, &fetched.media, ); - reply(bot, message.chat.id.0, message.id, report).await?; + // HTML report: the caption renders inside a
+ // 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). const MAX_TEST_REPORT_CHARS: usize = 4000; -/// Builds the plain-text report for the `/test` command: what the parser -/// produced for a link (site, canonical URL, title/author/tags, caption and -/// the media list) — no media is sent and nothing is cached or forwarded. -/// 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). +/// Builds the HTML report for the `/test` command: what the parser produced +/// for a link (site, canonical URL, title/author/tags, caption and the media +/// list) — no media is sent and nothing is cached or forwarded. Sent with +/// HTML parse mode: raw fields are escaped, the pre-escaped render fields are +/// embedded as-is, and the caption is wrapped in a `
` so it shows +/// 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)] fn test_parse_report( url: &str, @@ -405,32 +410,38 @@ fn test_parse_report( media: &[x_media::media::Media], ) -> String { let mut lines = vec![ - format!("Parse result for {url}"), + format!("Parse result for {}", html_escape::encode_text(url)), format!("site: {site_id}"), format!( "key: {}", - x_media::site::cache_key(url).unwrap_or_else(|| "".to_string()) + html_escape::encode_text( + &x_media::site::cache_key(url).unwrap_or_else(|| "".to_string()) + ) ), ]; - lines.push(format!("source_url: {source_url}")); - lines.push(format!("title: {title}")); + lines.push(format!( + "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 { - // The render fields are pre-escaped for HTML captions; decode them - // so the plain-text report shows the text as it will be rendered - // (no visible & / < / >). + // The render fields are already pre-escaped for HTML captions; embed + // them as-is so the report renders them exactly like the final + // caption. `author_url` is raw and gets escaped here. + lines.push(format!("author: {author}")); lines.push(format!( - "author: {}", - html_escape::decode_html_entities(author) + "author_url: {}", + html_escape::encode_text(author_url) )); - lines.push(format!("author_url: {author_url}")); - lines.push(format!("tags: {}", html_escape::decode_html_entities(tags))); + lines.push(format!("tags: {tags}")); } lines.push(format!("sensitive: {sensitive}")); + // The caption is wrapped in a
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!( - "caption: {}", - x_media::site::truncate_caption(&html_escape::decode_html_entities(&strip_html_tags( - caption - ))) + "caption:
{}
", + x_media::site::truncate_caption(caption) )); lines.push(format!("media ({}):", media.len())); 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::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( " @@ -452,31 +467,9 @@ fn test_parse_report( 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 -/// (`<` `&`) 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)] 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; #[test] @@ -531,10 +524,11 @@ mod tests { } #[test] - fn test_parse_report_renders_caption_as_plain_text() { - // The report is a plain-text message: pre-escaped caption fields and - // the HTML caption must be shown as rendered — tags stripped, entities - // decoded — never with visible `` markup or & / < / >. + fn test_parse_report_wraps_caption_in_blockquote() { + // The report is an HTML message: raw fields are escaped, pre-escaped + // render fields are embedded as-is, and the caption is wrapped in a + //
so it shows exactly as it will render in the sent + // media caption (escaped text and links included). let report = test_parse_report( "https://x.com/u/status/1", "twitter", @@ -550,27 +544,22 @@ mod tests { "A & B: C <D> & E", &[], ); - assert!(report.contains("title: A & B "), "{report}"); - assert!(report.contains("author: A & B"), "{report}"); - assert!(report.contains("tags: #a & #b"), "{report}"); - // Anchor markup gone, entity-encoded text preserved through the strip - // and then decoded. - assert!(report.contains("caption: A & B: C & E"), "{report}"); - for entity in ["&", "<", ">"] { - assert!(!report.contains(entity), "unexpected {entity} in: {report}"); - } - assert!(!report.contains("A & B: >^ω^<"), - "A & B: >^ω^<" + // Raw fields escaped (they render back to the original text in HTML). + assert!(report.contains("title: A & B <C>"), "{report}"); + assert!( + report.contains("source_url: https://x.com/u/status/1"), + "{report}" + ); + // Pre-escaped render fields embedded as-is. + assert!(report.contains("author: A & B"), "{report}"); + assert!(report.contains("tags: #a & #b"), "{report}"); + // Caption wrapped in a blockquote with its HTML preserved. + assert!( + report.contains( + "caption:
A & B: C <D> & E
" + ), + "{report}" ); - assert_eq!(strip_html_tags("plain text"), "plain text"); } #[test] diff --git a/crates/xmedia-bot/src/handlers/mod.rs b/crates/xmedia-bot/src/handlers/mod.rs index 30038d9..9eb9b33 100644 --- a/crates/xmedia-bot/src/handlers/mod.rs +++ b/crates/xmedia-bot/src/handlers/mod.rs @@ -22,7 +22,7 @@ use crate::media_sender::MediaSender; use commands::{Command, execute_command}; use teloxide::RequestError; 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 urls::{URL_JOBS, extract_urls}; @@ -42,6 +42,23 @@ where .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 `
` 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 { + // `::` disambiguates from the MediaSender trait's + // same-named method (see media_sender.rs). + ::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 → /// 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