mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-23 23:32:05 +00:00
feat(ux): onboard users, expose the chat's settings, name failed posts
`/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.
This commit is contained in:
@@ -26,6 +26,8 @@ User-facing failure text is a function of the error class, never one generic sen
|
||||
|
||||
The `/test <url>` 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<Media>, 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/<twitter\|pixiv\|bsky\|misskey\|bilibili>/` | One directory per site: `mod.rs` (re-exports), `interface.rs` (PATTERN, `enabled()`, `fetch_from_url()`, `cache_key`/`is_retryable`/`media_headers`, unit struct `<Name>Site` implementing `site::Site`, `From<SiteStruct> 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 <url>` (send-only) / `/debug <url>` (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 <url>` send-only, `/debug <url>` 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 <url>` send-only, `/debug <url>` 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`.
|
||||
|
||||
+6
-2
@@ -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 <link>`); 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 <link>`) — 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 <name>` | 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 <name>` | 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 <site> <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 <link>`) |
|
||||
| `/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) |
|
||||
|
||||
@@ -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` | 查看当前聊天状态(调试用;仅管理员) |
|
||||
|
||||
@@ -483,6 +483,15 @@ async fn fetch_with_attempts(url: &str, attempts: u32) -> Result<Option<Fetched>
|
||||
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
|
||||
|
||||
@@ -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 <name> — reply to a message containing [] to save it
|
||||
/remove_template <name> — see /settings for the saved names
|
||||
/set_format <site> <format> — '-' restores the built-in format
|
||||
/test <link> / /debug <link>
|
||||
|
||||
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<String> {
|
||||
let mut names: Vec<String> = 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<String> = 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::<Vec<_>>()
|
||||
.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 <name> (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<String> = 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);
|
||||
|
||||
@@ -146,6 +146,15 @@ async fn answer_inline_query(bot: Bot, query: InlineQuery) -> Result<bool, Reque
|
||||
);
|
||||
for (i, media) in fetched.media.iter().enumerate() {
|
||||
let id = format!("{i}");
|
||||
// Telegram fetches an inline result's URL itself and cannot
|
||||
// send site-specific headers, so hotlink-protected media
|
||||
// (pixiv's pximg.net) would render as a broken file there.
|
||||
// Locally produced media (ugoira MP4, bsky remux) is a local
|
||||
// path and does not parse as a URL at all — same skip.
|
||||
if x_media::site::needs_media_headers(media.url()) {
|
||||
log::debug!("inline: skipping hotlink-protected media {id}");
|
||||
continue;
|
||||
}
|
||||
let Some(url) = url::Url::parse(media.url()).ok() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -192,11 +192,16 @@ async fn dispatch_send(
|
||||
log_key(url)
|
||||
);
|
||||
send::enqueue_retry(ctx.task_queue, *task, delay_seconds).await;
|
||||
// Name the post and the wait: "queued for retry" alone left the
|
||||
// user guessing which link it was and how long the wait is.
|
||||
let _ = reply(
|
||||
ctx.sender,
|
||||
chat_id,
|
||||
reply_to,
|
||||
"Send failed. Task queued for retry.",
|
||||
format!(
|
||||
"Send failed for {} — retrying in {delay_seconds:.0}s.",
|
||||
log_key(url)
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -342,8 +342,16 @@ impl QueueWorker {
|
||||
payload,
|
||||
}) => {
|
||||
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 {
|
||||
|
||||
@@ -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<String, String> = (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<String, String> = (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::<usize>(),
|
||||
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;
|
||||
|
||||
@@ -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<String, String>) -> 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<String, String>) -> InlineKe
|
||||
InlineKeyboardMarkup::new(rows)
|
||||
}
|
||||
|
||||
/// How many templates the markup could not fit, for the prompt text.
|
||||
pub(super) fn hidden_template_count(templates: &HashMap<String, String>) -> 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<Ve
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing text for a task that will never run again: which link died and
|
||||
/// why. The raw error alone left the user guessing which post it was about.
|
||||
pub(super) fn failure_text(task: Option<&Task>, 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::<Task>(payload.clone()) {
|
||||
settle_task(ctx, &task, Settled::Failed).await;
|
||||
let task = serde_json::from_value::<Task>(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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user