diff --git a/crates/xmedia-bot/src/handlers/urls.rs b/crates/xmedia-bot/src/handlers/urls.rs index d12d00f..22aec7f 100644 --- a/crates/xmedia-bot/src/handlers/urls.rs +++ b/crates/xmedia-bot/src/handlers/urls.rs @@ -615,19 +615,25 @@ async fn url_media_inner( let _ = reply(ctx.sender, chat_id, reply_to, fetch_error_message(e)).await; } Ok(Some(fetched)) => { - if fetched.media.is_empty() { - let _ = reply( - ctx.sender, - chat_id, - reply_to, - "No media found or media type is not supported.", - ) - .await; - return; - } let chat_data = ctx.chat_store.get(chat_id).await; // Per-site caption format override (empty -> built-in caption). let format = chat_data.format_for(fetched.site_id); + if fetched.media.is_empty() { + // A post with no media is still a post: its text goes out as a + // message (through the same caption the media path would + // attach, plus the long-post quoting the senders apply to + // their own), instead of answering "No media found" to text + // the fetch already parsed. + let text = fetched + .render_fields() + .map(|(_, _, title, content, _)| x_media::site::compose_text(title, content)) + .unwrap_or_default(); + let caption = fetched.caption_with(&format); + let caption = + send::quote_long_caption(&caption, &text, ctx.config.caption_quote_text_chars); + send::send_text_post(ctx, chat_id, reply_to.0 as i64, caption.into_owned()).await; + return; + } let caption = fetched.caption_with(&format); // Raw render data for the link cache; the send fills in the // Telegram file ids and persists the entry. @@ -820,6 +826,36 @@ mod tests { } } + /// A post with no media of its own is delivered as its text instead of + /// "No media found" (live: reaching the branch needs a real fetch, since + /// `Fetched` cannot be built outside x-media). Run with + /// `cargo test -p xmedia-bot -- --ignored live`. + #[tokio::test] + #[ignore = "live network: fetches the post from its site"] + async fn live_a_text_only_link_is_sent_as_text() { + let stores = TestStores::new(); + let sender = MockSender::scripted(vec![Outcome::MessageOk], permanent_error); + let ctx = stores.ctx(&sender); + + url_media( + &ctx, + 1, + 2, + "https://x.com/i/status/1992471125734142256", + PostSend::FromChat, + ) + .await; + + assert_eq!( + sender.calls(), + vec!["send_chat_action", "send_message"], + "a media-less post is one message, not a media send" + ); + let text = &sender.messages()[0]; + assert!(text.contains("https://x.com/"), "{text}"); + assert!(text.contains("1992471125734142256"), "{text}"); + } + #[tokio::test] async fn unsupported_url_is_ignored_silently() { let stores = TestStores::new(); diff --git a/crates/xmedia-bot/src/send/mod.rs b/crates/xmedia-bot/src/send/mod.rs index 46e80f1..860dea5 100644 --- a/crates/xmedia-bot/src/send/mod.rs +++ b/crates/xmedia-bot/src/send/mod.rs @@ -657,6 +657,44 @@ pub async fn send_animation(ctx: &AppContext<'_>, task: &Task) -> Result, + chat_id: i64, + reply_to: i64, + caption: String, +) { + match ctx + .sender + .send_message( + ChatId(chat_id), + caption, + Some(MessageId(reply_to as i32)), + None, + ) + .await + { + Ok(_) => log::info!("sent the post's text for chat={chat_id}"), + Err(e) => { + log::warn!("could not send the post's text for chat={chat_id}: {e}"); + notify_failure( + ctx.sender, + Some(chat_id), + Some(reply_to), + "Could not send this post's text.", + ) + .await; + } + } +} + /// Copies already-sent messages to the forward channel. No download fallback: /// the files are already on Telegram's servers. pub async fn forward_messages(ctx: &AppContext<'_>, task: &Task) -> Result<(), SendError> { @@ -704,7 +742,7 @@ mod tests { use super::post_send::{build_edit_markup, cache_sent_task}; use super::upload::sniff_ext; use super::*; - use crate::ctx::test_support::{TestStores, cached_photo, photo_item}; + use crate::ctx::test_support::{TestStores, api_error, cached_photo, photo_item}; use std::collections::HashMap; use std::time::Duration; use teloxide::ApiError; @@ -785,6 +823,40 @@ mod tests { assert!(matches!(photos_first(items)[0], Photo { .. })); } + /// A media-less post goes out as a message; a failure is reported rather + /// than swallowed (there is no task to retry). + #[tokio::test] + async fn a_text_post_is_sent_or_reported() { + let sender = MockSender::scripted(vec![Outcome::MessageOk], || api_error("boom")); + let stores = TestStores::new(); + let ctx = stores.ctx(&sender); + + send_text_post( + &ctx, + 1, + 2, + "https://x.com/u/status/1\nu: hello".to_string(), + ) + .await; + + assert_eq!(sender.calls(), vec!["send_message"]); + assert_eq!( + sender.messages(), + vec!["https://x.com/u/status/1\nu: hello"] + ); + + // The failure path: the send fails, the notice follows. + let sender = MockSender::scripted(vec![Outcome::MessageErr, Outcome::MessageOk], || { + api_error("Bad Request: chat not found") + }); + let ctx = stores.ctx(&sender); + + send_text_post(&ctx, 1, 2, "text".into()).await; + + assert_eq!(sender.calls(), vec!["send_message", "send_message"]); + assert!(sender.messages()[1].contains("Could not send this post's text")); + } + #[test] fn retry_delay_seconds_bounds() { for attempts in 0..10 {