From 5d0acdac013819fb9d5c97261659480923719199 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 20 Sep 2026 15:57:45 +0800 Subject: [PATCH] feat(ux): onboard users, expose the chat's settings, name failed posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/start` was "Hello!" and `/help` was the bare command list teloxide can render — no argument syntax, no caption placeholders, no mention that links only work in private chats. Both now carry that guidance, and the bot's profile description / short description are set at startup so a shared link says what the bot does. `/settings` reports what this chat is configured to do (forward channel, edit-before-forward, per-site formats, saved templates) to anyone in the chat — `/bot_dict` is a raw admin-only dump. Templates can be removed (`/remove_template`, listing the live names on a typo) and the prompt's keyboard folds 3 per row with a cap: Telegram rejects a keyboard over 100 buttons outright, which would silently drop the whole prompt. Inline results hand URLs to Telegram, which fetches them without any site headers — pixiv's pximg.net answers 403 to that, so those items are skipped instead of shipped broken. `needs_media_headers` answers that question from the same per-site rule the downloader uses. Dead-letter and retry notices name the failing post and the cause (`failure_text`), since "Task failed after retries: task failed after 2 retries" said neither which link it was nor what happened. --- AGENTS.md | 12 +- README.en.md | 8 +- README.md | 8 +- crates/x-media/src/site/mod.rs | 27 +++ crates/xmedia-bot/src/handlers/commands.rs | 269 ++++++++++++++++++++- crates/xmedia-bot/src/handlers/inline.rs | 9 + crates/xmedia-bot/src/handlers/urls.rs | 7 +- crates/xmedia-bot/src/queue.rs | 12 +- crates/xmedia-bot/src/send/mod.rs | 60 +++++ crates/xmedia-bot/src/send/post_send.rs | 67 +++-- 10 files changed, 448 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5c5d124..5f8f71a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,8 @@ User-facing failure text is a function of the error class, never one generic sen The `/test ` command runs the ordinary link pipeline (`urls::url_media`) with `PostSend::Suppressed`: the media is sent and cached like any other link, but the chat's `forward_channel_id`/`edit_before_forward` are ignored, so a test never forwards to the channel and never opens the edit prompt (retries and dead-letter notifications behave as usual). Both commands use a custom `parse_arg_remainder` parser (whole remainder, trimmed) because teloxide's built-in `split` parser takes exactly one space-separated token. +The inline path (`handlers/inline.rs`) hands media URLs straight to Telegram, which fetches them itself and cannot send site-specific headers — so `x_media::site::needs_media_headers(url)` (true exactly where a site's `media_headers` is non-empty, i.e. pixiv's pximg.net) marks the media that must be skipped instead of shipped broken; locally produced media (ugoira MP4, bsky remux) fails `Url::parse` and is skipped the same way. Inline results are therefore URL-only by construction. + `url_media` is a thin wrapper over `url_media_inner`: `run_with_chat_action` sends the chat action, then re-sends it every `ACTION_REFRESH` (4 s) while the pipeline future is pending, because Telegram drops an action after ~5 s and a fetch (ugoira encode, HLS remux) plus an upload routinely outlasts that. The pipeline flips the shared `ActionHint` from `Typing` to `UploadPhoto`/`UploadVideo` once the media kinds are known. The `select!` is `biased` on the pipeline branch so a finished pipeline never emits a stray action. The `x-media` library: `site::fetch(url)` dispatches through the `SITES` registry (per-site `impl Site`, in order twitter → bsky → misskey → pixiv → bilibili) and returns `Ok(None)` for unmatched URLs (`Err(FetchError::Disabled { site })` when the URL matches a registered site whose `enabled()` is false — see `disabled_site`). `Fetched { source_url, caption, title, content, media: Vec, sensitive, site_id, … }` (title and content are split per platform: a pixiv artwork's title and description, a bilibili headline and body, and text-only posts whose text is all `content`); `caption_with(format)` substitutes `{url} {author} {author_url} {title} {content} {tags}`. @@ -36,7 +38,7 @@ The `x-media` library: `site::fetch(url)` dispatches through the `SITES` registr |---|---| | `crates/x-media/src/` | Fetch library. `site/mod.rs` = dispatcher + `Fetched`/`FetchError`/`download_media`/`media_size`; `media.rs` = `Media` enum; `examples/fetch.rs` = end-to-end usage sample | | `crates/x-media/src/site//` | One directory per site: `mod.rs` (re-exports), `interface.rs` (PATTERN, `enabled()`, `fetch_from_url()`, `cache_key`/`is_retryable`/`media_headers`, unit struct `Site` implementing `site::Site`, `From for Fetched`), `model.rs` (serde DTOs). Pixiv adds `api.rs` (auth + transport); twitter adds `auth.rs` (logged-in GraphQL `TweetDetail` fallback for NSFW tweets, gated on `TWITTER_AUTH_TOKEN`; without the token a withheld tweet stays `FetchError::Sensitive` and the bot reports it as age-restricted instead of "no media"). Misskey targets misskey.io only (`POST /api/notes/show`, 400+`NO_SUCH_NOTE` → NotFound). Bilibili fetches dynamics (images/animated images only — an attached video degrades to its cover, and its title stands in for the post text, which AV dynamics do not have) from `/x/polymer/web-dynamic/v1/detail` sent with `features=itemOpusStyle` (without that flag the legacy serialization drops an image/text post's body and headline entirely — `desc` comes back `null`; the adapter still parses the legacy `major.draw`/`desc`/`archive` shapes as a fallback). No WBI signature is involved; device cookies `buvid3`/`buvid4` are fetched automatically from `/x/frontend/finger/spi` because bilibili's `-352` risk control starts rejecting plain requests, `BILIBILI_COOKIE` is the escalation when an IP stays blocked; `b23.tv` short links are deliberately unmatched. Twitter's `from_syndication_json` HTML-decodes the API text — syndication and GraphQL `full_text` both arrive pre-escaped (`>` `<` `&` `'`) — so the stored text is raw and the caption escap… -| `crates/xmedia-bot/src/main.rs` | Entry point: env/log init, command registration (`register_commands`), shared `send::BOT` force-init, queue worker start, site login validation (`site::validate_all`), 300 s edit-expiry sweep (expired prompts are rewritten in place to `EDIT_PROMPT_EXPIRED_TEXT` with an empty keyboard — an edit, never a new message, so a background timer cannot wake a chat), dptree handler tree, webhook vs polling dispatch | +| `crates/xmedia-bot/src/main.rs` | Entry point: env/log init, command registration (`register_commands` — `setMyCommands` plus the profile description texts), shared `send::BOT` force-init, queue worker start, site login validation (`site::validate_all`), 300 s edit-expiry sweep (expired prompts are rewritten in place to `EDIT_PROMPT_EXPIRED_TEXT` with an empty keyboard — an edit, never a new message, so a background timer cannot wake a chat), dptree handler tree, webhook vs polling dispatch | | `crates/xmedia-bot/src/config.rs` | Manual env parsing into `Config` | | `crates/xmedia-bot/src/db.rs` | `DbPool`: one shared SQLite connection pool (`POOL_SIZE = 4`, WAL, busy_timeout) for all three tables over `$DATA_DIR/task_queue.db` (default `data/`) — the three stores share it; `open_store` creates file + schema, `with_conn` runs all rusqlite I/O in `spawn_blocking` | | `crates/xmedia-bot/src/handlers/` | Handler modules: `mod.rs` (message entry point, `reply`, `log_key`, the group-only `GROUP_LINK_HINT` for a supported link posted outside a private chat), `commands.rs` (teloxide `BotCommands` enum + command executor, incl. `/test ` (send-only) / `/debug ` (parse-only) and the admin-only `/bot_dict` state dump; `/set_format` rejects unknown `{…}` placeholders and resets with `-`), `urls.rs` (URL extraction + bounded job channel (256) drained by `URL_WORKERS = 8` workers (`start_url_workers`) — backpressure instead of unbounded spawns; teloxide's per-chat workers are sequential — batch-forwards need concurrency), `inline.rs`/`callback.rs` (inline queries / edit-before-forward buttons, incl. `skip`), `statics.rs` (global statics) | @@ -79,10 +81,10 @@ Docker: `docker build -t tgxmb .` then `docker run --rm -d --name tgxmb --env-fi | File | Why it matters | |---|---| | `crates/xmedia-bot/src/main.rs` | Startup sequence, webhook vs polling, graceful shutdown (SIGINT via teloxide ctrlc / SIGTERM via `stop_token` for docker, → sweep stop → admin msg → queue stop) | -| `crates/xmedia-bot/src/handlers/` | `statics.rs` = `CHAT_STORE`/`TASK_QUEUE`/`CONFIG` singletons (open `$DATA_DIR/task_queue.db`, default `data/` **relative to CWD**, dir auto-created); `commands.rs` = command dispatch (incl. `/test ` send-only, `/debug ` parse-only, and the admin-only `/bot_dict` state dump); `urls.rs` = URL extraction + the per-URL pipeline (`url_media` takes a `PostSend` mode: chat settings vs `/test`'s suppressed actions); `inline.rs` = debounced inline queries; `callback.rs` = edit-before-forward buttons (dptree entry + testable `handle_callback` core) | -| `crates/xmedia-bot/src/send/` | `mod.rs`: constants `MAX_MEDIA_GROUP = 10`; `classify_request_error`; the senders. `upload.rs`: download-and-reupload fallback triggered only by Telegram API errors (`is_media_fetch_failure` / `is_size_error`). `post_send.rs`: settlement (`settle_task`), cache write, post-send actions, queue handlers. `input_media.rs`: payload → `InputMedia` | +| `crates/xmedia-bot/src/handlers/` | `statics.rs` = `CHAT_STORE`/`TASK_QUEUE`/`CONFIG` singletons (open `$DATA_DIR/task_queue.db`, default `data/` **relative to CWD**, dir auto-created); `commands.rs` = command dispatch (incl. `/test ` send-only, `/debug ` parse-only, the read-only `/settings` every chat member can read — unlike the admin-only `/bot_dict` raw dump — and template removal; `/start`/`/help` carry the guidance teloxide's `descriptions()` cannot render, and `/set_format` rejects unknown `{…}` placeholders, resetting with `-`); `urls.rs` = URL extraction + the per-URL pipeline (`url_media` takes a `PostSend` mode: chat settings vs `/test`'s suppressed actions); `inline.rs` = debounced inline queries (hotlink-protected and local media skipped); `callback.rs` = edit-before-forward buttons (dptree entry + testable `handle_callback` core, incl. `skip`) | +| `crates/xmedia-bot/src/send/` | `mod.rs`: constants `MAX_MEDIA_GROUP = 10`; `classify_request_error`; the senders. `upload.rs`: download-and-reupload fallback triggered only by Telegram API errors (`is_media_fetch_failure` / `is_size_error`). `post_send.rs`: settlement (`settle_task`), cache write, post-send actions (dead-letter text via `failure_text`: post key + cause, since the raw error alone does not say which link died), queue handlers. `input_media.rs`: payload → `InputMedia` | | `crates/xmedia-bot/src/photo.rs` | Pure-Rust photo processing (no ffmpeg): `png` (image-png) decode/encode + `zune-jpeg` decode + `fast_image_resize` Lanczos3 downscale + `jpeg-encoder`. Photos over Telegram's limits (width + height > 10000 px → `PHOTO_INVALID_DIMENSIONS`; bytes > 10 MiB) are decoded, downscaled keeping the format, PNG bit depth > 24 (RGBA 32-bit / 16-bit per channel) reduced to 24-bit RGB with alpha flattened white (≤24-bit untouched, never upconverted), and transcoded to JPEG only if still over the cap; memory budget guarded, otherwise the item's smaller fallback URL | -| `crates/x-media/src/site/mod.rs` | Dispatcher, `Fetched`/`FetchError`, shared `CLIENT`, `download_media` (adds `Referer: https://www.pixiv.net/` for `pximg.net` hotlink protection) | +| `crates/x-media/src/site/mod.rs` | Dispatcher, `Fetched`/`FetchError`, shared `CLIENT`, `download_media` (adds `Referer: https://www.pixiv.net/` for `pximg.net` hotlink protection), `needs_media_headers` (the same per-site rule, asked by the inline path to skip what Telegram cannot fetch) | | `crates/x-media/src/site/pixiv/api.rs` | OAuth token exchange (hardcoded app client id/secret), access-token cache, ugoira zip→MP4 via ffmpeg in `spawn_blocking` | | `Dockerfile` | Multi-stage: cached dep layer via stub sources + `touch *.rs` mtime bump (cargo's freshness is mtime-based and `cargo clean -p` removes 0 files — the touch is what forces the real sources to rebuild while deps stay cached), static ffmpeg from ffmpeg.martin-riedl.de (`FFMPEG_URL` arg, optional `FFMPEG_SHA256` checksum, `unzip -t` integrity check), `debian:bookworm-slim` runtime, entrypoint. Runtime ships **no libssl/libcrypto/CA bundle** — rustls webpki-roots handles all TLS, and the static ffmpeg only processes local files (downloads go through reqwest) | | `docker-entrypoint.sh` | Privilege drop: `useradd` with `LOCAL_USER_ID` (default 9001) + `setpriv` (no gosu on bookworm-slim) | @@ -103,7 +105,7 @@ Docker: `docker build -t tgxmb .` then `docker run --rm -d --name tgxmb --env-fi ## Testing & QA -- **~190 tests, all inline `#[cfg(test)] mod tests`** — no `tests/` integration directories. Framework: built-in Rust test + `#[tokio::test]` (dev-deps only in `x-media`: tokio macros/rt-multi-thread, dotenv). +- **~200 tests, all inline `#[cfg(test)] mod tests`** — no `tests/` integration directories. Framework: built-in Rust test + `#[tokio::test]` (dev-deps only in `x-media`: tokio macros/rt-multi-thread, dotenv). - No mocking framework anywhere (no mockito/wiremock/mockall). Conventions: pure-function units (regex parsing, serde round-trips, chunking, retry math) tested synchronously; async tests use real dependencies — file-backed SQLite via `tempfile` (`queue.rs::new_queue()` helper), live network fetches. - Live-network tests exist in `site/twitter/interface.rs` (5), `site/bsky/interface.rs` (2), `site/misskey/interface.rs` (1), `site/bilibili/interface.rs` (4), `site/pixiv/api.rs` (1); `photo.rs` adds one `#[ignore = "heavy: …"]` test. `site/mod.rs` also has a **token-gated but not `#[ignore]`d** pixiv download test (`download_media_pixiv_original_with_referer`): it hits `i.pximg.net` whenever `PIXIV_REFRESH_TOKEN` is set, so a local `cargo test --workspace` is not fully offline and can flake on a pixiv CDN body timeout. `disabled_site_is_reported_not_ignored` (same file) is gated the other way round: it asserts `fetch` answers `FetchError::Disabled { site: "pixiv" }` for a pixiv link and early-returns when `PIXIV_REFRESH_TOKEN` **is** set (the site is then enabled). Test gating convention (enforced by `.github/workflows/ci.yml`): pure unit tests always run; live-network tests carry `#[ignore = "live network: ..."]` (run via `cargo test --workspace -- --ignored live`); token-gated pixiv tests early-return when `PIXIV_REFRESH_TOKEN` is absent **or empty** (an unset GitHub secret arrives as `""` — `is_err()` alone would run them tokenless and fail), and the bilibili live tests early-return when the API answers risk control (`-352`, which bilibili applies per IP by request volume). Run the full offline suite with `cargo test --workspace`. - Fixtures are inline `serde_json::json!` builder fns (`fixture()`, `thread_json()`, `illust_json()`), not files. The shared `CLIENT` sets `pool_max_idle_per_host(0)` under `#[cfg(test)]` to avoid cross-runtime `DispatchGone`. diff --git a/README.en.md b/README.en.md index b14a724..3dd4dc8 100644 --- a/README.en.md +++ b/README.en.md @@ -7,9 +7,11 @@ 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 (10 items per group) - Text-only posts report "no media"; unsupported links are silently ignored. Fetch failures name the reason (post gone / content withheld / source risk control / site not enabled) - 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 `); a supported link posted in a group gets a one-line hint to use the private chat or inline mode (channels stay silent) +- Inline queries (`@bot `) — except Pixiv images and locally transcoded animations, which Telegram cannot fetch (no Referer) and would show broken, so they are skipped; a supported link posted in a group gets a one-line hint to use the private chat or inline mode (channels stay silent) +- `/start` explains the supported sites and how to use it; `/help` lists the commands plus argument syntax, the caption placeholders and the private-chat rule; the bot's profile description texts are set at startup +- `/settings` shows this chat's configuration (forward channel, edit-before-forward, per-site caption formats, saved templates); templates are added with `/set_template` and removed with `/remove_template` - Bind a forward channel for automatic forwarding; edit the caption before forwarding and apply custom templates (the prompt carries Confirm / Skip buttons, states its expiry, and is marked expired in place once it lapses) -- Failed sends are retried automatically with persistence; the user is notified after retries are exhausted +- Failed sends are retried automatically with persistence; the notice names which link failed, how long the retry waits, or the final cause - The chat action stays on screen for the whole fetch, so long jobs (ugoira transcode, large uploads) do not look stalled - Pixiv ugoira animations are transcoded to MP4; Bluesky videos are remuxed (HLS stream → MP4) - Photos exceeding Telegram's size/dimension limits are compressed automatically (original format kept, JPEG fallback only when needed) @@ -117,6 +119,8 @@ Telegram only accepts ports 443/80/88/8443. | `/remove_forward_channel` | Remove the forward channel | | `/edit_before_forward` | Toggle "edit before forward": when enabled, the bot posts a prompt after forwarding; replying to it edits the first forwarded message's caption (or tapping a template button applies one), then `↩️ Confirm` forwards and `🛑 Skip` drops this forward; the prompt states its expiry and is marked expired in place when it lapses (nothing is forwarded) | | `/set_template ` | Reply to a message containing `[]` to save it as a named template; `[]` is replaced by the original post link when forwarding (used with "edit before forward") | +| `/remove_template ` | Remove a template (names are listed by `/settings`; the prompt's keyboard shows at most 60) | +| `/settings` | Show this chat's configuration: forward channel, edit-before-forward, per-site caption formats, saved templates | | `/set_format ` | Customize the caption format for one site. Sites: `twitter` / `bsky` / `pixiv` / `misskey` / `bilibili`. Placeholders: `{url}` `{author}` `{author_url}` `{title}` `{content}` `{tags}`; unknown placeholders are rejected with the list of valid ones, and `-` restores the site's built-in format (preview with `/debug `) | | `/clear_cache [link]` | Clear the link cache (admin only); with a link only that entry, otherwise everything | | `/bot_dict` | Show the current chat state (debugging; admin only) | diff --git a/README.md b/README.md index 2c78575..9add957 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,11 @@ Telegram 机器人,将 X / Twitter、Pixiv、Bluesky、Misskey (misskey.io)、 - 私聊发送链接后自动抓取并发送图片、视频与 GIF,超量图片自动分批(每批 10 张) - 纯文字帖提示无媒体;不支持的链接静默忽略。抓取失败会按原因分别提示(帖子已删除 / 内容受限 / 源站风控 / 站点未启用) - 长帖(正文 ≥ `CAPTION_QUOTE_TEXT_CHARS`,默认 200)的**正文部分**用可折叠引用块展示,链接与作者行留在引用块外 -- 支持内联查询(`@机器人 <链接>`);在群聊里发链接会提示改用私聊或内联查询(频道内保持静默) +- 支持内联查询(`@机器人 <链接>`;Pixiv 图片与本地转码的动图不支持内联 —— Telegram 取图时无法携带 Referer,会显示破图,因此跳过);在群聊里发链接会提示改用私聊或内联查询(频道内保持静默) +- `/start` 说明支持的站点与用法,`/help` 列出命令、参数格式、caption 占位符与私聊限制;bot 资料页(description / short description)启动时一并设置 +- `/settings` 查看本聊天配置(转发频道、转发前编辑开关、各站点 caption 格式、模板列表);模板可用 `/set_template` 增、`/remove_template` 删 - 可绑定转发频道自动转发;支持转发前编辑 caption 与自定义模板(提示消息带 Confirm / Skip 按钮并写明过期时间,过期后就地标记为已过期) -- 发送失败自动重试并持久化,重试耗尽后通知用户 +- 发送失败自动重试并持久化,重试耗尽后通知用户;提示会写明是哪条链接、重试等待多久、或最终失败的原因 - 抓取期间持续显示"正在输入 / 正在发送"状态,长任务(ugoira 转码、大图上传)不会看起来卡死 - Pixiv ugoira 动图自动转码为 MP4;Bluesky 视频自动转码(HLS 流 → MP4) - 超过 Telegram 尺寸/大小限制的图片自动压缩(保持原格式,必要时转 JPEG) @@ -117,6 +119,8 @@ Telegram 只接受 443/80/88/8443 端口。 | `/remove_forward_channel` | 取消转发频道 | | `/edit_before_forward` | 开关「转发前编辑」:开启后,转发成功后 bot 会发一条提示消息,回复它可修改第一条转发消息的 caption(或点击模板按钮套用模板),再点 `↩️ Confirm` 才会真正转发,`🛑 Skip` 放弃本次转发;提示消息写明过期时间,过期后原地标记为已过期且不会转发 | | `/set_template <名称>` | 回复一条含 `[]` 的消息,将其保存为命名模板;转发时 `[]` 会被替换为原帖链接(配合「转发前编辑」使用) | +| `/remove_template <名称>` | 删除某个模板(名称见 `/settings`;提示消息的模板按钮最多显示 60 个) | +| `/settings` | 查看本聊天配置:转发频道、转发前编辑开关、各站点 caption 格式、模板列表 | | `/set_format <站点> <格式>` | 自定义某站点的 caption 格式。站点:`twitter` / `bsky` / `pixiv` / `misskey` / `bilibili`。占位符:`{url}` `{author}` `{author_url}` `{title}` `{content}` `{tags}`;未识别的占位符会被拒绝并列出可用项,格式填 `-` 恢复站点默认格式(可用 `/debug <链接>` 预览效果) | | `/clear_cache [链接]` | 清空链接缓存(仅管理员);带链接只清该条,否则清空全部 | | `/bot_dict` | 查看当前聊天状态(调试用;仅管理员) | diff --git a/crates/x-media/src/site/mod.rs b/crates/x-media/src/site/mod.rs index 97ab2a7..bb9d745 100644 --- a/crates/x-media/src/site/mod.rs +++ b/crates/x-media/src/site/mod.rs @@ -483,6 +483,15 @@ async fn fetch_with_attempts(url: &str, attempts: u32) -> Result unreachable!("retry loop always returns") } +/// Whether fetching `url` requires site-specific headers (pixiv's `Referer` +/// for `pximg.net` hotlink protection, see [`Site::media_headers`]). Telegram's +/// own fetch of a media URL sends none of them, so a URL that needs them fails +/// there — callers that hand a URL to Telegram (inline query results) must +/// skip such media instead of shipping a broken item. +pub fn needs_media_headers(url: &str) -> bool { + SITES.iter().any(|site| site.media_headers(url).is_some()) +} + /// Applies every site's media-header rule to a download request (pixiv's /// `Referer` for pximg.net hotlink protection). Sites contribute via their /// `media_headers(url)` — the central download code carries no per-site logic. @@ -733,6 +742,24 @@ mod tests { assert!(matches!(result, Ok(None)), "got {result:?}"); } + #[test] + fn media_headers_are_reported_only_where_telegram_would_fail() { + // pixiv's CDN needs a Referer, which only the bot can send: an inline + // result pointing at it renders broken, so callers skip it. + assert!(needs_media_headers( + "https://i.pximg.net/img-original/img/2024/01/01/00/00/00/1_p0.jpg" + )); + // The rest serve direct requests (verified per site in their modules). + for url in [ + "https://pbs.twimg.com/media/1.jpg", + "https://cdn.bsky.app/img/1.jpg", + "https://media.misskeyusercontent.jp/io/1.webp", + "https://i0.hdslb.com/bfs/1.jpg", + ] { + assert!(!needs_media_headers(url), "{url}"); + } + } + #[tokio::test] async fn disabled_site_is_reported_not_ignored() { // pixiv is the only token-gated site; with PIXIV_REFRESH_TOKEN set it diff --git a/crates/xmedia-bot/src/handlers/commands.rs b/crates/xmedia-bot/src/handlers/commands.rs index 9b6b165..20005e7 100644 --- a/crates/xmedia-bot/src/handlers/commands.rs +++ b/crates/xmedia-bot/src/handlers/commands.rs @@ -4,6 +4,7 @@ use super::urls::{PostSend, url_media}; use super::{CHAT_STORE, CONFIG, LINK_CACHE, log_key, reply, reply_html}; use crate::ctx::AppContext; +use crate::state::ChatData; use teloxide::RequestError; use teloxide::prelude::*; use teloxide::types::{ChatId, Message, Recipient}; @@ -33,6 +34,10 @@ pub(crate) enum Command { parse_with = "split" )] SetTemplate(String), + #[command(description = "Remove a saved template", parse_with = "split")] + RemoveTemplate(String), + #[command(description = "Show this chat's settings")] + Settings, #[command(description = "Show chat state (debug; admin only)")] BotDict, #[command( @@ -69,6 +74,95 @@ fn parse_arg_remainder(s: String) -> Result<(String,), ParseError> { /// `x_media::site::caption_from_fields` substitutes. const FORMAT_PLACEHOLDERS: [&str; 6] = ["url", "author", "author_url", "title", "content", "tags"]; +/// `/start`'s welcome: what the bot is for, where links work, where to look +/// next. The old "Hello!" left a first-time user with nothing. +const START_TEXT: &str = "\ +Send me a post link and I'll send back its images, videos and GIFs with the title, author and tags. + +Supported: X/Twitter, Pixiv, Bluesky, Misskey (misskey.io), Bilibili. +In a private chat just paste the link. In a group, use inline mode (type @, pick me, then the link). + +/help lists every command."; + +/// Appended to `/help`'s command list: argument syntax, caption +/// placeholders and the private-chat rule — none of which teloxide's +/// `descriptions()` renders (it prints `/command — description` only). +const HELP_FOOTER: &str = "\ +Arguments + /set_forward_channel <@channel or channel id> + /set_template — reply to a message containing [] to save it + /remove_template — see /settings for the saved names + /set_format — '-' restores the built-in format + /test / /debug + +Caption placeholders (for /set_format) + {url} {author} {author_url} {title} {content} {tags} + A template's [] is replaced by the post link when forwarding. + +Links are handled in private chats only; in a group use inline mode."; + +/// Cap on template names echoed by `/settings`: a chat with hundreds of +/// templates must not produce a message Telegram rejects for length. +const MAX_SETTINGS_TEMPLATE_NAMES: usize = 30; + +/// Sorted template names: the order `/settings`, `/remove_template` and the +/// prompt's buttons all show. +fn sorted_template_names(data: &ChatData) -> Vec { + let mut names: Vec = data.template.keys().cloned().collect(); + names.sort(); + names +} + +/// `/settings`: what this chat is configured to do, readable by anyone in it +/// (unlike `/bot_dict`, which dumps the raw state and is admin-only). +fn settings_text(data: &ChatData) -> String { + let mut lines = Vec::new(); + match data.forward_channel_id { + Some(id) => lines.push(format!("Forward channel: {id}")), + None => lines.push( + "Forward channel: not set (use /set_forward_channel <@channel or id>)".to_string(), + ), + } + lines.push(format!( + "Edit before forward: {}", + if data.edit_before_forward { + "on" + } else { + "off" + } + )); + let mut formats: Vec = data + .message_format + .iter() + .map(|(site, format)| format!("{site} => {format}")) + .collect(); + formats.sort(); + lines.push(if formats.is_empty() { + "Caption formats: built-in for every site".to_string() + } else { + format!("Caption formats:\n {}", formats.join("\n ")) + }); + let names = sorted_template_names(data); + lines.push(match names.len() { + 0 => "Templates: none".to_string(), + n => format!( + "Templates ({n}): {}{}", + names + .iter() + .take(MAX_SETTINGS_TEMPLATE_NAMES) + .cloned() + .collect::>() + .join(", "), + if n > MAX_SETTINGS_TEMPLATE_NAMES { + format!(", +{} more", n - MAX_SETTINGS_TEMPLATE_NAMES) + } else { + String::new() + } + ), + }); + lines.join("\n") +} + /// The first `{…}` token in a caption format that is not a known placeholder /// (`None` when all of them are). The renderer replaces exact keys only, so an /// unknown token would be published verbatim in every caption of that site — @@ -166,11 +260,17 @@ pub(crate) async fn execute_command( ) -> Result<(), RequestError> { match command { Command::Start => { - bot.send_message(message.chat.id, "Hello!").await?; + bot.send_message(message.chat.id, START_TEXT).await?; } Command::Help => { - bot.send_message(message.chat.id, Command::descriptions().to_string()) - .await?; + // The command list plus the parts teloxide's `descriptions()` + // cannot show: argument syntax, caption placeholders, and where a + // link actually works. + bot.send_message( + message.chat.id, + format!("{}\n\n{}", Command::descriptions(), HELP_FOOTER), + ) + .await?; } Command::SetForwardChannel(channel) => { let result = match set_forward_channel_handler(bot, message, channel).await { @@ -258,6 +358,41 @@ pub(crate) async fn execute_command( }; reply(bot, message.chat.id.0, message.id, text).await?; } + Command::RemoveTemplate(name) => { + let chat_id = message.chat.id.0; + let name = name.trim().to_string(); + if name.is_empty() { + reply( + bot, + chat_id, + message.id, + "Usage: /remove_template (see /settings for the saved names)", + ) + .await?; + return Ok(()); + } + let removed = CHAT_STORE + .update(chat_id, |data| data.template.remove(&name).is_some()) + .await; + let text = if removed { + format!("Template '{name}' removed.") + } else { + // Name the live templates: a typo would otherwise look like a + // successful delete. + let names = sorted_template_names(&CHAT_STORE.get(chat_id).await); + if names.is_empty() { + format!("No template named '{name}'. None are saved yet.") + } else { + format!("No template named '{name}'. Saved: {}", names.join(", ")) + } + }; + reply(bot, chat_id, message.id, text).await?; + } + Command::Settings => { + let chat_id = message.chat.id.0; + let data = CHAT_STORE.get(chat_id).await; + reply(bot, chat_id, message.id, settings_text(&data)).await?; + } Command::BotDict => { // Debug dump of the chat's persisted state: admin only (it echoes // forward-channel ids and templates to whoever asks). @@ -510,12 +645,32 @@ fn plural(n: usize) -> &'static str { if n == 1 { "y" } else { "ies" } } +/// Bot profile texts (Bot API `setMyDescription` / `setMyShortDescription`): +/// shown on the bot's profile page and in the share sheet. Without them a +/// shared link says nothing about what the bot does. +const BOT_DESCRIPTION: &str = "\ +Send a post link from X/Twitter, Pixiv, Bluesky, Misskey (misskey.io) or Bilibili and get its images, videos and GIFs back with the title, author and tags. +Links are handled in private chats; a group can use inline mode. /help lists every command."; +const BOT_SHORT_DESCRIPTION: &str = + "Post links (X, Pixiv, Bluesky, Misskey, Bilibili) -> media messages"; + /// Registers the bot's command list with Telegram so clients show it in the -/// `/` menu (Bot API `setMyCommands`). +/// `/` menu (Bot API `setMyCommands`), plus its profile description texts. pub async fn register_commands(bot: &Bot) -> Result<(), RequestError> { let commands = Command::bot_commands(); bot.set_my_commands(commands.clone()).await?; log::info!("registered {} commands", commands.len()); + // Profile texts are cosmetic: a failure (rare) must not abort startup. + if let Err(e) = bot.set_my_description().description(BOT_DESCRIPTION).await { + log::warn!("failed to set the bot description: {e}"); + } + if let Err(e) = bot + .set_my_short_description() + .short_description(BOT_SHORT_DESCRIPTION) + .await + { + log::warn!("failed to set the bot short description: {e}"); + } Ok(()) } @@ -609,7 +764,7 @@ fn debug_report( #[cfg(test)] mod tests { - use super::{MAX_DEBUG_REPORT_CHARS, debug_report, unknown_placeholder}; + use super::{MAX_DEBUG_REPORT_CHARS, debug_report, settings_text, unknown_placeholder}; use x_media::media::Media; #[test] @@ -728,6 +883,110 @@ mod tests { assert!(report.ends_with('…'), "{report}"); } + #[test] + fn settings_text_reports_the_chat_configuration() { + use crate::state::ChatData; + + // A fresh chat: the defaults must be spelled out, including how to set + // the channel (an empty field is not a status). + let empty = settings_text(&ChatData::default()); + assert!(empty.contains("Forward channel: not set"), "{empty}"); + assert!(empty.contains("/set_forward_channel"), "{empty}"); + assert!(empty.contains("Edit before forward: off"), "{empty}"); + assert!(empty.contains("built-in for every site"), "{empty}"); + assert!(empty.contains("Templates: none"), "{empty}"); + + let configured = ChatData { + forward_channel_id: Some(-100123), + edit_before_forward: true, + template: [("b", "[]"), ("a", "[]")] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + message_format: [("twitter", "{author}: {content}")] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + ..ChatData::default() + }; + let text = settings_text(&configured); + assert!(text.contains("Forward channel: -100123"), "{text}"); + assert!(text.contains("Edit before forward: on"), "{text}"); + assert!(text.contains("twitter => {author}: {content}"), "{text}"); + // Sorted, so the same chat always reports the same thing. + assert!(text.contains("Templates (2): a, b"), "{text}"); + } + + #[test] + fn help_and_start_cover_what_the_command_list_cannot() { + // The placeholders the renderer substitutes must be the ones the help + // lists: a stale list is worse than none. + for placeholder in super::FORMAT_PLACEHOLDERS { + assert!( + super::HELP_FOOTER.contains(&format!("{{{placeholder}}}")), + "help does not document {{{placeholder}}}" + ); + } + // The private-chat rule and the template placeholder semantics are the + // two things users got wrong most often. + assert!(super::HELP_FOOTER.contains("private chats only")); + assert!(super::HELP_FOOTER.contains("[]")); + assert!(super::START_TEXT.contains("inline mode")); + assert!(super::START_TEXT.contains("/help")); + // Both must stay inside Telegram's message limit. + assert!(super::HELP_FOOTER.chars().count() < 2000); + assert!(super::START_TEXT.chars().count() < 2000); + } + + #[test] + fn every_command_is_registered_and_parses() { + use teloxide::utils::command::BotCommands; + + use super::Command; + + let registered: Vec = Command::bot_commands() + .into_iter() + .map(|command| command.command.trim_start_matches('/').to_string()) + .collect(); + for expected in [ + "start", + "help", + "settings", + "set_forward_channel", + "remove_template", + "set_format", + "test", + "debug", + ] { + assert!( + registered.iter().any(|name| name == expected), + "{expected} missing from {registered:?}" + ); + } + // Telegram caps a command description at 256 chars. + for command in Command::bot_commands() { + assert!( + command.description.chars().count() <= 256, + "{}: description too long", + command.command + ); + } + + // A command with a `String` argument must parse with its whole + // argument: without `parse_with`, teloxide's default parser rejects + // `/remove_template x` and the command silently falls through to the + // URL flow. + assert!(matches!( + Command::parse("/settings", ""), + Ok(Command::Settings) + )); + match Command::parse("/remove_template tpl", "") { + Ok(Command::RemoveTemplate(name)) => assert_eq!(name, "tpl"), + Ok(_) => panic!("/remove_template parsed as another command"), + Err(e) => panic!("parse error: {e}"), + } + } + #[test] fn unknown_placeholder_finds_typos_only() { assert_eq!(unknown_placeholder("{author} — {title}"), None); diff --git a/crates/xmedia-bot/src/handlers/inline.rs b/crates/xmedia-bot/src/handlers/inline.rs index 5f8fe9c..7245fb6 100644 --- a/crates/xmedia-bot/src/handlers/inline.rs +++ b/crates/xmedia-bot/src/handlers/inline.rs @@ -146,6 +146,15 @@ async fn answer_inline_query(bot: Bot, query: InlineQuery) -> Result { if row.attempts as u32 >= MAX_RETRIES { - let message = format!("task failed after {MAX_RETRIES} retries"); - log::error!("dead-lettering {}: {message}", row.id); + // The queue keeps only the payload, not the last error, so + // the cause of an exhausted retry is just that: exhausted. + // (The dead-letter message is read by the user, so it must + // not restate its own wrapper — see `failure_text`.) + let message = "retries exhausted".to_string(); + log::error!( + "dead-lettering {}: {message} after {} attempt(s)", + row.id, + row.attempts + 1 + ); self.delete_row(&row.id).await; (self.dead_letter)(payload, message).await; } else { diff --git a/crates/xmedia-bot/src/send/mod.rs b/crates/xmedia-bot/src/send/mod.rs index 74be2ef..40563d3 100644 --- a/crates/xmedia-bot/src/send/mod.rs +++ b/crates/xmedia-bot/src/send/mod.rs @@ -816,6 +816,66 @@ mod tests { ); } + #[test] + fn edit_markup_folds_and_caps_the_template_buttons() { + // Telegram rejects a keyboard over 100 buttons, which would drop the + // whole prompt; the cap keeps it well under that. + let templates: HashMap = (0..200) + .map(|i| (format!("t{i:03}"), "[]".to_string())) + .collect(); + let keyboard = build_edit_markup(&templates); + let buttons: usize = keyboard.inline_keyboard.iter().map(Vec::len).sum(); + assert!( + buttons <= 100, + "a keyboard Telegram rejects would lose the prompt: {buttons}" + ); + assert_eq!( + buttons, + super::post_send::MAX_TEMPLATE_BUTTONS + 2, + "the cap plus the confirm/skip pair" + ); + // Names are folded, not one per row. + assert_eq!(keyboard.inline_keyboard[0].len(), 3); + assert_eq!(keyboard.inline_keyboard.last().unwrap().len(), 2); + assert_eq!(super::post_send::hidden_template_count(&templates), 140); + // Under the cap nothing is hidden and every name gets a button. + let few: HashMap = (0..4) + .map(|i| (format!("t{i}"), "[]".to_string())) + .collect(); + assert_eq!(super::post_send::hidden_template_count(&few), 0); + assert_eq!( + build_edit_markup(&few) + .inline_keyboard + .iter() + .map(Vec::len) + .sum::(), + 6 + ); + } + + #[test] + fn failure_text_names_the_post_and_the_cause() { + // A send failure names the post (the cache key) and the cause, so the + // user knows which of their links died. + let task = sequence_task("https://x.com/u/status/1"); + let text = super::post_send::failure_text(Some(&task), "retries exhausted"); + assert!(text.contains("twitter:1"), "{text}"); + assert!(text.contains("retries exhausted"), "{text}"); + + // A channel-forward failure has no source URL: it must not claim a + // post failed. + let forward = Task::ForwardMessages { + from_chat_id: 1, + to_chat_id: 2, + message_ids: vec![1], + notify_chat_id: None, + notify_message_id: None, + }; + let text = super::post_send::failure_text(Some(&forward), "chat not found"); + assert!(text.starts_with("Forward failed permanently"), "{text}"); + assert!(text.contains("chat not found"), "{text}"); + } + #[test] fn edit_prompt_text_states_the_ttl_and_the_confirm_requirement() { use std::time::Duration; diff --git a/crates/xmedia-bot/src/send/post_send.rs b/crates/xmedia-bot/src/send/post_send.rs index 5d1a6a8..0955e9d 100644 --- a/crates/xmedia-bot/src/send/post_send.rs +++ b/crates/xmedia-bot/src/send/post_send.rs @@ -132,18 +132,31 @@ fn coarsest_unit(ttl: std::time::Duration) -> String { } } -/// Template buttons (one per row), then the confirm/skip pair. Sorted by name: -/// the templates live in a `HashMap`, so an unsorted walk would reshuffle the +/// Templates per keyboard row. Telegram rejects a keyboard with more than 100 +/// buttons *outright*, which would silently drop the whole prompt, so the +/// names are folded and capped rather than listed one per row. +pub(super) const TEMPLATE_BUTTONS_PER_ROW: usize = 3; +/// Hard cap on template buttons; the prompt text names the ones not shown. +pub(super) const MAX_TEMPLATE_BUTTONS: usize = 60; + +/// Template buttons ([`TEMPLATE_BUTTONS_PER_ROW`] per row, at most +/// [`MAX_TEMPLATE_BUTTONS`]), then the confirm/skip pair. Sorted by name: the +/// templates live in a `HashMap`, so an unsorted walk would reshuffle the /// buttons between prompts. pub(super) fn build_edit_markup(templates: &HashMap) -> InlineKeyboardMarkup { let mut names: Vec<&String> = templates.keys().collect(); names.sort(); - let mut rows = Vec::with_capacity(names.len() + 1); - for name in names { - rows.push(vec![InlineKeyboardButton::callback( - name.clone(), - format!("template|{name}"), - )]); + let shown = names.len().min(MAX_TEMPLATE_BUTTONS); + let mut rows = Vec::with_capacity(shown / TEMPLATE_BUTTONS_PER_ROW + 2); + for chunk in names[..shown].chunks(TEMPLATE_BUTTONS_PER_ROW) { + rows.push( + chunk + .iter() + .map(|name| { + InlineKeyboardButton::callback(name.as_str(), format!("template|{name}")) + }) + .collect(), + ); } // Skip exists because the prompt holds the forward hostage until Confirm: // without it the only escape was deleting the message and waiting out the @@ -155,6 +168,11 @@ pub(super) fn build_edit_markup(templates: &HashMap) -> InlineKe InlineKeyboardMarkup::new(rows) } +/// How many templates the markup could not fit, for the prompt text. +pub(super) fn hidden_template_count(templates: &HashMap) -> usize { + templates.len().saturating_sub(MAX_TEMPLATE_BUTTONS) +} + /// Notifies a chat about a dead-lettered task (skips when `notify_chat_id` is /// absent). pub(super) async fn notify_failure( @@ -217,12 +235,21 @@ pub(crate) async fn post_send_actions(ctx: &AppContext<'_>, task: &Task, message }; if edit_before_forward { - let keyboard = build_edit_markup(&ctx.chat_store.get(chat_id).await.template); + let templates = ctx.chat_store.get(chat_id).await.template; + let keyboard = build_edit_markup(&templates); + let mut text = edit_prompt_text(ctx.config.edit_message_ttl); + let hidden = hidden_template_count(&templates); + if hidden > 0 { + // The keyboard is capped; say so instead of silently hiding them. + text.push_str(&format!( + "\n({hidden} more templates not shown — /remove_template to prune.)" + )); + } let prompt = ctx .sender .send_message( ChatId(chat_id), - edit_prompt_text(ctx.config.edit_message_ttl), + text, Some(MessageId(reply_to as i32)), Some(keyboard), ) @@ -279,7 +306,7 @@ pub(crate) async fn post_send_actions(ctx: &AppContext<'_>, task: &Task, message ctx.sender, notify_chat_id, notify_message_id, - &format!("Task failed after retries: {message}"), + &failure_text(None, &message), ) .await; } @@ -373,6 +400,17 @@ async fn send_media_or_animation(ctx: &AppContext<'_>, task: &Task) -> Result, message: &str) -> String { + match task.and_then(|task| task.source_url()).map(log_key) { + Some(key) => format!("Send failed permanently for {key}: {message}"), + // `ForwardMessages` carries no source URL: that failure is about the + // channel copy, not about a post. + None => format!("Forward failed permanently: {message}"), + } +} + /// Dead-letter callback wired to the queue in main: settles the task and /// notifies its chat. pub(crate) async fn dead_letter_notify( @@ -383,8 +421,9 @@ pub(crate) async fn dead_letter_notify( // A dead-lettered task never runs again, and the queue dead-letters retry // exhaustion itself (the handler is not called again), so this is the only // place that sees the final payload. - if let Ok(task) = serde_json::from_value::(payload.clone()) { - settle_task(ctx, &task, Settled::Failed).await; + let task = serde_json::from_value::(payload.clone()).ok(); + if let Some(task) = &task { + settle_task(ctx, task, Settled::Failed).await; } let notify_chat_id = payload.get("notify_chat_id").and_then(|v| v.as_i64()); let notify_message_id = payload.get("notify_message_id").and_then(|v| v.as_i64()); @@ -392,7 +431,7 @@ pub(crate) async fn dead_letter_notify( ctx.sender, notify_chat_id, notify_message_id, - &format!("Task failed after retries: {message}"), + &failure_text(task.as_ref(), &message), ) .await; }