feat(urls): deliver a media-less post as its text

A post with no media was answered with "No media found or media type is not
supported.", throwing away text the fetch had already parsed, escaped and
built a caption for (the per-site format and the long-post quoting
included). It now goes out as a message through the same caption the media
path would attach — the senders' own quoting is applied here, since there is
no sender to do it. No queue entry: there is no Task shape for text and a
post with nothing to download is cheap to paste again, so a failure is
reported (send::send_text_post) rather than retried.

Proven live: live_a_text_only_link_is_sent_as_text fetches a real text-only
tweet through url_media and asserts one send_message carrying the post link
and no media send.
This commit is contained in:
2026-09-21 20:24:40 +08:00
parent c549a6d35e
commit f6523e021d
2 changed files with 119 additions and 11 deletions
+46 -10
View File
@@ -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();
+73 -1
View File
@@ -657,6 +657,44 @@ pub async fn send_animation(ctx: &AppContext<'_>, task: &Task) -> Result<Vec<i64
}
}
/// Sends a post that has no media of its own: its caption — the post's URL,
/// author and text, in the chat's per-site format, long-post quoting included
/// — becomes the message. Such a post used to be answered with "No media
/// found", throwing away text the fetch had already parsed and escaped.
///
/// No queue entry: there is no `Task` shape for text, and a post with nothing
/// to download is cheap for the user to paste again, so a failure is reported
/// rather than retried.
pub(crate) async fn send_text_post(
ctx: &AppContext<'_>,
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\n<a>u</a>: hello".to_string(),
)
.await;
assert_eq!(sender.calls(), vec!["send_message"]);
assert_eq!(
sender.messages(),
vec!["https://x.com/u/status/1\n<a>u</a>: 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 {