mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-23 23:32:05 +00:00
feat(send): quote a long post's text in an expandable blockquote
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 `<blockquote expandable>`
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.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 <link>`)
|
||||
- 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) |
|
||||
|
||||
@@ -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)必需 |
|
||||
|
||||
@@ -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<url::Url>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -127,8 +127,23 @@ async fn answer_inline_query(bot: Bot, query: InlineQuery) -> Result<bool, Reque
|
||||
Ok(Some(fetched)) => {
|
||||
let mut results: Vec<InlineQueryResult> = 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<bool, Reque
|
||||
.thumbnail_url()
|
||||
.and_then(|t| url::Url::parse(t).ok())
|
||||
.unwrap_or_else(|| url.clone());
|
||||
let caption = caption.clone();
|
||||
let caption = caption.clone().into_owned();
|
||||
let result = match media {
|
||||
Media::Illustration { .. } => {
|
||||
// Inline photo results have their own (smaller) size
|
||||
|
||||
@@ -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\n<a href=\"au\">a</a>: ";
|
||||
|
||||
for (text, expected) in [
|
||||
(
|
||||
"abc",
|
||||
format!("{prefix}<blockquote expandable>abc</blockquote>"),
|
||||
),
|
||||
("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();
|
||||
|
||||
@@ -327,9 +327,20 @@ pub(crate) mod test_support {
|
||||
&self,
|
||||
_chat_id: ChatId,
|
||||
_reply_to: MessageId,
|
||||
_items: Vec<InputMedia>,
|
||||
items: Vec<InputMedia>,
|
||||
) -> BoxFuture<'_, Result<Vec<Message>, 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()),
|
||||
|
||||
@@ -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("</a>: ")?;
|
||||
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("<blockquote") || text.chars().count() < threshold {
|
||||
return Cow::Borrowed(caption);
|
||||
}
|
||||
let Some(tail) = text_tail(caption, text) else {
|
||||
return Cow::Borrowed(caption);
|
||||
};
|
||||
let prefix = &caption[..caption.len() - tail.len()];
|
||||
Cow::Owned(format!(
|
||||
"{prefix}<blockquote expandable>{tail}</blockquote>"
|
||||
))
|
||||
}
|
||||
|
||||
/// 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<Ve
|
||||
};
|
||||
let chat_id = *chat_id;
|
||||
let reply_to = *reply_to_message_id;
|
||||
// A long post is quoted so the message reads as a card rather than a wall
|
||||
// of text; the text comes from the same cache snapshot as the caption.
|
||||
let text = task_text(task);
|
||||
let caption = quote_long_caption(caption, &text, ctx.config.caption_quote_text_chars);
|
||||
let mut sent = sent_message_ids.clone();
|
||||
// File ids accumulated across batches for the link cache. Only a fresh
|
||||
// (non-resumed) full send populates the cache.
|
||||
@@ -414,7 +474,7 @@ pub async fn send_media_sequence(ctx: &AppContext<'_>, task: &Task) -> Result<Ve
|
||||
for idx in *batch_index..media_batches.len() {
|
||||
let batch = &media_batches[idx];
|
||||
let caption = if idx == 0 {
|
||||
Some(caption.as_str())
|
||||
Some(caption.as_ref())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -515,6 +575,10 @@ pub async fn send_animation(ctx: &AppContext<'_>, task: &Task) -> Result<Vec<i64
|
||||
};
|
||||
let chat_id = *chat_id;
|
||||
let reply_to = *reply_to_message_id;
|
||||
// Same long-post quoting as the media-group path (see
|
||||
// `quote_long_caption`); both sends below share this string.
|
||||
let text = task_text(task);
|
||||
let caption = quote_long_caption(caption, &text, ctx.config.caption_quote_text_chars);
|
||||
let (media_url, has_spoiler) = match animation {
|
||||
MediaItemPayload::Animation {
|
||||
media, has_spoiler, ..
|
||||
@@ -536,7 +600,7 @@ pub async fn send_animation(ctx: &AppContext<'_>, task: &Task) -> Result<Vec<i64
|
||||
ctx.sender,
|
||||
chat_id,
|
||||
reply_to,
|
||||
caption,
|
||||
&caption,
|
||||
has_spoiler,
|
||||
url_file,
|
||||
)
|
||||
@@ -571,7 +635,7 @@ pub async fn send_animation(ctx: &AppContext<'_>, task: &Task) -> Result<Vec<i64
|
||||
ctx.sender,
|
||||
chat_id,
|
||||
reply_to,
|
||||
caption,
|
||||
&caption,
|
||||
has_spoiler,
|
||||
animation.media,
|
||||
)
|
||||
@@ -930,10 +994,16 @@ mod tests {
|
||||
}
|
||||
|
||||
fn sequence_task(media: &str) -> 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\n<a href=\"https://x.com/u\">me</a>: {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\n<a href=\"https://x.com/u\">me</a>: ";
|
||||
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}<blockquote expandable>{text}</blockquote>")
|
||||
);
|
||||
// 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!("<a href=\"https://pixiv.net/1\">{text}</a> / <a href=\"u\">me</a>\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!("<blockquote>{caption}</blockquote>");
|
||||
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\n<a href=\"https://x.com/u\">me</a>: ";
|
||||
let caption = format!("{prefix}一二三四五…");
|
||||
assert_eq!(
|
||||
quote_long_caption(&caption, text, 5),
|
||||
format!("{prefix}<blockquote expandable>一二三四五…</blockquote>")
|
||||
);
|
||||
}
|
||||
|
||||
#[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\n<a href=\"https://x.com/u\">me</a>: ";
|
||||
|
||||
for (text, expected) in [
|
||||
(
|
||||
"一二三四五",
|
||||
format!("{prefix}<blockquote expandable>一二三四五</blockquote>"),
|
||||
),
|
||||
("一二三四", 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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user