diff --git a/AGENTS.md b/AGENTS.md index deff08f..81c2c05 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 ≤ 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 ` 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 `
` so it renders exactly like the sent media caption (escaped text and links included). +Debug command: `/debug ` 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 `
` 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. diff --git a/crates/xmedia-bot/src/handlers/commands.rs b/crates/xmedia-bot/src/handlers/commands.rs index a25e90c..99d9d3c 100644 --- a/crates/xmedia-bot/src/handlers/commands.rs +++ b/crates/xmedia-bot/src/handlers/commands.rs @@ -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
@@ -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\nAuthor: 正…", + "https://x.com/u/status/1", + fields, + 200, + ); + assert!(quoted.contains("
"), "{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);