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}