From c968891ff69a2a10624621a4bd65fb91e07ae442 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 16 Aug 2026 17:01:47 +0800 Subject: [PATCH] fix(commands): render the /test caption as plain text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report's caption line still showed the raw HTML markup (...). strip_html_tags now drops the tags (keeping the visible text; the links are already reported via source_url / author_url) and the remaining entity-encoded text is decoded — the strip runs on the escaped caption so a tweet text like >^ω^< survives instead of being eaten as markup. Custom-format captions contain no tags and pass through unchanged. --- crates/xmedia-bot/src/handlers/commands.rs | 53 ++++++++++++++++++---- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/crates/xmedia-bot/src/handlers/commands.rs b/crates/xmedia-bot/src/handlers/commands.rs index 9ef39d8..5b0edfb 100644 --- a/crates/xmedia-bot/src/handlers/commands.rs +++ b/crates/xmedia-bot/src/handlers/commands.rs @@ -428,7 +428,9 @@ fn test_parse_report( lines.push(format!("sensitive: {sensitive}")); lines.push(format!( "caption: {}", - x_media::site::truncate_caption(&html_escape::decode_html_entities(caption)) + x_media::site::truncate_caption(&html_escape::decode_html_entities(&strip_html_tags( + caption + ))) )); lines.push(format!("media ({}):", media.len())); for (i, item) in media.iter().enumerate() { @@ -450,9 +452,31 @@ 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, test_parse_report}; + use super::{MAX_TEST_REPORT_CHARS, strip_html_tags, test_parse_report}; use x_media::media::Media; #[test] @@ -507,10 +531,10 @@ mod tests { } #[test] - fn test_parse_report_decodes_html_entities_for_display() { + 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 decoded (as rendered), never with - // visible & / < / >. + // the HTML caption must be shown as rendered — tags stripped, entities + // decoded — never with visible `` markup or & / < / >. let report = test_parse_report( "https://x.com/u/status/1", "twitter", @@ -529,13 +553,24 @@ mod tests { assert!(report.contains("title: A & B "), "{report}"); assert!(report.contains("author: A & B"), "{report}"); assert!(report.contains("tags: #a & #b"), "{report}"); - assert!( - report.contains("caption: A & B: C & E"), - "{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: >^ω^<" + ); + assert_eq!(strip_html_tags("plain text"), "plain text"); } #[test]