From d6707133cc570d54f098a3ed88efec8084833273 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 20 Sep 2026 15:48:46 +0800 Subject: [PATCH] feat(ux): answer every link, name fetch failures, keep the chat action alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four ways a user could get silence are closed: a registered-but-disabled site (pixiv without a token) now answers instead of being dropped as an unsupported link, `/test` on such a link replies instead of doing nothing, a supported link posted in a group gets a one-line hint (channels stay silent), and fetch failures name their cause — gone / withheld / source risk control / site disabled / source down — instead of one generic sentence. `FetchError::Disabled` carries the "matched but switched off" answer, which `find_site` used to fold into `Ok(None)`. A withheld tweet no longer degrades to "no media": without `TWITTER_AUTH_TOKEN` it stays `Sensitive` so the reply says the media is age-restricted, and a failed authenticated fallback propagates its own class instead of masquerading as an empty post (`empty_fetched` is gone). Long jobs stop looking stalled: `run_with_chat_action` re-sends the chat action every 4s while the pipeline is pending and the hint switches from typing to send-photo/video once the media kinds are known. Media groups go from 9 to Telegram's 10. `/set_format` rejects unknown `{…}` placeholders (a typo used to be published verbatim in every caption) and resets with `-`. The edit-before-forward prompt states its TTL and that Confirm is required, gains a Skip button, and is rewritten in place to "expired" by the sweep — an edit, never a new message, so a background timer cannot wake a chat. --- AGENTS.md | 22 +- README.en.md | 21 +- README.md | 21 +- crates/x-media/src/site/mod.rs | 54 ++++- crates/x-media/src/site/twitter/interface.rs | 38 +-- crates/xmedia-bot/src/handlers/callback.rs | 50 ++++ crates/xmedia-bot/src/handlers/commands.rs | 94 +++++++- crates/xmedia-bot/src/handlers/mod.rs | 65 ++++- crates/xmedia-bot/src/handlers/urls.rs | 240 ++++++++++++++++++- crates/xmedia-bot/src/main.rs | 14 +- crates/xmedia-bot/src/send/mod.rs | 44 +++- crates/xmedia-bot/src/send/post_send.rs | 48 +++- 12 files changed, 614 insertions(+), 97 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 44684de..5c5d124 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,24 +18,28 @@ Telegram update → Dispatcher (polling or axum webhook) → dptree branches └─ callback_query → "forward" (copy to channel) / "template|" (apply caption template) ``` -Message flow: `message_handler` extracts URLs (from `url`/`text_link` entities, text + caption, deduped) → `x_media::site::fetch(url)` → `Fetched` → builds a `Task` → `send::send_media_sequence` (media groups ≤ 9, caption on first item) or `send::send_animation`. On Telegram URL-fetch failure or size error (`send_batch_via_upload`): download via `x_media::site::download_media` to a temp file (≤ 10 MiB), sniff magic bytes (`sniff_ext`), upload via multipart; oversized items fall back to `fallback_url`. On failure: `enqueue_retry` persists resume-state `Task` into the SQLite queue → workers lease (120 s lock TTL) → retry with exponential backoff (≤ 30 s, `MAX_RETRIES = 2`) → dead-letter → `notify_failure`. Success → `post_send_actions`: edit-before-forward prompt with inline buttons, or `copy_messages` to the bound forward channel. +Message flow: `message_handler` extracts URLs (from `url`/`text_link` entities, text + caption, deduped) → `x_media::site::fetch(url)` → `Fetched` → builds a `Task` → `send::send_media_sequence` (media groups ≤ 10, caption on first item) or `send::send_animation`. On Telegram URL-fetch failure or size error (`send_batch_via_upload`): download via `x_media::site::download_media` to a temp file (≤ 10 MiB), sniff magic bytes (`sniff_ext`), upload via multipart; oversized items fall back to `fallback_url`. On failure: `enqueue_retry` persists resume-state `Task` into the SQLite queue → workers lease (120 s lock TTL) → retry with exponential backoff (≤ 30 s, `MAX_RETRIES = 2`) → dead-letter → `notify_failure`. Success → `post_send_actions`: edit-before-forward prompt with inline buttons, or `copy_messages` to the bound forward channel. Debug command: `/debug ` runs the same `x_media::site::fetch` and replies with `debug_report` (`handlers/commands.rs`) — site id, normalized cache key, source URL, title/author/tags, sensitive flag, caption and the media list — nothing is sent, cached or forwarded; the report is capped at 4000 chars and sent with HTML parse mode: raw fields are escaped, and the caption is wrapped in a `
` so it renders exactly like the sent media caption (escaped text and links included). +User-facing failure text is a function of the error class, never one generic sentence: `urls::fetch_error_message` maps `FetchError::NotFound` (post gone), `Sensitive` (withheld, needs `TWITTER_AUTH_TOKEN`), `Blocked` (source risk control), `Disabled { site }` (a registered site switched off — pixiv without a token, the one case `fetch` answers `Err` instead of `Ok(None)`) and `Transient`/`Http` (source down) apart. The same distinction drives the group hint: a supported link posted in a group (not a channel) gets one `GROUP_LINK_HINT` reply, because the link pipeline is private-chat only. + 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 `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. `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}`. +`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}`. ## Key Directories | Path | Purpose | |---|---| | `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`). 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, dptree handler tree, webhook vs polling dispatch | +| `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/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`), `commands.rs` (teloxide `BotCommands` enum + command executor, incl. `/test ` (send-only) / `/debug ` (parse-only) and the admin-only `/bot_dict` state dump), `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), `statics.rs` (global statics) | +| `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) | | `crates/xmedia-bot/src/state.rs` | `ChatStore`: parking_lot `Mutex` cache + SQLite write-through (`chat_state` table) | | `crates/xmedia-bot/src/link_cache.rs` | `LinkCache`: SQLite-backed cache (`link_cache` table) of successfully sent posts — raw caption fields + Telegram `file_id`s; repeat links re-send locally (no fetch/upload), TTL + prune, invalidated on permanent send failure | | `crates/xmedia-bot/src/queue.rs` | `PersistentTaskQueue`: SQLite-backed queue (`tasks` table), `QUEUE_WORKERS = 4` concurrent workers (lease via `BEGIN IMMEDIATE` + `locked_until` TTL), retry→dead-letter, `notify_one` worker wakeup plus a separate `Notify` for the 30 s lease-expiry sweep (a shared one let the sweep steal the workers' wakeup permit), `busy_timeout` on all connections | @@ -60,7 +64,7 @@ Docker: `docker build -t tgxmb .` then `docker run --rm -d --name tgxmb --env-fi ## Code Conventions & Common Patterns -- **Errors via `thiserror` derive** (no anyhow): the public, stringified errors — `FetchError` (`Http`/`Json`/`Pixiv`/`Site`/`NotFound`/`Blocked`) and `PixivError` — derive `thiserror::Error` with `#[from]` conversions; `Display`/`source()` come from the derive. The internal control-flow enums — `QueueError` (`Retryable { delay_seconds, payload }` / `Permanent`), `SendError` (Retryable/Permanent), `Classification`, `FallbackError` — carry no `Display` and are handled by direct variant matching. New errors should follow the same split: stringified/public errors derive `thiserror`, internal flow enums stay plain. +- **Errors via `thiserror` derive** (no anyhow): the public, stringified errors — `FetchError` (`Http`/`Json`/`Pixiv`/`Site`/`NotFound`/`Blocked`/`Disabled`/`Sensitive`/`TooLarge`/`Transient`/`Io`) and `PixivError` — derive `thiserror::Error` with `#[from]` conversions; `Display`/`source()` come from the derive. The internal control-flow enums — `QueueError` (`Retryable { delay_seconds, payload }` / `Permanent`), `SendError` (Retryable/Permanent), `Classification`, `FallbackError` — carry no `Display` and are handled by direct variant matching. New errors should follow the same split: stringified/public errors derive `thiserror`, internal flow enums stay plain. - **Global state via `std::sync::LazyLock` statics**, not DI: `CONFIG`, `CHAT_STORE`, `TASK_QUEUE` in `handlers/statics.rs`; shared reqwest `CLIENT` in `x-media/src/site/mod.rs`. `Bot` is passed/cloned into handlers; queue workers share the process-wide `send::BOT` (`LazyLock`, force-initialized in `main` so a missing token fails at startup). - **Async**: tokio multi-thread runtime (`#[tokio::main]` default). All rusqlite I/O inside `tokio::task::spawn_blocking`. Long loops use `tokio::select!` with `tokio::sync::{watch, Notify}` stop/wake channels. No streams. - **Blocking sync primitives**: `parking_lot::Mutex` for hot caches, `tokio::sync::Mutex` for async-shared state (pixiv token cache), `AtomicBool` for feature gates. @@ -76,7 +80,7 @@ Docker: `docker build -t tgxmb .` then `docker run --rm -d --name tgxmb --env-fi |---|---| | `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 = 9`; `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/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/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/pixiv/api.rs` | OAuth token exchange (hardcoded app client id/secret), access-token cache, ugoira zip→MP4 via ffmpeg in `spawn_blocking` | @@ -99,9 +103,9 @@ Docker: `docker build -t tgxmb .` then `docker run --rm -d --name tgxmb --env-fi ## Testing & QA -- **~180 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). +- **~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). - 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. 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`. +- 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`. - **CI** — `.github/workflows/ci.yml` (actions pinned to commit SHAs, `--locked` on every cargo invocation, `concurrency` cancels superseded runs, `RUST_BACKTRACE=1`) runs `cargo fmt --check` + `cargo clippy --workspace --all-targets --locked -- -D warnings` + `cargo test --workspace --locked` + a release-profile `cargo build --release --locked` + an `actions-rust-lang/audit` dependency-vulnerability gate (offline, no secrets, on every push/PR) and a `live` job (schedule/manual/tag only, `-p x-media` since every network/secret-gated test lives there, `continue-on-error`) for the `#[ignore]`d live + token tests. `.github/workflows/docker.yml` builds and pushes the image on master/tag and runs a **build-only check on pull requests touching the build inputs** (`Dockerfile`, entrypoint, manifests, `.dockerignore`); a release tag must match both crate versions or the build stops, and `FFMPEG_URL`/`FFMPEG_SHA256` are taken from repository variables when set (a release can pin an exact ffmpeg build). `.github/dependabot.yml` keeps crates, the pinned actions and the Docker base images current. - Untested and hard to test without a mock seam: `main.rs`, `config.rs`, `db.rs`, `handlers/statics.rs`, `media_sender.rs` (holds the `MockSender` itself); in `x-media`: `media.rs`, `lib.rs`, all `model.rs`. The `commands.rs` *executor* needs a real `Bot` (only its pure report builder is tested). Everything else — `handlers/{mod,callback,inline,urls}.rs`, `send/*`, `ctx.rs`, `state.rs`, `queue.rs`, `link_cache.rs`, `rate_limit.rs` — is driven through `TestStores`/`ctx::test_support` and the scripted `MockSender`. diff --git a/README.en.md b/README.en.md index 4edef39..b14a724 100644 --- a/README.en.md +++ b/README.en.md @@ -4,12 +4,13 @@ A Telegram bot that turns post links from X / Twitter, Pixiv, Bluesky, Misskey ( ## Features -- 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 +- 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 `) -- Bind a forward channel for automatic forwarding; edit the caption before forwarding and apply custom templates +- 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) +- 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 +- 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) - Link-result cache: after a successful send the Telegram file ids and caption fields are cached locally, so a repeated link is re-sent from local state — no source-site request, no media file stored (expiry controlled by `LINK_CACHE_TTL_SECONDS`, default 7 days) @@ -33,7 +34,7 @@ docker run --rm -d --name tgxmb --env-file .env -v ./data:/app/data tgxmb Environment variables: `TELOXIDE_TOKEN` (required), `PIXIV_REFRESH_TOKEN`, `BOT_ADMIN`, `EDIT_MESSAGE_TTL_SECONDS`, `LINK_CACHE_TTL_SECONDS`, `RUST_LOG`, `TELOXIDE_PROXY`, `WEBHOOK*`, `TWITTER_AUTH_TOKEN` (optional), `BILIBILI_COOKIE` (optional). -NSFW tweets: the public syndication endpoint does not return sensitive content. Setting `TWITTER_AUTH_TOKEN` (the `auth_token` cookie value of a logged-in x.com session) lets the bot fetch NSFW media in the logged-in state only when it hits a withheld tweet; without it, the bot reports no media. +NSFW tweets: the public syndication endpoint does not return sensitive content. Setting `TWITTER_AUTH_TOKEN` (the `auth_token` cookie value of a logged-in x.com session) lets the bot fetch NSFW media in the logged-in state only when it hits a withheld tweet; without it the bot answers that the post's media is withheld and needs `TWITTER_AUTH_TOKEN`. Bilibili dynamics are fetched anonymously by default (no login; the bot fetches bilibili's anonymous `buvid3`/`buvid4` device cookies itself to raise the success rate). If the server's egress IP gets hard-flagged by bilibili (persistent `risk control (-352)` log lines or HTTP 412), set `BILIBILI_COOKIE` (the whole cookie string from a logged-in browser, e.g. `SESSDATA=…; bili_jct=…`) to restore access. Only a dynamic's images and animations are sent; an attached video degrades to its cover image. @@ -83,10 +84,10 @@ Telegram only accepts ports 443/80/88/8443. | Variable | Description | |---|---| | `TELOXIDE_TOKEN` | Bot token (required) | -| `PIXIV_REFRESH_TOKEN` | Pixiv refresh token; Pixiv is disabled without it | +| `PIXIV_REFRESH_TOKEN` | Pixiv refresh token; Pixiv is disabled without it (a pixiv link then gets an explicit "site not enabled" reply instead of silence) | | `BILIBILI_COOKIE` | Optional bilibili cookie string (`SESSDATA=…; bili_jct=…`); only needed when the egress IP stays risk-controlled (device cookies are fetched automatically) | | `BOT_ADMIN` | Admin chat IDs, comma-separated; receives start/stop notifications | -| `EDIT_MESSAGE_TTL_SECONDS` | Edit-before-forward record expiry in seconds, default 86400 | +| `EDIT_MESSAGE_TTL_SECONDS` | Edit-before-forward record expiry in seconds, default 86400; once lapsed the prompt is rewritten in place to "expired — nothing was forwarded" (no extra message) | | `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) | @@ -114,15 +115,15 @@ Telegram only accepts ports 443/80/88/8443. | `/help` | List all commands and usage (this command table) | | `/set_forward_channel ` | Set the forward channel: `@channel` or channel ID; media messages are forwarded to it automatically afterwards | | `/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 taps a template button to apply one) | +| `/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") | -| `/set_format ` | Customize the caption format for one site. Sites: `twitter` / `bsky` / `pixiv` / `misskey` / `bilibili`. Placeholders: `{url}` `{author}` `{author_url}` `{title}` `{content}` `{tags}` | +| `/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) | | `/test ` | Parse a link and send its media; no channel forward, no edit-before-forward prompt (send only) | | `/debug ` | Debug: parse a link and report the parse result only (site, title, author, tags, media list) — no media is sent | -Link processing works only in private chats; commands work in any chat. +Link processing works only in private chats; commands work in any chat. A supported link posted in a group gets a one-line hint to use the private chat or inline mode; channels stay silent. ## Notes diff --git a/README.md b/README.md index 7f6d753..2c78575 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@ Telegram 机器人,将 X / Twitter、Pixiv、Bluesky、Misskey (misskey.io)、 ## 功能 -- 私聊发送链接后自动抓取并发送图片、视频与 GIF,超量图片自动分批 -- 纯文字帖提示无媒体;不支持的链接静默忽略 +- 私聊发送链接后自动抓取并发送图片、视频与 GIF,超量图片自动分批(每批 10 张) +- 纯文字帖提示无媒体;不支持的链接静默忽略。抓取失败会按原因分别提示(帖子已删除 / 内容受限 / 源站风控 / 站点未启用) - 长帖(正文 ≥ `CAPTION_QUOTE_TEXT_CHARS`,默认 200)的**正文部分**用可折叠引用块展示,链接与作者行留在引用块外 -- 支持内联查询(`@机器人 <链接>`) -- 可绑定转发频道自动转发;支持转发前编辑 caption 与自定义模板 +- 支持内联查询(`@机器人 <链接>`);在群聊里发链接会提示改用私聊或内联查询(频道内保持静默) +- 可绑定转发频道自动转发;支持转发前编辑 caption 与自定义模板(提示消息带 Confirm / Skip 按钮并写明过期时间,过期后就地标记为已过期) - 发送失败自动重试并持久化,重试耗尽后通知用户 +- 抓取期间持续显示"正在输入 / 正在发送"状态,长任务(ugoira 转码、大图上传)不会看起来卡死 - Pixiv ugoira 动图自动转码为 MP4;Bluesky 视频自动转码(HLS 流 → MP4) - 超过 Telegram 尺寸/大小限制的图片自动压缩(保持原格式,必要时转 JPEG) - 链接结果本地缓存:成功发送后缓存 Telegram file id 与 caption 等,再次收到相同链接直接本地重发,不再请求源站、不保存媒体文件(`LINK_CACHE_TTL_SECONDS` 控制过期,默认 7 天) @@ -33,7 +34,7 @@ docker run --rm -d --name tgxmb --env-file .env -v ./data:/app/data tgxmb 环境变量:`TELOXIDE_TOKEN`(必填)、`PIXIV_REFRESH_TOKEN`、`BOT_ADMIN`、`EDIT_MESSAGE_TTL_SECONDS`、`LINK_CACHE_TTL_SECONDS`、`RUST_LOG`、`TELOXIDE_PROXY`、`WEBHOOK*`、`TWITTER_AUTH_TOKEN`(可选)、`BILIBILI_COOKIE`(可选)。 -NSFW 推文:公开的 syndication 接口不返回敏感内容。设置 `TWITTER_AUTH_TOKEN`(登录 x.com 后浏览器 Cookie 里的 `auth_token` 值)后,bot 会仅在遇到 NSFW 推文时以登录态获取媒体;未设置则提示无媒体。 +NSFW 推文:公开的 syndication 接口不返回敏感内容。设置 `TWITTER_AUTH_TOKEN`(登录 x.com 后浏览器 Cookie 里的 `auth_token` 值)后,bot 会仅在遇到 NSFW 推文时以登录态获取媒体;未设置则回复该推文内容受限(需要配置 `TWITTER_AUTH_TOKEN`)。 Bilibili 动态默认匿名抓取(无需登录,bot 会自动从 B 站的匿名指纹接口取 `buvid3`/`buvid4` 设备 cookie 以提高成功率)。若服务器出口 IP 被 B 站重度风控(日志里的 `risk control (-352)` 或 HTTP 412,且持续出现),设置 `BILIBILI_COOKIE`(登录后浏览器里整条 Cookie 串,如 `SESSDATA=…; bili_jct=…`)可恢复访问。当前只发送动态里的图片与动图,动态内嵌视频发送其封面。 @@ -83,10 +84,10 @@ Telegram 只接受 443/80/88/8443 端口。 | 变量 | 说明 | |---|---| | `TELOXIDE_TOKEN` | Bot token(必填) | -| `PIXIV_REFRESH_TOKEN` | Pixiv 刷新令牌;未设置则禁用 Pixiv | +| `PIXIV_REFRESH_TOKEN` | Pixiv 刷新令牌;未设置则禁用 Pixiv(此时收到 pixiv 链接会明确回复「站点未启用」,不会静默忽略) | | `BILIBILI_COOKIE` | 可选的 B 站 Cookie 串(`SESSDATA=…; bili_jct=…`),仅在出口 IP 被持续风控时才需要(设备 cookie 由 bot 自动获取) | | `BOT_ADMIN` | 管理员聊天 ID,逗号分隔;接收启动/停止通知 | -| `EDIT_MESSAGE_TTL_SECONDS` | 转发前编辑记录过期秒数,默认 86400 | +| `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`(相对工作目录,会自动创建) | @@ -114,15 +115,15 @@ Telegram 只接受 443/80/88/8443 端口。 | `/help` | 查看全部命令及用法(即本文档的命令表) | | `/set_forward_channel <频道>` | 设置转发频道,参数为 `@频道名` 或频道 ID;设置后发送的媒体消息会自动转发到该频道 | | `/remove_forward_channel` | 取消转发频道 | -| `/edit_before_forward` | 开关「转发前编辑」:开启后,转发成功后 bot 会发一条提示消息,回复它可修改第一条转发消息的 caption(或点击模板按钮套用模板) | +| `/edit_before_forward` | 开关「转发前编辑」:开启后,转发成功后 bot 会发一条提示消息,回复它可修改第一条转发消息的 caption(或点击模板按钮套用模板),再点 `↩️ Confirm` 才会真正转发,`🛑 Skip` 放弃本次转发;提示消息写明过期时间,过期后原地标记为已过期且不会转发 | | `/set_template <名称>` | 回复一条含 `[]` 的消息,将其保存为命名模板;转发时 `[]` 会被替换为原帖链接(配合「转发前编辑」使用) | -| `/set_format <站点> <格式>` | 自定义某站点的 caption 格式。站点:`twitter` / `bsky` / `pixiv` / `misskey` / `bilibili`。占位符:`{url}` `{author}` `{author_url}` `{title}` `{content}` `{tags}` | +| `/set_format <站点> <格式>` | 自定义某站点的 caption 格式。站点:`twitter` / `bsky` / `pixiv` / `misskey` / `bilibili`。占位符:`{url}` `{author}` `{author_url}` `{title}` `{content}` `{tags}`;未识别的占位符会被拒绝并列出可用项,格式填 `-` 恢复站点默认格式(可用 `/debug <链接>` 预览效果) | | `/clear_cache [链接]` | 清空链接缓存(仅管理员);带链接只清该条,否则清空全部 | | `/bot_dict` | 查看当前聊天状态(调试用;仅管理员) | | `/test <链接>` | 解析链接并发送媒体;不转发到频道、不弹转发前编辑提示(仅发送) | | `/debug <链接>` | 调试:只解析链接并返回解析结果(站点、标题、作者、标签、媒体列表),不发送任何媒体 | -链接处理仅限私聊;命令在任意聊天可用。 +链接处理仅限私聊;命令在任意聊天可用。在群聊里发受支持的链接会回复一条提示(改用私聊或内联查询),频道内保持静默。 ## 备注 diff --git a/crates/x-media/src/site/mod.rs b/crates/x-media/src/site/mod.rs index f1d1ada..97ab2a7 100644 --- a/crates/x-media/src/site/mod.rs +++ b/crates/x-media/src/site/mod.rs @@ -247,6 +247,12 @@ pub enum FetchError { NotFound, #[error("blocked")] Blocked, + /// The URL matches a registered site that is disabled right now (pixiv + /// without `PIXIV_REFRESH_TOKEN`, or after a failed login). Distinct from + /// `Ok(None)` — an unsupported link — so the bot can tell the user why + /// the link was not handled instead of silently ignoring it. + #[error("{site} support is disabled")] + Disabled { site: &'static str }, /// The post exists but its content is withheld (twitter NSFW / /// age-restricted tweets come back as an empty `{}` from syndication). #[error("content withheld (sensitive)")] @@ -385,6 +391,17 @@ fn find_site(url: &str) -> Option<&'static dyn Site> { .map(|site| site.as_ref()) } +/// The site whose pattern matches `url` but which is disabled right now. +/// `None` when no site matches the URL at all, or when the matching site is +/// enabled. Lets the dispatcher tell "unsupported link" (silently ignored) +/// apart from "this bot has that site switched off" (reported to the user). +fn disabled_site(url: &str) -> Option<&'static str> { + SITES + .iter() + .find(|site| !site.enabled() && site.pattern().is_match(url)) + .map(|site| site.id()) +} + /// Every supported site id, in dispatch order. The bot's SetFormat whitelist /// derives from this list. pub fn site_ids() -> Vec<&'static str> { @@ -408,7 +425,9 @@ pub async fn validate_all() -> Vec<(&'static str, String)> { } /// Fetches a post from its URL. Returns `Ok(None)` when no site pattern -/// matches (unsupported links are silently ignored by the bot). +/// matches (unsupported links are silently ignored by the bot) and +/// [`FetchError::Disabled`] when the URL belongs to a registered site that is +/// switched off right now — the two are different answers for the user. /// /// Transient failures are retried: 3 total attempts with 1s then 2s delays. /// What counts as transient is the matched site's own policy (`is_retryable` @@ -432,7 +451,13 @@ const MAX_FETCH_ATTEMPTS: u32 = 3; async fn fetch_with_attempts(url: &str, attempts: u32) -> Result, FetchError> { let Some(site) = find_site(url) else { - return Ok(None); + // A registered-but-disabled site (pixiv without a token) is not an + // unsupported link: report it, so the bot answers the user instead of + // ignoring the message. + return match disabled_site(url) { + Some(site) => Err(FetchError::Disabled { site }), + None => Ok(None), + }; }; for attempt in 0..attempts.max(1) { match site.fetch_from_url(url).await { @@ -708,6 +733,31 @@ mod tests { assert!(matches!(result, Ok(None)), "got {result:?}"); } + #[tokio::test] + async fn disabled_site_is_reported_not_ignored() { + // pixiv is the only token-gated site; with PIXIV_REFRESH_TOKEN set it + // is enabled and this link would hit the network, so skip then. + if std::env::var("PIXIV_REFRESH_TOKEN") + .ok() + .filter(|s| !s.is_empty()) + .is_some() + { + eprintln!("skipping: PIXIV_REFRESH_TOKEN is set"); + return; + } + let result = fetch("https://www.pixiv.net/artworks/1").await; + assert!( + matches!(result, Err(FetchError::Disabled { site: "pixiv" })), + "got {result:?}" + ); + // The cache key still resolves: the bot keys the reply and the link + // cache off it even when the site is off. + assert_eq!( + cache_key("https://www.pixiv.net/artworks/1"), + Some("pixiv:1".into()) + ); + } + #[tokio::test] async fn download_media_pixiv_original_with_referer() { // Proves the Referer header is attached for i.pximg.net: a header-less diff --git a/crates/x-media/src/site/twitter/interface.rs b/crates/x-media/src/site/twitter/interface.rs index 8ee6b04..7d633ec 100644 --- a/crates/x-media/src/site/twitter/interface.rs +++ b/crates/x-media/src/site/twitter/interface.rs @@ -43,27 +43,25 @@ pub async fn fetch_from_url(url: &str) -> Result { match fetch(id).await { Ok(tweet) => Ok(tweet.into()), // Syndication withholds NSFW/age-restricted tweets (empty `{}`). - // Retry as the logged-in user when TWITTER_AUTH_TOKEN is set; - // otherwise degrade to an empty result (the bot replies - // "No media found"). + // Retry as the logged-in user when TWITTER_AUTH_TOKEN is set; without + // the token the withholding is reported as `Sensitive`, so the bot can + // answer "age-restricted / needs TWITTER_AUTH_TOKEN" instead of the + // misleading "No media found". Err(FetchError::Sensitive) => { if super::auth::enabled() { match super::auth::fetch(id).await { Ok(tweet) => Ok(tweet.into()), - // The tweet is genuinely gone (deleted / suspended / - // tombstoned): report it instead of degrading to an - // empty result ("No media found"). Only unexpected - // fallback failures (network, parse) keep the NSFW - // placeholder. - Err(FetchError::NotFound) => Err(FetchError::NotFound), + // Deleted/suspended (tombstoned) and unexpected fallback + // failures keep their own class: the bot reports what + // actually happened rather than "No media found". Err(e) => { log::warn!("twitter auth fallback failed for {id}: {e}"); - Ok(empty_fetched(url)) + Err(e) } } } else { log::debug!("tweet {id} is sensitive; set TWITTER_AUTH_TOKEN to fetch NSFW media"); - Ok(empty_fetched(url)) + Err(FetchError::Sensitive) } } Err(e) => Err(e), @@ -90,24 +88,6 @@ pub fn media_headers(_url: &str) -> Option> { None } -/// A Fetched with no media for withheld tweets: the bot replies -/// "No media found" and moves on instead of erroring. -fn empty_fetched(url: &str) -> Fetched { - Fetched { - source_url: url.to_string(), - // The raw user-supplied URL goes into an HTML caption; escape it so - // crafted links cannot break the parse (Telegram 400). - caption: encode_text(url).into_owned(), - title: String::new(), - content: String::new(), - media: vec![], - sensitive: true, - site_id: "twitter", - render_data: None, - _keep_alive: None, - } -} - /// Fetches a tweet from the syndication endpoint. Deleted/blocked tweets /// surface as `FetchError::NotFound`; withheld content (empty tombstone, /// age-restricted) as `FetchError::Sensitive`. diff --git a/crates/xmedia-bot/src/handlers/callback.rs b/crates/xmedia-bot/src/handlers/callback.rs index 45aa4f4..7f69d9a 100644 --- a/crates/xmedia-bot/src/handlers/callback.rs +++ b/crates/xmedia-bot/src/handlers/callback.rs @@ -14,6 +14,8 @@ use teloxide::types::{CallbackQuery, CallbackQueryId, MessageId}; /// The `"forward"` button's data. const FORWARD: &str = "forward"; +/// The `"skip"` button's data: drop the prompt without forwarding. +const SKIP: &str = "skip"; /// Prefix of a template button's data: `"template|"`. const TEMPLATE_PREFIX: &str = "template|"; @@ -70,6 +72,29 @@ async fn handle_callback( } log::info!("callback from {chat_id} on prompt {prompt_message_id}: {data}"); + if data == SKIP { + // Skip works with or without a forward channel: it is the explicit + // "do not forward this" answer, and it drops the record so the forward + // can never happen later. + log::info!("edit-before-forward prompt {prompt_message_id} skipped"); + ctx.chat_store + .update(chat_id, |data| { + data.edit_message.remove(&prompt_message_id); + }) + .await; + let _ = ctx + .sender + .delete_message(ChatId(chat_id), MessageId(prompt_message_id as i32)) + .await; + let _ = ctx + .sender + .answer_callback_query( + callback_query_id, + Some("Skipped — nothing was forwarded.".to_string()), + ) + .await; + return; + } if data == FORWARD { match chat_data.forward_channel_id { Some(channel_id) => { @@ -244,6 +269,31 @@ mod tests { ); } + #[tokio::test] + async fn skip_drops_the_prompt_without_forwarding() { + // "skip" needs no forward channel and no scripted outcomes: it deletes + // the prompt and drops the record, so no forward can ever happen. + let sender = MockSender::scripted(vec![], api_error); + let stores = TestStores::new(); + let ctx = stores.ctx(&sender); + seed_prompt(&ctx, crate::db::unix_now()).await; + + handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "skip").await; + + assert_eq!( + sender.calls(), + vec!["delete_message", "answer_callback_query"] + ); + assert_eq!( + sender.answers(), + vec![Some("Skipped — nothing was forwarded.".to_string())] + ); + assert!( + ctx.chat_store.get(1).await.edit_message.is_empty(), + "a skipped prompt must drop its record" + ); + } + #[tokio::test] async fn forward_without_a_channel_is_reported() { let sender = MockSender::scripted(vec![], api_error); diff --git a/crates/xmedia-bot/src/handlers/commands.rs b/crates/xmedia-bot/src/handlers/commands.rs index 0f8c17e..9b6b165 100644 --- a/crates/xmedia-bot/src/handlers/commands.rs +++ b/crates/xmedia-bot/src/handlers/commands.rs @@ -35,7 +35,10 @@ pub(crate) enum Command { SetTemplate(String), #[command(description = "Show chat state (debug; admin only)")] BotDict, - #[command(description = "Set site caption format", parse_with = "split")] + #[command( + description = "Set site caption format (- to reset)", + parse_with = "split" + )] SetFormat(String), #[command( description = "Clear link cache (admin; optional URL, else all)", @@ -62,6 +65,29 @@ fn parse_arg_remainder(s: String) -> Result<(String,), ParseError> { Ok((s.trim().to_string(),)) } +/// Placeholders `/set_format` accepts, mirroring what +/// `x_media::site::caption_from_fields` substitutes. +const FORMAT_PLACEHOLDERS: [&str; 6] = ["url", "author", "author_url", "title", "content", "tags"]; + +/// 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 — +/// caught here instead. +fn unknown_placeholder(format: &str) -> Option<&str> { + let mut rest = format; + while let Some(open) = rest.find('{') { + let after = &rest[open + 1..]; + // An unclosed `{` is not a placeholder token at all. + let close = after.find('}')?; + let token = &after[..close]; + if !FORMAT_PLACEHOLDERS.contains(&token) { + return Some(token); + } + rest = &after[close + 1..]; + } + None +} + enum SetForwardChannelError { EmptyParameter, NotChannel, @@ -284,12 +310,56 @@ pub(crate) async fn execute_command( .await?; return Ok(()); } + // `-` resets to the site's built-in caption: without it a chat that + // set a format once could never get back to the default (the + // built-in format string is not something a user can retype). + if format == "-" { + CHAT_STORE + .update(chat_id, |data| { + data.message_format.remove(site); + }) + .await; + reply( + bot, + message.chat.id.0, + message.id, + "Format reset to the built-in one.", + ) + .await?; + return Ok(()); + } + // A typo like {titel} would otherwise be rendered literally into + // every caption of that site (the renderer only substitutes the + // exact keys), which is invisible until a post arrives. + if let Some(token) = unknown_placeholder(&format) { + reply( + bot, + message.chat.id.0, + message.id, + format!( + "Unknown placeholder {{{token}}}. Available: {}", + FORMAT_PLACEHOLDERS + .iter() + .map(|name| format!("{{{name}}}")) + .collect::>() + .join(" ") + ), + ) + .await?; + return Ok(()); + } CHAT_STORE .update(chat_id, |data| { data.message_format.insert(site.to_string(), format); }) .await; - reply(bot, message.chat.id.0, message.id, "Format set.").await?; + reply( + bot, + message.chat.id.0, + message.id, + "Format set. Use /debug to preview the caption.", + ) + .await?; } Command::ClearCache(arg) => { let sender_id = message @@ -539,7 +609,7 @@ fn debug_report( #[cfg(test)] mod tests { - use super::{MAX_DEBUG_REPORT_CHARS, debug_report}; + use super::{MAX_DEBUG_REPORT_CHARS, debug_report, unknown_placeholder}; use x_media::media::Media; #[test] @@ -657,4 +727,22 @@ mod tests { assert!(report.chars().count() <= MAX_DEBUG_REPORT_CHARS, "{report}"); assert!(report.ends_with('…'), "{report}"); } + + #[test] + fn unknown_placeholder_finds_typos_only() { + assert_eq!(unknown_placeholder("{author} — {title}"), None); + // Every key the renderer substitutes must pass, in any combination. + assert_eq!( + unknown_placeholder("{url}{author}{author_url}{title}{content}{tags}"), + None + ); + // Plain text and braces Telegram renders literally are not tokens. + assert_eq!(unknown_placeholder("no placeholders here"), None); + assert_eq!(unknown_placeholder("{unclosed"), None); + + assert_eq!(unknown_placeholder("{titel}"), Some("titel")); + assert_eq!(unknown_placeholder("{title} {Content}"), Some("Content")); + // A typo after a valid token is still found. + assert_eq!(unknown_placeholder("{url} {tag}"), Some("tag")); + } } diff --git a/crates/xmedia-bot/src/handlers/mod.rs b/crates/xmedia-bot/src/handlers/mod.rs index 39a3851..1ef5bff 100644 --- a/crates/xmedia-bot/src/handlers/mod.rs +++ b/crates/xmedia-bot/src/handlers/mod.rs @@ -23,7 +23,9 @@ use crate::media_sender::MediaSender; use commands::{Command, execute_command}; use teloxide::RequestError; use teloxide::prelude::*; -use teloxide::types::{ChatId, ChatKind, Message, MessageId, ParseMode, ReplyParameters}; +use teloxide::types::{ + ChatId, ChatKind, Message, MessageId, ParseMode, PublicChatKind, ReplyParameters, +}; use teloxide::utils::command::BotCommands; use urls::{URL_JOBS, extract_urls}; @@ -175,10 +177,38 @@ pub async fn message_handler(bot: Bot, message: Message) -> Result<(), RequestEr break; } } + } else if is_group(&message.chat.kind) + && extract_urls(&message) + .iter() + .any(|url| x_media::site::cache_key(url).is_some()) + { + // A supported link in a group used to be dropped in silence, which + // reads as a broken bot (the command menu is registered globally, so + // the expectation is there). Unsupported links stay ignored; the hint + // names the two paths that do work. Channels are excluded — the reply + // would be posted into the channel itself. + let _ = reply(&bot, message.chat.id.0, message.id, GROUP_LINK_HINT).await; } respond(()) } +/// Answer for a link posted where the pipeline does not run (a group): links +/// are private-chat only, inline mode is the group path. +const GROUP_LINK_HINT: &str = + "Links are handled in private chat only — send me this link there, or use inline mode here."; + +/// Groups and supergroups, as opposed to private chats and channels. +fn is_group(kind: &ChatKind) -> bool { + matches!( + kind, + ChatKind::Public(chat) + if matches!( + chat.kind, + PublicChatKind::Group | PublicChatKind::Supergroup(_) + ) + ) +} + #[cfg(test)] mod tests { use super::*; @@ -271,4 +301,37 @@ mod tests { assert!(!edit_message_handler(&ctx, 1, PROMPT_ID, "hello").await); assert!(sender.calls().is_empty()); } + + #[test] + fn the_link_hint_is_for_groups_only() { + use teloxide::types::{ChatPrivate, ChatPublic, PublicChatChannel, PublicChatSupergroup}; + + let group = ChatKind::Public(ChatPublic { + title: None, + kind: PublicChatKind::Group, + }); + let supergroup = ChatKind::Public(ChatPublic { + title: None, + kind: PublicChatKind::Supergroup(PublicChatSupergroup { + username: None, + is_forum: false, + }), + }); + // A channel must stay silent: the hint reply would be posted into the + // channel itself. + let channel = ChatKind::Public(ChatPublic { + title: None, + kind: PublicChatKind::Channel(PublicChatChannel { username: None }), + }); + let private = ChatKind::Private(ChatPrivate { + username: None, + first_name: None, + last_name: None, + }); + + assert!(is_group(&group)); + assert!(is_group(&supergroup)); + assert!(!is_group(&channel)); + assert!(!is_group(&private)); + } } diff --git a/crates/xmedia-bot/src/handlers/urls.rs b/crates/xmedia-bot/src/handlers/urls.rs index 0f7a9e2..84cff5f 100644 --- a/crates/xmedia-bot/src/handlers/urls.rs +++ b/crates/xmedia-bot/src/handlers/urls.rs @@ -4,9 +4,11 @@ use super::{log_key, reply}; use crate::ctx::{AppContext, CONTEXT}; use crate::link_cache::{CachedMediaKind, CachedPost}; +use crate::media_sender::MediaSender; use crate::send::{self, MediaItemPayload, Task}; use crate::state::ChatData; use std::collections::HashSet; +use std::future::Future; use std::sync::LazyLock; use teloxide::types::{ChatAction, ChatId, Message, MessageEntityKind, MessageId}; use x_media::media::Media; @@ -285,6 +287,11 @@ fn build_send_task( /// workers pass [`PostSend::FromChat`], the `/test` command /// [`PostSend::Suppressed`]. Everything else (cache write, retry enqueue, /// dead-letter notification) is identical. +/// +/// Wraps [`url_media_inner`] with the chat-action keep-alive: Telegram expires +/// an action indicator after ~5s, while a fetch (ugoira encode, HLS remux) plus +/// a download-and-reupload fallback routinely takes longer — without the +/// refresh the chat shows nothing and the bot reads as stalled. pub(crate) async fn url_media( ctx: &AppContext<'_>, chat_id: i64, @@ -292,14 +299,136 @@ pub(crate) async fn url_media( url: &str, post_send: PostSend, ) { - let reply_to = MessageId(reply_to_message_id as i32); - if let Err(e) = ctx - .sender - .send_chat_action(ChatId(chat_id), ChatAction::Typing) - .await - { + // Shared with the pipeline: once the media types are known the indicator + // switches from "typing" to "sending photo/video". + let hint = parking_lot::Mutex::new(ActionHint::Typing); + run_with_chat_action( + ctx.sender, + chat_id, + &hint, + url_media_inner(ctx, chat_id, reply_to_message_id, url, post_send, &hint), + ) + .await; +} + +/// Runs `pipeline` while keeping the chat's action indicator alive: Telegram +/// expires an action after ~5s, while a fetch (ugoira encode, HLS remux) plus a +/// download-and-reupload fallback routinely takes longer. The pipeline updates +/// `hint` when it knows what it is sending. +async fn run_with_chat_action>( + sender: &dyn MediaSender, + chat_id: i64, + hint: &parking_lot::Mutex, + pipeline: F, +) { + // The guard is released before the await: a parking_lot guard held across + // it makes the future !Send, and the URL workers spawn these. + let action = hint.lock().action(); + if let Err(e) = sender.send_chat_action(ChatId(chat_id), action).await { log::error!("send_chat_action failed: {e}"); } + tokio::pin!(pipeline); + loop { + tokio::select! { + // `biased` polls the pipeline first, so a finished pipeline returns + // without ever arming the refresh timer (no stray actions). + biased; + () = &mut pipeline => return, + () = tokio::time::sleep(ACTION_REFRESH) => { + let action = hint.lock().action(); + if let Err(e) = sender.send_chat_action(ChatId(chat_id), action).await { + log::error!("send_chat_action failed: {e}"); + } + } + } + } +} + +/// How often the chat-action indicator is refreshed while a pipeline runs. +/// Telegram's indicator lasts ~5s; refreshing slightly inside that keeps it +/// on-screen continuously. +const ACTION_REFRESH: std::time::Duration = std::time::Duration::from_secs(4); + +/// What the chat action should say. Unknown before the fetch, so the pipeline +/// starts with `Typing` and switches as soon as the media types are known. +#[derive(Clone, Copy)] +enum ActionHint { + Typing, + Photo, + Video, +} + +impl ActionHint { + /// Photos make Telegram label the send "sending photo"; video/animation + /// only payloads get "sending video". A mixed post takes the photo label + /// (the group's first item is always a photo, see `photos_first`). + fn for_items(items: &[MediaItemPayload]) -> Self { + if items + .iter() + .any(|item| matches!(item, MediaItemPayload::Photo { .. })) + { + Self::Photo + } else { + Self::Video + } + } + + fn action(self) -> ChatAction { + match self { + Self::Typing => ChatAction::Typing, + Self::Photo => ChatAction::UploadPhoto, + Self::Video => ChatAction::UploadVideo, + } + } +} + +/// User-facing text for a failed fetch. The [`FetchError`] class is what tells +/// the user whether the post is gone, withheld or the source is refusing +/// requests; a single generic sentence threw that away. +fn fetch_error_message(err: &x_media::site::FetchError) -> String { + use x_media::site::FetchError; + match err { + FetchError::NotFound => "Post not found (deleted, private or unavailable).".to_string(), + FetchError::Sensitive => concat!( + "This post's media is withheld (age-restricted). ", + "The bot owner must set TWITTER_AUTH_TOKEN to fetch it." + ) + .to_string(), + FetchError::Blocked => { + "The source site refused the request (risk control). Try again later.".to_string() + } + FetchError::Disabled { site } => { + format!("{} support is disabled on this bot.", site_title(site)) + } + FetchError::Transient(_) | FetchError::Http(_) => { + "The source site is unavailable right now (tried 3 times). Try again later.".to_string() + } + // Parse/shape surprises, pixiv auth details, oversized media: nothing + // actionable for the user beyond "this did not work". + _ => "Failed to fetch media from this link.".to_string(), + } +} + +/// Site ids are lowercase ASCII (`pixiv`); user-facing text capitalizes the +/// first letter. +fn site_title(site: &str) -> String { + let mut chars = site.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +#[allow(clippy::too_many_arguments)] +async fn url_media_inner( + ctx: &AppContext<'_>, + chat_id: i64, + reply_to_message_id: i64, + url: &str, + post_send: PostSend, + hint: &parking_lot::Mutex, +) { + let reply_to = MessageId(reply_to_message_id as i32); // Link cache: a post sent before is re-sent from Telegram file ids — // no source-site request, no download, no upload. Keyed by the @@ -355,6 +484,9 @@ pub(crate) async fn url_media( }, }) .collect(); + // The indicator switches to "sending photo/video" once the kinds are + // known; `items` is moved into the task below. + *hint.lock() = ActionHint::for_items(&items); let task = build_send_task( &chat_data, chat_id, @@ -378,13 +510,7 @@ pub(crate) async fn url_media( // Retries exhausted: notify the user (Rust-only requirement 3). Err(e) => { log::error!("fetch {url}: {e}"); - let _ = reply( - ctx.sender, - chat_id, - reply_to, - "Failed to fetch media from this link.", - ) - .await; + let _ = reply(ctx.sender, chat_id, reply_to, fetch_error_message(&e)).await; } Ok(Some(mut fetched)) => { if fetched.media.is_empty() { @@ -426,6 +552,9 @@ pub(crate) async fn url_media( .iter() .map(|media| media_to_payload(media, fetched.sensitive)) .collect(); + // The indicator switches to "sending photo/video" once the kinds + // are known; `items` is moved into the task below. + *hint.lock() = ActionHint::for_items(&items); let task = build_send_task( &chat_data, chat_id, @@ -695,4 +824,89 @@ mod tests { // Dead-letter notification still reaches the chat that asked. assert_eq!(notify_chat_id, Some(1)); } + + #[test] + fn fetch_errors_map_to_distinct_user_messages() { + use x_media::site::FetchError; + + let disabled = fetch_error_message(&FetchError::Disabled { site: "pixiv" }); + assert_eq!(disabled, "Pixiv support is disabled on this bot."); + assert_eq!( + fetch_error_message(&FetchError::NotFound), + "Post not found (deleted, private or unavailable)." + ); + let sensitive = fetch_error_message(&FetchError::Sensitive); + assert!(sensitive.contains("TWITTER_AUTH_TOKEN"), "{sensitive}"); + let blocked = fetch_error_message(&FetchError::Blocked); + assert!(blocked.contains("refused"), "{blocked}"); + + // Each class that has something to say must differ from the generic + // fallback — one generic sentence for everything is what this fixes. + let generic = fetch_error_message(&FetchError::TooLarge); + for text in [disabled, sensitive, blocked] { + assert_ne!(text, generic); + } + } + + #[test] + fn action_hint_follows_the_media_kind() { + use MediaItemPayload::{Animation, Photo, Video}; + + let photo = || Photo { + media: "https://p/1.jpg".into(), + has_spoiler: false, + fallback_url: None, + file_id: false, + }; + let video = || Video { + media: "https://v/1.mp4".into(), + has_spoiler: false, + thumbnail: None, + fallback_url: None, + file_id: false, + }; + + // Unknown before the fetch: the pipeline starts on "typing". + assert!(matches!(ActionHint::Typing.action(), ChatAction::Typing)); + assert!(matches!( + ActionHint::for_items(&[photo()]).action(), + ChatAction::UploadPhoto + )); + assert!(matches!( + ActionHint::for_items(&[ + video(), + Animation { + media: "https://v/2.mp4".into(), + has_spoiler: false, + file_id: false, + } + ]) + .action(), + ChatAction::UploadVideo + )); + // A mixed post takes the photo label: `photos_first` always leads with + // a photo, which is what Telegram shows. + assert!(matches!( + ActionHint::for_items(&[video(), photo()]).action(), + ChatAction::UploadPhoto + )); + } + + #[tokio::test(start_paused = true)] + async fn a_long_pipeline_keeps_the_chat_action_alive() { + let sender = MockSender::scripted(vec![], permanent_error); + let hint = parking_lot::Mutex::new(ActionHint::Typing); + // Three refresh windows of work: Telegram would have dropped the + // indicator twice without the keep-alive. + let pipeline = async { tokio::time::sleep(ACTION_REFRESH * 3).await }; + + run_with_chat_action(&sender, 1, &hint, pipeline).await; + + let actions = sender + .calls() + .iter() + .filter(|call| **call == "send_chat_action") + .count(); + assert_eq!(actions, 3, "expected the initial action plus two refreshes"); + } } diff --git a/crates/xmedia-bot/src/main.rs b/crates/xmedia-bot/src/main.rs index cfd4651..5982d01 100644 --- a/crates/xmedia-bot/src/main.rs +++ b/crates/xmedia-bot/src/main.rs @@ -2,7 +2,7 @@ use dotenv::dotenv; use teloxide::dptree::endpoint; use teloxide::prelude::*; use teloxide::stop::StopToken; -use teloxide::types::{ChatId, InputFile, MessageId}; +use teloxide::types::{ChatId, InlineKeyboardMarkup, InputFile, MessageId}; use teloxide::update_listeners::{self, UpdateListener, webhooks}; use tokio::sync::watch; use x_media::site; @@ -119,13 +119,19 @@ async fn main() { log::debug!("rate limiter: dropped {idle_limiters} idle bucket(s)"); } for (chat_id, prompt_message_id) in removed { - // If the prompt was already deleted, this fails with a - // 400 "message to edit not found" — log and ignore. + // Rewritten in place, not announced: the sweep is a + // background timer, and a fresh message would wake the chat + // up to a full TTL later about a prompt the user already + // walked away from. The edit drops the buttons too. If the + // prompt was already deleted this fails with a 400 + // "message to edit not found" — log and ignore. if let Err(e) = bot - .edit_message_reply_markup( + .edit_message_text( ChatId(chat_id), MessageId(prompt_message_id as i32), + send::EDIT_PROMPT_EXPIRED_TEXT, ) + .reply_markup(InlineKeyboardMarkup::default()) .await { log::info!("edit-expiry sweep: prompt message gone: {e}"); diff --git a/crates/xmedia-bot/src/send/mod.rs b/crates/xmedia-bot/src/send/mod.rs index 7e81ea1..74be2ef 100644 --- a/crates/xmedia-bot/src/send/mod.rs +++ b/crates/xmedia-bot/src/send/mod.rs @@ -28,8 +28,8 @@ use upload::{FallbackError, PreparedItem, prepare_upload_item, send_batch_via_up // The crate-facing API of this module lives in its submodules; re-export the // parts other modules use so call sites stay `send::x`. pub(crate) use post_send::{ - KEEP_ALIVE, Settled, dead_letter_notify, enqueue_retry, handle_task, post_send_actions, - settle_task, + EDIT_PROMPT_EXPIRED_TEXT, KEEP_ALIVE, Settled, dead_letter_notify, enqueue_retry, handle_task, + post_send_actions, settle_task, }; /// One process-wide Bot for queue workers. Building a fresh Bot (and its HTTP @@ -230,7 +230,9 @@ fn collect_file_ids(messages: &[Message], batch: &[MediaItemPayload], out: &mut } } -pub const MAX_MEDIA_GROUP: usize = 9; +/// Telegram's `sendMediaGroup` accepts 2–10 items per group; 10 (not the older +/// 9) means a 10-image post arrives as one album instead of two messages. +pub const MAX_MEDIA_GROUP: usize = 10; /// Splits media into batches of at most [`MAX_MEDIA_GROUP`] items, moving the /// items out (no per-item clone). @@ -728,13 +730,14 @@ mod tests { fn chunk_media_items_sizes() { assert_eq!(chunk_media_items::(vec![]), Vec::>::new()); assert_eq!(chunk_media_items((0..9).collect()).len(), 1); - assert_eq!(chunk_media_items((0..10).collect()).len(), 2); + assert_eq!(chunk_media_items((0..10).collect()).len(), 1); + assert_eq!(chunk_media_items((0..11).collect()).len(), 2); assert_eq!(chunk_media_items((0..25).collect()).len(), 3); - assert_eq!(chunk_media_items((0..25).collect())[2].len(), 7); + assert_eq!(chunk_media_items((0..25).collect())[2].len(), 5); assert!( chunk_media_items((0..25).collect()) .iter() - .all(|c| c.len() <= 9) + .all(|c| c.len() <= MAX_MEDIA_GROUP) ); } @@ -807,7 +810,28 @@ mod tests { .flatten() .map(|button| button.text.clone()) .collect(); - assert_eq!(labels, ["a", "b", "m", "q", "y", "z", "↩️ Confirm"]); + assert_eq!( + labels, + ["a", "b", "m", "q", "y", "z", "↩️ Confirm", "🛑 Skip"] + ); + } + + #[test] + fn edit_prompt_text_states_the_ttl_and_the_confirm_requirement() { + use std::time::Duration; + + let text = super::post_send::edit_prompt_text(Duration::from_secs(24 * 3600)); + assert!(text.contains("Expires in 24h"), "{text}"); + assert!(text.contains("Confirm"), "{text}"); + // The wording of the whole point: no Confirm, no forward. + assert!(text.contains("Nothing is forwarded"), "{text}"); + // Sub-hour TTLs must not render "0h". + assert!( + super::post_send::edit_prompt_text(Duration::from_secs(90)).contains("Expires in 1m") + ); + assert!( + super::post_send::edit_prompt_text(Duration::from_secs(30)).contains("Expires in 30s") + ); } #[test] @@ -1311,7 +1335,11 @@ mod tests { post_send_actions(&ctx, &task, vec![10, 11]).await; assert_eq!(sender.calls(), vec!["send_message"]); - assert_eq!(sender.messages(), vec!["Reply to edit message."]); + // The prompt explains the Confirm requirement and the TTL (see the + // pure `edit_prompt_text` test for the exact wording). + let prompt_text = sender.messages().first().cloned().unwrap_or_default(); + assert!(prompt_text.contains("Expires in"), "{prompt_text}"); + assert!(prompt_text.contains("Confirm"), "{prompt_text}"); // The prompt's own message id keys the record the reply will edit. let data = stores.chat_store().get(1).await; let record = data diff --git a/crates/xmedia-bot/src/send/post_send.rs b/crates/xmedia-bot/src/send/post_send.rs index ff23051..5d1a6a8 100644 --- a/crates/xmedia-bot/src/send/post_send.rs +++ b/crates/xmedia-bot/src/send/post_send.rs @@ -103,9 +103,38 @@ pub(crate) fn release_keep_alive(task: &Task) { }); } -/// One button per template name (column layout), then the confirm button. -/// Sorted by name: the templates live in a `HashMap`, so an unsorted walk -/// would reshuffle the buttons between prompts. +/// The edit-before-forward prompt's text. It names both controls and the TTL, +/// because the buttons alone left users waiting for a forward that never came +/// (nothing is forwarded until Confirm). +pub(super) fn edit_prompt_text(ttl: std::time::Duration) -> String { + format!( + "Reply to edit the caption, or tap a template, then ↩️ Confirm to forward. \ + Expires in {}. Nothing is forwarded until you confirm.", + coarsest_unit(ttl) + ) +} + +/// Text the prompt is rewritten to once its record expires. The sweep edits +/// the prompt in place (see `main`): announcing the expiry with a new message +/// would wake the chat up to a full TTL later about a prompt nobody is +/// waiting on. +pub(crate) const EDIT_PROMPT_EXPIRED_TEXT: &str = "⌛ Expired — nothing was forwarded."; + +/// `24h` / `90m` / `45s`: the coarsest whole unit, so the prompt stays short. +fn coarsest_unit(ttl: std::time::Duration) -> String { + let secs = ttl.as_secs(); + if secs >= 3600 { + format!("{}h", secs / 3600) + } else if secs >= 60 { + format!("{}m", secs / 60) + } else { + format!("{secs}s") + } +} + +/// 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 +/// buttons between prompts. pub(super) fn build_edit_markup(templates: &HashMap) -> InlineKeyboardMarkup { let mut names: Vec<&String> = templates.keys().collect(); names.sort(); @@ -116,10 +145,13 @@ pub(super) fn build_edit_markup(templates: &HashMap) -> InlineKe format!("template|{name}"), )]); } - rows.push(vec![InlineKeyboardButton::callback( - "↩️ Confirm", - "forward", - )]); + // Skip exists because the prompt holds the forward hostage until Confirm: + // without it the only escape was deleting the message and waiting out the + // TTL for a forward that then never happens. + rows.push(vec![ + InlineKeyboardButton::callback("↩️ Confirm", "forward"), + InlineKeyboardButton::callback("🛑 Skip", "skip"), + ]); InlineKeyboardMarkup::new(rows) } @@ -190,7 +222,7 @@ pub(crate) async fn post_send_actions(ctx: &AppContext<'_>, task: &Task, message .sender .send_message( ChatId(chat_id), - "Reply to edit message.".to_string(), + edit_prompt_text(ctx.config.edit_message_ttl), Some(MessageId(reply_to as i32)), Some(keyboard), )