feat(commands): /test sends the media, /debug takes over the parse report

- `/test <url>` now runs the ordinary link pipeline and actually sends the
  media, but with the chat's post-send actions suppressed: no channel forward,
  no edit-before-forward prompt. It is the same code path as a normal link
  (same caption/format handling, link cache, retries, dead-letter
  notification), so "does this link work?" is answered by the send itself.
- `/debug <url>` keeps what `/test` used to do: fetch and reply with the HTML
  parse report, sending/caching/forwarding nothing.
- `urls::url_media` takes a `PostSend` mode (`FromChat` for the URL workers,
  `Suppressed` for `/test`); `build_send_task` maps it to the task's
  `edit_before_forward`/`forward_channel_id`. Notification ids stay set in both
  modes, so a queued retry still reports a dead-letter to the chat.
- `/test` rejects an unsupported URL with the same message the old parse-only
  command used (the URL flow would otherwise ignore it silently).
- Report builder renamed `test_parse_report` -> `debug_report` (with the cap
  constant), `parse_test_arg` -> `parse_arg_remainder` (now shared by both
  commands). README/README.en command tables and AGENTS.md updated; `/help`
  descriptions come from the enum.

Tests: +3 (normal flow still honours the chat's settings, `/test` sends with
them suppressed and keeps the cache entry, `build_send_task` mode mapping). The
suppression test was verified to fail when the mode is ignored.
fmt/clippy clean, 73 + 69 tests pass.
This commit is contained in:
2026-09-17 02:25:42 +08:00
parent bd032e3d68
commit 893ab7a1e0
5 changed files with 248 additions and 39 deletions
+68 -24
View File
@@ -1,7 +1,9 @@
//! Bot command parsing, the `/`-command executor and `setMyCommands`
//! registration. URL/inline/callback flows live in their own modules.
use super::urls::{PostSend, url_media};
use super::{CHAT_STORE, CONFIG, LINK_CACHE, log_key, reply, reply_html};
use crate::ctx::AppContext;
use teloxide::RequestError;
use teloxide::prelude::*;
use teloxide::types::{ChatId, Message, Recipient};
@@ -41,17 +43,22 @@ pub(crate) enum Command {
)]
ClearCache(String),
#[command(
description = "Test link parsing (debug; no media sent)",
parse_with = parse_test_arg
description = "Send a link's media (no forwarding)",
parse_with = parse_arg_remainder
)]
Test(String),
#[command(
description = "Parse a link and report it (debug; nothing sent)",
parse_with = parse_arg_remainder
)]
Debug(String),
}
/// `/test` argument parser: the whole remainder after the command name,
/// trimmed. The built-in `split` parser takes exactly one space-separated
/// `/test` and `/debug` argument parser: the whole remainder after the command
/// name, trimmed. The built-in `split` parser takes exactly one space-separated
/// token and rejects the rest, so a URL followed by a trailing space (or
/// pasted text) would silently fall through to the URL flow instead.
fn parse_test_arg(s: String) -> Result<(String,), ParseError> {
fn parse_arg_remainder(s: String) -> Result<(String,), ParseError> {
Ok((s.trim().to_string(),))
}
@@ -346,10 +353,47 @@ pub(crate) async fn execute_command(
.await?;
return Ok(());
}
if x_media::site::cache_key(url).is_none() {
reply(
bot,
message.chat.id.0,
message.id,
"No enabled site matches this link (twitter/x, pixiv, bsky or misskey).",
)
.await?;
return Ok(());
}
// The ordinary link pipeline with the chat's post-send actions
// suppressed: the media is sent (and cached) like a normal link,
// but nothing is forwarded to the channel and no
// edit-before-forward prompt opens. Info level echoes the
// normalized key (never the raw URL) per the logging convention.
log::info!("test: sending [key={}]", log_key(url));
let ctx = AppContext::from_statics(bot);
url_media(
&ctx,
message.chat.id.0,
message.id.0 as i64,
url,
PostSend::Suppressed,
)
.await;
}
Command::Debug(arg) => {
let url = arg.trim();
if url.is_empty() {
reply(
bot,
message.chat.id.0,
message.id,
"Usage: /debug <post url>",
)
.await?;
return Ok(());
}
// Debug tool: report the parse result only — nothing is sent,
// cached or forwarded. Info level echoes the normalized key
// (never the raw URL) per the logging convention.
log::info!("test: parsing [key={}]", log_key(url));
// cached or forwarded.
log::info!("debug: parsing [key={}]", log_key(url));
match x_media::site::fetch(url).await {
Ok(None) => {
reply(
@@ -370,7 +414,7 @@ pub(crate) async fn execute_command(
.await?;
}
Ok(Some(fetched)) => {
let report = test_parse_report(
let report = debug_report(
url,
fetched.site_name(),
&fetched.source_url,
@@ -406,13 +450,13 @@ pub async fn register_commands(bot: &Bot) -> Result<(), RequestError> {
/// Telegram's plain-text message limit is 4096 chars; the report stays under
/// it even for very large threads (many media lines + a long caption).
const MAX_TEST_REPORT_CHARS: usize = 4000;
const MAX_DEBUG_REPORT_CHARS: usize = 4000;
/// Cap for the `/bot_dict` debug dump: the state is echoed as one plain-text
/// message, so it must stay under Telegram's 4096-char limit.
const MAX_DEBUG_DUMP_CHARS: usize = 3500;
/// Builds the HTML report for the `/test` command: what the parser produced
/// 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
/// HTML parse mode: raw fields are escaped, the pre-escaped render fields are
@@ -422,7 +466,7 @@ const MAX_DEBUG_DUMP_CHARS: usize = 3500;
/// constructing a `Fetched` (its render fields are `pub(crate)` to the
/// x-media crate).
#[allow(clippy::too_many_arguments)]
fn test_parse_report(
fn debug_report(
url: &str,
site_id: &str,
source_url: &str,
@@ -483,8 +527,8 @@ fn test_parse_report(
"
",
);
if out.chars().count() > MAX_TEST_REPORT_CHARS {
let end = out.floor_char_boundary(MAX_TEST_REPORT_CHARS - 1);
if out.chars().count() > MAX_DEBUG_REPORT_CHARS {
let end = out.floor_char_boundary(MAX_DEBUG_REPORT_CHARS - 1);
out = format!("{}…", &out[..end]);
}
out
@@ -492,11 +536,11 @@ fn test_parse_report(
#[cfg(test)]
mod tests {
use super::{MAX_TEST_REPORT_CHARS, test_parse_report};
use super::{MAX_DEBUG_REPORT_CHARS, debug_report};
use x_media::media::Media;
#[test]
fn test_parse_report_renders_fields_and_media() {
fn debug_report_renders_fields_and_media() {
let media = vec![
Media::Illustration {
title: None,
@@ -510,7 +554,7 @@ mod tests {
thumbnail_url: "https://cdn.example/2.jpg".into(),
},
];
let report = test_parse_report(
let report = debug_report(
"https://x.com/u/status/1",
"twitter",
"https://x.com/u/status/1",
@@ -539,20 +583,20 @@ mod tests {
}
#[test]
fn test_parse_report_without_render_data_and_no_media() {
let report = test_parse_report("u", "pixiv", "s", "t", None, true, "c", &[]);
fn debug_report_without_render_data_and_no_media() {
let report = debug_report("u", "pixiv", "s", "t", None, true, "c", &[]);
assert!(!report.contains("author:"), "{report}");
assert!(report.contains("sensitive: true"), "{report}");
assert!(report.contains("media (0):"), "{report}");
}
#[test]
fn test_parse_report_wraps_caption_in_blockquote() {
fn debug_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
// <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 = debug_report(
"https://x.com/u/status/1",
"twitter",
"https://x.com/u/status/1",
@@ -586,7 +630,7 @@ mod tests {
}
#[test]
fn test_parse_report_is_capped() {
fn debug_report_is_capped() {
// 200 media lines ≈ 8 KB, comfortably over the cap.
let media: Vec<Media> = (0..200)
.map(|i| Media::Illustration {
@@ -596,8 +640,8 @@ mod tests {
fallback_url: None,
})
.collect();
let report = test_parse_report("u", "twitter", "s", "t", None, false, "c", &media);
assert!(report.chars().count() <= MAX_TEST_REPORT_CHARS, "{report}");
let report = debug_report("u", "twitter", "s", "t", None, false, "c", &media);
assert!(report.chars().count() <= MAX_DEBUG_REPORT_CHARS, "{report}");
assert!(report.ends_with('…'), "{report}");
}
}