mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-23 23:32:05 +00:00
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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,14 @@ pub async fn start_url_workers() {
|
||||
let job = rx.lock().await.recv().await;
|
||||
match job {
|
||||
Some((message, url)) => {
|
||||
url_media(&CONTEXT, message.chat.id.0, message.id.0 as i64, &url).await
|
||||
url_media(
|
||||
&CONTEXT,
|
||||
message.chat.id.0,
|
||||
message.id.0 as i64,
|
||||
&url,
|
||||
PostSend::FromChat,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
@@ -208,8 +215,20 @@ async fn dispatch_send(
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a send also runs the chat's post-send actions. `/test` sends with
|
||||
/// them suppressed so a test can never forward to the channel or open the
|
||||
/// edit-before-forward prompt; a normal link uses whatever the chat is
|
||||
/// configured with.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum PostSend {
|
||||
/// Apply the chat's `forward_channel_id` / `edit_before_forward`.
|
||||
FromChat,
|
||||
/// Send only: no channel forward, no edit prompt.
|
||||
Suppressed,
|
||||
}
|
||||
|
||||
/// Builds the send task from ready-made items, sharing the payload shape
|
||||
/// between the fresh-fetch and link-cache paths.
|
||||
/// between the fresh-fetch, link-cache and `/test` paths.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_send_task(
|
||||
chat_data: &ChatData,
|
||||
@@ -219,7 +238,14 @@ fn build_send_task(
|
||||
caption: String,
|
||||
items: Vec<MediaItemPayload>,
|
||||
cache_data: Option<CachedPost>,
|
||||
post_send: PostSend,
|
||||
) -> Task {
|
||||
// Notification ids stay set in both modes: a queued retry that
|
||||
// dead-letters should still tell the chat.
|
||||
let (edit_before_forward, forward_channel_id) = match post_send {
|
||||
PostSend::FromChat => (chat_data.edit_before_forward, chat_data.forward_channel_id),
|
||||
PostSend::Suppressed => (false, None),
|
||||
};
|
||||
if items.len() == 1 && matches!(items[0], MediaItemPayload::Animation { .. }) {
|
||||
Task::SendAnimation {
|
||||
chat_id,
|
||||
@@ -227,8 +253,8 @@ fn build_send_task(
|
||||
caption,
|
||||
animation: items.into_iter().next().unwrap(),
|
||||
source_url,
|
||||
edit_before_forward: chat_data.edit_before_forward,
|
||||
forward_channel_id: chat_data.forward_channel_id,
|
||||
edit_before_forward,
|
||||
forward_channel_id,
|
||||
notify_chat_id: Some(chat_id),
|
||||
notify_message_id: Some(reply_to_message_id),
|
||||
cache_data,
|
||||
@@ -244,8 +270,8 @@ fn build_send_task(
|
||||
batch_index: 0,
|
||||
sent_message_ids: vec![],
|
||||
source_url,
|
||||
edit_before_forward: chat_data.edit_before_forward,
|
||||
forward_channel_id: chat_data.forward_channel_id,
|
||||
edit_before_forward,
|
||||
forward_channel_id,
|
||||
notify_chat_id: Some(chat_id),
|
||||
notify_message_id: Some(reply_to_message_id),
|
||||
cache_data,
|
||||
@@ -253,7 +279,19 @@ fn build_send_task(
|
||||
}
|
||||
}
|
||||
|
||||
async fn url_media(ctx: &AppContext<'_>, chat_id: i64, reply_to_message_id: i64, url: &str) {
|
||||
/// The per-URL pipeline: link cache → fetch → build → send → post-send.
|
||||
///
|
||||
/// `post_send` selects whether the chat's forward/edit settings apply: the URL
|
||||
/// workers pass [`PostSend::FromChat`], the `/test` command
|
||||
/// [`PostSend::Suppressed`]. Everything else (cache write, retry enqueue,
|
||||
/// dead-letter notification) is identical.
|
||||
pub(crate) async fn url_media(
|
||||
ctx: &AppContext<'_>,
|
||||
chat_id: i64,
|
||||
reply_to_message_id: i64,
|
||||
url: &str,
|
||||
post_send: PostSend,
|
||||
) {
|
||||
let reply_to = MessageId(reply_to_message_id as i32);
|
||||
if let Err(e) = ctx
|
||||
.sender
|
||||
@@ -324,6 +362,7 @@ async fn url_media(ctx: &AppContext<'_>, chat_id: i64, reply_to_message_id: i64,
|
||||
caption,
|
||||
items,
|
||||
Some(cached),
|
||||
post_send,
|
||||
);
|
||||
dispatch_send(ctx, chat_id, reply_to, &task, url).await;
|
||||
return;
|
||||
@@ -392,6 +431,7 @@ async fn url_media(ctx: &AppContext<'_>, chat_id: i64, reply_to_message_id: i64,
|
||||
caption,
|
||||
items,
|
||||
cache_data,
|
||||
post_send,
|
||||
);
|
||||
// Hand the keep-alive temp dir (ugoira / bsky remux MP4) to the
|
||||
// retry registry: a queued retry runs after this function returns
|
||||
@@ -449,7 +489,7 @@ mod tests {
|
||||
.put("twitter:1", &cached_photo_entry())
|
||||
.await;
|
||||
|
||||
url_media(&ctx, 1, 2, "https://x.com/u/status/1").await;
|
||||
url_media(&ctx, 1, 2, "https://x.com/u/status/1", PostSend::FromChat).await;
|
||||
|
||||
// The cached file id went out as a group send; the permanent failure
|
||||
// then triggered the fire-and-forget reply (its mock error is fine).
|
||||
@@ -477,7 +517,7 @@ mod tests {
|
||||
.put("twitter:1", &cached_photo_entry())
|
||||
.await;
|
||||
|
||||
url_media(&ctx, 1, 2, "https://x.com/u/status/1").await;
|
||||
url_media(&ctx, 1, 2, "https://x.com/u/status/1", PostSend::FromChat).await;
|
||||
|
||||
assert_eq!(sender.calls(), vec!["send_chat_action", "send_media_group"]);
|
||||
// Success must not evict the entry.
|
||||
@@ -498,7 +538,128 @@ mod tests {
|
||||
|
||||
// No cache key → the fetch dispatcher returns Ok(None) without any
|
||||
// network; nothing is sent or replied.
|
||||
url_media(&ctx, 1, 2, "https://example.com/not-a-post").await;
|
||||
url_media(
|
||||
&ctx,
|
||||
1,
|
||||
2,
|
||||
"https://example.com/not-a-post",
|
||||
PostSend::FromChat,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(sender.calls(), vec!["send_chat_action"]);
|
||||
}
|
||||
|
||||
// ── Send modes: the URL flow vs `/test` ─────────────────────────────
|
||||
|
||||
/// A chat that has both post-send actions configured.
|
||||
async fn seed_post_send_settings(ctx: &AppContext<'_>) {
|
||||
ctx.chat_store
|
||||
.update(1, |data| {
|
||||
data.forward_channel_id = Some(2);
|
||||
data.edit_before_forward = true;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_settings_apply_to_the_normal_link_flow() {
|
||||
let stores = TestStores::new();
|
||||
let sender =
|
||||
MockSender::scripted(vec![Outcome::GroupOk, Outcome::MessageOk], permanent_error);
|
||||
let ctx = stores.ctx(&sender);
|
||||
stores
|
||||
.link_cache()
|
||||
.put("twitter:1", &cached_photo_entry())
|
||||
.await;
|
||||
seed_post_send_settings(&ctx).await;
|
||||
|
||||
url_media(&ctx, 1, 2, "https://x.com/u/status/1", PostSend::FromChat).await;
|
||||
|
||||
// Media group, then the edit prompt (edit-before-forward wins over the
|
||||
// channel forward, which only runs once the prompt is confirmed).
|
||||
assert_eq!(
|
||||
sender.calls(),
|
||||
vec!["send_chat_action", "send_media_group", "send_message"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mode_sends_the_media_without_forwarding_or_editing() {
|
||||
let stores = TestStores::new();
|
||||
// Only the group send is scripted: any forward (copy_messages) or edit
|
||||
// prompt (send_message) would panic with "unexpected outcome".
|
||||
let sender = MockSender::scripted(vec![Outcome::GroupOk], permanent_error);
|
||||
let ctx = stores.ctx(&sender);
|
||||
stores
|
||||
.link_cache()
|
||||
.put("twitter:1", &cached_photo_entry())
|
||||
.await;
|
||||
seed_post_send_settings(&ctx).await;
|
||||
|
||||
url_media(&ctx, 1, 2, "https://x.com/u/status/1", PostSend::Suppressed).await;
|
||||
|
||||
assert_eq!(sender.calls(), vec!["send_chat_action", "send_media_group"]);
|
||||
// The send is otherwise ordinary: the post stays cached.
|
||||
assert!(
|
||||
stores
|
||||
.link_cache()
|
||||
.get("twitter:1", Duration::from_secs(3600))
|
||||
.await
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_mode_decides_whether_chat_actions_ride_along() {
|
||||
let chat = ChatData {
|
||||
forward_channel_id: Some(2),
|
||||
edit_before_forward: true,
|
||||
..ChatData::default()
|
||||
};
|
||||
|
||||
let with_chat = build_send_task(
|
||||
&chat,
|
||||
1,
|
||||
2,
|
||||
"https://x.com/u/status/1".into(),
|
||||
"cap".into(),
|
||||
vec![],
|
||||
None,
|
||||
PostSend::FromChat,
|
||||
);
|
||||
let Task::SendMediaSequence {
|
||||
edit_before_forward,
|
||||
forward_channel_id,
|
||||
..
|
||||
} = with_chat
|
||||
else {
|
||||
panic!("expected a media sequence task");
|
||||
};
|
||||
assert!(edit_before_forward);
|
||||
assert_eq!(forward_channel_id, Some(2));
|
||||
|
||||
let suppressed = build_send_task(
|
||||
&chat,
|
||||
1,
|
||||
2,
|
||||
"https://x.com/u/status/1".into(),
|
||||
"cap".into(),
|
||||
vec![],
|
||||
None,
|
||||
PostSend::Suppressed,
|
||||
);
|
||||
let Task::SendMediaSequence {
|
||||
edit_before_forward,
|
||||
forward_channel_id,
|
||||
notify_chat_id,
|
||||
..
|
||||
} = suppressed
|
||||
else {
|
||||
panic!("expected a media sequence task");
|
||||
};
|
||||
assert!(!edit_before_forward, "`/test` must not open an edit prompt");
|
||||
assert_eq!(forward_channel_id, None, "`/test` must not forward");
|
||||
// Dead-letter notification still reaches the chat that asked.
|
||||
assert_eq!(notify_chat_id, Some(1));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user