fix(commands): make /debug preview the caption a link would actually send

`/debug` reported `Fetched::caption`, the site's built-in caption, so a
chat's `/set_format` override never showed up in the preview — the command
looked like a no-op, and the `/set_format` success reply now tells users to
preview with `/debug`, which made that advice wrong.

`preview_caption` mirrors the send paths instead: the per-site format
override (`caption_from_fields`, empty → built-in) plus the long-post
quoting. Reported from a live run against the test bot.
This commit is contained in:
2026-09-20 17:30:55 +08:00
parent d4c36feb9a
commit 5b77d14497
2 changed files with 113 additions and 3 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 ≤ 10, 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: `/debug <url>` runs the same `x_media::site::fetch` and replies with `debug_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).
Debug command: `/debug <url>` runs the same `x_media::site::fetch` and replies with `debug_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). The caption it shows is `preview_caption`'s: the chat's per-site format override plus the long-post quoting, i.e. exactly what the send paths produce — showing the raw built-in caption made `/set_format` look like a no-op, and the `/set_format` success reply points users at `/debug` to preview.
User-facing failure text is a function of the error class, never one generic sentence: `urls::fetch_error_message` maps `FetchError::NotFound` (post gone), `Sensitive` (withheld, needs `TWITTER_AUTH_TOKEN`), `Blocked` (source risk control), `Disabled { site }` (a registered site switched off — pixiv without a token, the one case `fetch` answers `Err` instead of `Ok(None)`) and `Transient`/`Http` (source down) apart. The same distinction drives the group hint: a supported link posted in a group (not a channel) gets one `GROUP_LINK_HINT` reply, because the link pipeline is private-chat only.
+112 -2
View File
@@ -619,6 +619,24 @@ pub(crate) async fn execute_command(
.await?;
}
Ok(Some(fetched)) => {
// The preview must show what a link would actually send:
// the chat's per-site format override plus the long-post
// quoting. Rendering the raw built-in caption here made
// `/set_format` look like it did nothing.
let format = CHAT_STORE
.get(message.chat.id.0)
.await
.message_format
.get(fetched.site_name())
.cloned()
.unwrap_or_default();
let caption = preview_caption(
&format,
&fetched.caption,
&fetched.source_url,
fetched.render_fields(),
CONFIG.caption_quote_text_chars,
);
let report = debug_report(
url,
fetched.site_name(),
@@ -627,7 +645,7 @@ pub(crate) async fn execute_command(
&fetched.content,
fetched.render_fields(),
fetched.sensitive,
&fetched.caption,
&caption,
&fetched.media,
);
// HTML report: the caption renders inside a <blockquote>
@@ -682,6 +700,31 @@ const MAX_DEBUG_REPORT_CHARS: usize = 4000;
/// message, so it must stay under Telegram's 4096-char limit.
const MAX_DEBUG_DUMP_CHARS: usize = 3500;
/// The caption a link would actually send for this chat: the per-site format
/// override (empty = the site's built-in caption) and, on a long post, the
/// same text quoting the send paths apply. `/debug` shows this so the preview
/// cannot drift from what the send paths produce.
fn preview_caption(
format: &str,
built_in: &str,
url: &str,
fields: Option<(&str, &str, &str, &str, &str)>,
quote_chars: usize,
) -> String {
let caption = match fields {
// Same call the send paths make through `Fetched::caption_with`: an
// empty format falls back to the built-in caption.
Some((author, author_url, title, content, tags)) => x_media::site::caption_from_fields(
format, built_in, url, author, author_url, title, content, tags,
),
None => x_media::site::truncate_caption(built_in),
};
let text = fields
.map(|(_, _, title, content, _)| x_media::site::compose_text(title, content))
.unwrap_or_default();
crate::send::quote_long_caption(&caption, &text, quote_chars).into_owned()
}
/// Builds the HTML report for the `/debug` 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
@@ -764,7 +807,9 @@ fn debug_report(
#[cfg(test)]
mod tests {
use super::{MAX_DEBUG_REPORT_CHARS, debug_report, settings_text, unknown_placeholder};
use super::{
MAX_DEBUG_REPORT_CHARS, debug_report, preview_caption, settings_text, unknown_placeholder,
};
use x_media::media::Media;
#[test]
@@ -1057,6 +1102,71 @@ mod tests {
}
}
#[test]
fn preview_caption_applies_the_chat_format_and_the_long_post_quote() {
let fields = Some((
"Author",
"https://x.com/u",
"Pinned title",
"Pinned body",
"#tag",
));
// No format override → the site's built-in caption, untouched.
assert_eq!(
preview_caption(
"",
"built-in caption",
"https://x.com/u/status/1",
fields,
200
),
"built-in caption"
);
// The bug this pins: `/debug` used to print the built-in caption even
// with a format set, so `/set_format` looked like it did nothing.
let formatted = preview_caption(
"{author} · {title}",
"built-in caption",
"https://x.com/u/status/1",
fields,
200,
);
assert_eq!(formatted, "Author · Pinned title");
// `{url}` comes from the canonical post URL, as in the send paths.
assert_eq!(
preview_caption(
"{url} {title}",
"built-in",
"https://x.com/u/status/1",
fields,
200
),
"https://x.com/u/status/1 Pinned title"
);
// A long post's text is quoted exactly like the send paths quote it.
let long = "".repeat(300);
let fields = Some(("Author", "https://x.com/u", "", long.as_str(), ""));
let quoted = preview_caption(
"",
"https://x.com/u/status/1\n<a href=\"https://x.com/u\">Author</a>: 正…",
"https://x.com/u/status/1",
fields,
200,
);
assert!(quoted.contains("<blockquote expandable>"), "{quoted}");
// Without render fields (a site that does not expose them) the
// built-in caption is all there is.
assert_eq!(
preview_caption("", "built-in", "https://x.com/u/status/1", None, 200),
"built-in"
);
}
#[test]
fn unknown_placeholder_finds_typos_only() {
assert_eq!(unknown_placeholder("{author} — {title}"), None);