From af96caff40260e625aaeb3051ff888ad3e5f3edc Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Fri, 18 Sep 2026 00:50:30 +0800 Subject: [PATCH] feat(send): quote a long post's text in an expandable blockquote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A post whose text (the split `title` plus `content`, joined by `site::compose_text`) reaches `CAPTION_QUOTE_TEXT_CHARS` — default 200, `0` disables — now has that text wrapped in `
` inside its caption, leaving the URL and author line outside the quote. Applied at the send boundary (`send_media_sequence`, `send_animation` and the inline answers), where the caption is already truncated and the same cache snapshot supplies the text, so a fresh send, a link-cache resend and a queued retry all decide identically. The text is located as what follows the author link, with the visible prefix accepted as a match because `truncate_caption` may cut inside it — that keeps the longest posts, the ones that most need folding, quoted. Captions whose layout moves the text elsewhere (pixiv's title-inside-a-link, a `/set_format` that puts `{title}`/`{content}` first) stay unquoted rather than risking a blockquote nested in a tag, and a caption that already carries one is never wrapped again. Telegram measures a caption *after entities parsing*, so the tags cost no length and the 1024-character limit cannot be breached; retries replay the unwrapped caption, so a threshold change takes effect immediately. The edit-before-forward rewrite stays unquoted by design. Verified against Telegram: a media-group caption built this way comes back with `caption_entities` `url` @0, `text_link` @50, `expandable_blockquote` @56 — the quote starts after the author line. --- AGENTS.md | 2 +- README.en.md | 2 + README.md | 2 + crates/xmedia-bot/src/config.rs | 6 + crates/xmedia-bot/src/ctx.rs | 6 + crates/xmedia-bot/src/handlers/inline.rs | 19 ++- crates/xmedia-bot/src/handlers/urls.rs | 29 ++++ crates/xmedia-bot/src/media_sender.rs | 13 +- crates/xmedia-bot/src/send/mod.rs | 172 ++++++++++++++++++++++- 9 files changed, 242 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 48eb952..cbb2761 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ Docker: `docker build -t tgxmb .` then `docker run --rm -d --name tgxmb --env-fi - Package manager: **Cargo** (workspace with path dep `x-media` ← `xmedia-bot`). No `[workspace.package]`/shared deps — each crate lists deps independently. - **TLS is rustls end-to-end** (no native-tls/openssl in the tree, no libssl in the Docker runtime image): `teloxide` is declared `default-features = false` with `["webhooks-axum", "macros", "rustls", "ctrlc_handler"]` (the removed `default` also carried `native-tls` and `ctrlc_handler` — the latter must stay); x-media's reqwest is `default-features = false` with `["json", "rustls-tls"]` (webpki-roots baked in, so the image ships no CA bundle). One reqwest 0.12.28 in the lock. - **Versioning**: bump the version in all three places (`crates/x-media/Cargo.toml`, `crates/xmedia-bot/Cargo.toml`, `Cargo.lock`) and **keep `README.md`, `README.en.md` and `AGENTS.md` in sync with the code on every bump**, then commit (`chore: bump version to X.Y.Z`), create an annotated tag `vX.Y.Z`, and push branch + tag (the tag push triggers the Docker Hub build). The tag must equal both crate versions: `.github/workflows/docker.yml` verifies that before building, and `--locked` verifies the lock file. -- Config is **environment-variable driven** (dotenv loads `.env`, gitignored; no `.env.example` exists). Key vars: `TELOXIDE_TOKEN` (required), `PIXIV_REFRESH_TOKEN`, `TWITTER_AUTH_TOKEN` (optional; x.com `auth_token` cookie — enables the logged-in GraphQL fallback that fetches NSFW tweets syndication withholds), `BILIBILI_COOKIE` (optional; whole bilibili cookie string — bilibili dynamics fetch anonymously and add their own device cookies, this only rescues an egress IP that bilibili has hard-flagged with `-352`/412), `BOT_ADMIN` (comma-separated ids), `EDIT_MESSAGE_TTL_SECONDS` (default 86400), `LINK_CACHE_TTL_SECONDS` (default 604800), `DATA_DIR` (default `data`, CWD-relative; the SQLite dir, auto-created), `WEBHOOK`/`WEBHOOK_URL`/`WEBHOOK_LISTEN`/`WEBHOOK_PORT`/`WEBHOOK_CERT`/`WEBHOOK_SECRET_TOKEN` (webhook mode requires URL/listen/port, `.expect`ed; `WEBHOOK_CERT` is Telegram-facing self-signed validation only — TLS must be terminated by a reverse proxy), `RUST_LOG`, `TELOXIDE_PROXY`, `LOCAL_USER_ID` (entrypoint only). +- Config is **environment-variable driven** (dotenv loads `.env`, gitignored; no `.env.example` exists). Key vars: `TELOXIDE_TOKEN` (required), `PIXIV_REFRESH_TOKEN`, `TWITTER_AUTH_TOKEN` (optional; x.com `auth_token` cookie — enables the logged-in GraphQL fallback that fetches NSFW tweets syndication withholds), `BILIBILI_COOKIE` (optional; whole bilibili cookie string — bilibili dynamics fetch anonymously and add their own device cookies, this only rescues an egress IP that bilibili has hard-flagged with `-352`/412), `BOT_ADMIN` (comma-separated ids), `EDIT_MESSAGE_TTL_SECONDS` (default 86400), `LINK_CACHE_TTL_SECONDS` (default 604800), `CAPTION_QUOTE_TEXT_CHARS` (default 200; a post whose text — the `title` plus `content` joined, see `site::compose_text` — reaches this length gets that text wrapped in an expandable blockquote inside its caption, the URL and author line staying outside; `0` disables it. Applied at the send boundary in `send::quote_long_caption`, which locates the text as what follows the author link, so a `/set_format` that moves `{title}`/`{content}` elsewhere and pixiv's title-inside-a-link layout opt out; `copy_messages` forwards and queued retries inherit the wrap, while the edit-before-forward rewrite stays unquoted by design), `DATA_DIR` (default `data`, CWD-relative; the SQLite dir, auto-created), `WEBHOOK`/`WEBHOOK_URL`/`WEBHOOK_LISTEN`/`WEBHOOK_PORT`/`WEBHOOK_CERT`/`WEBHOOK_SECRET_TOKEN` (webhook mode requires URL/listen/port, `.expect`ed; `WEBHOOK_CERT` is Telegram-facing self-signed validation only — TLS must be terminated by a reverse proxy), `RUST_LOG`, `TELOXIDE_PROXY`, `LOCAL_USER_ID` (entrypoint only). - SQLite via `rusqlite` with `bundled` feature (no system libsqlite needed). DB file `$DATA_DIR/task_queue.db` (default `data/task_queue.db`, CWD-relative — run from the workspace root, or `/app` in Docker; set `DATA_DIR` to pin state anywhere). Mount `./data` and `./cert` volumes. - `.gitattributes` enforces LF for `*.sh` (CRLF breaks shebangs in containers). `.gitignore`: `.env`, `data/`, `cert/`, `docker-compose.yml`, `/target`, `.idea/`. - Docs are in Chinese (README, AGENTS.md); user-facing bot strings are in English. Keep that split when editing user-facing strings and docs. diff --git a/README.en.md b/README.en.md index 69803bf..4edef39 100644 --- a/README.en.md +++ b/README.en.md @@ -6,6 +6,7 @@ A Telegram bot that turns post links from X / Twitter, Pixiv, Bluesky, Misskey ( - Sending a link in a private chat fetches and sends the images, videos and GIFs automatically; oversized media is split into batches - Text-only posts report "no media"; unsupported links are silently ignored +- Long posts (text ≥ `CAPTION_QUOTE_TEXT_CHARS`, default 200) show **the text part** of their caption inside a collapsible blockquote, with the link and author line left outside it - Inline queries (`@bot `) - Bind a forward channel for automatic forwarding; edit the caption before forwarding and apply custom templates - Failed sends are retried automatically with persistence; the user is notified after retries are exhausted @@ -87,6 +88,7 @@ Telegram only accepts ports 443/80/88/8443. | `BOT_ADMIN` | Admin chat IDs, comma-separated; receives start/stop notifications | | `EDIT_MESSAGE_TTL_SECONDS` | Edit-before-forward record expiry in seconds, default 86400 | | `LINK_CACHE_TTL_SECONDS` | Link-result cache expiry in seconds, default 604800 (7 days) | +| `CAPTION_QUOTE_TEXT_CHARS` | **The text part** of the caption (the joined `{title}` + `{content}`) is wrapped in a collapsible blockquote once it reaches this many characters, default 200; `0` disables | | `DATA_DIR` | Data directory (where the SQLite `task_queue.db` lives), default `data` (relative to the working directory, created automatically) | | `RUST_LOG` | Log level | | `TELOXIDE_PROXY` | HTTP proxy (e.g. `http://127.0.0.1:10808`); applies to both the Telegram Bot API and site fetches — required on restricted networks (e.g. behind the GFW) | diff --git a/README.md b/README.md index 8ef8d60..7f6d753 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Telegram 机器人,将 X / Twitter、Pixiv、Bluesky、Misskey (misskey.io)、 - 私聊发送链接后自动抓取并发送图片、视频与 GIF,超量图片自动分批 - 纯文字帖提示无媒体;不支持的链接静默忽略 +- 长帖(正文 ≥ `CAPTION_QUOTE_TEXT_CHARS`,默认 200)的**正文部分**用可折叠引用块展示,链接与作者行留在引用块外 - 支持内联查询(`@机器人 <链接>`) - 可绑定转发频道自动转发;支持转发前编辑 caption 与自定义模板 - 发送失败自动重试并持久化,重试耗尽后通知用户 @@ -87,6 +88,7 @@ Telegram 只接受 443/80/88/8443 端口。 | `BOT_ADMIN` | 管理员聊天 ID,逗号分隔;接收启动/停止通知 | | `EDIT_MESSAGE_TTL_SECONDS` | 转发前编辑记录过期秒数,默认 86400 | | `LINK_CACHE_TTL_SECONDS` | 链接结果缓存过期秒数,默认 604800(7 天) | +| `CAPTION_QUOTE_TEXT_CHARS` | 正文(`{title}` + `{content}` 合计)达到该长度(字符)时,caption 的**正文部分**用可折叠引用块包裹,默认 200;`0` 关闭 | | `DATA_DIR` | 数据目录(SQLite 数据库 `task_queue.db` 所在目录),默认 `data`(相对工作目录,会自动创建) | | `RUST_LOG` | 日志级别 | | `TELOXIDE_PROXY` | HTTP 代理(如 `http://127.0.0.1:10808`);同时作用于 Telegram Bot API 与站点抓取请求,网络受限环境(如 GFW)必需 | diff --git a/crates/xmedia-bot/src/config.rs b/crates/xmedia-bot/src/config.rs index e09d1fe..9eb5030 100644 --- a/crates/xmedia-bot/src/config.rs +++ b/crates/xmedia-bot/src/config.rs @@ -13,6 +13,10 @@ pub struct Config { pub edit_message_ttl: Duration, /// LINK_CACHE_TTL_SECONDS, default 604800 (7 days). pub link_cache_ttl: Duration, + /// CAPTION_QUOTE_TEXT_CHARS, default 200: a post whose text (title plus + /// content) is at least this many characters gets that text wrapped in an + /// expandable blockquote inside its caption. `0` disables the wrap. + pub caption_quote_text_chars: usize, // Webhook settings (moved out of main; names/defaults unchanged). pub webhook_enabled: bool, pub webhook_url: Option, @@ -58,6 +62,7 @@ impl Config { Duration::from_secs(parse_u64("EDIT_MESSAGE_TTL_SECONDS", 24 * 3600)); let link_cache_ttl = Duration::from_secs(parse_u64("LINK_CACHE_TTL_SECONDS", 7 * 24 * 3600)); + let caption_quote_text_chars = parse_u64("CAPTION_QUOTE_TEXT_CHARS", 200) as usize; let webhook_enabled = env::var("WEBHOOK") .is_ok_and(|v| matches!(v.to_lowercase().as_str(), "true" | "yes" | "1")); @@ -93,6 +98,7 @@ impl Config { admin_ids, edit_message_ttl, link_cache_ttl, + caption_quote_text_chars, webhook_enabled, webhook_url, webhook_listen, diff --git a/crates/xmedia-bot/src/ctx.rs b/crates/xmedia-bot/src/ctx.rs index e6a1f10..b15e6d0 100644 --- a/crates/xmedia-bot/src/ctx.rs +++ b/crates/xmedia-bot/src/ctx.rs @@ -88,6 +88,12 @@ pub(crate) mod test_support { &self.chat_store } + /// The parsed config, mutable so a test can pin a knob (e.g. the + /// caption-quote threshold) instead of depending on the environment. + pub(crate) fn config_mut(&mut self) -> &mut Config { + &mut self.config + } + pub(crate) fn link_cache(&self) -> &LinkCache { &self.link_cache } diff --git a/crates/xmedia-bot/src/handlers/inline.rs b/crates/xmedia-bot/src/handlers/inline.rs index fd5bdc0..5f8fe9c 100644 --- a/crates/xmedia-bot/src/handlers/inline.rs +++ b/crates/xmedia-bot/src/handlers/inline.rs @@ -127,8 +127,23 @@ async fn answer_inline_query(bot: Bot, query: InlineQuery) -> Result { let mut results: Vec = Vec::new(); // Inline results have the same 1024-char caption limit as regular - // messages; truncate once here for all items. + // messages; truncate once here for all items, then apply the same + // long-post quoting as the send paths. `answer_inline_query` has no + // `AppContext` (the debounce spawns it), so the parsed config comes + // from the process-wide static, and the text is the *escaped* + // title/content the built-in caption embeds (the raw + // `Fetched.title`/`content` differ whenever the post contains + // `<`/`&`). let caption = x_media::site::truncate_caption(&fetched.caption); + let text = fetched + .render_fields() + .map(|(_, _, title, content, _)| x_media::site::compose_text(title, content)) + .unwrap_or_default(); + let caption = crate::send::quote_long_caption( + &caption, + &text, + super::CONFIG.caption_quote_text_chars, + ); for (i, media) in fetched.media.iter().enumerate() { let id = format!("{i}"); let Some(url) = url::Url::parse(media.url()).ok() else { @@ -138,7 +153,7 @@ async fn answer_inline_query(bot: Bot, query: InlineQuery) -> Result { // Inline photo results have their own (smaller) size diff --git a/crates/xmedia-bot/src/handlers/urls.rs b/crates/xmedia-bot/src/handlers/urls.rs index 77c49e5..0f7a9e2 100644 --- a/crates/xmedia-bot/src/handlers/urls.rs +++ b/crates/xmedia-bot/src/handlers/urls.rs @@ -534,6 +534,35 @@ mod tests { ); } + /// The caption-quote threshold matches the post's text inside the caption, + /// so a long-text cache hit is quoted and a short-text one is not. + #[tokio::test] + async fn cache_hit_quotes_a_long_text_caption() { + let mut stores = TestStores::new(); + stores.config_mut().caption_quote_text_chars = 3; + let prefix = "https://x.com/u/status/1\na: "; + + for (text, expected) in [ + ( + "abc", + format!("{prefix}
abc
"), + ), + ("ab", format!("{prefix}ab")), + ] { + let sender = MockSender::scripted(vec![Outcome::GroupOk], permanent_error); + let ctx = stores.ctx(&sender); + let mut entry = cached_photo_entry(); + entry.caption = format!("{prefix}{text}"); + entry.title = String::new(); + entry.content = text.into(); + stores.link_cache().put("twitter:1", &entry).await; + + url_media(&ctx, 1, 2, "https://x.com/u/status/1", PostSend::FromChat).await; + + assert_eq!(sender.captions(), vec![expected], "text {text:?}"); + } + } + #[tokio::test] async fn unsupported_url_is_ignored_silently() { let stores = TestStores::new(); diff --git a/crates/xmedia-bot/src/media_sender.rs b/crates/xmedia-bot/src/media_sender.rs index 30cf575..2d45bac 100644 --- a/crates/xmedia-bot/src/media_sender.rs +++ b/crates/xmedia-bot/src/media_sender.rs @@ -327,9 +327,20 @@ pub(crate) mod test_support { &self, _chat_id: ChatId, _reply_to: MessageId, - _items: Vec, + items: Vec, ) -> BoxFuture<'_, Result, RequestError>> { Box::pin(async move { + // Record the captions exactly as Telegram receives them (only + // the first item of a group carries one), so tests can assert + // what a recipient sees. + self.captions + .lock() + .extend(items.iter().filter_map(|item| match item { + InputMedia::Photo(photo) => photo.caption.clone(), + InputMedia::Video(video) => video.caption.clone(), + InputMedia::Animation(animation) => animation.caption.clone(), + _ => None, + })); match self.next("send_media_group") { Outcome::GroupOk => Ok(Vec::new()), Outcome::GroupErr => Err(self.error()), diff --git a/crates/xmedia-bot/src/send/mod.rs b/crates/xmedia-bot/src/send/mod.rs index 452d187..7e81ea1 100644 --- a/crates/xmedia-bot/src/send/mod.rs +++ b/crates/xmedia-bot/src/send/mod.rs @@ -18,6 +18,7 @@ use crate::media_sender::MediaSender; use input_media::{build_media_group, input_file_for, item_url}; use post_send::{cache_animation_send, cache_sent_task}; use serde::{Deserialize, Serialize}; +use std::borrow::Cow; use std::sync::LazyLock; use teloxide::prelude::*; use teloxide::types::{ChatId, InputFile, InputMedia, MessageId}; @@ -388,6 +389,61 @@ fn updated_sequence_task(task: &Task, batch_index: usize, sent_message_ids: Vec< updated } +/// The caption's text tail: everything after the author link, provided it +/// really is the post's text. +/// +/// `text` is the *escaped* title + content the caption embeds; the caption may +/// have been truncated inside it, in which case only its prefix is present, so +/// the tail only has to match the text's start. `None` for a caption with +/// another layout — pixiv's title-inside-a-link, a `/set_format` that moves +/// `{title}`/`{content}` off the author line — which is left unquoted instead +/// of guessing where the text begins. +fn text_tail<'c>(caption: &'c str, text: &str) -> Option<&'c str> { + let (_, tail) = caption.rsplit_once(": ")?; + let visible = tail.strip_suffix('\u{2026}').unwrap_or(tail); + (!visible.is_empty() && text.starts_with(visible)).then_some(tail) +} + +/// The text a task's caption embeds, read from the same cache snapshot the +/// caption came from: `title` and `content` joined the way the sites' built-in +/// captions join them. +fn task_text(task: &Task) -> String { + task.cache_data() + .map(|data| x_media::site::compose_text(&data.title, &data.content)) + .unwrap_or_default() +} + +/// Wraps the post's text inside the caption in an expandable blockquote once +/// that text is long enough that the message would otherwise be a wall of text +/// (`threshold` is `CAPTION_QUOTE_TEXT_CHARS`; `0` disables the wrap). The URL +/// and the author line stay outside the quote. +/// +/// Applied at the send boundary, after the caller's `truncate_caption`: +/// Telegram measures a caption *after entities parsing*, so the tags cost no +/// length and a wrapped caption cannot exceed the 1024-character limit. +/// Retries replay the task's (unwrapped) caption, so the decision is remade on +/// every attempt — changing the threshold takes effect immediately. +/// +/// A caption that already carries a blockquote is left as it is: the API +/// rejects nested ones ("all other entities can't contain each other"), and a +/// user-written `/set_format` template may contain one. +pub(crate) fn quote_long_caption<'a>( + caption: &'a str, + text: &str, + threshold: usize, +) -> Cow<'a, str> { + if threshold == 0 || caption.contains("{tail}
" + )) +} + /// Sends the media batches starting at `task.batch_index`, extending /// `sent_message_ids`. Returns all sent message ids on full success; on /// failure returns a [`SendError`] whose task carries the resumed state. @@ -406,6 +462,10 @@ pub async fn send_media_sequence(ctx: &AppContext<'_>, task: &Task) -> Result, task: &Task) -> Result, task: &Task) -> Result, task: &Task) -> Result, task: &Task) -> Result Task { + sequence_task_with(media, "cap", None) + } + + /// A media-group task; `text` (when given) rides in the link-cache + /// snapshot as `content`, which is where the quote threshold reads it. + fn sequence_task_with(media: &str, caption: &str, text: Option<&str>) -> Task { Task::SendMediaSequence { chat_id: 1, reply_to_message_id: 2, - caption: "cap".into(), + caption: caption.into(), media_batches: vec![vec![MediaItemPayload::Photo { media: media.to_string(), has_spoiler: false, @@ -947,7 +1017,99 @@ mod tests { forward_channel_id: None, notify_chat_id: Some(1), notify_message_id: Some(2), - cache_data: None, + // The snapshot splits the post's text into title/content the way a + // real fetch does; the quote threshold joins them again. + cache_data: text.map(|text| CachedPost { + url: "https://x.com/u/status/1".into(), + caption: caption.into(), + title: String::new(), + content: text.into(), + author: "me".into(), + author_url: "https://x.com/u".into(), + tags: String::new(), + sensitive: false, + media: vec![], + }), + } + } + + /// A media-group task whose built-in caption carries `text` behind the + /// author link — the shape the quote threshold locates the text in. + fn sequence_task_with_text(media: &str, text: &str) -> Task { + let caption = + format!("https://x.com/u/status/1\nme: {text}"); + sequence_task_with(media, &caption, Some(text)) + } + + #[test] + fn quote_long_caption_wraps_only_the_text_tail() { + let text = "一二三四五"; + let prefix = "https://x.com/u/status/1\nme: "; + let caption = format!("{prefix}{text}"); + + // Only the text goes inside the quote; the URL and author line stay + // outside. + assert_eq!( + quote_long_caption(&caption, text, 5), + format!("{prefix}
{text}
") + ); + // One char below the threshold, disabled, and a short text: untouched. + assert_eq!( + quote_long_caption(&caption, text, 6), + format!("{prefix}{text}") + ); + assert_eq!(quote_long_caption(&caption, text, 0), caption); + // No author-line anchor means no text to locate — a pixiv caption + // (title inside the link) and a `{content}`-first format stay as they + // are rather than risking a blockquote nested in a tag. + let pixiv = + format!("{text} / me\ntag"); + assert_eq!(quote_long_caption(&pixiv, text, 5), pixiv); + let content_first = format!("{text}\nhttps://x.com/u/status/1"); + assert_eq!(quote_long_caption(&content_first, text, 5), content_first); + // An empty body has nothing to quote. + assert_eq!(quote_long_caption(prefix, text, 5), prefix); + // A caption that already carries a blockquote is never nested. + let quoted = format!("
{caption}
"); + assert_eq!(quote_long_caption("ed, text, 5), quoted); + } + + /// `truncate_caption` cuts inside the text and appends an ellipsis; the + /// visible prefix still marks it, so the long-text case that most needs + /// quoting is still quoted. + #[test] + fn quote_long_caption_wraps_a_truncated_text() { + let text = "一二三四五六七八九十"; + let prefix = "https://x.com/u/status/1\nme: "; + let caption = format!("{prefix}一二三四五…"); + assert_eq!( + quote_long_caption(&caption, text, 5), + format!("{prefix}
一二三四五…
") + ); + } + + #[tokio::test] + async fn long_text_caption_reaches_telegram_quoted() { + // The threshold is pinned here instead of read from the environment. + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("media.jpg"); + std::fs::write(&file, b"not-a-real-jpeg").unwrap(); + let mut stores = TestStores::new(); + stores.config_mut().caption_quote_text_chars = 5; + let prefix = "https://x.com/u/status/1\nme: "; + + for (text, expected) in [ + ( + "一二三四五", + format!("{prefix}
一二三四五
"), + ), + ("一二三四", format!("{prefix}一二三四")), + ] { + let sender = MockSender::scripted(vec![Outcome::GroupOk], media_fetch_error); + let ctx = stores.ctx(&sender); + let task = sequence_task_with_text(file.to_str().unwrap(), text); + assert!(send_media_sequence(&ctx, &task).await.is_ok()); + assert_eq!(sender.captions(), vec![expected], "text {text:?}"); } }