Compare commits

...
171 Commits
Author SHA1 Message Date
YoursFunny 8063777962 chore: bump version to 1.9.2 2026-09-25 03:12:07 +08:00
YoursFunny ac10b8715c docs: sync template limits and live-test count 2026-09-25 02:12:17 +08:00
YoursFunny 517e1362a5 docs: document template storage limits 2026-09-25 01:49:31 +08:00
YoursFunny 50299bacbb fix: redact user-controlled log fields 2026-09-25 01:49:31 +08:00
YoursFunny 9d40f3bc50 fix: bound template storage and callback data 2026-09-25 01:49:31 +08:00
YoursFunny 2045c3cea1 fix: keep photo memory permit until processing ends 2026-09-25 01:49:30 +08:00
YoursFunny 09656810fe fix: prune persisted expired prompts 2026-09-25 01:49:30 +08:00
YoursFunny 786bafca42 fix: account for photo processing peak memory 2026-09-25 01:49:30 +08:00
YoursFunny 18da6e97c2 fix: reject oversized local media before upload 2026-09-25 01:49:29 +08:00
YoursFunny 7b816d4153 fix: split long message forwards 2026-09-25 01:49:29 +08:00
YoursFunny d8b9a06453 fix: bound direct command fetches 2026-09-25 01:40:09 +08:00
YoursFunny 4e0f1b834e fix: restrict media download hosts 2026-09-25 01:40:09 +08:00
YoursFunny 21437d1843 fix: bound queue panic retries 2026-09-25 01:11:28 +08:00
YoursFunny a54029c19b fix: propagate startup repair database errors 2026-09-25 01:06:09 +08:00
YoursFunny c6120b7cc0 fix: make sqlite migrations atomic 2026-09-24 21:47:48 +08:00
YoursFunny 7b4e273533 fix: bound site JSON response bodies 2026-09-24 21:25:03 +08:00
YoursFunny 6a87583cc0 fix: honor bsky HLS retry-after 2026-09-24 21:01:36 +08:00
YoursFunny 1ba64e858d fix: enforce inline result limit centrally 2026-09-24 21:00:00 +08:00
YoursFunny 008d31b9bd test: cover inline answer edge cases 2026-09-24 20:57:08 +08:00
YoursFunny 66ec14662c style: simplify inline answer returns 2026-09-24 20:57:08 +08:00
YoursFunny 8ae7d9947d fix: honor media retry-after delay 2026-09-24 20:57:08 +08:00
YoursFunny 28e8fec68d fix: rate limit plain chat operations 2026-09-24 20:57:08 +08:00
YoursFunny b2b01721b2 fix: debounce inline generations and cap results 2026-09-24 20:40:27 +08:00
YoursFunny 85c3be5ef5 fix: always answer inline query 2026-09-24 20:33:23 +08:00
YoursFunny 5e7a87c0dd fix: send text posts as HTML 2026-09-24 20:29:04 +08:00
YoursFunny 6a2ba6a50a fix: handle empty media and prompt persistence 2026-09-24 19:51:50 +08:00
YoursFunny fb354c9434 test: handle chat state Result in callback 2026-09-24 19:46:23 +08:00
YoursFunny bc7f249c20 fix: filter local animation cache sources 2026-09-24 19:44:48 +08:00
YoursFunny 403b84996f test: cover ugoira expansion budgets 2026-09-24 19:42:50 +08:00
YoursFunny f08b187964 fix: drop unsafe upstream media entries 2026-09-24 19:39:46 +08:00
YoursFunny 8428468712 fix: surface chat state read failures 2026-09-24 19:37:29 +08:00
YoursFunny 412359e84c fix: do not cache local media paths 2026-09-24 18:52:18 +08:00
YoursFunny 438ced278f fix: align CI tags and webhook healthcheck 2026-09-24 18:49:20 +08:00
YoursFunny 704b14f05a fix: bound ffmpeg encoding processes 2026-09-24 18:46:09 +08:00
YoursFunny 1f9da14772 fix: bound ugoira archive expansion 2026-09-24 18:44:21 +08:00
YoursFunny 4694e60ee3 fix: reject upstream local media paths 2026-09-24 18:41:23 +08:00
YoursFunny 88dc0ccb9e fix: stop persistent queue before URL workers 2026-09-24 18:37:17 +08:00
YoursFunny b563b949bb fix: avoid overwriting chat state after read failure 2026-09-24 18:35:52 +08:00
YoursFunny e7855b02fe fix: fence queue dead-letter side effects 2026-09-24 18:33:21 +08:00
YoursFunny 5e265f2413 chore(log): align levels with the documented convention
Three places disagreed with AGENTS' own level rules: get_chat/get_chat_administrators failures during /set_channel printed at error even though the usual cause is a mistyped channel name the user fixes on the spot (error is reserved for dead letters and the like — now warn, same as other user-served degradations); the edit-before-forward callback arrival line logged at info while the sibling arrival lines for commands and inline queries log at debug (now debug, still carries the escaped payload); and the two upload-fallback lines logged at info while photo.rs's parallel degradations log at warn (now warn both — Telegram failing to fetch our media is a degradation that leaves the user served). AGENTS' convention sentence is now explicit about all three instead of the ambiguous '(fallback, retry enqueue, dead-letter is error)'.
2026-09-24 15:38:25 +08:00
YoursFunny 5060b760ba fix(fetch): cap one fetch at a 900-second total budget
The per-request CLIENT timeout and the per-chunk DOWNLOAD_IDLE_TIMEOUT both reset on progress, so neither bounded the fetch as a whole: a URL that keeps dripping (a trickle the idle timeout reads as life) could hold one of the eight FETCH_SLOTS for effectively ever, and the queue behind the slots is what then stalls. The attempts loop now runs inside tokio::time::timeout(900 s) — generous for a genuinely large ugoira zip on a honest slow link (minutes), fatal for a drip — and answers Transient with the cache key, so the retry and its jittered backoff take over. The permit is taken outside the timeout: the slot is released on either path. Audit low finding 'url_workers long tail' (process-wide total-deadline option).
2026-09-24 15:38:25 +08:00
YoursFunny 7019f34801 fix(send): refuse a file id at the upload fallback's door
download_to_temp guarded against a file id, but prepare_upload_item answered a non-http media_url first — and item_url maps FileId to the id itself, so the id was presented as a local *path* and died later with a confusing file-open error instead of a classification. The guard now sits at the entry, download_to_temp takes the already-narrowed URL (the inner guard deleted as dead), and the test pins the refusal as Permanent before any I/O. The file also carries item10's download-classification arm (RateLimited is retryable there too) — same file, landed here to keep each tree compiling.
2026-09-24 15:35:55 +08:00
YoursFunny d05450d88a fix(fetch): jitter the retry backoff and honor a429's Retry-After
Two gaps in fetch_with_attempts: the sleep was the bare 1 << attempt, so every worker that failed together (a source coming back, a shared proxy blip) also recovered on the same tick and re-stamped the source; and a429 arrived as an anonymous Transient, its Retry-After header — the one place a source tells you exactly how long it wants silence — dropped on the floor.

retry_wait(attempt, roll, err) is the pure decision: doubling base plus a random slice of itself ([base, 2x base)), floored at a named Retry-After. status_error now takes the response, reads the seconds-form header and returns the new FetchError::RateLimited { site, retry_after_secs } (HTTP-date parses to None and stays transient); the pure table moved to classify_status so tests still build it from bare status codes. The delay is capped at MAX_RETRY_AFTER_SECS = 60 — the header is server-supplied and must not park one of the eight fetch slots.

Everywhere a Transient meant 'retryable' the new variant joins: the Site trait default, pixiv's override (plus its ugoira-zip mapping), bsky's HLS second attempt, the download classifier, and the user-facing message arm. Tests pin the429 rows (with and without the header, cap included) and retry_wait's math; AGENTS' retries bullet and variant list follow.
2026-09-24 15:35:55 +08:00
YoursFunny 053ae0ec25 fix(send): charge the photo download window to the memory budget
A non-photo body charged the process-wide budget for as long as download_to_temp held it; a photo charged nothing until its prepare step, so the download itself — up to MAX_PHOTO_DOWNLOAD_BYTES per item, six items per batch, eight prep slots process-wide — was memory the MEMORY_BUDGET comment promised was bounded but was not (~6x32 MiB on top of the accounted512 MiB). Photos now reserve their own download cap for that window; the prepare step still charges header probe + decode buffer, the only overlap that is actually held in memory together. Net accounting stays within the declared budget instead of exceeding it whenever a batch of large photos downloads at once (audit: upload.rs photo window uncharged).
2026-09-24 15:29:17 +08:00
YoursFunny d1f2ae09b2 fix(rate-limit): charge plain messages and chat actions to the global bucket
send_media_group, send_animation and copy_messages paced themselves against both buckets; send_message and send_chat_action paced against neither. A dead-letter storm — or one error reply per failed item, plus the action refresh loop's repeated typing requests — could therefore burst past Telegram's per-bot30/s ceiling with only429s left to absorb it, exactly what the bot-wide bucket exists to prevent. Both now acquire one global token like their siblings (per-chat pacing for messages already happens at their call sites; chat actions are cheap and frequent, so only the global bucket applies to them).
2026-09-24 15:28:59 +08:00
YoursFunny 715d6a59b0 fix(photo): sum dimensions in u64 so a huge header cannot wrap
plan_photo, target_dims and both pipeline branches added the two u32 dimensions before comparing against PHOTO_MAX_DIMENSION_SUM: u32::MAX + 2 wrapped to 1 and read 'within limits' — the plan said AsIs, and an absurd-sized image would go to Telegram untouched (in debug builds the addition panics instead). The four production sums now widen to u64 first; the test pins the wrap case as TooLarge. PNG caps each dimension at 2^31-1, so a spec-valid file cannot reach this today — the raw header is parsed before any crate validation, which is exactly where a hostile file lands (audit: photo.rs u32 wrap).
2026-09-24 15:25:19 +08:00
YoursFunny ef8a1dae15 fix(handlers): honour the TTL when a caption-edit reply arrives
A button press on an edit-before-forward prompt already dropped records past EDIT_MESSAGE_TTL lazily (before the 300 s sweep clears them); a text reply to the same prompt did not — it rewrote the caption from a record the sweep was about to treat as dead, so the two paths disagreed about what 'expired' means. The reply path now applies the same rule: drop the stale record, return false, and let the message flow on as if no prompt existed. The test seeds a prompt90000 s old (past the TTL under any config a parallel test can hold) and pins all three effects: no edit consumed, no API call, record gone.
2026-09-24 15:23:02 +08:00
YoursFunny ac34759b02 fix(state): report a failed chat-state write to /set_format
set()/update() logged a failed DB write and returned nothing: the cache already held the new value, so /set_format answered 'Format set' for a change that vanishes on the next restart — a promise the retry queue's enqueue path is already forbidden from making. Both now return whether the write landed, and /set_format's two save paths (set and reset) answer 'in memory only … lost on restart' when it did not. The flag travels as the second tuple element; callers that only relay the value destructure it, callers that ignore it are untouched. A false cannot be forced in a test without a pool-failure seam, so the honest path is pinned by the code, not a fixture.
2026-09-24 15:21:05 +08:00
YoursFunny c420c95165 fix(docker): refuse a root or non-numeric LOCAL_USER_ID
LOCAL_USER_ID=0 passed every check and reached `setpriv --reuid=0`: the bot would run root while looking properly configured, and useradd -o accepted the duplicate uid without complaint. A non-numeric value failed later inside useradd behind `|| true`, which hid the real cause. The entrypoint now rejects both up front with a message naming the variable, and the id/uid expansions are quoted so a value with spaces cannot word-split into extra useradd arguments (audit SEC-007).

Verified by sourcing the entrypoint under a faked `id`: uid 0, 'abc' and '12 3' each exit 1 with the refusal line, while a non-root caller passes straight through to exec. AGENTS and .env.example state the constraint.
2026-09-24 15:12:22 +08:00
YoursFunny 4d6ec7924b fix(handlers): escape user-supplied text in log lines
A Telegram display name, callback payload or channel handle is attacker-controlled text, and it went straight into info/error lines: one embedded newline forged a second log entry, and control characters could hide inside a line (log injection, audit SEC-006). handlers::log_escape replaces newlines, carriage returns and other control characters with visible escapes — clean input borrows, so logging allocates nothing extra — and every site that prints such a value uses it: the callback arrival line, the set-forward-channel info line (name + handle) and both get_chat error lines. The test pins that no raw newline can survive; AGENTS' logging convention names the helper.
2026-09-24 15:02:31 +08:00
YoursFunny 9bae46eb9a chore(compose): pin acme-companion to its paired release
The companion was the file's only unpinned image: it runs with a rw docker.sock, so 'latest' let tomorrow's upstream change deploy itself on the next pull, with full visibility into this stack's containers and environment. 2.8.2 is the release paired with the already-pinned nginx-proxy 1.11.6 — the two projects cut releases a day apart (1.11.6 on 2026-07-16, 2.8.2 on 2026-07-17).
2026-09-24 15:00:45 +08:00
YoursFunny cb91913088 docs(agents): correct the CI, coverage and cert-mount claims
Five statements had drifted from the repo:

- the test-suite comment claimed no CI test step exists; ci.yml has run
  cargo test --workspace --locked all along (the audit's H5).
- the untested list still named config.rs as fully untested although three
  parsing tests pin its webhook truth table, TTL fallback and blank secrets.
- the commands.rs executor was said to need a real Bot; its three tests run
  through the scripted MockSender, and it joins the driven-through-mocks list.
- model.rs was called untested although every adapter fixture deserializes
  into those DTOs.
- the volume line claimed ./cert is mounted; the shipped compose mounts only
  ./data, and WEBHOOK_CERT (a Telegram-facing self-signed upload) needs the
  operator to add mount and env line themselves, which .env.example already
  says. The live-test census also gained site/download.rs (2) and the bot
  crate's repair.rs/urls.rs pair.
2026-09-24 14:54:07 +08:00
YoursFunny 2b6cb506c7 refactor(download): fold apply_media_headers into media_request
apply_media_headers had exactly one caller — media_request, the documented 'one choke point' every download already goes through — so the site-header walk sits inside that choke point now instead of one indirection past it, and the two doc comments merge into the one that describes both rules (guard + headers).
2026-09-24 04:37:54 +08:00
YoursFunny c49e2175a1 refactor(site): drop RenderData's url field, source_url already carries it
Every adapter built RenderData { url } from the exact value it then moved into Fetched.source_url — a copy of the same canonical URL held in two places, feeding one placeholder. caption_with reads {url} from self.source_url now and the field is gone from the struct (bsky's and twitter's halves of this change landed with their caption commits; their render builders no longer set it either).
2026-09-24 04:37:54 +08:00
YoursFunny 2ec13a0624 refactor(send): inline hidden_template_count into its two call sites
A one-line saturating_sub wrapper exported for one production line (the prompt text) and two assertions — the expression is shorter than its name at every use.
2026-09-24 04:37:54 +08:00
YoursFunny 0765b7deec refactor(send): media_from reads the thumbnail off the item itself
All three callers passed item.thumbnail_url() as the fourth argument — a parameter that could never vary without defeating its own purpose, and only the video arm ever reads it. The function takes the item already; it asks the item. Three call sites lose an argument.
2026-09-24 04:37:53 +08:00
YoursFunny e72cb1b98f refactor(send): inline the per-kind media builders into media_from
photo_media, video_media and animation_media each wrapped the same five lines (build the kind, attach the caption, attach the spoiler) for exactly one caller — media_from's own match arms. The arms carry those lines now; three pub(super) functions and their call indirection are gone, and the dispatch the doc already described as 'the one place' actually is the one place.
2026-09-24 04:37:53 +08:00
YoursFunny 9fc5533676 refactor(twitter): delegate the tweet caption to the shared builder
Tweet::caption duplicated site::caption byte for byte for non-empty text (and dropped the shared empty-text rule, leaving a dangling ': ' behind the author link when a tweet's text comes out empty after link expansion). It delegates now, like bsky's Post::caption a commit earlier and bilibili/misskey before them; encode_double_quoted_attribute loses its last twitter user.
2026-09-24 04:37:53 +08:00
YoursFunny 46bbe8639d refactor(bsky): delegate the post caption to the shared builder
Post::caption hand-formatted exactly the string site::caption produces — same template, same three escapes (the shared fn even handles the empty-text case these two lines did not: no dangling ': ' after the author link). One line now, matching what bilibili and misskey already do; the encode_double_quoted_attribute import loses its last bsky user.
2026-09-24 04:37:53 +08:00
YoursFunny 2ff213e22f refactor(inline): delete InlineKind, convert straight to CachedMediaKind
InlineKind mirrored CachedMediaKind variant for variant (Photo/Video/Gif) and existed only to feed url_result, with two conversions kept in step: Media → InlineKind at the fetch call and CachedMediaKind → InlineKind at the cache call (plus the enum and the mapper). url_result takes CachedMediaKind now — the type the cache path already carries and the fetch path maps to with the same three-arm match — and the degraded-video skip collapses to a guard before the call. One enum fewer between a media kind and its Telegram result.
2026-09-24 04:37:53 +08:00
YoursFunny 2c45b3491d refactor(main): the sweep takes one AppContext instead of five collaborators
periodic_sweep listed sender, chat_store, link_cache, task_queue and config as separate parameters — exactly the five fields AppContext already carries, and exactly what handle_message and the workers pass around as one value. It takes &AppContext now (production via from_statics(&bot), the test via stores.ctx(&sender), which that test already had); CHAT_STORE and LINK_CACHE lose their last direct use in main and leave the import. The collaborator bundle is unchanged, so the paused-clock test drives the same loop.
2026-09-24 04:37:52 +08:00
YoursFunny a3d03cf30f refactor(site): one ffmpeg gate instead of a probe plus a log call
ffmpeg_available() and log_once_ffmpeg_missing() only ever appeared together (both encoders, three lines each: check, log, return), and calling one without the other was a bug in waiting — a probe that never logged, or a log that never gated. ffmpeg_missing() is the single gate: false when the binary is there, otherwise log once per process and say yes. Both call sites shrink to one condition.
2026-09-24 04:37:52 +08:00
YoursFunny e64b48a453 refactor(site): match the site pattern once, then check enabled
find_site walked the registry with "enabled && matches" and disabled_site walked it again with "!enabled && matches" — two passes re-running the same regexes to answer one question. matching_site finds the first match and the fetch decides what a disabled site means; the patterns are disjoint (one domain each), so "first enabled match" and "first match, then check" never disagree.
2026-09-24 04:37:52 +08:00
YoursFunny 022f63916c refactor(site): drop site_id_from_key, split the key at its caller
site_id_from_key split the prefix out and then walked the registry to confirm the prefix was a registered site id — a round trip over a value cache_key itself produced from that registry: the unknown and no-colon branches were unreachable for any key the bot makes. The single caller (the link-cache hit path) splits the prefix directly; the registry-echo test and the misskey assertion of it go with it.
2026-09-24 04:06:13 +08:00
YoursFunny f984428169 refactor(photo): fold photo_plan into its only caller
photo_plan wrapped the header dispatch for exactly one consumer (decode_budget_bytes); plan_photo itself has three callers and stays. The dispatch is inlined into the budget function with its doc merged, saving the wrapper's signature, doc duplication and call indirection.
2026-09-24 04:03:59 +08:00
YoursFunny 85fb12edc7 refactor(photo): read the PNG header with the png crate
parse_png_header hand-decoded the IHDR — byte offsets, the depth byte's five legal values, the color byte's five — roughly thirty lines the png crate already implements (and validates properly: CRC included). It is now Decoder::new + read_info, which is all the header a plan needs; no pixels are decoded. The synthetic test fixture gained the IHDR CRC and an IDAT header (read_info stops at the first IDAT; the hand parser stopped four bytes earlier and checked no CRC), with a nine-line reflected CRC-32 alongside it; the assertions on width/height/depth/color are unchanged, as are the real-file cases.
2026-09-24 04:03:08 +08:00
YoursFunny 7bdf760c7b ops(compose): align the deploy template defaults and gate the healthcheck
Two template landmines for anyone running docker compose from the tracked files. (1) LOCAL_USER_ID defaulted to 1000 in compose and .env.example while the entrypoint and both READMEs say 9001 — the ./data owner on the host silently depended on which doc you read; all four now say 9001. (2) The healthcheck probed WEBHOOK_PORT unconditionally, so the template's own WEBHOOK=false (polling, no listener) shipped a permanently unhealthy container — the probe is now conditional on the interpolated WEBHOOK value (test '<v>' != true || exec 3<>/dev/tcp/…): polling deployments answer healthy without a port, webhook deployments still surface a dead listener to the orchestrator. Both expressions verified locally; compose YAML parses.
2026-09-24 03:47:34 +08:00
YoursFunny 3dbcb45899 test(config): pin the webhook truth table, TTL fallback and blank secrets
Config::load had exactly one test (BOT_ADMIN parsing) while carrying the branches an operator is most likely to mistype: the WEBHOOK truth table (case-insensitive true|yes|1 — 'on' must not enable it), an unparseable EDIT_MESSAGE_TTL_SECONDS (warn + default, not a silent 0 that expires prompts instantly), and empty WEBHOOK_CERT/WEBHOOK_SECRET_TOKEN (compose injects ${VAR:-} as an empty string, which must read as unset, not as a one-character secret). Three table-style tests reuse the existing env save/restore pattern; none of them asserts a field another test reads, so they are safe under parallel execution.
2026-09-24 03:46:59 +08:00
YoursFunny aee1cf35ae ci(docker): run the image build check on PRs that touch crate sources
The pull_request paths filter listed only Dockerfile/entrypoint/.dockerignore/Cargo.{toml,lock}/workflow/crate manifests, so a PR changing only Rust sources never triggered the build-only check — and the Dockerfile's stub-plus-touch layering is exactly the thing that breaks against source structure it has never seen (a new crate directory, a build script). Such PRs now get the check before master/tag reveals the problem; crates/**/Cargo.toml is subsumed by crates/**.
2026-09-24 03:45:35 +08:00
YoursFunny 7b50ad82b0 ci: make the live job honest about what it ran and skipped
Three holes in the live job. (1) It ran -p x-media only, on the comment that everything network-gated lives there — false: xmedia-bot carries two #[ignore]d live tests (repair refetch, text-only link) that CI never executed; both crates run now, still behind the 'live' name filter, and the bot crate's tests need no token (their sends go through MockSender). (2) BILIBILI_COOKIE was never passed, so the bilibili live tests always hit the risk-control early return. (3) That early return — like the pixiv download test's token gate — printed an indistinguishable 'skipping:' line that --show-output never showed, so with continue-on-error a fully-skipped weekly run read exactly like full coverage; skips now print a 'SKIP ' prefix, the steps tee with --show-output, and a summary step lists every skip in the run summary plus a workflow warning. The redundant 'Run token-gated tests' step (the whole non-ignored suite, a rerun of the test job) goes away with the comment that justified it.
2026-09-24 03:45:17 +08:00
YoursFunny a49bb1d1af test(x-media): gate the pixiv download test as live-network too
The pixiv download test was token-gated but not #[ignore]d, so a shell with PIXIV_REFRESH_TOKEN exported made every local cargo test --workspace hit i.pximg.net — where it demonstrably flaked (twice in one session: TLS EOF mid-body) — and CI's non-ignored step ran it as a side effect of having a secret. It carries the convention's #[ignore = "live network: …"] now and is renamed with the live_ prefix so the live job's --ignored live name filter actually picks it up (the old name did not contain 'live' and would have been filtered out). Its skip prints the SKIP prefix the next commit's live job greps, and AGENTS.md stops claiming a local workspace run is not fully offline.
2026-09-24 03:44:06 +08:00
YoursFunny e99f8b0bc0 build: pin ffmpeg to a versioned URL and verify its sha256 unconditionally
The default build fetched /redirect/latest/ — a floating URL that changes under every build — and skipped the sha256 check whenever FFMPEG_SHA256 was empty, which it always was: neither the Dockerfile nor docker.yml carried a hash, so any binary the CDN served reached the image unverified and then processed untrusted media bytes. Both now pin the 9.0.2 release build with the hash the mirror publishes beside it (verified here by direct digest of the downloaded zip: fa8ecf4a…909d7f matches the sidecar), docker.yml's build-args fall back to the same pair, and the check is unconditional — an FFMPEG_URL override without its matching hash fails the build at the download step. The ffmpeg layer also moves above the dependency layer so a manifest/lock edit no longer re-downloads it (the old comment claimed caching 'unless FFMPEG_URL changes', which was never true), and the stale step reference in the sources comment goes with it. Local note: Git-Bash's sha256sum -c reads files in text mode here and cannot roundtrip anything binary — the -c line itself is standard and is exercised by the PR build in CI.
2026-09-24 03:43:04 +08:00
YoursFunny 3fd04e4420 feat(x-media): gate every fetch entry with one process-wide semaphore
The URL workers bound only their own path (8 workers, bounded channel); inline queries, /debug and /test spawned fetches straight from handler tasks with no limit at all — and the expensive part runs inside the fetch (ugoira: up to 512 MiB plus ffmpeg; bsky: an HLS remux), so N users could mean N concurrent encodes. fetch_with_attempts now takes one permit from an 8-slot gate (the count matching URL_WORKERS, so the bot's own pipeline keeps its full width), which covers every entry at once: message links, queue-side repair, inline, /debug, /test, and retries. Unsupported and disabled links answer before the gate, and the shared-fetch dedup already waits outside x-media, so nothing double-counts.
2026-09-24 03:38:53 +08:00
YoursFunny 0e91753402 perf(x-media): give the slot-holding fallback download its own budget
download_media_limited had one hard-coded total (600s) for every caller, and its heaviest caller — the bot's upload fallback — holds a PREP slot (and its memory reservation) for the whole transfer: six slow-but-alive downloads (a byte every 29s satisfies the idle window) could stall the fallback chain for ten minutes, queue retries included. The budget is a parameter now: the fallback passes 300s of its own (50 MiB in 300s ≈ 1.4 Mbit/s; a slower link is better served by retrying toward the item's smaller URL than by pinning a slot), while bsky's in-fetch HLS segments keep the generous 600s DOWNLOAD_TOTAL_TIMEOUT, now pub(crate) and re-exported for them. download_too_slow reports whichever budget it got.
2026-09-24 03:37:38 +08:00
YoursFunny 8f254c3779 perf(x-media): stream the frame zip through a tokio file handle
download_media_to_file took &mut std::fs::File and wrote every network chunk with a sync write_all on the executor thread — for a ugoira frame zip (up to 512 MiB) that is the whole download stalling a runtime worker, while bsky's remux had already been moved to tokio::fs for exactly this reason (its comment: a multi-megabyte std::fs::write blocks the executor thread). The signature takes &mut tokio::fs::File now; the single caller (pixiv's ugoira path) clones the NamedTempFile's handle — the clone shares the file offset, so the ZipArchive extraction in spawn_blocking reads what the download wrote — and drops it after the download hands the bytes to the OS.
2026-09-24 03:33:23 +08:00
YoursFunny e9dd3aece2 perf(send): write the fallback temp file on a blocking thread
download_to_temp wrote the whole buffered body — up to MAX_MEDIA_UPLOAD_BYTES, 50 MiB — with a sync write_all on whatever executor thread ran the prep task; six PREP slots could stall six runtime threads at once on a slow volume (Docker volume, SD card), pausing unrelated handlers and chat-action refreshes with them. The write now runs in spawn_blocking, the pattern the photo decode in the same file already uses; the failure classes are unchanged (write error = retryable resource exhaustion, panic = permanent, worker bugs must be loud).
2026-09-24 03:30:30 +08:00
YoursFunny 67b6bd3038 fix(commands): stop /debug from nesting blockquotes on long posts
debug_report wrapped every caption in <blockquote>, but preview_caption for a post over CAPTION_QUOTE_TEXT_CHARS already carries quote_long_caption's expandable blockquote — and the API rejects nested entities, so /debug on any long post answered 400. The wrapper now skips a caption that already carries a blockquote, exactly the rule quote_long_caption itself applies; the new test pins that the quoted caption passes through with a single blockquote while the existing test keeps pinning the wrap for ordinary captions.
2026-09-24 02:53:49 +08:00
YoursFunny 3fb6b3b4da fix(fetch): one status table — a persistent 4xx is permanent everywhere
status_error's catch-all called every unlisted status Transient, so a 400/405/418/451 got three retries per link before the same answer (twitter syndication's broken-token 400 being the live example), and download_status_error plus misskey's and bilibili's local fallbacks each carried their own copy of the table — bilibili and misskey classifying a 404 as Transient while the center classified it NotFound. The center now makes any client error except 408/429 a refusal (permanent), the media path delegates to it as status_error("media", ...) and its duplicated fn is deleted, and misskey/bilibili fall through to the center after their own special statuses (misskey's 400 body, bilibili's 412). A table test pins every class.
2026-09-24 02:52:10 +08:00
YoursFunny e6800fd27b fix(pixiv): keep a frame-zip download hiccup retryable
download_media_to_file's non-transport errors all folded into PixivError::Api, and Api is permanent in pixiv_error_is_retryable — so one 429/5xx, stalled transfer or temp-file write failure while fetching the ugoira frame zip permanently failed the whole post, while the bot's own upload downloads classify the very same classes as retryable (classify_download_error). A new PixivError::Transient carries those classes into the existing retry policy (and into startup validation's 'stays enabled' branch); Http keeps its arm and everything else stays permanent. The retryable table and the startup-validation loop pin both halves.
2026-09-24 02:50:22 +08:00
YoursFunny 38e65a3791 fix(queue): close the lost-wakeup window on shutdown
stop() flags the shutdown and fires notify_waiters, but a worker parked between its loop-top stop check and its notified() registration — i.e. inside earliest_run_after's DB await — was not registered when the notification fired, so it slept until the next enqueue that never comes; stop() then blocked until main's 30s shutdown timeout force-killed the drain. The sweep had the same window before recover_expired's await and would sit out a full 30s tick. Both loops now enable() the waiter first and re-check the stop flag: either the stop already happened (recheck returns) or the waiter is registered (notify_waiters reaches it) — no gap. The window itself is a scheduling race with no test seam, so this is pinned by reasoning rather than a regression test; the existing stop tests cover the ordinary path.
2026-09-24 02:49:10 +08:00
YoursFunny eecd4320f8 fix(state): keep failed and stale reads from poisoning the chat cache
Three related races in ChatStore. A read that errored (busy/IO) was indistinguishable from an absent row, so the default got cached — and the next update would write that default back over the chat's real settings (forward channel, templates, formats). A cache-miss backfill inserted unconditionally, letting a stale DB snapshot overwrite the value a concurrent set had just written. And sweep eviction removed per-chat locks unconditionally, so a lock pulled out from under an in-flight update let a second writer create a fresh one and enter the critical section concurrently; eviction now keeps any lock with a holder (the same rule rate_limit's prune applies), which still bounds the map because an uncontended leftover is caught by a later sweep. Two regression tests pin the failed-read and contended-lock cases.
2026-09-24 02:47:49 +08:00
YoursFunny d774f0b37c fix(pixiv): treat an empty refresh token as unset
docker-compose injects PIXIV_REFRESH_TOKEN='' for a blank .env value and enabled() only checked that the variable exists, so a default deployment read pixiv as configured, sent an empty refresh token to OAuth and failed login validation on every boot (error line + admin DM) — and it disagreed with the empty-as-unset gating the tests already use. Filter empty strings in PIXIV_CLIENT and enabled(), like twitter and bilibili; a unit test pins empty == unset.
2026-09-24 00:48:29 +08:00
YoursFunny 5d44946690 perf(db): size the pool to cover every DB consumer
POOL_SIZE = 4 was the process-wide cap on concurrent DB operations while 4 queue workers, 8 URL workers, dispatcher handlers and the sweep all share the pool — WAL readers queued behind writers and every hot-path round trip (3-5 per message) contended for four permits. 16 covers every consumer at once; SQLite's single writer serializes writes regardless.
2026-09-24 00:48:29 +08:00
YoursFunny 33b1f04f0e fix(send): release one keep-alive reference per settled task
release_keep_alive retained every KEEP_ALIVE entry whose path matched the settling task, deleting the shared TempDir out from under a concurrent duplicate of the same post (a shared fetch pushes one Arc per pipeline): the duplicate's queued retry then dead-lettered on local media that no longer existed. Release now removes exactly one matching entry, which requires settle to run once per task — handle_task settled on Permanent right before the queue invoked dead_letter_notify, which settles the same payload again, so the redundant settle is dropped. A regression test pins one settle to one entry removed.
2026-09-24 00:48:28 +08:00
YoursFunny 4c1fa857c4 fix(webhook): refuse to start without a secret token
Webhook mode passed the secret token to the axum listener only when WEBHOOK_SECRET_TOKEN was set, and docker-compose defaults it to empty (an empty string counts as unset) — the default deployment therefore ran its listener on a public port with no check on X-Telegram-Bot-Api-Secret-Token, so anyone could POST forged updates and impersonate admins (/bot_dict, /clear_cache, /test). Startup now fails when webhook mode has no secret; .env.example and AGENTS.md spell out the requirement.
2026-09-24 00:48:21 +08:00
YoursFunny 3ebc1e4a8f feat(inline): answer from the link cache, and put the answer on the trait
Inline results were URL-only: Telegram fetches an inline result's URL itself
and cannot send site headers, so every pixiv item (and every locally encoded
ugoira/bsky MP4) was skipped and such a query answered empty. A post that is
already in the link cache now answers with InlineQueryResultCached* built
from its Telegram file ids — no fetch, no upload, and the hotlink-protected
case simply works. A degraded entry (file ids gone) falls back to URLs, and
there a video with no poster is skipped (Telegram would try to render the
mp4 as its own thumbnail).

The answer call moves onto MediaSender (answer_inline_query, mirroring the
other user-flow methods), which is what makes the path testable at all: the
two new tests drive the cache answer and the degraded/empty answer through
TestStores + MockSender, which recorded nothing about inline before.

The result builders are shared by both paths now (url_result/cached_result +
inline_kind), so the fetch path's behaviour is unchanged.
2026-09-21 20:35:18 +08:00
YoursFunny 20a0cf3ea3 feat(pixiv): fall back to the large variant when a page has no original
A page whose `original` the API left out (the restricted ones) was dropped
whole by the multi-page branch's filter_map — a missing picture in the album
while `large` sat right there — and the single-page branch produced no media
at all when both places it looks for an original were empty. Both now share
one builder that falls back to `large` (the same picture, lower resolution).
The two tests that pinned the old behaviour now pin the fallback.
2026-09-21 20:29:48 +08:00
YoursFunny f6523e021d feat(urls): deliver a media-less post as its text
A post with no media was answered with "No media found or media type is not
supported.", throwing away text the fetch had already parsed, escaped and
built a caption for (the per-site format and the long-post quoting
included). It now goes out as a message through the same caption the media
path would attach — the senders' own quoting is applied here, since there is
no sender to do it. No queue entry: there is no Task shape for text and a
post with nothing to download is cheap to paste again, so a failure is
reported (send::send_text_post) rather than retried.

Proven live: live_a_text_only_link_is_sent_as_text fetches a real text-only
tweet through url_media and asserts one send_message carrying the post link
and no media send.
2026-09-21 20:24:40 +08:00
YoursFunny c549a6d35e feat(upload): give videos and animations Telegram's real 50 MB cap
One MAX_UPLOAD_BYTES (10 MiB) bounded every upload, but that is the *photo*
limit: Telegram's own docs say sendVideo/sendAnimation/sendDocument take up
to 50 MB, and RequestEntityTooLarge is "larger than 50 MB". So a 10-50 MB
video that Telegram refused to fetch by URL was refused a download too, and
a video has no smaller variant — the post was lost. The non-photo cap is now
MAX_MEDIA_UPLOAD_BYTES, and such a body charges the process-wide budget for
the length of the preparation (one 64 MiB unit covers the cap), since
PREP_SLOTS alone no longer bounds their added RAM. A const test pins both
caps against Telegram's numbers.
2026-09-21 20:21:01 +08:00
YoursFunny 9e5b6df21c docs: note the payload's MediaRef in AGENTS.md 2026-09-21 18:44:42 +08:00
YoursFunny aa70b45ae6 refactor(send): model a payload's media as MediaRef, not media + file_id
MediaItemPayload carried one String field with two meanings and a file_id
bool beside it to say which, in all three variants; every reader re-checked
the flag (input_file had a three-arm pattern just to find the file-id case).
MediaRef::Source/FileId says it once, and the readers now match on it —
local_media_paths, item_url, input_file and the download path each shrank to
the one branch they care about.

Fixes a real failure that the flag was hiding: send_animation built its
InputFile with input_file_for(media_url), which read a cached file id as a
local path and answered "local media file missing" — permanent. So the
second request for a single-gif post always failed (the third worked, from
the degraded entry). It now uses the payload's own input_file, and
a_cached_animation_sends_by_file_id fails without that line.

Wire shape: a queued row from before this change no longer parses, and the
queue already handles that shape (handle_task dead-letters it as an invalid
payload, and dead_letter_notify still names the post and drops the stale
cache entry).
2026-09-21 18:44:26 +08:00
YoursFunny 70d4f2ec88 refactor(x-media): move the download stack into site/download.rs
site/mod.rs held the registry, the Site trait, Fetched, the caption helpers,
FetchError and, at the end, the whole media-download stack: two HTTP clients
with different timeouts, the guard that refuses a URL inside the host's own
network (start URL and every redirect hop), and the two streaming entry
points. That stack is its own reason to change and moves whole, with its
tests; CLIENT and the download fns stay re-exported at site::… so no adapter
or bot call site moved. SITES becomes pub(crate) for the header rule.
2026-09-21 18:35:58 +08:00
YoursFunny b7763a6572 refactor(send): move the Bot API error policy into send/error.rs
classify_request_error, the marker tables it matches on, Classification and
SendError (with its fallback conversion) are one policy — which failures are
retried, which are permanent, which the reupload fallback owns — and were
interleaved with the payload types and the senders. They move whole into
send/error.rs and are re-exported, so every existing send::… path is
unchanged.
2026-09-21 18:32:07 +08:00
YoursFunny 459bfe5803 refactor(media_sender): move the test support into its own file
Two thirds of media_sender.rs was cfg(test)-only scaffolding (MockSender and
the fake_api), which made the trait's own surface hard to find. The module
becomes media_sender/mod.rs (trait + the Bot impl, 260 lines) plus
media_sender/test_support.rs (445); the path
crate::media_sender::test_support is unchanged, so no caller moved.
2026-09-21 18:30:01 +08:00
YoursFunny 01e097a8b6 refactor(handlers): split urls.rs into workers, pipeline and startup repair
urls.rs carried five reasons to change: the job channel and its worker pool,
the single-flight fetch, URL parsing, the per-URL pipeline, and the startup
repair of queued retries. The two with their own lifecycle move out:

- url_workers.rs: the bounded channel, its supervised pool and
  start/stop_url_workers (the pipeline stays in urls.rs, which the workers
  call).
- repair.rs: needs_refetch/apply_refresh/refetch/repair_lost_local_media with
  their tests, moved whole (the live one keeps its #[ignore]).

Also folds the two byte-identical render_fields -> CachedPost mappings
(urls.rs and repair.rs) into urls::cached_snapshot, and moves the shared
permanent_error test fixture into ctx::test_support.
2026-09-21 18:28:41 +08:00
YoursFunny 435c8c4cc4 refactor(commands): the executor takes its context instead of the statics
execute_command reached for CHAT_STORE/LINK_CACHE/CONFIG in 16 places, which
is why the one handler body with no test could not have one: those statics
point at the real $DATA_DIR/task_queue.db, so any test would have written to
the developer's state. It now takes &AppContext (the shape handle_message
uses) and answers through the given sender; bot stays for what the
MediaSender surface does not carry (channel admin lookups, the HTML report).

Three tests cover what that unlocks: the settings/set_format round-trip
against the chat's own store (including the refused placeholder), the admin
gate on both admin-only commands, and /debug answering without sending.
2026-09-21 18:25:08 +08:00
YoursFunny df8fd77d29 docs: sync AGENTS.md with the refactors
download_media is gone (the bot uses download_media_limited), the per-site
modules no longer export enabled/is_retryable/media_headers for sites that
inherit the trait defaults, and the shared fetch is shared_fetch.
2026-09-21 17:43:43 +08:00
YoursFunny f7179d65ee refactor(db): open_store no longer creates the directory
handlers::db_path already creates the DATA_DIR before it opens
$DATA_DIR/task_queue.db, and every other caller (tests) passes a path whose
parent exists, so the guard plus its one-caller rusqlite_error mapper were
duplicated work. The doc now says which caller owns the directory.
2026-09-21 17:42:56 +08:00
YoursFunny 65c9aa6c5c refactor(send): inline parse_media_url, drop photos_first's rebind
parse_media_url had a single caller (input_file_for) and read better as the
one expression it wrapped; photos_first rebound its argument only to gain
mut.
2026-09-21 17:41:57 +08:00
YoursFunny c58202e683 refactor(send): call send_animation directly
send_animation_inner only reshuffled its arguments into MediaSender's
send_animation; both call sites (URL send and the reupload fallback) now call
the sender themselves, which also drops the InputFile and MediaSender imports.
2026-09-21 17:40:43 +08:00
YoursFunny ea63dbb9f9 refactor(send): inline Task::media_items into its only caller
local_media_paths was the sole caller; the match that flattens the batches
(or the lone animation) moves into it.
2026-09-21 17:39:24 +08:00
YoursFunny 9db1a6093b refactor(main): drop the no-op dispatcher dependency
dptree::deps![""] inserted a &'static str no handler ever asked for;
Dispatcher::builder already starts from an empty DependencyMap, so the call
was the default written out.
2026-09-21 17:38:11 +08:00
YoursFunny bd98001d42 refactor(urls): inline fetch_shared into its one caller
It bound the process-wide map and the site fetch for shared_fetch and had a
single call site; the caller now passes both directly.
2026-09-21 17:37:31 +08:00
YoursFunny 90663a51cc refactor(handlers): inline reply_html into its only caller
The /debug arm was the sole caller; the HTML parse mode and reply decoration
move there with the Requester-disambiguation comment.
2026-09-21 17:36:44 +08:00
YoursFunny 7ecb61399d refactor(handlers): reply returns unit
Every one of its call sites discarded the message id (a bare ?; or let _ =),
so the Result carried a value nothing could use.
2026-09-21 17:35:50 +08:00
YoursFunny 40ab07882d fix(handlers): restore the group hint's link filter
Replacing is_group(kind) with teloxide's predicates dropped the parentheses:
&& binds tighter than ||, so the branch read as is_group() OR (is_supergroup()
AND has-link) -- a plain group got the hint for any link, supported or not.
The e2e test only covered a group with a supported link (which is true either
way); it now also covers an unsupported link in a group.
2026-09-21 17:35:07 +08:00
YoursFunny 676b4bc2ba refactor(rate_limit): one balance() behind tokens() and is_idle()
Both took the lock, refilled and read the field, differing only in the
comparison.
2026-09-21 17:34:25 +08:00
YoursFunny 2d50440bf1 refactor(commands): one require_admin gate for the admin-only commands
/bot_dict and /clear_cache each resolved the sender id and replied "Admin
only." on their own; the helper returns the id or answers the refusal, so
both arms read as one line.
2026-09-21 17:33:45 +08:00
YoursFunny 6b4da18be5 refactor(commands): one cap_text for the two truncated replies
/bot_dict's dump and /debug's report each cut a string at a byte boundary
and appended an ellipsis, with slightly different bookkeeping (one left a
byte for the ellipsis, one did not). cap_text is the version that keeps the
result within the cap, with a test covering the exact-fit, truncating and
multi-byte cases.
2026-09-21 17:32:46 +08:00
YoursFunny 5222cfa1cb refactor(x-media): hoist the shared caption builder
bilibili and misskey each had a byte-identical caption() (same escaping,
same empty-text early return, differing only in a named format argument).
site::caption holds it once; both adapters call it.
2026-09-21 17:29:18 +08:00
YoursFunny dd98a90a45 refactor(state): add ChatData::format_for for the per-site caption format
Four call sites (cache hit, fresh fetch, startup refetch, /debug preview)
spelled out the same message_format.get(...).cloned().unwrap_or_default();
the accessor names the lookup and keeps the empty-format contract in one
place.
2026-09-21 17:27:42 +08:00
YoursFunny 26581cfca1 refactor(handlers): use teloxide's own chat-kind predicates
is_group hand-rolled what Chat::is_group/is_supergroup/is_private already
answer (verified in teloxide-core's chat.rs), and the private check was a
ChatKind pattern match. The unit test that exercised the helper asserted
teloxide's semantics; the channel case it guarded (a channel must not get the
group hint) is now asserted through handle_message instead, next to the group
case.
2026-09-21 17:26:35 +08:00
YoursFunny d9f3ee99c1 refactor(db): add DbPool::with_conn_or for the default-and-log tails
Ten call sites across link_cache, state and queue repeated the same
match/if-let over a with_conn result with their own log line and default.
with_conn_or takes the level, the operation name and the default; each site
keeps its exact message and the same Ok/Err behaviour.
2026-09-21 17:24:51 +08:00
YoursFunny f21570f579 test: share the photo payload fixture, drop two test-only aliases
The one-photo MediaItemPayload literal was written out at six sites across
the urls and send test modules; ctx::test_support::photo_item holds it once.
permanent_error now delegates to api_error (it stays a fn pointer because
that is what MockSender::scripted takes), misskey's x_media_site_id wrapper
is gone in favour of the function it renamed, and PixivAPI is no longer
re-exported -- nothing outside pixiv/api.rs names it.
2026-09-21 17:22:02 +08:00
YoursFunny 7a61077d80 refactor(pixiv): keep the artwork type as the API's string
TypeModel's Illust and Manga variants existed only so serde would accept
those values -- both call sites ask a single question (is this ugoira?), so
the field is a String and the check is a comparison. Same for a type the API
adds later: it no longer fails the whole parse.
2026-09-21 17:19:26 +08:00
YoursFunny 7dd0e8f2d6 refactor(x-media): drop the download_media wrapper
It existed only to pass u64::MAX to download_media_limited and was called
from this file's tests alone; the bot's own download path already calls
download_media_limited directly. The tests now exercise that function, with
the same uncapped bound.
2026-09-21 17:17:43 +08:00
YoursFunny c8a32d49c9 refactor(x-media): read Fetched::site_id instead of the site_name alias
site_name() only returned the public site_id field; the five callers (the
/debug report, the caption-format lookup on both fetch paths, the per-site
override in urls.rs) read the field now.
2026-09-21 17:16:21 +08:00
YoursFunny 4e42855c59 refactor(x-media): drop Media's write-only title field
Every adapter set title (mostly None) on all three Media variants and
nothing ever read it: the bot's CachedMedia carries kind/file_id/url, and the
one read was misskey's own test. Removing it also drops misskey's
DriveFile.name, which existed only to feed it. Fetched::title (the post's
own headline, which captions do use) is untouched.
2026-09-21 17:15:27 +08:00
YoursFunny 9a7cda05c9 refactor(x-media): drop the per-site fns the Site defaults already cover
twitter, bsky, misskey and bilibili each carried enabled() -> true,
media_headers(url) -> None and is_retryable(err) -> the trait's own default,
with no caller outside their tests (the adapters never override those
methods, so the default was already the production policy). The tests that
only restated the default are gone; the two that pin site-specific classes
(bsky's MediaPrep, bilibili's risk-control codes) now ask the Site impl, and
site/mod.rs keeps one assertion of the shared retry policy. The live
verification notes (no Referer needed for hdslb/twimg) survive as comments.
2026-09-21 17:12:42 +08:00
YoursFunny 5630a86d88 refactor(x-media): share the HTTP-status error mapping
twitter, twitter auth and bsky carried the same 404/410 -> NotFound,
401/403 -> Blocked, else Transient block (comments included).
site::status_error holds it once. bilibili and misskey keep their own
matches: neither maps 404/410 and each has a status the others do not
(412 risk control, 400 + NO_SUCH_NOTE), so routing them through the shared
block would have reclassified those statuses for the user.
2026-09-21 17:09:07 +08:00
YoursFunny b9dd1f4d08 refactor(urls): fold the cache-hit caption branch into one call
caption_from_fields already returns truncate_caption(built_in) for an empty
format, which is exactly what the if-branch did; passing cached.caption as
built_in makes both paths one call. The format path is unmoved -- built_in is
read only when the format is empty.
2026-09-21 17:06:46 +08:00
YoursFunny 9aed6f4a24 refactor(config): one parse_opt helper, BOT_ADMIN parsed once
The three webhook settings repeated the same env -> parse -> warn shape
(warn text unchanged); BOT_ADMIN partitioned and then re-parsed every entry,
building a throwaway vector of the bad ones. The ids an operator gets are
unchanged, pinned by a new test.
2026-09-21 17:05:20 +08:00
YoursFunny 3d377f68fb refactor(photo): one plan decides within-limits and decode budget
decode_budget_bytes mirrored the within-limits early return and the
MAX_DECODE_BYTES guard that prepare_png and prepare_jpeg each spelled out
inline (three copies of the same arithmetic, which the reservation and the
branches had to keep in sync by hand). PhotoPlan/plan_photo now hold that
decision once and all three call it; the log order and the exact bounds are
unchanged. decode_budget_follows_the_processing_decision still pins the
reservation against the branches.
2026-09-21 17:03:22 +08:00
YoursFunny 5166d97545 refactor(db): use rusqlite query_row/optional instead of hand-rolled reads
pending_backlog, earliest_run_after, ChatStore::get and LinkCache::get each
hand-rolled prepare + query + rows.next() for what is a single-row read.
query_row + OptionalExtension::optional is the same statement and the same
error mapping with less scaffolding; the backlog's NULL-on-empty MIN still
goes through the count check, so a pending row with a NULL run_after is not
misread. Also fixes the rustfmt drift from the previous commit.
2026-09-21 17:01:38 +08:00
YoursFunny ebe7b8bdd7 refactor(send): one payload to InputMedia dispatch instead of three
media_from_file, media_from_url and build_media_group's closure each wrote
the same per-kind match plus the same video-thumbnail attach. media_from
takes the already-selected InputFile; the two builders that differ only in
how that file is chosen are now two-line calls to it. The local-file branch
keeps InputFile::file (no existence probe) and every caller still passes the
item's own has_spoiler / thumbnail_url, so what reaches Telegram is the same.
2026-09-21 16:59:54 +08:00
YoursFunny a5187981c7 refactor(send): share one task constructor between fresh sends and startup repair
build_send_task and apply_refresh each wrote the same animation-vs-sequence
branch and the same two 13-field literals; Task::from_items takes the
delivery envelope (chat, reply, forward/edit settings, notify targets) once.
The two callers keep computing that envelope from their own source -- chat
settings vs. the queued row being replaced -- so the repair path's delivery
semantics are unchanged.
2026-09-21 16:58:39 +08:00
YoursFunny 10a672787a docs: describe the CI gates in the testing notes 2026-09-21 15:55:08 +08:00
YoursFunny 9eb865bb02 ci: skip the heavy jobs when nothing but documentation changed
Every push and PR paid the full four-minute job — fmt, clippy, the offline
suite, a release-profile build and the dependency audit — even when the diff
was a README or AGENTS edit, and every master push built and published an
image for a commit that cannot have changed it.

`ci.yml` gains a `changes` gate job: a push or PR whose *entire* diff is
markdown skips `test`, which then reports as *skipped* instead of missing —
the reason this is a gate job and not a workflow-level `paths` filter, which
leaves a required status check waiting for a check run that will never appear.
Anything non-markdown (and an empty diff, e.g. a re-run of the same commit)
runs the full job, so a new directory of code cannot slip through a stale
allowlist. `schedule`/`workflow_dispatch`, which have no `before` commit, also
run it.

`docker.yml`'s `should-build` gate now also skips a branch push that touched
none of the image's inputs (`Dockerfile`, `docker-entrypoint.sh`,
`.dockerignore`, the manifests, `Cargo.lock`, this workflow, anything under
`crates/`); tag pushes always build.

Checked against this repo's real ranges: the docs-only commit 667f523
(AGENTS.md) → `code=false` (test skipped) and skip, a workflow commit →
`code=true` and build, the h2 bump (Cargo.lock) → build.
2026-09-21 15:54:39 +08:00
YoursFunny 74ffe66884 ci: correct the duplicate-build diagnosis
The previous commit blamed `actions/checkout` for not fetching tags. It does
(with `fetch-depth: 0`), and the run logs show it: the v1.9.1 master run's
checkout fetched every tag up to v1.9.0 and nothing newer, because v1.9.1 did
not exist on the remote yet — the branch push came first, the tag push eight
seconds later. The duplicate is a race with the tag push, not a missing
fetch. Comments corrected; the re-fetch before the decision stays, and it is
what makes the gap between the checkout and the decision irrelevant (the tag
only has to exist by the time *this* step runs).
2026-09-21 15:44:11 +08:00
YoursFunny f8796913e5 ci: make the docker duplicate check able to see tags
Pushing master and a release tag fires two workflow runs, and `should-build`
exists to keep only one of them building: a branch run skips when its commit
is already tagged. It never worked — `actions/checkout` does not fetch tags
(`fetch-tags` defaults to false, and `fetch-depth` does not imply it), so
`git tag --points-at "$GITHUB_SHA"` came up empty and the master run built the
same commit the tag run was building: two ~6 minute docker builds pushing the
same image, for v1.9.0 and again for v1.9.1.

The check step now fetches the tags itself, immediately before deciding, so
the view is as fresh as it can be. Reproduced and fixed against this repo: a
clone made the way the action makes it (`--no-tags`) reports "NO TAG ->
build=true (duplicate build!)" for the tagged v1.9.1 commit, and the same
clone after the step's `git fetch --tags --force origin` reports
"tag(s): v1.9.1 -> build=false (skip)".

A tag pushed *after* the branch run started cannot be anticipated, so the
release flow is documented as one push (`git push origin master vX.Y.Z`) in
both the workflow and AGENTS.md; pushing master first is exactly what made
today's pair build twice.

`cargo fmt --check`, `clippy`, the test suite and the workflow's YAML parse
are all clean (workflow/docs only, no Rust changes).
2026-09-21 15:38:44 +08:00
YoursFunny d60f849864 chore: bump version to 1.9.1 2026-09-21 15:28:51 +08:00
YoursFunny a981256b11 fix(deps): h2 0.4.19 (RUSTSEC-2026-0258)
Enabling reqwest's `http2` feature pulled in h2 0.4.15, which accepts
unbounded empty DATA frames — a remote peer could make the bot queue them
without limit (memory growth, or a panic on length overflow). Low severity,
but the CI dependency-audit gate fails on it, and the fix is a patch bump:
`cargo update -p h2` → 0.4.19.

`cargo audit` against the advisory database is now clean of vulnerabilities
(the two remaining entries are pre-existing `unmaintained` warnings for
`dotenv` and `proc-macro-error2`, which the gate allows), and the full suite,
the live suite and a release build pass on the new lock.
2026-09-21 15:27:19 +08:00
YoursFunny 8c085bea35 chore: bump version to 1.9.0 2026-09-21 15:05:03 +08:00
YoursFunny 670351d436 fix: let a successful send return a degraded entry to the fast path
Degrading a link-cache entry (previous commit) closed the "user retries right
after a failure" case, but left a new one open: the entry could never regain
file ids. `cache_sent_task` skipped *every* cached send — its rule was "the
entry already holds the ids the next repeat wants" — which is true for a
healthy entry and false for a degraded one. So a degraded entry (pixiv's
hotlink-protected media, say) kept sending by URL forever, and every repeat
paid a download and an upload through the fallback that the file ids would
have avoided. Worse than the re-fetch it replaced.

The rule is now the one it always meant: a send whose cache snapshot still
carries file ids leaves the entry alone, and a send served from a degraded
entry writes back the ids it produced (the URLs in the rewritten entry come
from that send's own items, so they stay correct).

Verified by a test that drives `cache_sent_task` directly with both task
shapes: the degraded one updates the entry to the fresh id, the healthy one
leaves it untouched. 125 bot tests + 91 x-media tests, live suite (14) pass.

`cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D
warnings` and `cargo test --workspace --locked` clean.
2026-09-21 14:38:26 +08:00
YoursFunny 64cf43dc01 perf: degrade a link-cache entry instead of dropping it on a failed send
`link_cache` exists so a repeat link costs nothing: no source request, no
download, no upload. It was written only on a *successful* send, and a send
that failed permanently deleted the entry — so the user's immediate retry, the
one case where they are most likely to try again, re-fetched everything:
site requests, a download, and for a ugoira or a bsky video a full ffmpeg
encode. Invalidation is right about the cause (the cached Telegram file id is
what went stale) and wrong about the cure (the media and its URLs are usually
fine).

Cached media now carries the source URL it was sent from, and a permanent
failure *degrades* the entry: the file ids are cleared, the URLs and the
caption fields stay, and the next request sends from those URLs — Telegram
fetches the media (or the upload fallback does) with no source round trip.
That is the same media a fresh fetch would have produced (site CDN URLs are
stable per post), and it is bounded: an entry that is already degraded, or one
from before this field existed, is removed instead, so a dead post still ends
up re-fetched and reported rather than retried forever.

Verified: a cached send that fails permanently leaves the entry with its URL
and no file id, a second failure drops it, and a degraded entry sends the
media with no fetch at all (the mock records no reply, which is what the
fetch-error path would have produced). 124 bot tests + 91 x-media tests pass,
including a direct test of the two payload shapes.

`cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D
warnings` and `cargo test --workspace --locked` clean.
2026-09-21 14:35:57 +08:00
YoursFunny 62507d3a01 perf: bound the memory photo preparation holds, not just its count
`PREP_SLOTS` caps how many items are prepared at once (6) but says nothing
about what they hold: one photo's decode buffer can be up to
`MAX_DECODE_BYTES` (512 MiB) and that guard is *per photo*, so six of them —
an album of large scans, two chats at once — could peak near 3 GiB on a host
sized for a fraction of it. The upload fallback is the only path that
allocates like this; nothing downstream notices until the kernel does.

Photo preparation now charges a process-wide memory budget
(`MEMORY_UNITS` × 64 MiB = 512 MiB) for what it actually holds: the
downloaded bytes plus the decode buffer the *header* predicts — the same
prediction the per-photo guards apply, now shared (`decode_bytes`,
`decode_budget_bytes`) so the reservation and the guard cannot drift. A photo
that is already within Telegram's limits is billed only its download, so an
ordinary 10-image album still runs several at a time; two photos near the
per-photo cap serialize (each takes the whole budget). The request is clamped
to the budget so a single huge photo runs alone instead of waiting for
permits that cannot exist.

Verified with a throwaway harness against a locally served 9999x9999 PNG
(126 KB on the wire, ~100 MB decoded) driven through the real
`prepare_upload_item`: with the budget held the preparation waits — "after
1516ms the prep is still waiting on the budget" — and finishes in 11.8s the
moment it is released, so the accounting binds in the real path and not just
in the semaphore.

Kept as permanent tests instead: the unit math (rounding, clamp, and that a
max-size photo still gets the whole budget rather than waiting forever), the
budget sharing (huge decodes cannot overlap, ordinary ones do not queue), and
the prediction agreeing with the processing decision (over-sized PNG/JPEG
charged, within-limits and unknown formats free).

`cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D
warnings` and `cargo test --workspace --locked` clean (122 bot + 91 x-media).
2026-09-21 14:31:50 +08:00
YoursFunny 667f523c8b docs: sync AGENTS with the shared fetch, download budget and inline answer 2026-09-21 13:53:54 +08:00
YoursFunny 507c8ac317 perf: index the link-cache prune, evict chats with no live prompt
Two things the 300 s sweep did the hard way:

- The link cache is pruned by `created_at` (`DELETE FROM link_cache WHERE
  created_at < ?`) and had no index on it, so every sweep scanned the whole
  table — every post sent inside the TTL window, which is up to a week of
  them — while the `url` primary key served none of it. A new migration
  (appended; migration 1 is frozen and already shipped) creates the index, and
  the upgrade test now asserts it exists after an upgrade.
- `ChatStore::prune_expired` only ever *looked* at chats that had an expired
  edit-before-forward record, so a chat with no prompt at all — the common
  case: every chat that ever sent a message or ran a command — stayed in the
  cache and in the per-chat lock map for the process lifetime. The candidate
  set now includes chats holding no records, which is what the eviction below
  was written for; the DB keeps the row, so the next use costs one SELECT
  (pinned by a new test that also shows the durable settings come back).

Deliberately *not* done: skipping the write in `ChatStore::set` when the state
is unchanged. Comparing against the cached copy would skip a serialize plus a
blocking DB round trip for a no-op update — but every one of the 13 `update`
callers mutates something, so the no-op case is a user repeating an identical
command, and the same comparison would also skip the write that repairs a row
whose earlier write failed. A rare saving against a rare repair, and the write
is what makes the cache a cache rather than a source of truth.

Verified: the new eviction test fails without the candidate change (checked by
reverting it) and passes with it; 118 bot tests and 91 x-media tests pass.

`cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D
warnings` and `cargo test --workspace --locked` clean.
2026-09-21 13:52:43 +08:00
YoursFunny 670a7bd02b fix: answer an inline query whose media Telegram cannot fetch
Every item was skipped — `needs_media_headers` for pixiv's pximg.net, or a
local ugoira/bsky MP4 that does not parse as a URL — and the function fell
through to `Ok(false)`, which the debounce reads as "retry this query". The
client got no answer at all (a spinner with nothing behind it) and every
keystroke re-ran the fetch, while an unsatisfiable link is the *permanent*
truth for that query: Telegram fetches inline result URLs itself and never
sends a Referer.

It now answers the empty result set with a 300 s window, so the client stops
spinning and the same query is served from Telegram's cache and from the
debounce (which only releases on a failure). A *failed* fetch still releases,
so a retry is not answered from a stale empty answer.

Verified with a real pixiv link through a real `Bot` against the stand-in
API: `AnswerInlineQuery` with `results: []` and `cache_time: 300` (and
`Ok(true)`, so no release).

`cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D
warnings` and `cargo test --workspace --locked` clean.
2026-09-21 13:49:21 +08:00
YoursFunny 5ff921222a fix: give a media download a total time budget
`MEDIA_CLIENT` deliberately has no reqwest total timeout (a 30s cap made a
hundreds-of-MB ugoira zip impossible to deliver), and the idle window only
covers *silence*: a server that drips a chunk every 29 s keeps the download
alive indefinitely. On the bot's side each such download holds one of the
process-wide upload-prep slots (`send::upload`'s `PREP_SLOTS`, 6), so a
handful of trickling sources can take the whole fallback path out of service
without ever looking broken.

`DOWNLOAD_TOTAL_TIMEOUT` (600s) bounds the whole transfer, checked between
chunks — a transfer that completes just over the budget is kept rather than
thrown away, and a genuinely slow link (the case the cap was removed for)
stays far inside it. Reported as `Transient` like the idle-window stall: the
transfer may simply have been unlucky, and a retry restarts it.

Verified with a local trickling server (a 1 KiB chunk every 1.2s, chunked so
the client cannot see the total up front): with the budget temporarily
lowered to 2s the download aborted after 2425ms with
`transient: download exceeded 2s` — two chunks in, the server seeing the
client go away — proving the budget and not the 30s idle window ended it.

`cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D
warnings` and `cargo test --workspace --locked` clean.
2026-09-21 13:44:53 +08:00
YoursFunny 0edaef56bd perf: share one fetch between concurrent duplicates of a link
`link_cache` is only written *after* a send succeeds, so two chats posting
the same link at the same moment each ran a full fetch: two sets of source
requests, and for an ugoira or a bsky video two ffmpeg encodes of the same
post — minutes of CPU for the second one. The same applies to a batch
forward racing a queued retry. Within one message `dedupe_urls` already
handled the duplicates; across calls nothing did.

`fetch_shared` keys an in-flight fetch by the post's cache key: the first
caller runs it, the rest subscribe and take its result. The entry is removed
by a guard when the fetch settles (cancellation included), so this dedupes
what is *concurrent* and never answers from an old result — a repeat later
fetches again, and a failure is deliberately not cached: the user is told to
try again, and a cached failure would answer that retry from a stale state.

Two hazards that shape the code, each with a test: a broadcast channel lives
while *any* sender does, so the waiter drops its own clone of the sender
before waiting — otherwise a cancelled sharer would leave it waiting forever
— and a waiter whose sharer vanished fetches for itself instead of failing a
link that is perfectly fetchable.

One fetched post can now serve several sends, which the temp files behind
`Fetched::keep_alive` had to support: the field is `Arc<TempDir>` and the
accessor hands out references (the bot's `KEEP_ALIVE` registry holds the
same), so the ugoira/bsky MP4 stays on disk until the *last* task settles
rather than the first. `take_keep_alive` is gone — a `take` could only ever
serve one of the senders.

Verified: 116 bot tests (three new sharing tests, including the cancelled
sharer) and 91 x-media tests (a new one pinning the keep-alive refcount)
pass, plus a live check that two concurrent `fetch_shared` calls for one
tweet return the very same `Arc` after one fetch's worth of wall time.

`cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D
warnings` and `cargo test --workspace --locked` clean.
2026-09-21 13:42:52 +08:00
YoursFunny 994734b001 perf: gzip and HTTP/2 for the site clients
x-media's reqwest had exactly `["json", "rustls-tls"]` — no decompression, no
HTTP/2 — so every adapter fetched its JSON as identity over HTTP/1.1. The
site APIs compress: twitter's syndication body measures 4469 bytes identity
against 1066 gzipped (4.2x) for a single-tweet response, and the fetch is on
every twitter link. The CDNs all negotiate h2 (the shared client reports
HTTP/2.0 against cdn.syndication.twimg.com, api.bilibili.com and
public.api.bsky.app), which also multiplexes the concurrent media downloads
that used to open a connection each.

Both features are one crate-level switch, so the pages' compression is what
the APIs answer with and requires no adapter change: reqwest adds
`accept-encoding: gzip` and decompresses transparently. `download_media_limited`
keeps a correct size cap either way — it checks the accumulated *body* while
streaming, not the declared Content-Length, which for a compressed response
is the compressed size.

Verified: a local echo server recorded `accept-encoding: gzip` on a request
from the shared client (both clients come from `build_client`), and a probe
over the shared `CLIENT` reported `HTTP/2.0` for the three site hosts above.
`cargo test -p x-media -- --ignored live` (14 passed) covers the real
endpoints with the new transport.

Cargo.lock gains async-compression (+codecs/core), fnv and h2.

`cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D
warnings` and `cargo test --workspace --locked` clean.
2026-09-21 13:09:32 +08:00
YoursFunny a501a17519 perf: stop spending API calls the link pipeline cannot use
Two calls per link went out that could not affect anything:

- `run_with_chat_action` awaited the opening `send_chat_action` to completion
  before the pipeline was polled at all, and again inside the loop on every
  `ACTION_REFRESH`. Telegram round trips are hundreds of ms: the first delay
  came out of the user's wait for every link, and each refresh suspended the
  fetch (an ugoira encode or HLS remux runs for seconds) by the same amount.
- `handle_message` enqueued *every* URL a private chat posted, including
  links no site adapter claims. Those cost a queue slot, a worker wake-up,
  a `Message` clone and (through the action above) one Telegram call, only
  for `url_media_inner` to conclude there was nothing to send. The group
  branch has always made the `cache_key(url).is_some()` test before it acts;
  the private branch now makes it before it enqueues.

The in-flight action is held (`Option<BoxFuture>` — the sender surface is
already type-erased, so it is `Unpin`) and polled as its own `select!`
branch: still polled *before* the pipeline, so the indicator is on screen
before the first send, but a slow Telegram response can no longer delay the
pipeline, and none of the branch bodies ever awaits one. One action is in
flight at a time; a refresh while one is unanswered is skipped rather than
dropping the request mid-flight. Note that `select!` evaluates every branch's
future expression eagerly, so the `None` case is an `async` block whose
`unwrap` only runs when the branch is polled (the eager form panicked).

Behavior pinned by the existing tests, unchanged: the opening action precedes
the first send, a 12s pipeline still sees exactly three actions
(`a_long_pipeline_keeps_the_chat_action_alive`), and an unsupported URL
reaching `url_media` still gets the one indicator before the pipeline settles
— in production it no longer reaches `url_media` at all.

`cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D
warnings` and `cargo test --workspace --locked` clean.
2026-09-21 13:01:45 +08:00
YoursFunny edf4dab26d fix: remember an unavailable bilibili fingerprint
`cookie()` cached the device cookies only on success: the `Option<String>`
it held could not tell "not fetched yet" from "the fetch failed", so a failed
fingerprint meant *every* later post re-asked the SPI endpoint — one extra
round trip per post, forever, exactly the case the fingerprint exists for (a
flagged IP, where that endpoint is the thing answering `-352`/412).

The cache is now `Option<Option<String>>`: the outer level is "an attempt has
been made", the inner one is the cookie it produced, so a failure is
remembered as no-cookie and reaches the request path unchanged. The guard is
also dropped before the request instead of being held across it, which had
serialized every concurrent bilibili fetch behind that one round trip.

A racing first pair still costs a duplicate fingerprint call
(`get_or_insert`, first writer wins) — never a wrong cookie.

Verified live: the 5 bilibili tests (`--ignored`, including
`live_fingerprint_yields_device_cookies` and the four dynamic fetches that
send the cookie) pass. The failure path itself has no unit test: `SPI_URL` is
a const, so there is no seam to make the endpoint fail on demand.

`cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D
warnings` and `cargo test --workspace --locked` clean.
2026-09-21 12:56:20 +08:00
YoursFunny 818a44d697 perf: fetch bsky HLS segments concurrently
A long bluesky video can be 500 segments, and the remux downloaded them
strictly one at a time: the user waited for every round trip in turn, which
is the dominant cost of the whole remux (the ffmpeg concat itself is local).
Each segment's multi-megabyte body also went to disk through a blocking
`std::fs::write` on an executor thread.

Segments now download and write under a small bound (`SEGMENT_CONCURRENCY`,
4 — a segment can be 20 MiB and the playlist is capped at 256 MiB, so this is
also what bounds the remux's peak memory) and the write goes through
`tokio::fs`. Concurrent downloads complete in completion order, and ffmpeg
concatenates the list in whatever order it holds — an out-of-order list is a
*silently* scrambled video, not an error — so `concat_list` sorts by segment
index and carries its own test.

x-media's own tokio features gain `rt` (JoinSet) and `fs`: the library
already used `spawn_blocking` on the strength of the bot crate's features.

Verified against a local HLS fixture — 12 one-second segments of solid
red/green/blue, each served with a 150 ms delay, reached through a name that
resolves to loopback (the guard refuses a literal 127.0.0.1) — with a
temporary in-module test: all 12 sampled frames come back in the right colour
order, and the server recorded a peak of 4 requests in flight, where the
serial version showed 1.

`cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D
warnings` and `cargo test --workspace --locked` clean.
2026-09-21 12:55:12 +08:00
YoursFunny e31bf92df7 perf: parse a twitter syndication body once
`fetch` classified the response with `parse_syndication_body` — which parses
the whole body into a `serde_json::Value` — and then threw that value away
and re-parsed the same text into a `SyndicationTweet`. Two full JSON scans
and two allocations of every string in the body (text, entities, media
details) per tweet.

`Tweet::from_syndication_value` takes the value classification already
built and deserializes from that: `from_value` moves the strings out of the
tree instead of allocating copies, so the response text is scanned once.
The auth fallback had the mirror image of the same waste — it built the
syndication shape as a `Value` and then serialized it back to a string for
a parse — and the 8 test call sites lose their `.to_string()` with it.

Verified live: `cargo test -p x-media -- --ignored live` (14 passed),
including the 5 twitter fetches that exercise this path.

`cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D
warnings` and `cargo test --workspace --locked` clean.
2026-09-21 12:49:26 +08:00
YoursFunny a1c452f19d perf: retry the bsky HLS request, not the whole fetch
A failed bsky video remux was reported as `FetchError::Transient`, so the
fetch loop retried the *whole* adapter — master playlist, variant playlist
and up to 500 segments again (256 MiB of cap each time), for a failure that
happened near the end of the work the retry was about to redo. The message
had to stay honest (returning `Ok` with no media reads as "this post has no
media"), so it needed a class of its own.

`FetchError::MediaPrep` is that class: post fetched, media could not be
prepared locally, not retryable (the per-site `is_retryable` whitelist
excludes it by construction), with its own user-facing text — the generic
"failed to fetch" would have hidden that the download or encode was what
broke. The retry itself is not lost, it moved: `fetch_hls` retries the
request that actually failed, once, for the classes a retry can change
(transport, 429/5xx).

Checked the sibling sites before widening the change: pixiv already degrades
to no media when the ugoira encode fails (`Ok(None)`), and its frame-zip
download failure is the last step so a re-fetch re-does only that; twitter's
auth leg replays two cheap metadata GETs and a GraphQL 5xx *is* worth
retrying. Neither needed the new class.

Verified with a throwaway proxy harness: a 503 answered twice-in-a-row path
costs 2 requests and succeeds, a 404 costs exactly 1 and fails (no pointless
retry). `site::bsky::interface::tests::media_prep_failure_is_not_retried`
pins the classification.

`cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D
warnings` and `cargo test --workspace --locked` clean.
2026-09-21 04:11:39 +08:00
YoursFunny 362eb9e729 perf: bound upload-fallback preparation process-wide
Each batch's items were prepared under their own `Semaphore::new(3)`, which
is not a memory bound: 8 URL workers and 4 queue workers can each be inside
a batch, so a burst could have two dozen downloads in flight at once, each
buffering a whole photo before it is processed. Nothing else on the media
path bounds them — the send itself is paced by the rate limiter, but the
download and the decode happen before it is charged.

One process-wide `PREP_SLOTS` (6) replaces the per-batch semaphore, and the
photo download gets its own cap: `MAX_PHOTO_DOWNLOAD_BYTES` (32 MiB) for the
transfer, with `MAX_DECODE_BYTES` (512 MiB) left as the pre-allocation guard
on a single decoded buffer. A photo over the download cap degrades to its
smaller URL exactly as one over the decode budget does
(`FallbackError::MediaTooLarge` → `fallback_url`) — never an error.

Verified with the same throwaway proxy harness: 4 concurrent 10-item batches
against a server that holds every response 150 ms peak at exactly 6
concurrent downloads (the per-batch three allowed 12) with all 40 items
prepared.

`cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D
warnings` and `cargo test --workspace --locked` clean.
2026-09-21 04:10:28 +08:00
YoursFunny 35074bab67 perf: stop probing a media item's size before downloading it
The upload fallback asked `x_media::site::media_size` for every remote item
before downloading it. That call is a real GET (not a HEAD) on the *un-
guarded* `CLIENT` — so every fallback item cost two requests where one would
do, the response body was never consumed (the connection cannot return to
the pool), and for photos the answer was discarded outright
(`too_large && !matches!(item, Photo { .. })` still fired the request). It
bypassed `media_request`'s private-network guard as well, the one choke
point every other egress goes through.

For videos the probe was redundant twice over: `download_media_limited`
reads the declared Content-Length before any body byte and aborts with
`FetchError::TooLarge`, which the call site already turns into the item's
smaller URL (`FallbackError::MediaTooLarge` → `fallback_url`).

`media_size` is deleted (no other caller) and the download's own cap is the
only size gate. The video cap is now exactly `MAX_UPLOAD_BYTES` instead of
`MAX_UPLOAD_BYTES + 1`, so the boundary the probe drew survives byte for
byte: a file of exactly the cap is admitted (`len > max_bytes` is false),
one byte over degrades to the smaller URL.

Verified with a throwaway harness (a local HTTP server reached through
`TELOXIDE_PROXY`, the one LAN egress the guard allows): a small video, a
photo and an oversized video each cost 1 request where the probe made it 2,
and the oversized one still lands on `/fallback.mp4` without fetching it.

`cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D
warnings` and `cargo test --workspace --locked` clean.
2026-09-21 04:09:34 +08:00
YoursFunny 1fb7837255 test: name the redirect-guard test so the live filter selects it
The repo runs the ignored network tests with `cargo test -- --ignored
live`, which matches on the test name; the new redirect test had no
`live_` prefix and would have been skipped by it.
2026-09-21 03:03:37 +08:00
YoursFunny 4e723e1657 test: drive a real Bot against a stand-in API
Every test went through `MockSender`, so `media_sender`'s `Bot`
implementation — the URL it builds, the multipart it sends, the per-chat
limiter and the bot-wide budget it charges — was never exercised, and neither
was any handler reached from a real update. The two things that made that hard
are gone:

- `media_sender::test_support::fake_api::FakeApi` is a stand-in for
  `api.telegram.org`: a `tokio` TCP listener that reads one HTTP/1.1 request
  (JSON or multipart), records it and answers the smallest result the method
  needs. No new dependency, and `Bot::new(token).set_api_url(api.url())`
  points a real `Bot` at it. Note for future tests: teloxide keys methods by
  payload type, so the path is `SendMediaGroup`, not `sendMediaGroup`.
- `message_handler` built its own `AppContext::from_statics` internally, so no
  test could reach its branches; its body is now `handle_message(ctx, bot,
  message)` with `message_handler` as the thin `dptree` entry.

Tests: a media group through the real `Bot` (asserting the multipart fields —
chat, media URL, caption — and that the send charged the chat's limiter), the
forward button through the real callback path (`CopyMessages`,
`DeleteMessage`, `AnswerCallbackQuery` with the prompt's ids and the toast
text), and `handle_message` twice (a prompt reply becoming an
`EditMessageCaption`, and a supported link in a group producing the one
explanatory `SendMessage`).

Also closes the redirect-hop gap left open by the download guard: the live
`a_redirect_into_the_hosts_network_is_refused` follows a public redirector to
`169.254.169.254` and asserts the policy refuses the hop (verified against
httpbin.org here, and by mutation — disabling the hop check fails it).

Docs: AGENTS.md's testing conventions and untested-modules list (the Bot
implementation and the handler branches are covered now; `main.rs`'s
startup/shutdown and its `dptree` tree still are not).

`cargo fmt`, `cargo clippy --workspace --all-targets --locked -- -D
warnings`, `cargo test --workspace --locked` (201 passed, 16 ignored) clean.
2026-09-21 03:02:43 +08:00
YoursFunny cd8b5ac67b fix: refuse media downloads into the host's own network
The media URLs the bot fetches come from a site's own API response (media
URLs, `fallback_url`, thumbnails), `build_client` left reqwest's default
redirect policy in place (up to 10 hops, any host), and the downloaded bytes
are uploaded to Telegram — so a response pointing at a cloud metadata
endpoint would read it back into a chat.

`media_request` is now the one choke point both download paths go through:
http(s) only, and a host that is no address or name of the host's own network
(`blocked_ip` covers loopback, private, link-local, unspecified, broadcast,
documentation, multicast, IPv6 unique-local/link-local, IPv4-mapped, plus
CGA-NAT and benchmarking ranges; `is_local_name` covers `localhost` and
`*.local`). A refusal is `FetchError::Blocked` — permanent, so the send path
does not retry a URL that would be refused again (a connection error used to
be retryable and burned attempts). The same guard runs on every redirect hop
through a custom redirect policy, keeping reqwest's 10-hop cap.

Deliberate gap, documented at the function: DNS rebinding (a name the site
controls resolving to a private address) needs a `reqwest::dns::Resolve`
wrapper, which would also resolve the operator's own proxy host — and
`TELOXIDE_PROXY` is routinely a LAN address — so it would take down working
deployments to block a much less likely attack.

Verified: three offline tests (the address table, the URL table, and a
refusal that holds with nothing listening at the metadata endpoint), both
mutations confirmed to fail them (guard disabled → the download test fails;
link-local dropped from `blocked_ip` → 169.254.169.254 is accepted), and the
live `download_media_pixiv_original_with_referer` still fetches from
i.pximg.net through the guarded client, so real media downloads are
unaffected. `cargo fmt`, `cargo clippy --workspace --all-targets --locked --
-D warnings`, `cargo test --workspace --locked` (198 passed, 15 ignored)
clean.
2026-09-21 02:51:03 +08:00
YoursFunny bd5a6846e8 test: cover the URL extraction the message entry point runs on
`extract_urls` decides whether a pasted link is seen at all — a bug there is
silence for the user, which is the complaint this bot's UX work keeps coming
back to — and it had no test: it takes a teloxide `Message`, which is not
worth building by hand.

Split it into the two decisions that are ours and keep the offset work
(teloxide's `parse_entities` turning entities into slices of the message
text) in the thin `extract_urls` composition:

- `url_of(kind, text)`: a bare `Url` entity is its own text, a `TextLink`
  keeps its target (not the words the user sees), everything else carries
  none.
- `dedupe_urls`: first occurrence wins, deduped by normalized post id — so
  `/status/1`, `/status/1/photo/1` and a text link to the same post are one
  entry — and by exact text for URLs no site claims.

Verified by mutation, each confirmed to fail the new tests: dropping the
`TextLink` branch, and deduping by raw text (`left: [.../status/1,
.../status/1/photo/1]`).

`cargo fmt`, `cargo clippy --workspace --all-targets --locked -- -D
warnings`, `cargo test --workspace --locked` clean.
2026-09-21 02:47:43 +08:00
YoursFunny da8fde6a4e test: pin the migration chain's append-only rule
`db.rs`'s two rules — `schema_init` is the version-0 baseline and never gains
a column, `MIGRATIONS` is append-only and never edited — were enforced by
comments only. Both have a silent failure mode across releases, and the worst
one (a baseline edit) makes `open_store` fail with `duplicate column name`,
i.e. a fresh deployment that will not start.

Three tests, no production change:

- A pre-migration database (the historical DDL written out literally, so an
  edit to the baseline shows up here instead of being followed) upgrades
  through `open_store`: version at the latest, exactly the migrated column
  set, rows intact.
- The shipped migration text is frozen and compared entry by entry; the
  assertion names the rule when it fires. Appending still passes — that is
  the one allowed change.
- A fresh database lands at the latest version, so a deployment that only
  ever saw fresh databases is on the same schema as an upgraded one, and
  re-opening the same file is a no-op.

Verified by mutation, both confirmed to fail the new tests: editing the
shipped migration (`shipped_migrations_are_frozen`, with the rule in the
message) and adding the column to `schema_init` instead of a migration
(`a_fresh_database_lands_at_the_latest_version`, `duplicate column name:
lease_token`).

Docs synced for this and the previous two items: `main.rs` (startup repair),
`queue.rs` (`runnable_rows`/`replace_payload`), `handlers/` (`urls.rs`'s
repair, `mod.rs`'s `apply_caption_edit`), `db.rs` (the migration tests) and
the untested-modules list (`db.rs` now covered for migrations; the bot-side
live test renamed to match the repo's `--ignored live` filter).

`cargo fmt`, `cargo clippy --workspace --all-targets --locked -- -D
warnings`, `cargo test --workspace --locked` (193 passed, 15 ignored) and
`cargo test -p x-media -- --ignored live` (13) clean.
2026-09-21 02:17:14 +08:00
YoursFunny 3b946b1eab fix: never eat a caption silently when the edit fails
Both caption-edit paths logged the error and carried on as if they had worked:
`edit_message_handler` consumed the user's reply (`let _ =`), and the
template button updated the prompt record and dismissed its toast with no
text. Since the edit surface is not rate limited, a 429 or a transient
failure meant the caption never changed and the user got no hint — the text
they sent was simply gone.

`apply_caption_edit` (handlers/mod.rs) is now the one place that applies a
caption edit and reports the outcome:

- A failure the API calls worth retrying (`classify_request_error` →
  `Retryable`) is retried once when the delay is at most 2 s — a reply or a
  button press has already been consumed by then, so a long flood-control
  wait must not stall the chat's update queue behind it.
- Otherwise the caller reports it: the reply path answers the user ("Could
  not update the caption (…). Send it again to retry."), the template path
  puts it in the callback toast and leaves the record alone — a swap that
  never happened must not be recorded as the prompt's template.

Tests: the swallowed-failure test now asserts the notice (it pinned the old
silent behaviour), plus a short `RetryAfter` that is retried and lands, a
60 s one that is not retried and is reported instead, and the template
button's failure toast with the record left unchanged.

`cargo fmt`, `cargo clippy --workspace --all-targets --locked -- -D
warnings`, `cargo test --workspace --locked` (190 passed, 15 ignored) clean.
One note: the first full run tripped `download_media_pixiv_original_with_
referer`, the token-gated pixiv CDN download test that AGENTS already
documents as a local-network flake; it passes in isolation and on the rerun.
2026-09-21 02:07:55 +08:00
YoursFunny d540fc31e9 fix: re-fetch queued retries whose local media did not survive a restart
A queued retry that holds a local file — the ugoira MP4, a bsky remux, or a
temp file the reupload fallback downloaded — could never succeed after a
restart: those files live in the system temp dir and `send::KEEP_ALIVE`, the
registry that keeps them alive for the retry, is in memory. The row retried
into an upload error, said nothing about why, and dead-lettered the user's
link even though the payload carries the `source_url`.

`handlers::repair_lost_local_media` now runs in `main` before any worker
starts (so no row can be leased while it writes payloads, which is why it can
replace them without the lease guard a worker's write-back carries):

- `Task::local_media_paths` decides which rows are affected: any local path
  that is gone. A partially delivered album is left alone — its remaining
  batches cannot be reconciled with a fresh media list without risking a
  second copy of what the user already received.
- The post is re-fetched from `source_url` through the ordinary `site::fetch`,
  so a repaired task looks like a first send: fresh media, the chat's caption
  format, a fresh link-cache snapshot, and a new keep-alive entry when the
  re-fetch produced another local file.
- The delivery envelope (chat, reply, forward/edit settings, notify targets) is
  kept, the attempt budget restarts, and nothing counts as sent.
- A post that cannot be fetched again (gone, withheld, site down) notifies the
  user with that reason instead of letting the retry die on a missing file.

New queue plumbing: `runnable_rows()` (pending + in-progress rows, read before
the workers exist) and `replace_payload()` (rewrites the payload, resets
`attempts`, marks the row pending).

Verified: 5 new offline tests (the two decisions above against a real temp
file, the queue scan/replace, and the envelope-preserving rewrite) plus
`a_lost_local_media_row_is_refetched_from_its_post`, a live test that seeds a
row pointing at a missing file with a real bsky post as its source and asserts
the row now carries http(s) media and that nothing was sent — run against the
live API here. `cargo fmt`, `cargo clippy --workspace --all-targets --locked --
-D warnings` and `cargo test --workspace --locked` (187 passed, 15 ignored)
are clean.
2026-09-21 01:48:50 +08:00
YoursFunny 024dfd50b3 fix: bound the inline state map, test the 300s sweep, add a bot-wide send budget
Three gaps the last audit list named, all in the "resource growth, background
timers and limits nobody watches" class.

**Idle inline-query entries are pruned.** `DebounceStates` had no eviction at
all: one entry per user who ever used inline mode, forever, while the rate
limiter's buckets and the chat store both prune in the 300s sweep. Entries
now carry a `last_seen` stamp and `prune_idle_states()` drops the ones idle
past 300s — the window Telegram caches an inline answer for
(`cache_time(300)`), after which a repeat reaches the bot again and has to be
answered fresh, so the entry would only suppress a fetch the user is waiting
for. The boundary is tested through `prune_idle_at(now, idle_for)` so it does
not depend on ageing a monotonic clock.

**The 300s sweep is a function, and tested.** It was an inline `tokio::spawn`
block: the expiry edit (the only part that talks to Telegram) had no test at
all. It is now `periodic_sweep(sender, chat_store, link_cache, task_queue,
config, stop)`, which also prunes the inline entries, driven in a test with
`start_paused` — the loop's own timer fires the tick, exactly one expired
prompt is rewritten in place, a live one keeps its record and buttons. The
interval is pinned as a constant because no assertion on the edits can see it
(a shorter one produces the same single edit; the paused clock can jump past
the boundary while a tick's DB work is in flight). To make the edit reachable
at all, `edit_message_text` joined the `MediaSender` trait (Bot impl + mock
recording), which is also what keeps `main.rs`'s remaining `Bot` calls
unambiguous. `main.rs` leaves the "untested modules" list except for
startup/shutdown and the dispatcher tree.

**The bot-wide send budget exists.** Telegram throttles a bot in total
(~30 msg/s) as well as per chat; only the per-chat bucket existed, so a batch
forward fanned out over many chats was unguarded and earned 429s the queue
then retried. `acquire_global` charges the same spend against a single shared
bucket at the three paced sites (`send_media_group`, `send_animation`,
`copy_messages`). The unpaced ones (`send_message`, the edits, the toasts) stay
unpaced on purpose: they are one call per action, far below the ceiling, and
pacing a user-visible reply would delay it. Not covered: that the send paths
call it (they need a real `Bot`), which is the same structural gap as the
dispatcher tree.

Also: the startup token-exchange decision is now `startup_validation(result)`
instead of living inside the `Site::validate` future, so "a 5xx while the
container comes up must not disable pixiv" is asserted as a decision — the
message the admin gets plus `enabled()` unchanged. The rejected-credential
half is deliberately not exercised: it calls `disable()`, a process-wide flag
with no reset, and a test touching it would order-couple every other pixiv
test.

Verified: `cargo fmt`, `cargo clippy --workspace --all-targets --locked -- -D
warnings`, `cargo test --workspace --locked` (184 passed, 14 ignored) — plus
mutations, each confirmed to fail the relevant test: the sweep not being
driven on its timer, the interval shortened to 60s, and (earlier) the queue
sweep's missing wake-up. Dropped an empty leftover `crates/x-media/tests/`
directory while there (never tracked by git).
2026-09-21 01:03:15 +08:00
YoursFunny 3828d5b483 test: share the handler/cache fixtures from ctx::test_support
The same fixtures were rebuilt in five test modules: a `CachedPost`
literal in `link_cache.rs`, `handlers/urls.rs` and twice in
`send/mod.rs`, the edit-before-forward prompt in `handlers/mod.rs` and
`handlers/callback.rs`, and a scripted API error in both handler
modules. They now live in `ctx::test_support` next to `TestStores`:

- `cached_photo()` — the canonical cached post (photo + file id at
  `https://x.com/u/status/1`, key `twitter:1`); tests mutate the fields
  they care about, as the caption-quote test already did.
- `seed_prompt(template, created_at)` + `PROMPT_ID`/`FORWARDED_ID` —
  the prompt record, the chat template and the bound forward channel.
  The two former copies differed only in which knob the caller set (the
  callback tests backdate it for the expiry cases, the reply tests pick
  the template), so the union is one helper.
- `api_error(message)` — construction only; each test module keeps its
  own message constant, because the wording is what that module's path
  answers with (`chat not found` vs `message not found`).

`send/mod.rs`'s `cached_sequence_cache_data()` (which re-extracted the
post out of the task it had just built) is gone: the two settle tests
seed the cache from the same builder the task uses.

No behaviour change: the values are the ones the tests used except
`file_id` (`AgAC-file-id` everywhere, asserted in the link-cache
round-trip) and `sensitive` (the unasserted `true` in the link-cache
fixture), and every test still passes unchanged.

Verified: `cargo fmt`, `cargo clippy --workspace --all-targets --locked
-- -D warnings` and `cargo test --workspace --locked` (180 passed, 14
ignored).
2026-09-21 00:25:01 +08:00
YoursFunny 39dbd0f3a2 test: drop redundant tests, make the vacuous ones real
Audit of all 205 tests (five read-only passes plus a line-by-line
re-check). Ten test functions were removed or merged and eight
subsumed assertion blocks trimmed; the suite is down to 180 tests with
no loss of mutation coverage, and four tests that were passing for
nothing now fail when the code they name is broken.

Redundant (deleted or merged):
- twitter: `syndication_text_only_has_no_media` (re-asserts its own
  empty fixture), `..._keeps_multibyte_text` (both transforms are
  no-ops for that text), `..._strips_trailing_short_link_without_entities`
  (same branch as `..._media_short_link`, which now also covers the
  real multibyte tweet), `..._regardless_of_index_units` (its
  `display_text_range` rationale outlived the function it described).
- pixiv: `test_fetch` (a bare `is_ok()` on the illustration
  `download_media_pixiv_original_with_referer` already asserts and
  downloads, and the only network touch in a plain `cargo test`),
  `startup_validation_only_disables_on_a_definitive_failure` (four rows
  that are a subset of the retry-policy table; the `validate()` branch
  it was named for is not asserted at all).
- bilibili: `from_item_legacy_draw_shape_still_parses` (its fixture is
  the same legacy `draw` shape `from_item_maps_draw_images_and_topic`
  builds, with a subset of its assertions).
- site/mod.rs: two `Ok(None)` cases merged into one test.
- urls.rs: `cache_hit_success_keeps_the_cache_entry` (the `/test` test
  asserts the same two things under stricter settings), plus a
  `assert_ne!` loop that re-states the mapping assertions above it.
- send/mod.rs: `media_group_success_and_forward_ok` (the forward half is
  covered by `post_send_forwards_immediately_when_configured`; the
  `is_ok()` half cannot see the returned file ids), and two boundary
  rows implied by the constant they sit next to.
- commands.rs: the parse tail that `every_documented_invocation_parses`
  already covers per README form, and three `debug_report` rows the
  escaping test pins with stronger input.

Passing for nothing (now real):
- `truncate_caption_does_not_split_an_html_entity` — the cut lands
  inside the entity, so `!contains("&amp")` never fired; it now asserts
  the exact output in both directions and fails when the guard in
  `truncate_caption` is deleted (verified).
- `pipeline_resizes_oversized_jpeg` — magic bytes and a non-empty buffer
  pass for a copy-through; it now decodes the output's headers and
  fails when the JPEG branch skips the resize (verified).
- `live_validate_with_bogus_token_fails` — expected `PixivError::Api`,
  which the status check before the body read made unreachable; a bogus
  token is a 4xx. Confirmed against the live endpoint: the old
  assertion fails with `got Err(Status(400))`, the new one passes.
- bsky `live_fetch_with_photos` — its URL is a text-only post and it had
  a byte-identical twin, so no live test pinned media; it now points at a
  labelled post with photos and asserts media + the label (live-verified).

Also fixed, found by turning the runtime-sweep test into a real one:
the 30 s lease-expiry sweep recovered crashed rows but never woke a
worker, so a recovered task waited for the next unrelated enqueue (every
worker is parked on `notify` when no row is pending). `recover_update`
now reports its count, `recover_expired` wakes a worker when it changed
something, and `runtime_sweep_recovers_expired_lease` drives the spawned
loop with a paused clock instead of calling the recovery by hand — it
fails on both the missing wake-up and a sweep that recovers nothing.

Verified: `cargo fmt --check`, `cargo clippy --workspace --all-targets
--locked -- -D warnings`, `cargo test --workspace --locked` (180 passed,
14 ignored) and `cargo test -p x-media -- --ignored live` (13 passed).
2026-09-21 00:16:11 +08:00
YoursFunny d3560dca52 docs: add .env.example as the deployment template
`cp .env.example .env` is now the documented starting point: the tracked
template carries every variable (grouped required / sites / bot behaviour /
network / webhook / reverse proxy) with the defaults the code would use
anyway, and the compose comment plus both READMEs point at it. The proxy note
is spelled out where it matters — teloxide panics on a blank `TELOXIDE_PROXY`,
and inside a container the proxy host must be `host.docker.internal`.

Two follow-ups the template exposed:

- `RUST_LOG=` (present but blank, which `.env` makes easy) silenced the log
  again: "unset" was handled, "empty" was not. A blank value now falls back to
  the same default. Verified: blank and unset both produce the full startup
  sequence.
- `TWITTER_AUTH_TOKEN` was in the README prose but missing from the env table
  (both languages).

Verified: `docker compose --env-file .env.example config -q` resolves, and a
script comparing the compose's `${VAR}` references against the template's keys
finds none missing.
2026-09-20 22:28:01 +08:00
YoursFunny 24cbfc2f27 chore(deploy): track docker-compose.yml and read instance values from .env
- `docker-compose.yml.example` becomes `docker-compose.yml`, committed as the
  real deployment file (the log caps came along) and no longer gitignored.
  Every instance value is now `${VAR}` with a `${VAR:-default}` fallback, so
  compose reads it from the gitignored `.env` beside the file and the committed
  composition needs no per-deployment edit; a variable not listed in a
  service's `environment:` never reaches that container. Two knobs the docs
  promised but the file lacked are now wired: `DEFAULT_HOST` on nginx-proxy and
  `BILIBILI_COOKIE`/`CAPTION_QUOTE_TEXT_CHARS` on the bot, and the healthcheck
  follows `WEBHOOK_PORT` instead of a hardcoded 8443. `TELOXIDE_PROXY` is
  deliberately *not* passed — a loopback proxy inside a container is the
  container itself, and teloxide panics on a blank value — so the compose
  comment and both READMEs explain when to add it by hand.
- `BILIBILI_PLAN.md` moves to `docs/BILIBILI_PLAN.md` (nothing referenced it).
- Docs synced: AGENTS.md (the deployment-file row, and `.gitignore` no longer
  lists the compose file) and both READMEs (deployment and webhook steps now
  say "set it in .env" rather than "edit the compose", plus the proxy trap in
  the env table).
2026-09-20 22:07:27 +08:00
YoursFunny 4cdf618c25 fix(retry): fence the queue lease, clean up after a kill, name dead-lettered posts
P2 (hardening) of the retry audit, closing the report's remaining findings.

- Lease fencing. `lease_next` now stamps a random `lease_token`, and every
  write-back a worker makes (the 30s heartbeat, `delete_row`, `reschedule`,
  `mark_done`) is guarded by it. A lease that expired while its holder was
  stalled and was then re-leased used to let *both* holders write the same row:
  one duplicated the send, the other silently discarded the new holder's retry
  (a 0-row update was not even logged). Now a worker that no longer holds the
  lease drops its attempt at the next heartbeat and writes nothing. Reaching
  existing databases needed a migration chain, which `db.rs` had been
  pre-committed to: `MIGRATIONS` + `migrate` track `PRAGMA user_version`, with
  `schema_init` as the version-0 baseline. Verified on a database created
  before this change: user_version 0 -> 1, column added, rows intact.
- Dead-letter notifications no longer mislabel an unparsable payload. A row
  whose payload no longer deserializes as a `Task` (an older version's shape,
  corruption) used to skip the cache invalidation *and* report "Forward failed
  permanently" for a send task, because both were derived from the parsed
  value. The identity now comes off the raw JSON, so the stale link-cache entry
  is dropped and the message names the post.
- Temp files are marked and swept. Every temp file/dir the project creates now
  carries `x_media::TEMP_FILE_PREFIX`, and startup removes entries with that
  prefix older than an hour — a killed process leaves its downloads (up to
  hundreds of MB) behind because no destructor runs, and the age gate keeps the
  sweep away from a second instance's in-flight files. Verified live: the log
  reports the sweep, an aged leftover goes, a fresh prefixed file and an
  unrelated file stay.
2026-09-20 21:34:45 +08:00
YoursFunny 0a82ca5a42 fix(retry): let slow downloads finish, and never re-run a finished task
P1 of the retry audit, from the report's "reliability and diagnosis" batch.

- Media downloads no longer share the 30s *total* timeout of metadata
  fetches. The size caps allowed 10 MiB (reupload fallback) and 512 MiB
  (ugoira frame zip) while the clock allowed 30s, so a slow link made those
  posts impossible: `MEDIA_CLIENT` has no total timeout and instead bounds
  the response head and every chunk with a 30s *idle* window, which keeps the
  stalled-connection protection. Verified against a local probe: the old
  policy aborts a 40s download at 30.0s, the new one completes it (2 MiB,
  40.1s), and a body that stops delivering still fails after exactly 30s.
- A finished row's write-back is no longer best-effort. `delete_row` failing
  left the row `in_progress` with a live lease, so the next sweep flipped it
  back to `pending` and re-ran a completed task — a second album, a second
  prompt, a second channel copy. Both terminal writes are now retried, and a
  delete that still fails falls back to a `done` tombstone that neither the
  lease query nor the sweep looks at; reschedule (no safe tombstone: marking
  it done would drop the retry silently) logs what the sweep will do.
- bsky and pixiv no longer present a *failed* video conversion as a post with
  no media: the remux/ugoira error propagates (pixiv keeps its retry class,
  bsky reports Transient), so the user sees the real cause and `fetch` gets
  its retries. bsky's "no ffmpeg" case stays a degradation — retrying a
  deployment gap cannot help.
- pixiv's token exchange checks the HTTP status before parsing the body, so a
  429/5xx from the OAuth endpoint stays retryable instead of becoming a
  permanent Api/Json error (via the shared `pixiv_error_is_retryable`), and
  startup validation only disables pixiv for a rejected credential — one 503
  while the container came up used to turn every later pixiv link into
  "pixiv support is disabled".
2026-09-20 21:02:46 +08:00
YoursFunny 4cf793cd7e fix(retry): stop losing posts to transient failures and broken promises
P0 of the retry audit. The main finding: a Telegram 5xx was classified
Permanent, so one Telegram-side blip dead-lettered the post.

- `classify_request_error`: a server error is retryable again. teloxide sleeps
  10s on a 5xx and then parses the body, so the HTTP status is gone by the
  time the error arrives; it is recognised by shape instead — a JSON
  server-error description, or an `InvalidJson` whose raw body is not JSON
  (a proxy/error page). A JSON body of the wrong shape stays permanent, since
  retrying a type mismatch cannot help. Reproduced end to end: with the old
  classification a fake 502 (HTML body) logged "failed permanently" and
  dead-lettered; now it logs "queued for retry" and the retry delivers.
- The same class of mistake elsewhere: `is_media_fetch_failure` was missing
  `failed to get HTTP url content`, the description single-media URL sends
  answer with, so hotlink-rejected media failed permanently instead of going
  through the reupload fallback.
- `enqueue_retry` now reports whether the row was written, and the callers
  only promise a retry when it was — a failed enqueue (DB write) used to tell
  the user "retrying in Ns" and then deliver nothing, ever.
- A forward that fails retryably now settles the prompt instead of leaving it
  live: the queued row carries the message ids itself, and a live prompt let
  a second Confirm copy the same messages to the channel twice and let Skip
  answer "nothing was forwarded" while the row still delivered.
- A prompt that could not be sent no longer swallows the gated forward
  silently: the chat is told, since nothing would ever forward.
- `scaled_retry_delay` only scales up, so a server-asked `retry_after` above
  the 300s cap is honoured instead of retried early (which earned another 429
  and then dead-lettered the post).
- Download classification: a 4xx media download is permanent (the media is
  gone or refused) while transport errors and 429/5xx retry — previously every
  download error counted as retryable and burned the whole budget. A temp-file
  *write* failure retries too (resource exhaustion clears; a temp dir that
  cannot be created stays permanent).
- Site status mapping: 401/403 are `Blocked` (permanent) rather than
  `Transient`, so a refusal is reported at once instead of after three
  wasted attempts; and a twitter 200 that is not a tweet is no longer
  reported as withheld content (the empty `{}` withheld shape keeps
  `Sensitive`, which is what triggers the auth fallback).
2026-09-20 20:46:18 +08:00
YoursFunny 36e5e8afe6 chore(log): cap container log growth and echo the resolved config
P2 of the logging plan (the README recipe landed with the code change):

- `docker-compose.yml.example`: one `x-logging` anchor applied to all three
  services. json-file grows without limit by default, so a long-running bot
  and the proxy in front of it fill the disk; capped at 10m × 3 files.
- The startup `config:` line now reports what the process actually resolved —
  the state DB path (a mistyped `DATA_DIR` or a surprising CWD was invisible
  until it bit), both TTLs, the caption-quote setting (`off` rather than a bare
  `0`) and whether a proxy is configured. The proxy URL is never printed (it
  may embed credentials) and admin ids — chat identifiers — stay at `debug`.

Verified: `docker compose config -q` accepts the file, and a scripted fake-API
run shows `caption quote off` / `link cache TTL 3600s` under overrides,
`proxy=yes` with no credential in any line, and the ids at `debug` only.
2026-09-20 19:14:33 +08:00
YoursFunny 3f9821d475 feat(log): survive a bare deployment and name what each line is about
P0 (foundation) + P1 (diagnostic depth) of the logging plan:

- main.rs initializes the timed builder with a default filter of
  `info,hyper_util=warn,reqwest=warn`. Without RUST_LOG nothing was logged at
  all (env_logger falls back to `error`), so `docker run --env-file .env` was
  silent, and the plain `init` had no timestamps.
- info-and-above lines stop printing user URLs (fetch/send failures, inline
  fetch, bsky's remux warnings). The full URL, the message text and the inline
  query move to `trace`, so a `debug` log can be handed to someone else.
- Lifecycle lines name the chat and the post: sent/failed/queued plus the
  total `ms`, the edit prompt, the channel forward, and every queue line
  (`chat=` + `[key=…]` + per-attempt `ms`, dead-letters included).
- Queue work is visible: `x-media`'s fetch line carries its duration (ugoira
  encode and HLS remux included), and the 300s sweep reports the pending count
  and how overdue the oldest task is — only when the queue is non-empty.
- URL workers are supervised like the queue workers: a panicking worker used
  to die silently and shrink the pool for the rest of the process.
- Degradations that still serve the user (cache/state write or read failures,
  a failed chat action) are `warn`, not `error`.

Verified against the scripted fake-API harness: unset RUST_LOG logs info with
timestamps, `debug` carries no user URL, `trace` does, a cache-hit send logs
`chat=111 in 5ms`, a failing send queues and dead-letters with chat+key, and
the sweep reports the pending retry.
2026-09-20 19:07:23 +08:00
YoursFunny 9e873131d4 chore: bump version to 1.8.0 2026-09-20 17:41:20 +08:00
YoursFunny 5b77d14497 fix(commands): make /debug preview the caption a link would actually send
`/debug` reported `Fetched::caption`, the site's built-in caption, so a
chat's `/set_format` override never showed up in the preview — the command
looked like a no-op, and the `/set_format` success reply now tells users to
preview with `/debug`, which made that advice wrong.

`preview_caption` mirrors the send paths instead: the per-site format
override (`caption_from_fields`, empty → built-in) plus the long-post
quoting. Reported from a live run against the test bot.
2026-09-20 17:30:55 +08:00
YoursFunny d4c36feb9a fix(commands): make /set_format and /clear_cache actually parse
teloxide's `split` parser takes exactly one space-separated token per
field, so `/set_format <site> <format>` — a two-token command — never
parsed: `Command::parse` failed, `message_handler` fell through to the URL
flow, and the user got silence. `/clear_cache` without its optional link
failed the same way ("too few arguments"), so clearing everything was
unreachable. Both now use the crate's `parse_arg_remainder` (whole
remainder, trimmed), which is what their executors were already written
against (`split_once(char::is_whitespace)`).

Found by driving the real binary against a scripted fake Bot API: the
`/set_format` replies never appeared while `/set_template` and the other
single-token commands did. `every_documented_invocation_parses` now pins
every documented form, which is what should have caught it.

The placeholder validation and `-` reset added earlier only work now that
the command reaches its executor at all.
2026-09-20 16:42:40 +08:00
YoursFunny 5d0acdac01 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.
2026-09-20 15:57:45 +08:00
YoursFunny d6707133cc feat(ux): answer every link, name fetch failures, keep the chat action alive
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.
2026-09-20 15:48:46 +08:00
58 changed files with 9928 additions and 3110 deletions
+83
View File
@@ -0,0 +1,83 @@
# Copy to `.env` (gitignored) and fill in:
#
# cp .env.example .env
#
# `docker compose` reads it for the `${VAR}` substitutions in
# docker-compose.yml, and `cargo run` reads it through dotenv. Every variable is
# described in README.md ("环境变量说明" / "Environment variables") — this file
# only shows the shape, with the defaults the code would use anyway.
# --- required -------------------------------------------------------------
# Token from @BotFather. Without it the bot exits at startup.
TELOXIDE_TOKEN=
# --- sites (all optional) -------------------------------------------------
# Pixiv: refresh token. Unset = pixiv links answer "support is disabled".
PIXIV_REFRESH_TOKEN=
# Twitter/X: the `auth_token` cookie of a logged-in session, used only for
# NSFW tweets that the public syndication endpoint withholds.
TWITTER_AUTH_TOKEN=
# bilibili: the whole cookie string; only needed when the egress IP stays
# risk-controlled (device cookies are fetched automatically).
BILIBILI_COOKIE=
# --- bot behaviour --------------------------------------------------------
# Admin chat IDs, comma-separated: start/stop notices, admin-only commands.
BOT_ADMIN=
# Log level. Leave the line commented out for the default
# (`info,hyper_util=warn,reqwest=warn`); do not set it to an empty value.
# RUST_LOG=info,xmedia_bot=debug,x_media=debug
# Edit-before-forward record TTL (seconds).
EDIT_MESSAGE_TTL_SECONDS=86400
# Link-result cache TTL (seconds).
LINK_CACHE_TTL_SECONDS=604800
# Wrap a post's text in a collapsible blockquote from this many characters on;
# 0 disables the wrap.
CAPTION_QUOTE_TEXT_CHARS=200
# State directory (local runs only — the container uses /app/data).
DATA_DIR=data
# --- network --------------------------------------------------------------
# HTTP proxy for the Bot API and site fetches. Two traps: teloxide panics on a
# *blank* value, so comment the line out rather than leaving it empty; and
# inside a container the proxy must be reachable from there (use
# host.docker.internal, not 127.0.0.1 — that is the container itself).
# docker-compose.yml does not pass this variable unless you add it to the bot
# service's `environment:` block.
# TELOXIDE_PROXY=http://127.0.0.1:10808
# --- webhook deployment (docker-compose.yml) ------------------------------
# false = long polling (no public URL needed). true = webhook behind the
# bundled nginx-proxy — and then WEBHOOK_LISTEN/PORT/URL are required.
# The compose healthcheck probes the listener only when this is true.
WEBHOOK=false
# WEBHOOK_LISTEN=0.0.0.0
# WEBHOOK_PORT=8443
# WEBHOOK_URL=https://your.domain/
# Validation token Telegram echoes back as X-Telegram-Bot-Api-Secret-Token.
# Required when WEBHOOK=true: the bot refuses to start without one (use a
# random value of 16+ chars — without it the listener accepts any request).
# WEBHOOK_SECRET_TOKEN=
# Self-signed certificate path, used only for Telegram-side validation (TLS is
# terminated by the reverse proxy); unneeded with acme-companion. Not passed by
# docker-compose.yml — add the line there if this deployment needs it.
# WEBHOOK_CERT=/app/cert/cert.pem
# --- reverse proxy (docker-compose.yml) -----------------------------------
# Public domain or IP that nginx-proxy routes for; empty = do not route.
VIRTUAL_HOST=
# Port inside the bot container nginx-proxy forwards to.
VIRTUAL_PORT=8443
# Certificate notification address for acme-companion.
DEFAULT_EMAIL=
# UID the container runs as; it must be able to write ./data on the host.
# The entrypoint's default (and the README's) is 9001 — keep them equal so
# the file owner on the host matches what you expect. Must be a non-zero
# numeric uid: the entrypoint refuses 0 (the bot would keep root through the
# privilege drop) and anything non-numeric.
LOCAL_USER_ID=9001
# Uncomment (here and the matching line in docker-compose.yml) to have
# acme-companion issue the certificate for VIRTUAL_HOST.
# ACME_HOST=
# Send requests with an unknown Host to this vhost (needed for plain-IP access).
# DEFAULT_HOST=
+71 -8
View File
@@ -4,6 +4,8 @@ name: CI
# job that exercises the real source sites and the token-gated pixiv tests. # job that exercises the real source sites and the token-gated pixiv tests.
# #
# Layering: # Layering:
# changes — decides whether anything but documentation changed; a docs-only
# push/PR skips `test` (which then reports as skipped, not missing).
# test — fmt + clippy + the full offline unit suite + a release-profile # test — fmt + clippy + the full offline unit suite + a release-profile
# build + cargo-audit dependency gate. Runs on every push and PR, # build + cargo-audit dependency gate. Runs on every push and PR,
# including forks (it needs no secrets). # including forks (it needs no secrets).
@@ -26,6 +28,7 @@ name: CI
on: on:
push: push:
branches: [master] branches: [master]
tags: ['v*']
pull_request: pull_request:
schedule: schedule:
# Weekly probe of the live endpoints, so external API changes surface. # Weekly probe of the live endpoints, so external API changes surface.
@@ -46,7 +49,53 @@ env:
RUST_BACKTRACE: 1 RUST_BACKTRACE: 1
jobs: jobs:
# Docs-only changes skip the heavy job: a README edit does not need a four
# minute Rust build (and it cannot break one). A gate job rather than a
# workflow-level `paths` filter — that leaves the run without a `test` check
# at all, and a required status check then waits for something that will
# never be reported, while a *skipped* job reports as neutral.
changes:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
code: ${{ steps.diff.outputs.code }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0 # the diff below needs the pushed range
- id: diff
shell: bash
run: |
set -euo pipefail
zero=0000000000000000000000000000000000000000
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="origin/${{ github.base_ref }}"
git fetch --quiet --no-tags origin "${{ github.base_ref }}"
changed="$(git diff --name-only "$base...HEAD")"
else
before="${{ github.event.before }}"
if [ -z "$before" ] || [ "$before" = "$zero" ]; then
# New branch or force push: no usable base to compare against,
# so the full suite runs. Same for schedule/dispatch, which have
# no `before` at all.
changed=""
else
changed="$(git diff --name-only "$before..${{ github.sha }}")"
fi
fi
# Only a change that is *entirely* markdown may skip the job;
# anything else — and an empty diff, i.e. a re-run of the same
# commit — counts as code.
code=true
if [ -n "$changed" ] && ! grep -qvE '\.md$' <<<"$changed"; then
code=false
fi
echo "changed: ${changed:-<no diff>}"
echo "code=$code" >> "$GITHUB_OUTPUT"
test: test:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Generous on purpose: the release-profile build below is cold on the very # Generous on purpose: the release-profile build below is cold on the very
# first run (thin LTO + codegen-units = 1 across every dependency), and a # first run (thin LTO + codegen-units = 1 across every dependency), and a
@@ -92,15 +141,29 @@ jobs:
env: env:
PIXIV_REFRESH_TOKEN: ${{ secrets.PIXIV_REFRESH_TOKEN }} PIXIV_REFRESH_TOKEN: ${{ secrets.PIXIV_REFRESH_TOKEN }}
TWITTER_AUTH_TOKEN: ${{ secrets.TWITTER_AUTH_TOKEN }} TWITTER_AUTH_TOKEN: ${{ secrets.TWITTER_AUTH_TOKEN }}
BILIBILI_COOKIE: ${{ secrets.BILIBILI_COOKIE }}
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
# Everything network- or secret-gated lives in x-media, and the bot # All #[ignore]d live tests, by the "live" name filter, in both crates:
# crate's suite (MockSender + tempdir stores, no network) already ran in # the bot crate has two of its own (repair refetch, text-only link)
# the `test` job — rebuilding it here bought nothing. # whose fetches are network-bound but whose sends go through MockSender.
- name: Run token-gated tests # --show-output keeps each test's stdout — the SKIP lines printed by a
run: cargo test -p x-media --locked # skipped test are what the summary step below greps, so a skip stays
# The live-network tests, by the "live" name filter (all #[ignore]d). # distinguishable from a pass.
- name: Run live-network tests - name: Run live-network tests (x-media)
run: cargo test -p x-media --locked -- --ignored live run: cargo test -p x-media --locked -- --ignored live --show-output 2>&1 | tee live-x-media.log
- name: Run live-network tests (xmedia-bot)
run: cargo test -p xmedia-bot --locked -- --ignored live --show-output 2>&1 | tee live-bot.log
# A green live run must not be able to mean "nothing actually ran"
# (pixiv without its secret, bilibili risk-controlling the runner IP):
# collect every SKIP line into the run summary.
- name: Surface skipped live tests
if: always()
run: |
skips=$(grep -h '^SKIP ' live-*.log 2>/dev/null || true)
if [ -n "$skips" ]; then
echo "::warning::live job skipped tests, see the job summary"
{ echo "### Live tests skipped"; echo "$skips" | sed 's/^/- /'; } >> "$GITHUB_STEP_SUMMARY"
fi
+45 -12
View File
@@ -21,7 +21,10 @@ on:
- Cargo.toml - Cargo.toml
- Cargo.lock - Cargo.lock
- .github/workflows/docker.yml - .github/workflows/docker.yml
- 'crates/**/Cargo.toml' # Any crate source, not just manifests: the stub/touch layering only
# breaks against real source structure, which a manifest-only PR never
# exercises.
- 'crates/**'
env: env:
APP_NAME: telegram-twitter-media-bot APP_NAME: telegram-twitter-media-bot
@@ -39,7 +42,9 @@ concurrency:
jobs: jobs:
# A tag push and a branch push to the same commit fire two workflow runs; # A tag push and a branch push to the same commit fire two workflow runs;
# build only once. Tag runs always build; master runs build only when the # build only once. Tag runs always build; master runs build only when the
# pushed commit is not already tagged (the tag run covers it). # pushed commit is not already tagged (the tag run covers it). That check
# can only see tags that already exist on the remote — see the check step's
# re-fetch and the one-push release flow in AGENTS.md.
should-build: should-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
@@ -73,12 +78,38 @@ jobs:
- id: check - id: check
shell: bash shell: bash
run: | run: |
if [ "$GITHUB_REF_TYPE" = "branch" ] && git tag --points-at "$GITHUB_SHA" | grep -q .; then zero=0000000000000000000000000000000000000000
echo "commit already tagged; the tag run builds the image" if [ "$GITHUB_REF_TYPE" = "branch" ]; then
echo "build=false" >> "$GITHUB_OUTPUT" # A branch run can start before the release tag for its commit
else # reaches the remote — pushing master first is the usual way to hit
echo "build=true" >> "$GITHUB_OUTPUT" # it — and then `git tag --points-at` legitimately finds nothing
# and this run builds the same commit the tag run is building: two
# docker builds, one release. (Seen on v1.9.0 and v1.9.1: the
# branch run's checkout had every tag *except* the one being
# pushed.) Re-fetching here, immediately before the decision,
# shrinks the window to "the tag was pushed after this step ran";
# pushing the branch and the tag together
# (`git push origin master vX.Y.Z`) removes it.
git fetch --tags --force --quiet origin
if git tag --points-at "$GITHUB_SHA" | grep -q .; then
echo "commit already tagged; the tag run builds the image"
echo "build=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Nothing the image is made of changed — a documentation or
# workflow-only commit — so there is no new image to publish. The
# PR trigger's path list plus the crate sources, which the image
# compiles into the binary.
before="${{ github.event.before }}"
if [ -n "$before" ] && [ "$before" != "$zero" ] \
&& ! git diff --name-only "$before..$GITHUB_SHA" \
| grep -qE '^(Dockerfile|docker-entrypoint\.sh|\.dockerignore|Cargo\.toml|Cargo\.lock|\.github/workflows/docker\.yml|crates/)'; then
echo "no build input changed; skipping the image build"
echo "build=false" >> "$GITHUB_OUTPUT"
exit 0
fi
fi fi
echo "build=true" >> "$GITHUB_OUTPUT"
# No `actions/checkout` here on purpose: `docker/build-push-action` defaults # No `actions/checkout` here on purpose: `docker/build-push-action` defaults
# to the Git context (`https://github.com/<owner>/<repo>.git#<ref>`), so # to the Git context (`https://github.com/<owner>/<repo>.git#<ref>`), so
@@ -122,9 +153,11 @@ jobs:
# would give every new tag a cold cache on release builds. PR runs only # would give every new tag a cold cache on release builds. PR runs only
# read it (cache-to is empty) so they cannot evict the release cache. # read it (cache-to is empty) so they cannot evict the release cache.
# #
# FFMPEG_URL/FFMPEG_SHA256 come from repository variables when set, so a # FFMPEG_URL/FFMPEG_SHA256 come from repository variables when set —
# release can pin an exact ffmpeg build (the Dockerfile default follows # both or neither: the Dockerfile checks the sha256 unconditionally, so
# the project's `/redirect/latest/` URL, which has no sha256 sidecar). # a URL without its matching hash fails the build. The fallbacks pin the
# same 9.0.2 release the Dockerfile defaults to (keep the three in step
# when bumping).
# #
# Single-arch (amd64) on purpose: adding arm64 means re-adding # Single-arch (amd64) on purpose: adding arm64 means re-adding
# `docker/setup-qemu-action`, `platforms: linux/amd64,linux/arm64`, and # `docker/setup-qemu-action`, `platforms: linux/amd64,linux/arm64`, and
@@ -136,8 +169,8 @@ jobs:
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
build-args: | build-args: |
APP_NAME=${{ env.APP_NAME }} APP_NAME=${{ env.APP_NAME }}
FFMPEG_URL=${{ vars.FFMPEG_URL || 'https://ffmpeg.martin-riedl.de/redirect/latest/linux/amd64/release/ffmpeg.zip' }} FFMPEG_URL=${{ vars.FFMPEG_URL || 'https://ffmpeg.martin-riedl.de/download/linux/amd64/1789931100_9.0.2/ffmpeg.zip' }}
FFMPEG_SHA256=${{ vars.FFMPEG_SHA256 }} FFMPEG_SHA256=${{ vars.FFMPEG_SHA256 || 'fa8ecf4abbd290d98f7d188b8649cc6b391ae209a98452be955a15aab1909d7f' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=tgxmb-build cache-from: type=gha,scope=tgxmb-build
-2
View File
@@ -6,8 +6,6 @@ nginx-certs/
nginx-vhost.d/ nginx-vhost.d/
nginx-html/ nginx-html/
nginx-acme/ nginx-acme/
docker-compose.yml
.env .env
+43 -37
View File
@@ -4,7 +4,7 @@
Telegram bot (teloxide) that turns post links from X/Twitter, Pixiv, Bluesky, Misskey (misskey.io), and Bilibili dynamics into media messages (images, video, GIF) with the post's title, author, and tags. It supports batch media splitting, retry with persistence, inline queries, forward-channel rebinding with caption templates, and Pixiv ugoira→MP4 transcoding. README is in Chinese; user-facing bot strings are in English. The project is a Rust port of a Python predecessor (see `queue.rs` comments referencing `utils/task_queue.py`). Telegram bot (teloxide) that turns post links from X/Twitter, Pixiv, Bluesky, Misskey (misskey.io), and Bilibili dynamics into media messages (images, video, GIF) with the post's title, author, and tags. It supports batch media splitting, retry with persistence, inline queries, forward-channel rebinding with caption templates, and Pixiv ugoira→MP4 transcoding. README is in Chinese; user-facing bot strings are in English. The project is a Rust port of a Python predecessor (see `queue.rs` comments referencing `utils/task_queue.py`).
Two-crate Cargo workspace (both v1.7.0, edition 2024, resolver 3): Two-crate Cargo workspace (both v1.9.2, edition 2024, resolver 3):
- **`crates/x-media`** — library that fetches and normalizes media from the four sites. Pure, no Telegram knowledge. - **`crates/x-media`** — library that fetches and normalizes media from the four sites. Pure, no Telegram knowledge.
- **`crates/xmedia-bot`** — the bot binary: teloxide dispatcher, SQLite-backed chat state, persistent task queue. - **`crates/xmedia-bot`** — the bot binary: teloxide dispatcher, SQLite-backed chat state, persistent task queue.
@@ -18,31 +18,37 @@ Telegram update → Dispatcher (polling or axum webhook) → dptree branches
└─ callback_query → "forward" (copy to channel) / "template|<name>" (apply caption template) └─ callback_query → "forward" (copy to channel) / "template|<name>" (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_limited` 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 (it reports whether the row was really written, and only then does the user get the "retrying in Ns" notice — an enqueue that fails says so instead) → workers lease (120 s lock TTL) → retry with exponential backoff (≤ 30 s for the bot's own delays, `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 <url>` 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 `<blockquote>` so it renders exactly like the sent media caption (escaped text and links included). Debug command: `/debug <url>` 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 `<blockquote>` so it renders exactly like the sent media caption (escaped text and links included). The caption it shows is `preview_caption`'s: the chat's per-site format override plus the long-post quoting, i.e. exactly what the send paths produce — showing the raw built-in caption made `/set_format` look like a no-op, and the `/set_format` success reply points users at `/debug` to preview.
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. 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 `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<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}`. 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). `/test`, `/debug`, `/set_format` and `/clear_cache` use the custom `parse_arg_remainder` parser (whole remainder, trimmed) because teloxide's built-in `split` parser takes exactly one space-separated token per field: `/set_format <site> <format>` never parsed with it (and `/clear_cache` without an argument did not either), and a command that fails to parse falls through to the URL flow in silence. `commands::tests::every_documented_invocation_parses` pins every documented form against exactly that.
The inline path (`handlers/inline.rs`) answers from the **link cache** first: a post already sent somewhere answers with `InlineQueryResultCached*` built from its Telegram file ids, so no fetch happens and — unlike a URL result — media Telegram could never fetch itself still works (pixiv's pximg.net, a locally encoded ugoira/bsky MP4). Only a cache miss fetches (`fetch_once`), and then the media URLs go 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. A degraded cache entry keeps only URLs, so it takes the same URL path (a video with no poster is skipped there, Telegram has no thumbnail to show). A query whose every item was skipped is answered *empty* (with a cache window) rather than left unanswered — an unanswered query keeps the client spinning and, through the debounce's release, re-runs the fetch on every keystroke. The answer goes through `MediaSender::answer_inline_query` (the trait carries it so the path is mock-testable; `inline.rs`'s own tests cover the cache and degraded-entry answers offline).
`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}`.
## Key Directories ## Key Directories
| Path | Purpose | | 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/` | Fetch library. `site/mod.rs` = dispatcher + `Fetched`/`FetchError`/`download_media*` (the streaming `download_media_to_file` and the capped `download_media_limited`, which is where a download's size and its total time budget are enforced); `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`). 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 (`&gt;` `&lt;` `&amp;` `&#39;`) — so the stored text is raw and the caption escap… | `crates/x-media/src/site/<twitter\|pixiv\|bsky\|misskey\|bilibili>/` | One directory per site: `mod.rs` (re-exports), `interface.rs` (`PATTERN`, `fetch_from_url()`, `cache_key`, the unit struct `<Name>Site` implementing `site::Site` — the trait supplies the `enabled`/`is_retryable`/`media_headers` defaults unless the site differs, as pixiv does — and `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_value` HTML-decodes the API text — syndication and GraphQL `full_text` both arrive pre-escaped (`&gt;` `&lt;` `&amp;` `&#39;`) — 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/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, startup sweep of this project's leftover temp files (`x_media::TEMP_FILE_PREFIX` + an age gate, since a killed process runs no destructors), startup repair of queued retries whose local media did not survive a restart (`handlers::repair_lost_local_media`, before any worker can lease: those rows are re-fetched from their `source_url`), queue worker start, site login validation (`site::validate_all`), `periodic_sweep` (`SWEEP_INTERVAL` 300 s): 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 — plus the link-cache prune, the idle rate-limit buckets and the idle inline-query entries, and the queue backlog line (only when non-empty). Takes its collaborators rather than the statics so its loop is testable with a paused clock, dptree handler tree, webhook vs polling dispatch |
| `crates/xmedia-bot/src/config.rs` | Manual env parsing into `Config` | | `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/db.rs` | `DbPool`: one shared SQLite connection pool (`POOL_SIZE = 16`, 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 and then applies the `PRAGMA user_version` migration chain (`MIGRATIONS` + `migrate` — append-only; `schema_init` is the version-0 baseline and must not gain columns an existing database would never receive — `db.rs`'s tests pin a pre-migration database upgrading intact, the shipped migration text frozen (appending is the only allowed change) and a fresh database landing at the latest version), `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 <url>` (send-only) / `/debug <url>` (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), `url_workers.rs`/`repair.rs` (worker pool; startup repair), `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 + the per-URL pipeline; one *shared* in-flight fetch per cache key (`shared_fetch`+`IN_FLIGHT_FETCHES`: a second chat, a batch forward or a retry asking for the same post meanwhile waits for the first caller's result, the entry is dropped the moment the fetch settles so nothing is ever answered from an old fetch, and a waiter whose sharer was cancelled fetches for itself); plus the startup repair `repair_lost_local_media`, whose decision (`needs_refetch`) and rewrite (`apply_refresh`) are pure and tested while the fetch itself is a live test), `url_workers.rs` (the bounded job channel (256) and its `URL_WORKERS = 8` supervised workers, `start_url_workers`/`stop_url_workers` — backpressure instead of unbounded spawns; teloxide's per-chat workers are sequential, so batch-forwards need this concurrency), `repair.rs` (startup `repair_lost_local_media` with its `needs_refetch`/`apply_refresh`/`refetch`), `inline.rs`/`callback.rs` (inline queries / edit-before-forward buttons, incl. `skip`; a forward that fails retryably is both queued *and* settles the prompt — the queued row carries the message ids itself, and a prompt left live let a second Confirm copy the same messages twice and let Skip answer "nothing was forwarded" while the row still delivered), `statics.rs` (global statics) |
| `crates/xmedia-bot/src/state.rs` | `ChatStore`: parking_lot `Mutex<HashMap>` cache + SQLite write-through (`chat_state` table) | | `crates/xmedia-bot/src/state.rs` | `ChatStore`: parking_lot `Mutex<HashMap>` cache + SQLite write-through (`chat_state` table); the 300 s sweep's `prune_expired` evicts any chat with no live edit-before-forward prompt, so the cache (and the per-chat lock map) stays bounded to active prompts — durable settings reload from the DB on next use |
| `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/link_cache.rs` | `LinkCache`: SQLite-backed cache (`link_cache` table) of successfully sent posts — raw caption fields + the source media URLs + Telegram `file_id`s; repeat links re-send locally (no fetch/upload), TTL + prune; a permanent send failure *degrades* the entry instead of dropping it (the file ids go, the URLs stay, so the next request re-sends from those without a fetch), and a degraded entry that fails again is removed |
| `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 | | `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, a `lease_token` fence: `lease_next` stamps a random token and every write-back (heartbeat, `delete`, `reschedule`, `mark_done`) is guarded by it, so a lease that expired and was re-leased cannot be written by its former holder — a lost lease stops the attempt instead; a finished row's `DELETE`/reschedule retried and a failed delete falling back to a `done` tombstone (the lease query and the sweep only look at `pending`/`in_progress`, so a task that already ran cannot be resurrected and re-run), `runnable_rows`/`replace_payload` (the startup repair's read/rewrite path: it runs before the workers exist, which is why it needs no lease token), `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; the sweep does notify the workers after it actually recovered a row, since a recovered task is due immediately while every worker may be parked on `notify` with no pending row to sleep on), `busy_timeout` on all connections |
| `crates/xmedia-bot/src/ctx.rs` | `AppContext`: the injected collaborators (`sender` + `ChatStore`/`PersistentTaskQueue`/`LinkCache`/`Config`), `from_statics` for production and the `CONTEXT` static the worker closures hold. `test_support::TestStores` backs handler tests with a tempdir store set | | `crates/xmedia-bot/src/ctx.rs` | `AppContext`: the injected collaborators (`sender` + `ChatStore`/`PersistentTaskQueue`/`LinkCache`/`Config`), `from_statics` for production and the `CONTEXT` static the worker closures hold. `test_support::TestStores` backs handler tests with a tempdir store set, and the module also carries the fixtures those tests share — the canonical cached post (`cached_photo`), the edit-before-forward prompt (`seed_prompt` with its `PROMPT_ID`/`FORWARDED_ID`) and a scripted API error (`api_error`) — so no two test modules keep their own copies |
| `crates/xmedia-bot/src/send/` | `send/mod.rs`: `Task`/`MediaItemPayload` payloads, `SendError`/`Classification`, `send_media_sequence`/`send_animation`/`forward_messages`; `send/input_media.rs`: payload → `InputFile`/`InputMedia` + `build_media_group` (caption on the first item only); `send/upload.rs`: the download-and-reupload fallback (`prepare_upload_item`/`send_batch_via_upload`, photo downscale handoff); `send/post_send.rs`: link-cache write, `KEEP_ALIVE` registry, `settle_task`, `post_send_actions`, `handle_task`/`dead_letter_notify` | | `crates/xmedia-bot/src/send/` | `send/mod.rs`: `Task`/`MediaItemPayload` payloads (`media: MediaRef`, i.e. `Source` URL-or-path vs `FileId` — one field used to carry both with a flag), `send_media_sequence`/`send_animation`/`forward_messages`; `send/error.rs`: the Bot API error policy (`SendError`/`Classification`, `classify_request_error`, the media-fetch/size markers); `send/input_media.rs`: payload → `InputFile`/`InputMedia` + `build_media_group` (caption on the first item only); `send/upload.rs`: the download-and-reupload fallback (`prepare_upload_item`/`send_batch_via_upload`, photo downscale handoff); `send/post_send.rs`: link-cache write, `KEEP_ALIVE` registry, `settle_task`, `post_send_actions`, `handle_task`/`dead_letter_notify` |
| `crates/xmedia-bot/src/media_sender.rs` | `MediaSender` trait: the user-flow surface (`send_media_group`/`send_animation`/`copy_messages`/`send_message`/`answer_callback_query`/`edit_message_caption`/`delete_message`/`send_chat_action`) implemented by teloxide `Bot` (per-chat rate-limited) and by a recording `MockSender` in tests. Admin/setup APIs (`get_chat`, `set_my_commands`, …) stay on the concrete `Bot` | | `crates/xmedia-bot/src/media_sender/{mod.rs,test_support.rs}` | `MediaSender` trait: the user-flow surface (`send_media_group`/`send_animation`/`copy_messages`/`send_message`/`answer_callback_query`/`edit_message_text`/`edit_message_caption`/`delete_message`/`send_chat_action`/`answer_inline_query`) implemented by teloxide `Bot` (per-chat rate-limited) and by a recording `MockSender` in tests. Admin/setup APIs (`get_chat`, `set_my_commands`, …) stay on the concrete `Bot`. `test_support.rs` (cfg(test)-only) holds the scripted `MockSender` and `fake_api` (the stand-in API the real-`Bot` tests drive) |
| `crates/xmedia-bot/src/rate_limit.rs` | Per-chat token bucket (`CAPACITY = 20`, ~20 msg/min refill) paced before sends reach the API so batch forwards don't trip flood control | | `crates/xmedia-bot/src/rate_limit.rs` | Two token buckets paced before sends reach the API so batch forwards don't trip flood control: one per chat (`CAPACITY = 20`, ~20 msg/min refill) and one bot-wide (`acquire_global`, 30/s — Telegram's per-bot ceiling, invisible to any per-chat bucket and only binding when a batch fans out over many chats). `prune_idle` drops the per-chat buckets that refilled while unheld |
## Development Commands ## Development Commands
@@ -50,7 +56,7 @@ The `x-media` library: `site::fetch(url)` dispatches through the `SITES` registr
export TELOXIDE_TOKEN=<token> # required; PIXIV_REFRESH_TOKEN optional (Pixiv disabled without it) export TELOXIDE_TOKEN=<token> # required; PIXIV_REFRESH_TOKEN optional (Pixiv disabled without it)
cargo run -p xmedia-bot # run the bot (polling by default) cargo run -p xmedia-bot # run the bot (polling by default)
cargo run -p x-media --example fetch -- <url> # test a link through the fetch library cargo run -p x-media --example fetch -- <url> # test a link through the fetch library
cargo test --workspace # full test suite (no CI test step exists — run locally) cargo test --workspace # full test suite (CI runs the same, with --locked)
cargo build --release -p xmedia-bot # release build (Dockerfile does this) cargo build --release -p xmedia-bot # release build (Dockerfile does this)
cargo clippy --workspace --all-targets # lint (Clippy is the configured IDE linter) cargo clippy --workspace --all-targets # lint (Clippy is the configured IDE linter)
cargo fmt --check # formatting cargo fmt --check # formatting
@@ -60,29 +66,29 @@ Docker: `docker build -t tgxmb .` then `docker run --rm -d --name tgxmb --env-fi
## Code Conventions & Common Patterns ## 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`/`MediaPrep`/`Transient`/`RateLimited`/`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<Bot>`, force-initialized in `main` so a missing token fails at startup). - **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<Bot>`, 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. - **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. - **Blocking sync primitives**: `parking_lot::Mutex` for hot caches, `tokio::sync::Mutex` for async-shared state (pixiv token cache), `AtomicBool` for feature gates.
- **Site adapter convention**: each site module exports `PATTERN: LazyLock<Regex>`, `enabled() -> bool`, `fetch_from_url(url) -> Result<Fetched, FetchError>`, plus `cache_key`/`is_retryable`/`media_headers`, and a unit struct `<Name>Site` implementing `site::Site`; the central dispatcher (`site/mod.rs`) only iterates the `SITES` registry. Adding a site = new `site/<name>/{mod.rs,interface.rs,model.rs}` + one `Box::new(...)` entry in `SITES` — the bot crate never lists sites (SetFormat whitelist, cache-key site lookup and startup validation all derive from the registry). Async trait methods return `SiteFuture` (a boxed `Pin<Box<dyn Future + Send>>`) because `async fn` in traits is not dyn-compatible. - **Site adapter convention**: each site module exports `PATTERN: LazyLock<Regex>`, `fetch_from_url(url) -> Result<Fetched, FetchError>` and `cache_key`, plus a unit struct `<Name>Site` implementing `site::Site`; `enabled`/`is_retryable`/`media_headers` come from the trait's defaults unless the site overrides them (only pixiv does); the central dispatcher (`site/mod.rs`) only iterates the `SITES` registry. Adding a site = new `site/<name>/{mod.rs,interface.rs,model.rs}` + one `Box::new(...)` entry in `SITES` — the bot crate never lists sites (SetFormat whitelist, cache-key site lookup and startup validation all derive from the registry). Async trait methods return `SiteFuture` (a boxed `Pin<Box<dyn Future + Send>>`) because `async fn` in traits is not dyn-compatible.
- **Serde**: per-site `model.rs` are pure `Deserialize` DTOs mirroring API JSON; site structs in `interface.rs` have private fields, a `caption()` builder, and `impl From<SiteStruct> for Fetched`. Persisted payloads use internally-tagged enums (`#[serde(tag = "kind")]` / `type`). - **Serde**: per-site `model.rs` are pure `Deserialize` DTOs mirroring API JSON; site structs in `interface.rs` have private fields, a `caption()` builder, and `impl From<SiteStruct> for Fetched`. Persisted payloads use internally-tagged enums (`#[serde(tag = "kind")]` / `type`).
- **Naming**: module-per-concern, snake_case files, `CamelCase` types, `snake_case` fns. `//!` module docs and `///` docs on non-obvious logic (syndication token, ugoira encoding, `display_text_range`). - **Naming**: module-per-concern, snake_case files, `CamelCase` types, `snake_case` fns. `//!` module docs and `///` docs on non-obvious logic (syndication token, ugoira encoding, `display_text_range`).
- **Retries**: only `x-media::site::fetch` retries (3 attempts, `1 << attempt` backoff, HTTP errors only); `site::fetch_once` is the same code path with a single attempt, used by inline queries whose answer window is shorter than the backoff. Queue retries are explicit `QueueError::Retryable` with computed delay (`retry_delay_seconds`). - **Retries**: only `x-media::site::fetch` retries (3 attempts, a doubling backoff widened by a random slice of itself so workers that failed together do not recover together, with a 429's `Retry-After` honored up to `MAX_RETRY_AFTER_SECS` = 60 s, over HTTP errors only); `site::fetch_once` is the same code path with a single attempt, used by inline queries whose answer window is shorter than the backoff. A status a site answers with is classified by what a *retry* can change: 404/410 are `NotFound` and 401/403 are `Blocked` (permanent, reported at once), 429/5xx are `Transient` and retried. Queue retries are explicit `QueueError::Retryable` with computed delay (`retry_delay_seconds`), scaled per attempt by `scaled_retry_delay` — which only ever scales **up**, so a delay the server asked for (Telegram `retry_after`) is never shortened. `send::classify_request_error` is the send-side counterpart: `RetryAfter` and `Network` are retryable, and so is a 5xx — teloxide sleeps 10 s on a server error and then parses the body, so by then the HTTP status is gone and the condition is recognised by shape instead (a JSON server-error description, or an `InvalidJson` whose raw body is not JSON, i.e. a proxy/error page).
- Logging via `log` macros (`pretty_env_logger`, level from `RUST_LOG`). Level convention: `info` = lifecycle + per-post business results (`sent`/`forwarded`/`copied`), admin/operator actions and anomalies (fallback, retry enqueue, dead-letter is `error`); `debug` = per-request detail (message/command/URL extraction, `fetching`/`fetched`, batch sends, queue processing, photo processing, inline queries). Full user-submitted URLs and message text only appear at `debug`; at `info` and above links are printed via the normalized cache key (`handlers::log_key`, e.g. `[key=twitter:123...]`) so logs stay short and do not echo user data. - Logging via `log` macros (`pretty_env_logger`, level from `RUST_LOG`). `main.rs` initializes the **timed** builder with a default filter of `info,hyper_util=warn,reqwest=warn` when `RUST_LOG` is unset: the plain `init` had no timestamps and fell back to `error`, so a deployment that forgot the variable logged nothing at all, and at `debug` the HTTP client's own lines outnumbered the bot's two to one. An explicit `RUST_LOG` overrides the default wholesale. Level convention: `info` = lifecycle + per-post business results (`sent`/`forwarded`/`copied`, with `chat=` and the total `ms`), admin/operator actions and anomalies (the upload fallback and other user-served degradations are `warn`; retry enqueue and dead-letter are `error`); `debug` = per-request detail (URL extraction, `fetching`/`fetched` with the fetch duration, batch sends, queue processing with the row's `chat=`/`key=` and per-attempt `ms`, photo processing, inline queries); `trace` = user data (the full URL, the message text, the inline query). At `debug` and above links are printed via the normalized cache key (`handlers::log_key`, e.g. `[key=twitter:123...]`), so a `debug` log can be shared without echoing what users pasted; user-supplied text that does reach a line (display names, callback data, channel handles) goes through `handlers::log_escape`, whose escapes keep a crafted value from splitting or forging a log entry, and degradations that leave the user served (a failed cache read/write, a failed chat action) are `warn`, not `error`. The only queue/sweep aggregate is the 300 s sweep's queue line, and it speaks only when the queue is non-empty.
## Important Files ## Important Files
| File | Why it matters | | 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/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/handlers/` | `statics.rs` = `CHAT_STORE`/`TASK_QUEUE`/`CONFIG` singletons (open `$DATA_DIR/task_queue.db`, default `data/` **relative to CWD**, dir auto-created); `mod.rs` also holds `apply_caption_edit`, the one place a caption edit is applied and its failure classified: a short retryable delay is retried once, anything else is reported to the user instead of being swallowed (`callback.rs`'s template button answers its toast with the failure and leaves the record alone); `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 `-`; `/set_template` enforces 50 templates per chat, 55 UTF-8-byte names, and 1024-character escaped bodies; `/settings` output is capped at 4000 characters); `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 = 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` and the senders; `error.rs`: `classify_request_error` (5xx/non-JSON bodies retry, see the Retries bullet) and the media-fetch markers that route a URL send into the reupload fallback — including `failed to get HTTP url content`, the description single-media URL sends answer with. `upload.rs`: download-and-reupload fallback triggered only by Telegram API errors (`is_media_fetch_failure` / `is_size_error`), the per-kind upload cap (`MAX_MEDIA_UPLOAD_BYTES` = 50 MB for video/animation/other, Telegram's multipart limit; `photo::MAX_UPLOAD_BYTES` stays the 10 MiB photo one) with a download's class from `classify_download_error` (transport/429/5xx retry; 4xx is permanent — the media itself is gone or refused — and a temp-file *write* failure retries, being resource exhaustion far more often than a broken temp dir). Item preparation is bounded **process-wide** (`PREP_SLOTS` in `upload.rs`: URL workers and queue workers can each be inside a batch, so a per-batch bound is not a memory bound), and the check that routes an oversized item to `fallback_url` is the download's own declared-Content-Length abort (`FetchError::TooLarge` → `MediaTooLarge`) — there is no separate size probe, which used to cost a second request per item. `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/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. Two budgets, not one: `MAX_PHOTO_DOWNLOAD_BYTES` (32 MiB) caps the *download* in the send fallback — the whole body is buffered, once per prep slot — while `MAX_DECODE_BYTES` (512 MiB) stays the pre-allocation guard that decides whether a decoded photo can be processed at all; over either one the item degrades to its smaller 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,download}.rs` | `mod.rs`: dispatcher, `Fetched`/`FetchError`, `needs_media_headers` (the per-site rule, asked by the inline path to skip what Telegram cannot fetch). `download.rs`: the media-download stack — the metadata vs. media HTTP clients, the host-network guard (applied to the start URL and every redirect hop) and `download_media_limited`/`download_media_to_file` (which add the site's headers, e.g. `Referer: https://www.pixiv.net/` for `pximg.net`) |
| `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` | | `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) | | `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) | |`docker-entrypoint.sh` | Privilege drop: `useradd` with `LOCAL_USER_ID` (default 9001) + `setpriv` (no gosu on bookworm-slim); rejects a non-numeric or `0` `LOCAL_USER_ID`, which would otherwise survive the drop and run the bot root |
| `docker-compose.yml.example` | Deployment env reference (real `docker-compose.yml` is gitignored). Ships nginx-proxy + acme-companion: webhook mode needs TLS termination in front (teloxide's axum listener is HTTP-only; `WEBHOOK_CERT` only feeds `set_webhook`), bot exposes `VIRTUAL_HOST`/`VIRTUAL_PORT` on the shared `proxy` network, no host port; container names `nginx-proxy`/`acme-companion`/`tgxmb`, start order via `depends_on` (proxy → acme → bot) | | `docker-compose.yml` | The deployment composition, committed as-is: every instance value (token, admins, site credentials, domain) is a `${VAR}` substitution read from the gitignored `.env` beside it, so the file needs no per-deployment edit — and a variable not listed in a service's `environment:` never reaches that container. Ships nginx-proxy + acme-companion: webhook mode needs TLS termination in front (teloxide's axum listener is HTTP-only; `WEBHOOK_CERT` only feeds `set_webhook`), bot exposes `VIRTUAL_HOST`/`VIRTUAL_PORT` on the shared `proxy` network, no host port; container names `nginx-proxy`/`acme-companion`/`tgxmb`, start order via `depends_on` (proxy → acme → bot) |
| `.github/workflows/docker.yml` | CI: build+push to Docker Hub on tag `v*`/master, plus a build-only check on PRs touching the build inputs; **no test step**; verifies a release tag matches both crate versions; buildx gha cache (`cache-from` always, `cache-to` except on PRs, scope `tgxmb-build`, `mode=max`) so cargo deps + ffmpeg layers are restored across runs; `FFMPEG_URL`/`FFMPEG_SHA256` come from repo variables when set | | `.github/workflows/docker.yml` | CI: build+push to Docker Hub on tag `v*`/master, plus a build-only check on PRs touching the build inputs; **no test step**; verifies a release tag matches both crate versions; buildx gha cache (`cache-from` always, `cache-to` except on PRs, scope `tgxmb-build`, `mode=max`) so cargo deps + ffmpeg layers are restored across runs; `FFMPEG_URL`/`FFMPEG_SHA256` come from repo variables when set |
| `README.md` | Feature docs + command table (Chinese) | | `README.md` | Feature docs + command table (Chinese) |
@@ -90,19 +96,19 @@ Docker: `docker build -t tgxmb .` then `docker run --rm -d --name tgxmb --env-fi
- **Rust, stable, edition 2024**, workspace resolver 3. No `rust-version`/MSRV pin, no `rust-toolchain.toml` — recent stable is assumed. No nightly features. - **Rust, stable, edition 2024**, workspace resolver 3. No `rust-version`/MSRV pin, no `rust-toolchain.toml` — recent stable is assumed. No nightly features.
- Package manager: **Cargo** (workspace with path dep `x-media` ← `xmedia-bot`). No `[workspace.package]`/shared deps — each crate lists deps independently. - Package manager: **Cargo** (workspace with path dep `x-media` ← `xmedia-bot`). No `[workspace.package]`/shared deps — each crate lists deps independently.
- **TLS is rustls end-to-end** (no native-tls/openssl in the tree, no libssl in the Docker runtime image): `teloxide` is declared `default-features = false` with `["webhooks-axum", "macros", "rustls", "ctrlc_handler"]` (the removed `default` also carried `native-tls` and `ctrlc_handler` — the latter must stay); x-media's reqwest is `default-features = false` with `["json", "rustls-tls"]` (webpki-roots baked in, so the image ships no CA bundle). One reqwest 0.12.28 in the lock. - **TLS is rustls end-to-end** (no native-tls/openssl in the tree, no libssl in the Docker runtime image): `teloxide` is declared `default-features = false` with `["webhooks-axum", "macros", "rustls", "ctrlc_handler"]` (the removed `default` also carried `native-tls` and `ctrlc_handler` — the latter must stay); x-media's reqwest is `default-features = false` with `["json", "rustls-tls", "gzip", "http2"]` (webpki-roots baked in, so the image ships no CA bundle; `gzip` because the site APIs answer their JSON compressed — twitter's syndication body is 4469 bytes identity vs 1066 gzipped — and `http2` because every site CDN here negotiates h2). One reqwest 0.12.28 in the lock.
- **Versioning**: bump the version in all three places (`crates/x-media/Cargo.toml`, `crates/xmedia-bot/Cargo.toml`, `Cargo.lock`) and **keep `README.md`, `README.en.md` and `AGENTS.md` in sync with the code on every bump**, then commit (`chore: bump version to X.Y.Z`), create an annotated tag `vX.Y.Z`, and push branch + tag (the tag push triggers the Docker Hub build). The tag must equal both crate versions: `.github/workflows/docker.yml` verifies that before building, and `--locked` verifies the lock file. - **Versioning**: bump the version in all three places (`crates/x-media/Cargo.toml`, `crates/xmedia-bot/Cargo.toml`, `Cargo.lock`) and **keep `README.md`, `README.en.md` and `AGENTS.md` in sync with the code on every bump**, then commit (`chore: bump version to X.Y.Z`), create an annotated tag `vX.Y.Z`, and push branch + tag **in one push** (`git push origin master vX.Y.Z`; the tag push triggers the Docker Hub build). Pushing them separately with the branch first makes the master run of `docker.yml` build the same commit as the tag run — its duplicate check can only see the tags that already exist on the remote. The tag must equal both crate versions: `.github/workflows/docker.yml` verifies that before building, and `--locked` verifies the lock file.
- Config is **environment-variable driven** (dotenv loads `.env`, gitignored; no `.env.example` exists). Key vars: `TELOXIDE_TOKEN` (required), `PIXIV_REFRESH_TOKEN`, `TWITTER_AUTH_TOKEN` (optional; x.com `auth_token` cookie — enables the logged-in GraphQL fallback that fetches NSFW tweets syndication withholds), `BILIBILI_COOKIE` (optional; whole bilibili cookie string — bilibili dynamics fetch anonymously and add their own device cookies, this only rescues an egress IP that bilibili has hard-flagged with `-352`/412), `BOT_ADMIN` (comma-separated ids), `EDIT_MESSAGE_TTL_SECONDS` (default 86400), `LINK_CACHE_TTL_SECONDS` (default 604800), `CAPTION_QUOTE_TEXT_CHARS` (default 200; a post whose text — the `title` plus `content` joined, see `site::compose_text` — reaches this length gets that text wrapped in an expandable blockquote inside its caption, the URL and author line staying outside; `0` disables it. Applied at the send boundary in `send::quote_long_caption`, which locates the text as what follows the author link, so a `/set_format` that moves `{title}`/`{content}` elsewhere and pixiv's title-inside-a-link layout opt out; `copy_messages` forwards and queued retries inherit the wrap, while the edit-before-forward rewrite stays unquoted by design), `DATA_DIR` (default `data`, CWD-relative; the SQLite dir, auto-created), `WEBHOOK`/`WEBHOOK_URL`/`WEBHOOK_LISTEN`/`WEBHOOK_PORT`/`WEBHOOK_CERT`/`WEBHOOK_SECRET_TOKEN` (webhook mode requires URL/listen/port, `.expect`ed; `WEBHOOK_CERT` is Telegram-facing self-signed validation only — TLS must be terminated by a reverse proxy), `RUST_LOG`, `TELOXIDE_PROXY`, `LOCAL_USER_ID` (entrypoint only). - Config is **environment-variable driven** (dotenv loads `.env`, which is gitignored; `.env.example` is the tracked template — `cp .env.example .env` — and is also the file `docker compose` substitutes `${VAR}` from, so every variable the compose passes must be documented there). Key vars: `TELOXIDE_TOKEN` (required), `PIXIV_REFRESH_TOKEN`, `TWITTER_AUTH_TOKEN` (optional; x.com `auth_token` cookie — enables the logged-in GraphQL fallback that fetches NSFW tweets syndication withholds), `BILIBILI_COOKIE` (optional; whole bilibili cookie string — bilibili dynamics fetch anonymously and add their own device cookies, this only rescues an egress IP that bilibili has hard-flagged with `-352`/412), `BOT_ADMIN` (comma-separated ids), `EDIT_MESSAGE_TTL_SECONDS` (default 86400), `LINK_CACHE_TTL_SECONDS` (default 604800), `CAPTION_QUOTE_TEXT_CHARS` (default 200; a post whose text — the `title` plus `content` joined, see `site::compose_text` — reaches this length gets that text wrapped in an expandable blockquote inside its caption, the URL and author line staying outside; `0` disables it. Applied at the send boundary in `send::quote_long_caption`, which locates the text as what follows the author link, so a `/set_format` that moves `{title}`/`{content}` elsewhere and pixiv's title-inside-a-link layout opt out; `copy_messages` forwards and queued retries inherit the wrap, while the edit-before-forward rewrite stays unquoted by design), `DATA_DIR` (default `data`, CWD-relative; the SQLite dir, auto-created), `WEBHOOK`/`WEBHOOK_URL`/`WEBHOOK_LISTEN`/`WEBHOOK_PORT`/`WEBHOOK_CERT`/`WEBHOOK_SECRET_TOKEN` (webhook mode requires URL/listen/port **and the secret token**, all `.expect`ed — a listener without a secret accepts unauthenticated updates; `WEBHOOK_CERT` is Telegram-facing self-signed validation only — TLS must be terminated by a reverse proxy), `RUST_LOG`, `TELOXIDE_PROXY`, `LOCAL_USER_ID` (entrypoint only).
- SQLite via `rusqlite` with `bundled` feature (no system libsqlite needed). DB file `$DATA_DIR/task_queue.db` (default `data/task_queue.db`, CWD-relative — run from the workspace root, or `/app` in Docker; set `DATA_DIR` to pin state anywhere). Mount `./data` and `./cert` volumes. - SQLite via `rusqlite` with `bundled` feature (no system libsqlite needed). DB file `$DATA_DIR/task_queue.db` (default `data/task_queue.db`, CWD-relative — run from the workspace root, or `/app` in Docker; set `DATA_DIR` to pin state anywhere). Mount the `./data` volume (the shipped compose mounts only that); `./cert` matters solely for a self-signed `WEBHOOK_CERT` you wire in yourself — add both its mount and the env line to compose then, as the template's own comment says.
- `.gitattributes` enforces LF for `*.sh` (CRLF breaks shebangs in containers). `.gitignore`: `.env`, `data/`, `cert/`, `docker-compose.yml`, `/target`, `.idea/`. - `.gitattributes` enforces LF for `*.sh` (CRLF breaks shebangs in containers). `.gitignore`: `.env`, `data/`, `cert/`, `nginx-*` (proxy state), `/target`, `.idea/` (the compose file is tracked; only `.env` carries the deployment's own values).
- Docs are in Chinese (README, AGENTS.md); user-facing bot strings are in English. Keep that split when editing user-facing strings and docs. - Docs are in Chinese (README, AGENTS.md); user-facing bot strings are in English. Keep that split when editing user-facing strings and docs.
## Testing & QA ## 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). - **~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).
- 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. - 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. Tests that must go through a **real `Bot`** (its URL/multipart building, the per-chat limiter and the bot-wide budget) talk to a stand-in API instead (`media_sender::test_support::fake_api::FakeApi`, a `tokio` TCP listener that records every call and answers the smallest result each method needs — teloxide keys methods by payload type, so the recorded name is `SendMediaGroup`, not `sendMediaGroup`): a media group, the edit-before-forward prompt through the real callback path, and `handlers::handle_message` (the context-taking body of `message_handler`, split out for exactly this).
- 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` (1), `site/misskey/interface.rs` (1), `site/bilibili/interface.rs` (5), `site/pixiv/api.rs` (1) and `site/download.rs` (1: the pixiv download below); the bot crate adds one live test each in `handlers/repair.rs` and `handlers/urls.rs`; `photo.rs` adds one `#[ignore = "heavy: …"]` test. `site/download.rs`'s pixiv download test (`live_download_media_pixiv_original_with_referer`) is gated **both ways** — `#[ignore = "live network: …"]` *and* an early return without `PIXIV_REFRESH_TOKEN` — so a local `cargo test --workspace` stays fully offline and the pixiv CDN flake surfaces only in the live job. `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`. - 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. - **CI** — `.github/workflows/ci.yml` (actions pinned to commit SHAs, `--locked` on every cargo invocation, `concurrency` cancels superseded runs, `RUST_BACKTRACE=1`) runs (behind a `changes` gate job, so a push/PR whose entire diff is markdown skips it instead of burning four minutes on nothing) `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, `continue-on-error`) that runs the `#[ignore]`d `live` tests in **both** crates — `PIXIV_REFRESH_TOKEN`/`TWITTER_AUTH_TOKEN`/`BILIBILI_COOKIE` pass through as secrets, and a summary step lists every `SKIP` a test printed so a green live run cannot mean zero coverage. `.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**; its `should-build` gate skips a branch push that is already tagged (`git tag --points-at` — the tag run builds it, so push both refs together) or that touched no build input at all, while a tag push always builds (`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`. - Untested and hard to test without a mock seam: `handlers/statics.rs`; `config.rs` only partly (the webhook truth table, TTL fallback and blank-secret parsing are pinned, the rest of the env parsing is not); `db.rs` is covered for the migration chain but not for pool behaviour under contention; `main.rs` is covered where it was split out (`periodic_sweep`, `sweep_temp_dir`) but not for startup/shutdown or its `dptree` branch tree (the handlers themselves are, through the stand-in API); in `x-media`: `media.rs`, `lib.rs`, all `model.rs` (their serde shapes are exercised indirectly by the adapter fixtures that deserialize into them). Everything else — `handlers/{mod,callback,inline,urls}.rs`, `send/*`, `ctx.rs`, `state.rs`, `queue.rs`, `link_cache.rs`, `rate_limit.rs`, `commands.rs` (its executor, through scripted outcomes) — is driven through `TestStores`/`ctx::test_support` and the scripted `MockSender`.
- No coverage tracking. - No coverage tracking.
Generated
+70 -9
View File
@@ -60,6 +60,18 @@ dependencies = [
"object", "object",
] ]
[[package]]
name = "async-compression"
version = "0.4.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8"
dependencies = [
"compression-codecs",
"compression-core",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -263,9 +275,26 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]]
name = "compression-codecs"
version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
dependencies = [
"compression-core",
"flate2",
"memchr",
]
[[package]]
name = "compression-core"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
[[package]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.10.2" version = "0.10.2"
@@ -524,7 +553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -583,6 +612,12 @@ dependencies = [
"zlib-rs", "zlib-rs",
] ]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.2.0" version = "0.2.0"
@@ -713,6 +748,25 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "h2"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.2",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@@ -849,6 +903,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@@ -1098,7 +1153,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [ dependencies = [
"hermit-abi", "hermit-abi",
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -1589,7 +1644,7 @@ dependencies = [
"once_cell", "once_cell",
"socket2", "socket2",
"tracing", "tracing",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -1740,6 +1795,7 @@ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
@@ -1836,7 +1892,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -2262,7 +2318,7 @@ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -2425,12 +2481,17 @@ version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [ dependencies = [
"async-compression",
"bitflags 2.13.2", "bitflags 2.13.2",
"bytes", "bytes",
"futures-core",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util",
"pin-project-lite", "pin-project-lite",
"tokio",
"tokio-util",
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
@@ -2666,7 +2727,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -2818,7 +2879,7 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]] [[package]]
name = "x-media" name = "x-media"
version = "1.7.0" version = "1.9.2"
dependencies = [ dependencies = [
"bytes", "bytes",
"dotenv", "dotenv",
@@ -2838,7 +2899,7 @@ dependencies = [
[[package]] [[package]]
name = "xmedia-bot" name = "xmedia-bot"
version = "1.7.0" version = "1.9.2"
dependencies = [ dependencies = [
"bytes", "bytes",
"dotenv", "dotenv",
+28 -24
View File
@@ -8,20 +8,36 @@ ARG APP_NAME=telegram-twitter-media-bot
# encoding. Served from https://ffmpeg.martin-riedl.de (Cloudflare CDN, # encoding. Served from https://ffmpeg.martin-riedl.de (Cloudflare CDN,
# built on Debian 12 — glibc-compatible with the bookworm-slim runtime). # built on Debian 12 — glibc-compatible with the bookworm-slim runtime).
# johnvansickle.com throttles datacenter IPs and served garbage from GitHub # johnvansickle.com throttles datacenter IPs and served garbage from GitHub
# runners. `/redirect/latest/` floats to the newest release build; each build # runners.
# also ships a .sha256. Swap `amd64` for `arm64` when building arm64 images. #
ARG FFMPEG_URL=https://ffmpeg.martin-riedl.de/redirect/latest/linux/amd64/release/ffmpeg.zip # Pinned to one release build instead of `/redirect/latest/`: the floating
# Arm64 images need this URL swapped for the `linux/arm64` build (currently # URL changes under every build and ships no sha256 sidecar, while this pair
# hardcoded amd64; the workflow builds amd64 only — see docker.yml). # (zip + the sha256 the mirror publishes beside it, `<url>.sha256`) is
# Optional sha256 of ffmpeg.zip (pinned releases only): set to verify the # verified on every run. Bump both together — the site lists the current
# download. The mirror publishes .sha256 sidecars next to pinned builds, e.g. # ids, e.g. https://ffmpeg.martin-riedl.de. Swap `amd64` for `arm64` when
# https://ffmpeg.martin-riedl.de/download/linux/amd64/<id>_9.0/ffmpeg.zip.sha256 # building arm64 images (the workflow builds amd64 only — see docker.yml).
# (the /redirect/latest/ URL itself has no sidecar — pin the effective URL). ARG FFMPEG_URL=https://ffmpeg.martin-riedl.de/download/linux/amd64/1789931100_9.0.2/ffmpeg.zip
ARG FFMPEG_SHA256= # sha256 of that zip, checked unconditionally: an FFMPEG_URL override must
# pair with the new zip's sha256 or the build fails here, so an unverifiable
# binary never reaches the image.
ARG FFMPEG_SHA256=fa8ecf4abbd290d98f7d188b8649cc6b391ae209a98452be955a15aab1909d7f
WORKDIR /build WORKDIR /build
# 1. Rust dependencies first: only the manifests plus stub sources, so the # 1. Static ffmpeg first: only the two ARGs above invalidate this layer, so a
# manifest or source edit never re-downloads it. The zip contains a single
# `ffmpeg` binary at the root. `unzip -t` verifies the archive before
# extraction so a bad download fails loudly here instead of a cryptic
# later error.
RUN wget -q -O /tmp/ffmpeg.zip "$FFMPEG_URL" \
&& echo "$FFMPEG_SHA256 /tmp/ffmpeg.zip" | sha256sum -c - \
&& unzip -tq /tmp/ffmpeg.zip \
&& unzip -q /tmp/ffmpeg.zip -d /usr/local/bin \
&& chmod +x /usr/local/bin/ffmpeg \
&& rm /tmp/ffmpeg.zip \
&& /usr/local/bin/ffmpeg -version >/dev/null
# 2. Rust dependencies next: only the manifests plus stub sources, so the
# expensive dependency fetch + compile lives in a layer invalidated only by # expensive dependency fetch + compile lives in a layer invalidated only by
# manifest/lock changes. # manifest/lock changes.
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
@@ -32,21 +48,9 @@ RUN mkdir -p crates/x-media/src crates/xmedia-bot/src \
&& : > crates/x-media/src/lib.rs \ && : > crates/x-media/src/lib.rs \
&& cargo build --release --locked -p xmedia-bot && cargo build --release --locked -p xmedia-bot
# 2. Static ffmpeg next (cached unless FFMPEG_URL changes), so source edits
# never re-download it. The zip contains a single `ffmpeg` binary at the
# root. `unzip -t` verifies the archive before extraction so a bad
# download fails loudly here instead of a cryptic later error.
RUN wget -q -O /tmp/ffmpeg.zip "$FFMPEG_URL" \
&& if [ -n "$FFMPEG_SHA256" ]; then echo "$FFMPEG_SHA256 /tmp/ffmpeg.zip" | sha256sum -c -; fi \
&& unzip -tq /tmp/ffmpeg.zip \
&& unzip -q /tmp/ffmpeg.zip -d /usr/local/bin \
&& chmod +x /usr/local/bin/ffmpeg \
&& rm /tmp/ffmpeg.zip \
&& /usr/local/bin/ffmpeg -version >/dev/null
# 3. Real sources last: only our crates recompile on source changes. Cargo's # 3. Real sources last: only our crates recompile on source changes. Cargo's
# freshness check is mtime-based; the COPY'd host files usually predate the # freshness check is mtime-based; the COPY'd host files usually predate the
# step-1 stub build, so cargo would consider the stub up to date and never # stub build, so cargo would consider the stub up to date and never
# compile the real sources. `touch` makes every .rs newer than the stub # compile the real sources. `touch` makes every .rs newer than the stub
# artifacts, forcing a rebuild of just the two crates while the compiled # artifacts, forcing a rebuild of just the two crates while the compiled
# dependency layer stays cached. (`cargo clean -p` does NOT work here — it # dependency layer stays cached. (`cargo clean -p` does NOT work here — it
+28 -19
View File
@@ -4,12 +4,15 @@ A Telegram bot that turns post links from X / Twitter, Pixiv, Bluesky, Misskey (
## Features ## Features
- Sending a link in a private chat fetches and sends the images, videos and GIFs automatically; oversized media is split into batches - 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 - 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 - 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>`) - 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 (such a query answers empty rather than spinning or re-fetching); 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 - `/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
- Failed sends are retried automatically with persistence; the user is notified after retries are exhausted - `/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 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) - 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) - 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) - 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)
@@ -24,26 +27,29 @@ export PIXIV_REFRESH_TOKEN=<token>
cargo run -p xmedia-bot cargo run -p xmedia-bot
``` ```
Docker deployment (see `docker-compose.yml.example`): Docker deployment (`docker-compose.yml` in this repo is the orchestration; instance values live in the `.env` next to it, and compose substitutes every `${VAR}` from there):
```bash ```bash
cp .env.example .env # fill in TELOXIDE_TOKEN and the rest; every line is commented
docker build -t tgxmb . docker build -t tgxmb .
docker run --rm -d --name tgxmb --env-file .env -v ./data:/app/data tgxmb docker run --rm -d --name tgxmb --env-file .env -v ./data:/app/data tgxmb
# or use the bundled orchestration (nginx-proxy + acme-companion):
docker compose up -d
``` ```
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). 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. 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.
### Webhook deployment (needs a reverse proxy) ### Webhook deployment (needs a reverse proxy)
`docker-compose.yml.example` ships an [nginx-proxy](https://github.com/nginx-proxy/nginx-proxy) + [acme-companion](https://github.com/nginx-proxy/acme-companion) reverse-proxy orchestration. Pick one deployment shape: `docker-compose.yml` ships an [nginx-proxy](https://github.com/nginx-proxy/nginx-proxy) + [acme-companion](https://github.com/nginx-proxy/acme-companion) reverse-proxy orchestration. The committed file needs **no editing**: domain, tokens and admins are instance values and live in the `.env` beside it (compose reads and substitutes `${VAR}` at startup). Pick one deployment shape:
**With a domain** **With a domain**
1. Point a DNS A record at the server 1. Point a DNS A record at the server
2. In compose set `VIRTUAL_HOST` and `WEBHOOK_URL` to the domain, and uncomment `ACME_HOST` (set it to the domain) 2. In `.env` set `VIRTUAL_HOST` and `WEBHOOK_URL` to the domain; to have acme-companion issue the certificate, also uncomment the `ACME_HOST` line in `docker-compose.yml` and set `ACME_HOST` in `.env`
3. acme-companion issues and renews certificates automatically — nothing manual 3. acme-companion issues and renews certificates automatically — nothing manual
**IP only** **IP only**
@@ -71,7 +77,7 @@ Let's Encrypt can issue certificates for public IPs (available since 2026, valid
--key-file /acme.sh/<SERVER_IP>.key \ --key-file /acme.sh/<SERVER_IP>.key \
--reloadcmd "curl --unix-socket /var/run/docker.sock -X POST http://localhost/containers/nginx-proxy/kill?signal=HUP" --reloadcmd "curl --unix-socket /var/run/docker.sock -X POST http://localhost/containers/nginx-proxy/kill?signal=HUP"
``` ```
3. In compose set `VIRTUAL_HOST: '<SERVER_IP>'` and `WEBHOOK_URL: 'https://<SERVER_IP>/'`; no `WEBHOOK_CERT` needed. Renewal is handled by the acme.sh daemon (`--days 3` = renew every 3 days, buffer against the 7-day validity), and a successful renewal HUP-notifies nginx-proxy to load the new certificate. 3. In `.env` set `VIRTUAL_HOST=<SERVER_IP>` and `WEBHOOK_URL=https://<SERVER_IP>/`; no `WEBHOOK_CERT` needed. Renewal is handled by the acme.sh daemon (`--days 3` = renew every 3 days, buffer against the 7-day validity), and a successful renewal HUP-notifies nginx-proxy to load the new certificate.
Limitations: certificate validity ~7 days; only http-01/tls-alpn-01 validation (port 80 must be publicly reachable); no DNS-01, private IPs or IP ranges; at most 5 certificates per 168 hours for the same IP set. It is recommended to trial-issue with `--server letsencrypt_test` first, then switch to the production server. Limitations: certificate validity ~7 days; only http-01/tls-alpn-01 validation (port 80 must be publicly reachable); no DNS-01, private IPs or IP ranges; at most 5 certificates per 168 hours for the same IP set. It is recommended to trial-issue with `--server letsencrypt_test` first, then switch to the production server.
@@ -83,17 +89,18 @@ Telegram only accepts ports 443/80/88/8443.
| Variable | Description | | Variable | Description |
|---|---| |---|---|
| `TELOXIDE_TOKEN` | Bot token (required) | | `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) |
| `TWITTER_AUTH_TOKEN` | Optional; the `auth_token` cookie of a logged-in x.com session, used only to fetch NSFW tweets' media |
| `BILIBILI_COOKIE` | Optional bilibili cookie string (`SESSDATA=…; bili_jct=…`); only needed when the egress IP stays risk-controlled (device cookies are fetched automatically) | | `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 | | `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) | | `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 | | `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) | | `DATA_DIR` | Data directory (where the SQLite `task_queue.db` lives), default `data` (relative to the working directory, created automatically) |
| `RUST_LOG` | Log level | | `RUST_LOG` | Log level, default `info,hyper_util=warn,reqwest=warn` (an unset variable no longer silences the log). Recipes: `info,xmedia_bot=debug,x_media=debug` (app detail, no dependency noise) / `debug,hyper_util=off` (everything) / `trace` (also prints full links and message text — **user data**) |
| `TELOXIDE_PROXY` | HTTP proxy (e.g. `http://127.0.0.1:10808`); applies to both the Telegram Bot API and site fetches — required on restricted networks (e.g. behind the GFW) | | `TELOXIDE_PROXY` | HTTP proxy (e.g. `http://127.0.0.1:10808`); applies to both the Telegram Bot API and site fetches — required on restricted networks (e.g. behind the GFW). **Never leave it blank** (`TELOXIDE_PROXY=`) — teloxide panics on a value it cannot parse; omit the line when unused. `docker-compose.yml` deliberately does not pass it to the container (a `127.0.0.1` proxy there is the container itself): add the line and use `host.docker.internal:<port>` when a deployment needs one |
| `LOCAL_USER_ID` | UID the container runs as, default 9001 | | `LOCAL_USER_ID` | UID the container runs as, default 9001 |
| `VIRTUAL_HOST` | Public domain or IP; nginx-proxy routes by this | | `VIRTUAL_HOST` | Public domain or IP; nginx-proxy routes by this (set it in `.env`, which compose reads) |
| `VIRTUAL_PORT` | Port the bot listens on inside the container; nginx-proxy's forwarding target | | `VIRTUAL_PORT` | Port the bot listens on inside the container; nginx-proxy's forwarding target |
| `ACME_HOST` | Domain deployment: when set to the domain, acme-companion issues/renews certificates automatically | | `ACME_HOST` | Domain deployment: when set to the domain, acme-companion issues/renews certificates automatically |
| `DEFAULT_HOST` | nginx-proxy routes requests with unknown Host headers to this vhost (needed for IP access) | | `DEFAULT_HOST` | nginx-proxy routes requests with unknown Host headers to this vhost (needed for IP access) |
@@ -114,15 +121,17 @@ Telegram only accepts ports 443/80/88/8443.
| `/help` | List all commands and usage (this command table) | | `/help` | List all commands and usage (this command table) |
| `/set_forward_channel <channel>` | Set the forward channel: `@channel` or channel ID; media messages are forwarded to it automatically afterwards | | `/set_forward_channel <channel>` | Set the forward channel: `@channel` or channel ID; media messages are forwarded to it automatically afterwards |
| `/remove_forward_channel` | Remove the forward channel | | `/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 <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") | | `/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"). Names are limited to 55 UTF-8 bytes, bodies to 1024 escaped characters, and 50 templates per chat |
| `/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}` | | `/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 | | `/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) | | `/bot_dict` | Show the current chat state (debugging; admin only) |
| `/test <link>` | Parse a link and send its media; no channel forward, no edit-before-forward prompt (send only) | | `/test <link>` | Parse a link and send its media; no channel forward, no edit-before-forward prompt (send only) |
| `/debug <link>` | Debug: parse a link and report the parse result only (site, title, author, tags, media list) — no media is sent | | `/debug <link>` | 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 ## Notes
+28 -19
View File
@@ -4,12 +4,15 @@ Telegram 机器人,将 X / Twitter、Pixiv、Bluesky、Misskey (misskey.io)、
## 功能 ## 功能
- 私聊发送链接后自动抓取并发送图片、视频与 GIF,超量图片自动分批 - 私聊发送链接后自动抓取并发送图片、视频与 GIF,超量图片自动分批(每批 10 张)
- 纯文字帖提示无媒体;不支持的链接静默忽略 - 纯文字帖提示无媒体;不支持的链接静默忽略。抓取失败会按原因分别提示(帖子已删除 / 内容受限 / 源站风控 / 站点未启用)
- 长帖(正文 ≥ `CAPTION_QUOTE_TEXT_CHARS`,默认 200)的**正文部分**用可折叠引用块展示,链接与作者行留在引用块外 - 长帖(正文 ≥ `CAPTION_QUOTE_TEXT_CHARS`,默认 200)的**正文部分**用可折叠引用块展示,链接与作者行留在引用块外
- 支持内联查询(`@机器人 <链接>`) - 支持内联查询(`@机器人 <链接>`;Pixiv 图片与本地转码的动图不支持内联 —— Telegram 取图时无法携带 Referer,会显示破图,因此跳过;这类查询直接返回空结果,不会一直转圈或反复请求);在群聊里发链接会提示改用私聊或内联查询(频道内保持静默)
- 可绑定转发频道自动转发;支持转发前编辑 caption 与自定义模板 - `/start` 说明支持的站点与用法,`/help` 列出命令、参数格式、caption 占位符与私聊限制;bot 资料页(description / short description)启动时一并设置
- 发送失败自动重试并持久化,重试耗尽后通知用户 - `/settings` 查看本聊天配置(转发频道、转发前编辑开关、各站点 caption 格式、模板列表);模板可用 `/set_template` 增、`/remove_template` 删
- 可绑定转发频道自动转发;支持转发前编辑 caption 与自定义模板(提示消息带 Confirm / Skip 按钮并写明过期时间,过期后就地标记为已过期)
- 发送失败自动重试并持久化,重试耗尽后通知用户;提示会写明是哪条链接、重试等待多久、或最终失败的原因
- 抓取期间持续显示"正在输入 / 正在发送"状态,长任务(ugoira 转码、大图上传)不会看起来卡死
- Pixiv ugoira 动图自动转码为 MP4;Bluesky 视频自动转码(HLS 流 → MP4) - Pixiv ugoira 动图自动转码为 MP4;Bluesky 视频自动转码(HLS 流 → MP4)
- 超过 Telegram 尺寸/大小限制的图片自动压缩(保持原格式,必要时转 JPEG) - 超过 Telegram 尺寸/大小限制的图片自动压缩(保持原格式,必要时转 JPEG)
- 链接结果本地缓存:成功发送后缓存 Telegram file id 与 caption 等,再次收到相同链接直接本地重发,不再请求源站、不保存媒体文件(`LINK_CACHE_TTL_SECONDS` 控制过期,默认 7 天) - 链接结果本地缓存:成功发送后缓存 Telegram file id 与 caption 等,再次收到相同链接直接本地重发,不再请求源站、不保存媒体文件(`LINK_CACHE_TTL_SECONDS` 控制过期,默认 7 天)
@@ -24,26 +27,29 @@ export PIXIV_REFRESH_TOKEN=<token>
cargo run -p xmedia-bot cargo run -p xmedia-bot
``` ```
Docker 部署(参考 `docker-compose.yml.example`): Docker 部署(编排见仓库里的 `docker-compose.yml`,实例相关的值写在同目录的 `.env`,compose 会自动替换其中的 `${VAR}`):
```bash ```bash
cp .env.example .env # 填 TELOXIDE_TOKEN 等,逐项都有注释
docker build -t tgxmb . docker build -t tgxmb .
docker run --rm -d --name tgxmb --env-file .env -v ./data:/app/data tgxmb docker run --rm -d --name tgxmb --env-file .env -v ./data:/app/data tgxmb
# 或者用仓库里的编排(含 nginx-proxy + acme-companion):
docker compose up -d
``` ```
环境变量:`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`(可选)。 环境变量:`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=…`)可恢复访问。当前只发送动态里的图片与动图,动态内嵌视频发送其封面。 Bilibili 动态默认匿名抓取(无需登录,bot 会自动从 B 站的匿名指纹接口取 `buvid3`/`buvid4` 设备 cookie 以提高成功率)。若服务器出口 IP 被 B 站重度风控(日志里的 `risk control (-352)` 或 HTTP 412,且持续出现),设置 `BILIBILI_COOKIE`(登录后浏览器里整条 Cookie 串,如 `SESSDATA=…; bili_jct=…`)可恢复访问。当前只发送动态里的图片与动图,动态内嵌视频发送其封面。
### Webhook 部署(需要反向代理) ### Webhook 部署(需要反向代理)
`docker-compose.yml.example` 内置了 [nginx-proxy](https://github.com/nginx-proxy/nginx-proxy) + [acme-companion](https://github.com/nginx-proxy/acme-companion) 反向代理编排,按部署环境二选一: `docker-compose.yml` 内置了 [nginx-proxy](https://github.com/nginx-proxy/nginx-proxy) + [acme-companion](https://github.com/nginx-proxy/acme-companion) 反向代理编排,仓库里的这份文件**不需要改动**:域名、令牌、管理员等实例相关的值都写在同目录的 `.env` 里(compose 启动时自动读取并替换 `${VAR}`)。按部署环境二选一:
**有域名** **有域名**
1. DNS A 记录指向服务器 1. DNS A 记录指向服务器
2. compose 里设 `VIRTUAL_HOST`、`WEBHOOK_URL` 为域名,并取消注释 `ACME_HOST`(设为域名) 2. `.env` 里设 `VIRTUAL_HOST`、`WEBHOOK_URL` 为域名;要由 acme-companion 自动签发证书时,再取消 `docker-compose.yml` 里 `ACME_HOST` 那行的注释,并在 `.env` 里把 `ACME_HOST` 设为域名
3. acme-companion 自动签发与续期证书,无需手动处理 3. acme-companion 自动签发与续期证书,无需手动处理
**只有 IP** **只有 IP**
@@ -71,7 +77,7 @@ Let's Encrypt 支持为公网 IP 签发证书(2026 年起可用,有效期约
--key-file /acme.sh/<SERVER_IP>.key \ --key-file /acme.sh/<SERVER_IP>.key \
--reloadcmd "curl --unix-socket /var/run/docker.sock -X POST http://localhost/containers/nginx-proxy/kill?signal=HUP" --reloadcmd "curl --unix-socket /var/run/docker.sock -X POST http://localhost/containers/nginx-proxy/kill?signal=HUP"
``` ```
3. compose 里设 `VIRTUAL_HOST: '<SERVER_IP>'`、`WEBHOOK_URL: 'https://<SERVER_IP>/'`,无需 `WEBHOOK_CERT`。续期由 acme.sh daemon 自动完成(`--days 3` = 每 3 天续一次,证书 7 天有效有缓冲),续期成功后自动 HUP 通知 nginx-proxy 加载新证书。 3. `.env` 里设 `VIRTUAL_HOST=<SERVER_IP>`、`WEBHOOK_URL=https://<SERVER_IP>/`,无需 `WEBHOOK_CERT`。续期由 acme.sh daemon 自动完成(`--days 3` = 每 3 天续一次,证书 7 天有效有缓冲),续期成功后自动 HUP 通知 nginx-proxy 加载新证书。
限制:证书约 7 天有效;验证仅支持 http-01/tls-alpn-01(80 端口必须公网可达);不支持 DNS-01、私有 IP 与 IP 段;同一 IP 集合每 168 小时限签发 5 张。建议先用 `--server letsencrypt_test` 试签,成功后再切正式服务器。 限制:证书约 7 天有效;验证仅支持 http-01/tls-alpn-01(80 端口必须公网可达);不支持 DNS-01、私有 IP 与 IP 段;同一 IP 集合每 168 小时限签发 5 张。建议先用 `--server letsencrypt_test` 试签,成功后再切正式服务器。
@@ -83,17 +89,18 @@ Telegram 只接受 443/80/88/8443 端口。
| 变量 | 说明 | | 变量 | 说明 |
|---|---| |---|---|
| `TELOXIDE_TOKEN` | Bot token(必填) | | `TELOXIDE_TOKEN` | Bot token(必填) |
| `PIXIV_REFRESH_TOKEN` | Pixiv 刷新令牌;未设置则禁用 Pixiv | | `PIXIV_REFRESH_TOKEN` | Pixiv 刷新令牌;未设置则禁用 Pixiv(此时收到 pixiv 链接会明确回复「站点未启用」,不会静默忽略) |
| `TWITTER_AUTH_TOKEN` | 可选;登录 x.com 后浏览器 Cookie 里的 `auth_token`,仅在遇到 NSFW 推文时以登录态获取媒体 |
| `BILIBILI_COOKIE` | 可选的 B 站 Cookie 串(`SESSDATA=…; bili_jct=…`),仅在出口 IP 被持续风控时才需要(设备 cookie 由 bot 自动获取) | | `BILIBILI_COOKIE` | 可选的 B 站 Cookie 串(`SESSDATA=…; bili_jct=…`),仅在出口 IP 被持续风控时才需要(设备 cookie 由 bot 自动获取) |
| `BOT_ADMIN` | 管理员聊天 ID,逗号分隔;接收启动/停止通知 | | `BOT_ADMIN` | 管理员聊天 ID,逗号分隔;接收启动/停止通知 |
| `EDIT_MESSAGE_TTL_SECONDS` | 转发前编辑记录过期秒数,默认 86400 | | `EDIT_MESSAGE_TTL_SECONDS` | 转发前编辑记录过期秒数,默认 86400;过期后提示消息会被就地改写为「已过期,未转发」(不额外发消息打扰) |
| `LINK_CACHE_TTL_SECONDS` | 链接结果缓存过期秒数,默认 604800(7 天) | | `LINK_CACHE_TTL_SECONDS` | 链接结果缓存过期秒数,默认 604800(7 天) |
| `CAPTION_QUOTE_TEXT_CHARS` | 正文(`{title}` + `{content}` 合计)达到该长度(字符)时,caption 的**正文部分**用可折叠引用块包裹,默认 200;`0` 关闭 | | `CAPTION_QUOTE_TEXT_CHARS` | 正文(`{title}` + `{content}` 合计)达到该长度(字符)时,caption 的**正文部分**用可折叠引用块包裹,默认 200;`0` 关闭 |
| `DATA_DIR` | 数据目录(SQLite 数据库 `task_queue.db` 所在目录),默认 `data`(相对工作目录,会自动创建) | | `DATA_DIR` | 数据目录(SQLite 数据库 `task_queue.db` 所在目录),默认 `data`(相对工作目录,会自动创建) |
| `RUST_LOG` | 日志级别 | | `RUST_LOG` | 日志级别,默认 `info,hyper_util=warn,reqwest=warn`(未设置也**不会**哑掉)。排障配方:`info,xmedia_bot=debug,x_media=debug`(应用细节,无依赖噪音)/ `debug,hyper_util=off`(全量)/ `trace`(额外打印完整链接与消息原文,**含用户数据**) |
| `TELOXIDE_PROXY` | HTTP 代理(如 `http://127.0.0.1:10808`);同时作用于 Telegram Bot API 与站点抓取请求,网络受限环境(如 GFW)必需 | | `TELOXIDE_PROXY` | HTTP 代理(如 `http://127.0.0.1:10808`);同时作用于 Telegram Bot API 与站点抓取请求,网络受限环境(如 GFW)必需。**不要留空值**(`TELOXIDE_PROXY=`)——teloxide 对无法解析的值会直接 panic;不用代理就别写这一行。容器里要用代理时,`docker-compose.yml` 的 `environment` 里默认没有它(容器内的 `127.0.0.1` 是容器自己),需要时手动加上并把地址换成 `host.docker.internal:<port>` |
| `LOCAL_USER_ID` | 容器内运行用户 UID,默认 9001 | | `LOCAL_USER_ID` | 容器内运行用户 UID,默认 9001 |
| `VIRTUAL_HOST` | 对外域名或 IP,nginx-proxy 按此路由 | | `VIRTUAL_HOST` | 对外域名或 IP,nginx-proxy 按此路由(写在 `.env`,compose 读取) |
| `VIRTUAL_PORT` | bot 容器内监听端口,nginx-proxy 的转发目标 | | `VIRTUAL_PORT` | bot 容器内监听端口,nginx-proxy 的转发目标 |
| `ACME_HOST` | 域名部署:设为域名时由 acme-companion 自动签发/续期证书 | | `ACME_HOST` | 域名部署:设为域名时由 acme-companion 自动签发/续期证书 |
| `DEFAULT_HOST` | nginx-proxy 将未知 Host 的请求路由到该 vhost(IP 访问时需要) | | `DEFAULT_HOST` | nginx-proxy 将未知 Host 的请求路由到该 vhost(IP 访问时需要) |
@@ -114,15 +121,17 @@ Telegram 只接受 443/80/88/8443 端口。
| `/help` | 查看全部命令及用法(即本文档的命令表) | | `/help` | 查看全部命令及用法(即本文档的命令表) |
| `/set_forward_channel <频道>` | 设置转发频道,参数为 `@频道名` 或频道 ID;设置后发送的媒体消息会自动转发到该频道 | | `/set_forward_channel <频道>` | 设置转发频道,参数为 `@频道名` 或频道 ID;设置后发送的媒体消息会自动转发到该频道 |
| `/remove_forward_channel` | 取消转发频道 | | `/remove_forward_channel` | 取消转发频道 |
| `/edit_before_forward` | 开关「转发前编辑」:开启后,转发成功后 bot 会发一条提示消息,回复它可修改第一条转发消息的 caption(或点击模板按钮套用模板) | | `/edit_before_forward` | 开关「转发前编辑」:开启后,转发成功后 bot 会发一条提示消息,回复它可修改第一条转发消息的 caption(或点击模板按钮套用模板),再点 `↩️ Confirm` 才会真正转发,`🛑 Skip` 放弃本次转发;提示消息写明过期时间,过期后原地标记为已过期且不会转发 |
| `/set_template <名称>` | 回复一条含 `[]` 的消息,将其保存为命名模板;转发时 `[]` 会被替换为原帖链接(配合「转发前编辑」使用) | | `/set_template <名称>` | 回复一条含 `[]` 的消息,将其保存为命名模板;转发时 `[]` 会被替换为原帖链接(配合「转发前编辑」使用)。模板名称最长 55 个 UTF-8 字节、正文最长 1024 个转义后字符,每聊天最多 50 个模板 |
| `/set_format <站点> <格式>` | 自定义某站点的 caption 格式。站点:`twitter` / `bsky` / `pixiv` / `misskey` / `bilibili`。占位符:`{url}` `{author}` `{author_url}` `{title}` `{content}` `{tags}` | | `/remove_template <名称>` | 删除某个模板(名称见 `/settings`;提示消息的模板按钮最多显示 60 个) |
| `/settings` | 查看本聊天配置:转发频道、转发前编辑开关、各站点 caption 格式、模板列表 |
| `/set_format <站点> <格式>` | 自定义某站点的 caption 格式。站点:`twitter` / `bsky` / `pixiv` / `misskey` / `bilibili`。占位符:`{url}` `{author}` `{author_url}` `{title}` `{content}` `{tags}`;未识别的占位符会被拒绝并列出可用项,格式填 `-` 恢复站点默认格式(可用 `/debug <链接>` 预览效果) |
| `/clear_cache [链接]` | 清空链接缓存(仅管理员);带链接只清该条,否则清空全部 | | `/clear_cache [链接]` | 清空链接缓存(仅管理员);带链接只清该条,否则清空全部 |
| `/bot_dict` | 查看当前聊天状态(调试用;仅管理员) | | `/bot_dict` | 查看当前聊天状态(调试用;仅管理员) |
| `/test <链接>` | 解析链接并发送媒体;不转发到频道、不弹转发前编辑提示(仅发送) | | `/test <链接>` | 解析链接并发送媒体;不转发到频道、不弹转发前编辑提示(仅发送) |
| `/debug <链接>` | 调试:只解析链接并返回解析结果(站点、标题、作者、标签、媒体列表),不发送任何媒体 | | `/debug <链接>` | 调试:只解析链接并返回解析结果(站点、标题、作者、标签、媒体列表),不发送任何媒体 |
链接处理仅限私聊;命令在任意聊天可用。 链接处理仅限私聊;命令在任意聊天可用。在群聊里发受支持的链接会回复一条提示(改用私聊或内联查询),频道内保持静默。
## 备注 ## 备注
+3 -3
View File
@@ -1,10 +1,10 @@
[package] [package]
name = "x-media" name = "x-media"
version = "1.7.0" version = "1.9.2"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip", "http2"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
regex = "1.12" regex = "1.12"
@@ -16,7 +16,7 @@ tempfile = "3"
thiserror = "2" thiserror = "2"
rand = "0.10" rand = "0.10"
log = "0.4" log = "0.4"
tokio = { version = "1.40", features = ["time"] } tokio = { version = "1.40", features = ["time", "rt", "fs"] }
[dev-dependencies] [dev-dependencies]
tokio = { version = "1.40", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.40", features = ["macros", "rt-multi-thread"] }
+7
View File
@@ -1,2 +1,9 @@
pub mod media; pub mod media;
pub mod site; pub mod site;
/// Prefix every temp file and temp dir this project creates, so a startup
/// sweep can recognise its own leftovers: a killed process leaves them behind
/// (`TempDir`/`NamedTempFile` clean up on drop, and a killed process runs no
/// destructors), and without a marker the only safe assumption about the OS
/// temp directory is "not mine".
pub const TEMP_FILE_PREFIX: &str = "tgxmb-";
-3
View File
@@ -37,18 +37,15 @@ impl Media {
#[derive(Debug)] #[derive(Debug)]
pub enum Media { pub enum Media {
Illustration { Illustration {
title: Option<String>,
url: String, url: String,
thumbnail_url: Option<String>, thumbnail_url: Option<String>,
fallback_url: Option<String>, fallback_url: Option<String>,
}, },
Video { Video {
title: Option<String>,
url: String, url: String,
thumbnail_url: String, thumbnail_url: String,
}, },
Animated { Animated {
title: Option<String>,
url: String, url: String,
thumbnail_url: String, thumbnail_url: String,
}, },
+47 -84
View File
@@ -30,7 +30,7 @@
use super::model; use super::model;
use crate::media::Media; use crate::media::Media;
use crate::site::{FetchError, Fetched, RenderData, Site, SiteFuture, compose_text}; use crate::site::{FetchError, Fetched, RenderData, Site, SiteFuture, compose_text};
use html_escape::{encode_double_quoted_attribute, encode_text}; use html_escape::encode_text;
use regex::Regex; use regex::Regex;
use std::sync::LazyLock; use std::sync::LazyLock;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -72,10 +72,14 @@ static COOKIE: LazyLock<Option<String>> = LazyLock::new(|| {
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
}); });
/// Cached `buvid3`/`buvid4` header value from [`SPI_URL`], or `None` when the /// Cached `buvid3`/`buvid4` header value from [`SPI_URL`]. The outer `Option`
/// fingerprint endpoint was unavailable (requests then go out without a /// is "an attempt has been made", the inner one "it produced a cookie": a
/// cookie, as before). /// failed attempt is remembered too, since it arrives at the request path as
static BUVID: LazyLock<tokio::sync::Mutex<Option<String>>> = LazyLock::new(Default::default); /// no cookie either way. Caching only success meant every later post asked the
/// fingerprint endpoint again — one extra round trip per post, and on a
/// flagged IP the endpoint is what fails.
static BUVID: LazyLock<tokio::sync::Mutex<Option<Option<String>>>> =
LazyLock::new(Default::default);
/// Registry entry for the bilibili adapter (see [`crate::site::Site`]). /// Registry entry for the bilibili adapter (see [`crate::site::Site`]).
pub struct BilibiliSite; pub struct BilibiliSite;
@@ -107,10 +111,6 @@ pub static PATTERN: LazyLock<Regex> = LazyLock::new(|| {
.unwrap() .unwrap()
}); });
pub fn enabled() -> bool {
true
}
pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> { pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
let dynamic_id = PATTERN let dynamic_id = PATTERN
.captures(url) .captures(url)
@@ -129,18 +129,9 @@ pub fn cache_key(url: &str) -> Option<String> {
.map(|caps| format!("bilibili:{}", &caps[1])) .map(|caps| format!("bilibili:{}", &caps[1]))
} }
/// Bilibili's fetch-retry policy: transient classes only. Not-found, blocked // hdslb media serves without a `Referer` (verified live 2026-09-17 on
/// and parse failures are permanent. // `i0.hdslb.com` image URLs, requested both with and without one), so this
pub fn is_retryable(err: &FetchError) -> bool { // adapter does not override `Site::media_headers`.
matches!(err, FetchError::Http(_) | FetchError::Transient(_))
}
/// hdslb media serves without a `Referer` (verified live 2026-09-17 on
/// `i0.hdslb.com` image URLs, requested both with and without one), so no
/// extra headers.
pub fn media_headers(_url: &str) -> Option<Vec<(&'static str, String)>> {
None
}
/// `Cookie` header for bilibili requests: the operator's `BILIBILI_COOKIE` /// `Cookie` header for bilibili requests: the operator's `BILIBILI_COOKIE`
/// when set, otherwise the anonymous device cookies. /// when set, otherwise the anonymous device cookies.
@@ -154,20 +145,28 @@ async fn cookie() -> Option<String> {
if let Some(cookie) = COOKIE.as_deref() { if let Some(cookie) = COOKIE.as_deref() {
return Some(cookie.to_string()); return Some(cookie.to_string());
} }
// ponytail: cached for the process lifetime. Refetching after a `-352` // ponytail: cached for the process lifetime, a failed attempt included.
// would mint a new device id for the same flagged IP — the escalation // Refetching after a `-352` would mint a new device id for the same
// path is BILIBILI_COOKIE. // flagged IP — the escalation path is BILIBILI_COOKIE.
let mut cached = BUVID.lock().await; {
if cached.is_none() { // The guard is released before the request below: held across it, the
*cached = match fetch_buvid().await { // first fingerprint call serialized every concurrent bilibili fetch
Ok(cookie) => cookie, // behind one round trip.
Err(e) => { let cached = BUVID.lock().await;
log::debug!("bilibili fingerprint unavailable: {e}"); if let Some(cookie) = cached.as_ref() {
None return cookie.clone();
} }
};
} }
cached.clone() let fetched = match fetch_buvid().await {
Ok(cookie) => cookie,
Err(e) => {
log::debug!("bilibili fingerprint unavailable: {e}");
None
}
};
// A caller that got there first wins (`get_or_insert`): two requests racing
// the first time cost a duplicate fingerprint call, never a wrong cookie.
BUVID.lock().await.get_or_insert(fetched).clone()
} }
/// Fetches the device cookies bilibili hands to any visitor. The result is /// Fetches the device cookies bilibili hands to any visitor. The result is
@@ -175,10 +174,7 @@ async fn cookie() -> Option<String> {
/// requests go out without a cookie. /// requests go out without a cookie.
async fn fetch_buvid() -> Result<Option<String>, FetchError> { async fn fetch_buvid() -> Result<Option<String>, FetchError> {
let response = crate::site::CLIENT.get(SPI_URL).send().await?; let response = crate::site::CLIENT.get(SPI_URL).send().await?;
let fingerprint: model::Fingerprint = response.json().await.map_err(|e| FetchError::Site { let fingerprint: model::Fingerprint = crate::site::response_json(response, "bilibili").await?;
site: "bilibili",
error: Box::new(e),
})?;
Ok(buvid_cookie(&fingerprint)) Ok(buvid_cookie(&fingerprint))
} }
@@ -214,13 +210,10 @@ pub async fn fetch(dynamic_id: &str) -> Result<model::Item, FetchError> {
if !status.is_success() { if !status.is_success() {
return Err(match status.as_u16() { return Err(match status.as_u16() {
412 => risk_control("412"), 412 => risk_control("412"),
_ => FetchError::Transient(format!("bilibili status {status}")), _ => crate::site::status_error("bilibili", &response),
}); });
} }
let detail: model::Detail = response.json().await.map_err(|e| FetchError::Site { let detail: model::Detail = crate::site::response_json(response, "bilibili").await?;
site: "bilibili",
error: Box::new(e),
})?;
if let Some(err) = code_error(detail.code, detail.message.as_deref().unwrap_or_default()) { if let Some(err) = code_error(detail.code, detail.message.as_deref().unwrap_or_default()) {
return Err(err); return Err(err);
} }
@@ -274,7 +267,7 @@ impl From<model::Item> for Fetched {
let text = compose_text(&title, &content); let text = compose_text(&title, &content);
let tags = topic_name(&item).to_string(); let tags = topic_name(&item).to_string();
let caption = caption(&url, &author_url, &author, &text); let caption = crate::site::caption(&url, &author_url, &author, &text);
let media = media_of(&item); let media = media_of(&item);
Fetched { Fetched {
@@ -286,7 +279,6 @@ impl From<model::Item> for Fetched {
sensitive: false, sensitive: false,
site_id: "bilibili", site_id: "bilibili",
render_data: Some(RenderData { render_data: Some(RenderData {
url,
author: encode_text(&author).into_owned(), author: encode_text(&author).into_owned(),
author_url, author_url,
title: encode_text(&title).into_owned(), title: encode_text(&title).into_owned(),
@@ -456,7 +448,6 @@ fn image(url: &str) -> Option<Media> {
} }
Some(if url.ends_with(".gif") { Some(if url.ends_with(".gif") {
Media::Animated { Media::Animated {
title: None,
url, url,
// Left empty on purpose: the `@518w.jpg` variant is unverified for // Left empty on purpose: the `@518w.jpg` variant is unverified for
// animated sources, and Telegram generates a frame preview itself. // animated sources, and Telegram generates a frame preview itself.
@@ -464,7 +455,6 @@ fn image(url: &str) -> Option<Media> {
} }
} else { } else {
Media::Illustration { Media::Illustration {
title: None,
// Written before `url` moves so the formatting borrows it. // Written before `url` moves so the formatting borrows it.
thumbnail_url: Some(format!("{url}{THUMB_SUFFIX}")), thumbnail_url: Some(format!("{url}{THUMB_SUFFIX}")),
url, url,
@@ -485,19 +475,6 @@ fn to_https(url: &str) -> String {
} }
} }
fn caption(url: &str, author_url: &str, author: &str, text: &str) -> String {
let url = encode_double_quoted_attribute(url);
let author_url = encode_double_quoted_attribute(author_url);
let author = encode_text(author);
if text.is_empty() {
return format!("{url}\n<a href=\"{author_url}\">{author}</a>");
}
format!(
"{url}\n<a href=\"{author_url}\">{author}</a>: {}",
encode_text(text)
)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -560,7 +537,8 @@ mod tests {
/// bot keeps ignoring them instead of answering with a failure. /// bot keeps ignoring them instead of answering with a failure.
#[test] #[test]
fn pattern_ignores_short_links() { fn pattern_ignores_short_links() {
assert!(!PATTERN.is_match("https://b23.tv/abc123")); // Short links usually point at videos, so they stay unmatched: no cache
// key, and the fetch dispatcher answers `Ok(None)` (silence).
assert_eq!(cache_key("https://b23.tv/abc123"), None); assert_eq!(cache_key("https://b23.tv/abc123"), None);
} }
@@ -580,6 +558,8 @@ mod tests {
} }
} }
/// The legacy `major.draw` shape stays supported alongside the
/// `itemOpusStyle` serialization that moves pictures to `major.opus.pics`.
#[test] #[test]
fn from_item_maps_draw_images_and_topic() { fn from_item_maps_draw_images_and_topic() {
let fetched = parse(item_json( let fetched = parse(item_json(
@@ -743,24 +723,6 @@ mod tests {
} }
} }
/// The legacy shape stays supported: bilibili's `itemOpusStyle` flag is
/// what moves the pictures to `major.opus.pics`, but `major.draw` items
/// and a text-only `desc` must keep working if it is retired.
#[test]
fn from_item_legacy_draw_shape_still_parses() {
let fetched = parse(item_json(
draw_item("http://i0.hdslb.com/bfs/new_dyn/l.jpg"),
"legacy 正文",
));
assert_eq!(fetched.title, "");
assert_eq!(fetched.content, "legacy 正文");
assert_eq!(fetched.media.len(), 1);
assert_eq!(
fetched.media[0].url(),
"https://i0.hdslb.com/bfs/new_dyn/l.jpg"
);
}
/// The video stream is out of scope; an AV dynamic still yields its cover. /// The video stream is out of scope; an AV dynamic still yields its cover.
#[test] #[test]
fn from_item_maps_archive_cover() { fn from_item_maps_archive_cover() {
@@ -908,7 +870,7 @@ mod tests {
// dropping the post. // dropping the post.
for code in [-352, -412] { for code in [-352, -412] {
let err = code_error(code, "-352").unwrap(); let err = code_error(code, "-352").unwrap();
assert!(is_retryable(&err), "{err}"); assert!(BilibiliSite.is_retryable(&err), "{err}");
} }
// A removed dynamic is permanent. // A removed dynamic is permanent.
assert!(matches!(code_error(500, ""), Some(FetchError::NotFound))); assert!(matches!(code_error(500, ""), Some(FetchError::NotFound)));
@@ -917,7 +879,7 @@ mod tests {
Some(FetchError::NotFound) Some(FetchError::NotFound)
)); ));
let err = code_error(-400, "param parsing failed").unwrap(); let err = code_error(-400, "param parsing failed").unwrap();
assert!(!is_retryable(&err), "{err}"); assert!(!BilibiliSite.is_retryable(&err), "{err}");
assert!(err.to_string().contains("-400"), "{err}"); assert!(err.to_string().contains("-400"), "{err}");
} }
@@ -1034,13 +996,14 @@ mod tests {
/// Fetches a live dynamic, skipping the assertion when bilibili /// Fetches a live dynamic, skipping the assertion when bilibili
/// risk-controls this IP (the site blocks datacenter/over-used addresses /// risk-controls this IP (the site blocks datacenter/over-used addresses
/// with `-352` regardless of cookies — a real failure would surface as a /// with `-352` regardless of cookies — a real failure would surface as a
/// parse error or a not-found instead). Mirrors the token-gated pixiv /// parse error or a not-found instead). Mirrors the pixiv download
/// tests' "skipping: …" convention. /// test's `SKIP …` convention — CI's live job greps that prefix to list
/// the skips in the run summary instead of showing a silently green run.
async fn live_fetch(url: &str) -> Option<Fetched> { async fn live_fetch(url: &str) -> Option<Fetched> {
match fetch_from_url(url).await { match fetch_from_url(url).await {
Ok(fetched) => Some(fetched), Ok(fetched) => Some(fetched),
Err(e) if e.to_string().contains("risk control") => { Err(e) if e.to_string().contains("risk control") => {
eprintln!("skipping: {e}"); eprintln!("SKIP (bilibili risk control): {e}");
None None
} }
Err(e) => panic!("{e}"), Err(e) => panic!("{e}"),
+1 -3
View File
@@ -1,6 +1,4 @@
mod interface; mod interface;
mod model; mod model;
pub use interface::{ pub use interface::{BilibiliSite, PATTERN, cache_key, fetch_from_url};
BilibiliSite, PATTERN, cache_key, enabled, fetch_from_url, is_retryable, media_headers,
};
+174 -76
View File
@@ -1,7 +1,7 @@
use super::model; use super::model;
use crate::media::Media; use crate::media::Media;
use crate::site::{FetchError, Fetched, Site, SiteFuture}; use crate::site::{FetchError, Fetched, Site, SiteFuture};
use html_escape::{encode_double_quoted_attribute, encode_text}; use html_escape::encode_text;
use regex::Regex; use regex::Regex;
use std::sync::LazyLock; use std::sync::LazyLock;
@@ -30,10 +30,6 @@ pub static PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?:https?://)?bsky\.app/profile/([\w.\-:]+)/post/([\w.\-~]+)").unwrap() Regex::new(r"^(?:https?://)?bsky\.app/profile/([\w.\-:]+)/post/([\w.\-~]+)").unwrap()
}); });
pub fn enabled() -> bool {
true
}
pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> { pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
let caps = PATTERN.captures(url).ok_or(FetchError::NotFound)?; let caps = PATTERN.captures(url).ok_or(FetchError::NotFound)?;
let handle = caps let handle = caps
@@ -51,6 +47,17 @@ pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
// encode path — the temp file stays alive via `_keep_alive`). On any // encode path — the temp file stays alive via `_keep_alive`). On any
// failure the video item is dropped and the post degrades to its text. // failure the video item is dropped and the post degrades to its text.
let mut media = Vec::with_capacity(fetched.media.len()); let mut media = Vec::with_capacity(fetched.media.len());
// The remux warnings below name the post, not the CDN URL they were
// working on: the media URL is derived from what the user pasted, and
// `warn` is a level operators share.
let key = cache_key(url).unwrap_or_else(|| "?".into());
// A failed remux is remembered: if it leaves the post with no media at
// all, returning `Ok` would read as "this post has no media". It is
// reported as `FetchError::MediaPrep` rather than a transient failure —
// the download legs already got their own retry in place ([`fetch_hls`]),
// and the fetch loop's retry would only download every segment again to
// fail the same way.
let mut remux_failure: Option<String> = None;
for item in fetched.media { for item in fetched.media {
let is_hls = matches!(&item, Media::Video { url, .. } let is_hls = matches!(&item, Media::Video { url, .. }
if url.contains("playlist") || url.ends_with(".m3u8")); if url.contains("playlist") || url.ends_with(".m3u8"));
@@ -66,16 +73,28 @@ pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
_ => String::new(), _ => String::new(),
}; };
media.push(Media::Video { media.push(Media::Video {
title: None,
url: mp4_path.to_string_lossy().into_owned(), url: mp4_path.to_string_lossy().into_owned(),
thumbnail_url, thumbnail_url,
}); });
fetched._keep_alive = Some(keep_alive); fetched._keep_alive = Some(std::sync::Arc::new(keep_alive));
}
// No ffmpeg: a deployment gap, not a bad moment — retrying it
// would only waste the fetch budget, so the post degrades (and an
// all-video post reports the media type as unsupported).
Ok(None) => log::warn!("bsky video remux unavailable for [key={key}]"),
Err(e) => {
log::warn!("bsky video remux failed for [key={key}]: {e}");
remux_failure = Some(e);
} }
Ok(None) => log::warn!("bsky video remux unavailable for {url}"),
Err(e) => log::warn!("bsky video remux failed for {url}: {e}"),
} }
} }
if media.is_empty()
&& let Some(reason) = remux_failure
{
return Err(FetchError::MediaPrep(format!(
"bsky video remux failed: {reason}"
)));
}
fetched.media = media; fetched.media = media;
Ok(fetched) Ok(fetched)
} }
@@ -88,15 +107,47 @@ pub fn cache_key(url: &str) -> Option<String> {
.map(|caps| format!("bsky:{}/{}", &caps[1], &caps[2])) .map(|caps| format!("bsky:{}/{}", &caps[1], &caps[2]))
} }
/// Bluesky's fetch-retry policy: transient classes only. Not-found, blocked /// Segments fetched (and written) at once while remuxing an HLS video. Small
/// and parse failures are permanent. /// on purpose: a segment can be up to 20 MiB and the whole playlist is capped
pub fn is_retryable(err: &FetchError) -> bool { /// at 256 MiB, so this is also what bounds the remux's peak memory.
matches!(err, FetchError::Http(_) | FetchError::Transient(_)) const SEGMENT_CONCURRENCY: usize = 4;
/// The ffmpeg concat list for the downloaded segments, **in segment order**.
/// The downloads complete in completion order (`JoinSet`), and ffmpeg would
/// happily concatenate them in whatever order the list holds: an out-of-order
/// list produces a silently scrambled video, not an error.
fn concat_list(files: &mut [(usize, std::path::PathBuf)]) -> String {
files.sort_by_key(|(i, _)| *i);
files
.iter()
.map(|(_, path)| format!("file '{}'\n", path.to_string_lossy()))
.collect()
} }
/// bsky media (cdn.bsky.app) needs no extra headers. /// One HLS fetch (a playlist or a segment) with an in-place retry for a
pub fn media_headers(_url: &str) -> Option<Vec<(&'static str, String)>> { /// retryable class (transport, 429/5xx). These used to get their retry from the
None /// outer fetch loop, which pays for it by replaying the whole post: master
/// playlist, variant playlist and every segment again. A segment failing near
/// the end of a 500-segment video meant downloading the entire thing twice
/// more, so the second attempt belongs on the request that actually failed.
async fn fetch_hls(url: &str, cap: u64) -> Result<bytes::Bytes, String> {
match crate::site::download_media_limited(url, cap, crate::site::DOWNLOAD_TOTAL_TIMEOUT).await {
Err(FetchError::RateLimited {
retry_after_secs, ..
}) => {
tokio::time::sleep(std::time::Duration::from_secs(retry_after_secs)).await;
crate::site::download_media_limited(url, cap, crate::site::DOWNLOAD_TOTAL_TIMEOUT)
.await
.map_err(|e| e.to_string())
}
Err(FetchError::Http(_) | FetchError::Transient(_)) => {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
crate::site::download_media_limited(url, cap, crate::site::DOWNLOAD_TOTAL_TIMEOUT)
.await
.map_err(|e| e.to_string())
}
other => other.map_err(|e| e.to_string()),
}
} }
/// Downloads an HLS playlist (master or media) and remuxes its segments to a /// Downloads an HLS playlist (master or media) and remuxes its segments to a
@@ -110,11 +161,10 @@ pub fn media_headers(_url: &str) -> Option<Vec<(&'static str, String)>> {
async fn resolve_bsky_video( async fn resolve_bsky_video(
playlist_url: &str, playlist_url: &str,
) -> Result<Option<(std::path::PathBuf, tempfile::TempDir)>, String> { ) -> Result<Option<(std::path::PathBuf, tempfile::TempDir)>, String> {
if !crate::site::ffmpeg_available() { if crate::site::ffmpeg_missing() {
crate::site::log_once_ffmpeg_missing();
return Ok(None); return Ok(None);
} }
let master = crate::site::download_media_limited(playlist_url, 1_048_576) let master = fetch_hls(playlist_url, 1_048_576)
.await .await
.map_err(|e| format!("bsky video master playlist: {e}"))?; .map_err(|e| format!("bsky video master playlist: {e}"))?;
let master = String::from_utf8_lossy(&master); let master = String::from_utf8_lossy(&master);
@@ -149,7 +199,7 @@ async fn resolve_bsky_video(
playlist_url.to_string() playlist_url.to_string()
}; };
let variant = crate::site::download_media_limited(&playlist_url, 1_048_576) let variant = fetch_hls(&playlist_url, 1_048_576)
.await .await
.map_err(|e| format!("bsky video media playlist: {e}"))?; .map_err(|e| format!("bsky video media playlist: {e}"))?;
let variant = String::from_utf8_lossy(&variant); let variant = String::from_utf8_lossy(&variant);
@@ -169,30 +219,59 @@ async fn resolve_bsky_video(
return Err("bsky video has too many segments".to_string()); return Err("bsky video has too many segments".to_string());
} }
let frames_dir = tempfile::tempdir().map_err(|e| e.to_string())?; let frames_dir = tempfile::Builder::new()
let out_dir = tempfile::tempdir().map_err(|e| e.to_string())?; .prefix(crate::TEMP_FILE_PREFIX)
.tempdir()
.map_err(|e| e.to_string())?;
let out_dir = tempfile::Builder::new()
.prefix(crate::TEMP_FILE_PREFIX)
.tempdir()
.map_err(|e| e.to_string())?;
// Segments are fetched concurrently under a small bound, and written with
// `tokio::fs` (a multi-megabyte `std::fs::write` blocks the executor
// thread). Serially, a several-hundred-segment video made the user wait
// for every round trip in turn — the dominant cost of a remux.
let mut total: u64 = 0; let mut total: u64 = 0;
let mut list = String::new(); let mut written: Vec<(usize, std::path::PathBuf)> = Vec::with_capacity(segments.len());
for (i, seg) in segments.iter().enumerate() { let mut next = 0;
let bytes = crate::site::download_media_limited(seg, 20 * 1024 * 1024) let mut set = tokio::task::JoinSet::new();
.await loop {
.map_err(|e| format!("bsky segment {i}: {e}"))?; while set.len() < SEGMENT_CONCURRENCY && next < segments.len() {
total += bytes.len() as u64; let i = next;
next += 1;
let seg = segments[i].clone();
let path = frames_dir.path().join(format!("seg_{i:04}.ts"));
set.spawn(async move {
let bytes = fetch_hls(&seg, 20 * 1024 * 1024)
.await
.map_err(|e| format!("bsky segment {i}: {e}"))?;
tokio::fs::write(&path, &bytes)
.await
.map_err(|e| format!("bsky segment {i}: {e}"))?;
Ok::<_, String>((i, bytes.len() as u64, path))
});
}
let Some(joined) = set.join_next().await else {
break;
};
let (i, len, path) = joined.map_err(|e| format!("bsky segment task panicked: {e}"))??;
total += len;
if total > 256 * 1024 * 1024 { if total > 256 * 1024 * 1024 {
return Err("bsky video exceeds total size cap".to_string()); return Err("bsky video exceeds total size cap".to_string());
} }
let path = frames_dir.path().join(format!("seg_{i:04}.ts")); written.push((i, path));
std::fs::write(&path, &bytes).map_err(|e| e.to_string())?;
list.push_str(&format!("file '{}'\n", path.to_string_lossy()));
} }
let list = concat_list(&mut written);
let list_path = frames_dir.path().join("list.txt"); let list_path = frames_dir.path().join("list.txt");
std::fs::write(&list_path, &list).map_err(|e| e.to_string())?; tokio::fs::write(&list_path, &list)
.await
.map_err(|e| e.to_string())?;
let output = out_dir.path().join("video.mp4"); let output = out_dir.path().join("video.mp4");
let list_str = list_path.to_string_lossy().into_owned(); let list_str = list_path.to_string_lossy().into_owned();
let output_str = output.to_string_lossy().into_owned(); let output_str = output.to_string_lossy().into_owned();
let status = tokio::task::spawn_blocking(move || { let status = tokio::task::spawn_blocking(move || {
std::process::Command::new("ffmpeg") let mut child = std::process::Command::new("ffmpeg")
.args([ .args([
"-y", "-y",
"-f", "-f",
@@ -209,15 +288,30 @@ async fn resolve_bsky_video(
]) ])
.stdout(std::process::Stdio::null()) .stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null()) .stderr(std::process::Stdio::null())
.status() .spawn()
.map_err(|e| format!("ffmpeg spawn failed: {e}"))?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300);
loop {
match child
.try_wait()
.map_err(|e| format!("ffmpeg wait failed: {e}"))?
{
Some(status) => break Ok(status),
None if std::time::Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
break Err("ffmpeg exceeded 300s".to_string());
}
None => std::thread::sleep(std::time::Duration::from_millis(50)),
}
}
}) })
.await .await
.map_err(|e| format!("bsky remux worker panicked: {e}"))?; .map_err(|e| format!("bsky remux worker panicked: {e}"))??;
match status { if !status.success() {
Ok(s) if s.success() => Ok(Some((output, out_dir))), return Err(format!("ffmpeg exited with {status}"));
Ok(s) => Err(format!("ffmpeg exited with {s}")),
Err(e) => Err(format!("ffmpeg spawn failed: {e}")),
} }
Ok(Some((output, out_dir)))
} }
/// Fetches a post thread by handle or DID (`at://` URIs work for both). /// Fetches a post thread by handle or DID (`at://` URIs work for both).
@@ -233,12 +327,9 @@ pub async fn fetch(handle: &str, rkey: &str) -> Result<Post, FetchError> {
// 404/410 = gone (permanent); 429/5xx = transient and retried by fetch. // 404/410 = gone (permanent); 429/5xx = transient and retried by fetch.
let status = response.status(); let status = response.status();
if !status.is_success() { if !status.is_success() {
return match status.as_u16() { return Err(crate::site::status_error("bsky", &response));
404 | 410 => Err(FetchError::NotFound),
_ => Err(FetchError::Transient(format!("bsky status {status}"))),
};
} }
let text = response.text().await?; let text = crate::site::response_text(response, "bsky").await?;
Post::from_json(&text, rkey.to_string()) Post::from_json(&text, rkey.to_string())
} }
@@ -262,13 +353,7 @@ impl Post {
} }
pub fn caption(&self) -> String { pub fn caption(&self) -> String {
format!( crate::site::caption(&self.url(), &self.author_url(), &self.author, &self.text)
"{url}\n<a href=\"{author_url}\">{author}</a>: {text}",
url = encode_double_quoted_attribute(&self.url()),
author_url = encode_double_quoted_attribute(&self.author_url()),
author = encode_text(&self.author),
text = encode_text(&self.text),
)
} }
pub fn from_json(raw_json: &str, id: String) -> Result<Self, FetchError> { pub fn from_json(raw_json: &str, id: String) -> Result<Self, FetchError> {
@@ -284,7 +369,6 @@ impl Post {
match embed { match embed {
model::Media::Images { images } => { model::Media::Images { images } => {
media.extend(images.into_iter().map(|image| Media::Illustration { media.extend(images.into_iter().map(|image| Media::Illustration {
title: None,
url: image.fullsize, url: image.fullsize,
thumbnail_url: Some(image.thumb), thumbnail_url: Some(image.thumb),
fallback_url: None, fallback_url: None,
@@ -295,7 +379,6 @@ impl Post {
thumbnail, thumbnail,
} => { } => {
media.push(Media::Video { media.push(Media::Video {
title: None,
url: playlist, url: playlist,
thumbnail_url: thumbnail, thumbnail_url: thumbnail,
}); });
@@ -327,7 +410,6 @@ impl From<Post> for Fetched {
let url = post.url(); let url = post.url();
let author_url = post.author_url(); let author_url = post.author_url();
let render_data = Some(crate::site::RenderData { let render_data = Some(crate::site::RenderData {
url: url.clone(),
author: encode_text(&post.author).into_owned(), author: encode_text(&post.author).into_owned(),
author_url: author_url.clone(), author_url: author_url.clone(),
// A post has no title: its text is all content. // A post has no title: its text is all content.
@@ -360,6 +442,22 @@ mod tests {
serde_json::json!({ "thread": post_json }) serde_json::json!({ "thread": post_json })
} }
/// The downloads finish in completion order; ffmpeg concatenates whatever
/// order `list.txt` holds, so an unsorted list is a scrambled video rather
/// than an error.
#[test]
fn concat_list_is_in_segment_order() {
let mut files = vec![
(2, std::path::PathBuf::from("/t/seg_0002.ts")),
(0, std::path::PathBuf::from("/t/seg_0000.ts")),
(1, std::path::PathBuf::from("/t/seg_0001.ts")),
];
assert_eq!(
concat_list(&mut files),
"file '/t/seg_0000.ts'\nfile '/t/seg_0001.ts'\nfile '/t/seg_0002.ts'\n"
);
}
#[test] #[test]
fn pattern_matches_handle_and_did() { fn pattern_matches_handle_and_did() {
let cases = [ let cases = [
@@ -392,6 +490,19 @@ mod tests {
} }
} }
/// A remux failure is a `MediaPrep`, which the fetch loop does not retry:
/// replaying the post means downloading every HLS segment again, when the
/// request that failed already got its second attempt in place
/// ([`fetch_hls`]). The classes below are the ones still retried there.
#[test]
fn media_prep_failure_is_not_retried() {
use crate::site::Site as _;
assert!(!BskySite.is_retryable(&FetchError::MediaPrep(
"bsky video remux failed: segment 400: 503".into()
)));
assert!(BskySite.is_retryable(&FetchError::Transient("429".into())));
}
#[test] #[test]
fn from_json_images_with_missing_defaults() { fn from_json_images_with_missing_defaults() {
let raw = thread_json(serde_json::json!({ let raw = thread_json(serde_json::json!({
@@ -460,31 +571,18 @@ mod tests {
)); ));
} }
/// The one live bsky check: a labelled post with photos — source URL,
/// caption, media and the sensitive label all survive the parse. This
/// replaced a second byte-identical live test whose URL is a *text-only*
/// post, so neither copy pinned any media.
#[tokio::test] #[tokio::test]
#[ignore = "live network: requires outbound HTTPS to public.api.bsky.app"] #[ignore = "live network: requires outbound HTTPS to public.api.bsky.app"]
async fn live_fetch_with_photos() { async fn live_fetch_with_photos() {
let fetched = let url = "https://bsky.app/profile/fu-futa.bsky.social/post/3laoveufjv224";
fetch_from_url("https://bsky.app/profile/asagi0398.bsky.social/post/3mqkhrq5w6k2m") let fetched = fetch_from_url(url).await.unwrap();
.await assert_eq!(fetched.source_url, url);
.unwrap();
assert_eq!(
fetched.source_url,
"https://bsky.app/profile/asagi0398.bsky.social/post/3mqkhrq5w6k2m"
);
assert!(!fetched.caption.is_empty());
}
#[tokio::test]
#[ignore = "live network: requires outbound HTTPS to public.api.bsky.app"]
async fn live_fetch_smoke() {
let fetched =
fetch_from_url("https://bsky.app/profile/fu-futa.bsky.social/post/3laoveufjv224")
.await
.unwrap();
assert_eq!(
fetched.source_url,
"https://bsky.app/profile/fu-futa.bsky.social/post/3laoveufjv224"
);
assert!(!fetched.caption.is_empty()); assert!(!fetched.caption.is_empty());
assert!(!fetched.media.is_empty(), "expected photos in {url}");
assert!(fetched.sensitive, "expected a label on {url}");
} }
} }
+1 -3
View File
@@ -1,6 +1,4 @@
mod interface; mod interface;
mod model; mod model;
pub use interface::{ pub use interface::{BskySite, PATTERN, Post, cache_key, fetch_from_url};
BskySite, PATTERN, Post, cache_key, enabled, fetch_from_url, is_retryable, media_headers,
};
+388
View File
@@ -0,0 +1,388 @@
//! The media-download stack: the two HTTP clients (site metadata vs. media,
//! which need different timeouts), the CDN allowlist that keeps a download
//! out of the host's own network, and the two streaming entry points — a capped
//! body in memory ([`download_media_limited`]) and a large one written as it
//! arrives ([`download_media_to_file`]).
//!
//! Site-specific headers come from each adapter's `Site::media_headers`; no
//! code here knows about a particular site.
use super::{FetchError, SITES};
use std::sync::LazyLock;
use std::time::Duration;
/// How long a download may make no progress: the response head, and then each
/// individual chunk, must arrive within this window. Not a total timeout — see
/// [`DOWNLOAD_TOTAL_TIMEOUT`].
const DOWNLOAD_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
/// Absolute ceiling for one media download, on top of the idle window: a
/// server that drips a byte every 29 s keeps [`next_chunk`] satisfied
/// indefinitely, and a transfer that trickles forever holds whatever the
/// caller pinned to it — a fetch permit for an in-flight post, a prep slot
/// for the bot's upload fallback. Generous on purpose: the legitimate cases
/// are big — an ugoira frame zip runs to hundreds of MB and an HLS remux
/// pulls a whole video — so this is the budget for downloads *inside a
/// fetch*, while the slot-holding fallback passes its own shorter one (see
/// [`download_media_limited`]'s `total`). Checked between chunks, so a
/// transfer that completes just over the budget is kept rather than thrown
/// away.
pub(crate) const DOWNLOAD_TOTAL_TIMEOUT: Duration = Duration::from_secs(600);
/// The error a download reports when it spends its whole budget without
/// finishing. Retryable: the transfer may simply have been unlucky, and a retry
/// of the post restarts the download.
fn download_too_slow(total: Duration) -> FetchError {
FetchError::Transient(format!("download exceeded {}s", total.as_secs()))
}
/// Builds a client with the shared configuration (browser User-Agent, the
/// Bot API's proxy, per-runtime pools under test). `total_timeout` is what
/// differs between the two clients below.
fn build_client(total_timeout: Option<Duration>) -> reqwest::Client {
let mut builder = reqwest::Client::builder()
.user_agent("Mozilla/5.0")
.connect_timeout(Duration::from_secs(10));
// Redirects stay allowed for allowlisted CDN hops, but every hop goes
// through the same policy as the initial URL; a third-party response must
// not be able to introduce a new host.
builder = builder.redirect(reqwest::redirect::Policy::custom(|attempt| {
if !media_url_allowed(attempt.url()) {
log::warn!("refusing a media redirect outside the CDN allowlist");
return attempt.error(FetchError::Blocked);
}
if attempt.previous().len() >= 10 {
return attempt.stop();
}
attempt.follow()
}));
if let Some(total) = total_timeout {
// reqwest has no total timeout by default; a stalled connection
// would otherwise pin a fetch/handler forever.
builder = builder.timeout(total);
}
// Route site fetches through the same proxy the Bot API uses, so a
// network that needs TELOXIDE_PROXY (e.g. behind the GFW) does not
// leave site fetches dead while the bot itself works.
if let Some(proxy) = std::env::var("TELOXIDE_PROXY")
.ok()
.filter(|s| !s.is_empty())
&& let Ok(p) = reqwest::Proxy::all(&proxy)
{
builder = builder.proxy(p);
}
// Each `#[tokio::test]` runs on its own runtime; the connection pool is
// bound to the runtime that created it, so cross-runtime reuse of idle
// connections fails with DispatchGone. In test builds every request uses
// a fresh connection. Production runs on one runtime and keeps pooling.
#[cfg(test)]
let builder = builder.pool_max_idle_per_host(0);
builder.build().expect("failed to build HTTP client")
}
/// Shared HTTP client (browser User-Agent) for the site fetches — metadata
/// requests, where 30s is generous.
pub(crate) static CLIENT: LazyLock<reqwest::Client> =
LazyLock::new(|| build_client(Some(Duration::from_secs(30))));
/// Client for media *downloads*, with no reqwest-level total timeout: a 10 MiB
/// fallback download, or an ugoira frame zip that may be hundreds of MB,
/// legitimately takes minutes on a slow link — a 30s total cap made those posts
/// impossible to deliver at all (the size cap said 512 MiB, the clock said 30s).
/// What a stalled connection cannot do is hang a worker: the head and every
/// chunk are bounded by [`DOWNLOAD_IDLE_TIMEOUT`] (see [`next_chunk`]), and a
/// transfer that keeps trickling but never finishes is bounded by the
/// caller's total budget (see [`download_media_limited`]).
static MEDIA_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| build_client(None));
/// The error a download reports when it stops making progress.
fn download_stalled() -> FetchError {
FetchError::Transient(format!(
"download stalled for {}s",
DOWNLOAD_IDLE_TIMEOUT.as_secs()
))
}
/// Sends a media-download request: the response head must arrive within the
/// idle window, and a non-2xx status is classified by
/// [`super::status_error`] with `"media"` as the name — the same table the
/// site adapters use, so a dead URL and a bad moment read the same everywhere.
/// A transport error never reaches that table — it fails in `send()` and
/// stays [`FetchError::Http`].
async fn send_download(request: reqwest::RequestBuilder) -> Result<reqwest::Response, FetchError> {
let response = match tokio::time::timeout(DOWNLOAD_IDLE_TIMEOUT, request.send()).await {
Ok(Ok(response)) => response,
Ok(Err(e)) => return Err(e.into()),
Err(_) => return Err(download_stalled()),
};
if response.status().is_success() {
Ok(response)
} else {
Err(super::status_error("media", &response))
}
}
/// One body chunk, or `None` at the end. A body that stops delivering is a
/// transient download error rather than a hang.
async fn next_chunk(response: &mut reqwest::Response) -> Result<Option<bytes::Bytes>, FetchError> {
match tokio::time::timeout(DOWNLOAD_IDLE_TIMEOUT, response.chunk()).await {
Ok(Ok(chunk)) => Ok(chunk),
Ok(Err(e)) => Err(e.into()),
Err(_) => Err(download_stalled()),
}
}
/// Reads a successful API response body with a hard byte cap.
pub(crate) async fn send_json_response(
mut response: reqwest::Response,
site: &'static str,
) -> Result<bytes::Bytes, FetchError> {
if let Some(len) = response.content_length()
&& len > crate::site::MAX_SITE_JSON_BYTES as u64
{
return Err(FetchError::Site {
site,
error: "site response exceeds JSON size cap".into(),
});
}
let mut body = Vec::new();
while let Some(chunk) = next_chunk(&mut response).await? {
if body.len().saturating_add(chunk.len()) > crate::site::MAX_SITE_JSON_BYTES {
return Err(FetchError::Site {
site,
error: "site response exceeds JSON size cap".into(),
});
}
body.extend_from_slice(&chunk);
}
Ok(bytes::Bytes::from(body))
}
/// `localhost` (and anything under it) plus the mDNS `.local` suffix.
fn is_local_name(name: &str) -> bool {
let name = name.trim_end_matches('.').to_ascii_lowercase();
name == "localhost" || name.ends_with(".localhost") || name.ends_with(".local")
}
/// Media is fetched only from the CDN families used by the site adapters.
/// IP literals are rejected as well: a public IP is not a member of that
/// allowlist, and accepting one would turn the bot into a generic proxy.
fn media_host_allowed(name: &str) -> bool {
let name = name.trim_end_matches('.').to_ascii_lowercase();
name == "misskey.io"
|| name.ends_with(".misskey.io")
|| name == "misskeyusercontent.jp"
|| name.ends_with(".misskeyusercontent.jp")
|| name == "bsky.app"
|| name.ends_with(".bsky.app")
|| name == "twimg.com"
|| name.ends_with(".twimg.com")
|| name == "pximg.net"
|| name.ends_with(".pximg.net")
|| name == "hdslb.com"
|| name.ends_with(".hdslb.com")
}
fn media_url_allowed(url: &url::Url) -> bool {
if !matches!(url.scheme(), "http" | "https") {
return false;
}
matches!(url.host(), Some(url::Host::Domain(name)) if !is_local_name(name) && media_host_allowed(name))
}
/// Prepares a media download: refuses a URL outside the CDN allowlist
/// ([`FetchError::Blocked`], permanent — the same URL would be refused again),
/// then applies every site's media-header rule (pixiv's `Referer` for pximg.net
/// hotlink protection; sites contribute via `media_headers(url)`, so the
/// central download code carries no other per-site logic). One choke point so
/// every download path gets both.
fn media_request(url: &str) -> Result<reqwest::RequestBuilder, FetchError> {
let parsed = url::Url::parse(url).map_err(|e| {
log::warn!("media url is not a url: {e}");
FetchError::Blocked
})?;
if !media_url_allowed(&parsed) {
log::warn!("refusing media URL outside the CDN allowlist");
return Err(FetchError::Blocked);
}
let mut request = MEDIA_CLIENT.get(parsed);
for site in SITES.iter() {
if let Some(headers) = site.media_headers(url) {
for (name, value) in headers {
request = request.header(name, value);
}
}
}
Ok(request)
}
/// Downloads a media file with a hard size cap: the body is streamed and the
/// download aborts with [`FetchError::TooLarge`] the moment the cap is
/// crossed (or when a declared Content-Length already exceeds it). Keeps the
/// bot from buffering arbitrarily large bodies into memory — the size check
/// the bot's upload fallback needs is the one here, not a probe of its own.
///
/// This is the bot's download path for the upload fallback: when Telegram
/// cannot fetch a media URL itself (hotlink protection), the bot downloads
/// the file and uploads it via multipart. Site-appropriate headers come from
/// each site's `media_headers` (pixiv image hosts need `Referer`).
///
/// `total` is this caller's whole-transfer budget. The bot's upload fallback
/// holds a prep slot (and its memory reservation) while this runs, so it
/// passes a shorter one of its own; bsky's in-fetch segments take the
/// generous [`super::DOWNLOAD_TOTAL_TIMEOUT`].
pub async fn download_media_limited(
url: &str,
max_bytes: u64,
total: Duration,
) -> Result<bytes::Bytes, FetchError> {
let response = send_download(media_request(url)?).await?;
if let Some(len) = response.content_length()
&& len > max_bytes
{
return Err(FetchError::TooLarge);
}
let mut response = response;
let mut buf = Vec::new();
let started = std::time::Instant::now();
while let Some(chunk) = next_chunk(&mut response).await? {
if started.elapsed() > total {
return Err(download_too_slow(total));
}
buf.extend_from_slice(&chunk);
if buf.len() as u64 > max_bytes {
return Err(FetchError::TooLarge);
}
}
Ok(bytes::Bytes::from(buf))
}
/// Streams a download to `out`, aborting with [`FetchError::TooLarge`] the
/// moment the body crosses `max_bytes` (or when a declared Content-Length
/// already exceeds it). Unlike [`download_media_limited`] the body is never
/// buffered in memory — used for large files (e.g. the pixiv ugoira frame
/// zip, which can be hundreds of MB) that would otherwise spike RAM. Writes
/// go through the tokio handle so a sync write never stalls an executor
/// thread for the length of the download. Returns the number of bytes written.
pub async fn download_media_to_file(
url: &str,
max_bytes: u64,
out: &mut tokio::fs::File,
) -> Result<u64, FetchError> {
use tokio::io::AsyncWriteExt;
let response = send_download(media_request(url)?).await?;
if let Some(len) = response.content_length()
&& len > max_bytes
{
return Err(FetchError::TooLarge);
}
let mut response = response;
let mut total: u64 = 0;
let started = std::time::Instant::now();
while let Some(chunk) = next_chunk(&mut response).await? {
if started.elapsed() > DOWNLOAD_TOTAL_TIMEOUT {
return Err(download_too_slow(DOWNLOAD_TOTAL_TIMEOUT));
}
total += chunk.len() as u64;
if total > max_bytes {
return Err(FetchError::TooLarge);
}
out.write_all(&chunk).await.map_err(FetchError::Io)?;
}
Ok(total)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::site::{Fetched, pixiv};
#[test]
fn media_urls_outside_the_allowlist_are_refused() {
for url in [
"http://127.0.0.1:9/x",
"http://169.254.169.254/latest/meta-data/",
"http://[::1]:9/x",
"https://1.1.1.1/x",
"https://[2606:4700::1111]/x",
"https://localhost/",
"https://prompt.localhost/x",
"https://printer.local/x",
"https://example.com/a",
"https://evil.pximg.net.attacker.example/a",
"file:///etc/passwd",
"gopher://example.com/1",
] {
let parsed = url::Url::parse(url).unwrap();
assert!(!media_url_allowed(&parsed), "{url}");
}
for url in [
"https://i.pximg.net/img-original/img/1.jpg",
"https://cdn.bsky.app/img/feed_thumbnail/plain/x",
"https://pbs.twimg.com/media/1.jpg",
"https://media.misskeyusercontent.jp/io/1.jpg",
"https://i0.hdslb.com/bfs/1.jpg",
] {
let parsed = url::Url::parse(url).unwrap();
assert!(media_url_allowed(&parsed), "{url}");
}
}
#[tokio::test]
async fn a_download_from_a_refused_host_is_blocked() {
// Refused on the URL alone: nothing has to be listening (or leaking) at
// the metadata endpoint for this to hold, and the class is permanent so
// the send path does not retry it.
for url in [
"http://169.254.169.254/latest/meta-data/",
"http://127.0.0.1:9/secret",
"http://8.8.8.8/x",
] {
let err = download_media_limited(url, u64::MAX, DOWNLOAD_TOTAL_TIMEOUT)
.await
.unwrap_err();
assert!(matches!(err, FetchError::Blocked), "{url}: got {err:?}");
}
// A malformed URL is refused the same way instead of becoming a
// retryable transport error.
assert!(matches!(
download_media_limited("not a url", u64::MAX, DOWNLOAD_TOTAL_TIMEOUT)
.await
.unwrap_err(),
FetchError::Blocked
));
}
#[tokio::test]
#[ignore = "live network: requires PIXIV_REFRESH_TOKEN and i.pximg.net"]
async fn live_download_media_pixiv_original_with_referer() {
// Proves the Referer header is attached for i.pximg.net: a header-less
// GET to a pixiv original URL is rejected with 403. `#[ignore]` as
// well as the token gate: this hit the CDN on every `cargo test
// --workspace` in a token-exported shell (and flaked on a CDN body
// timeout there), and the `live_` name puts it inside the CI live
// job's `--ignored live` filter. Empty-string check too: an unset CI
// secret arrives as "" (GitHub Actions), which would otherwise run
// the test tokenless and fail — the `SKIP` prefix is what the live
// job greps to tell a skip from a pass.
if std::env::var("PIXIV_REFRESH_TOKEN")
.ok()
.filter(|s| !s.is_empty())
.is_none()
{
eprintln!("SKIP (no PIXIV_REFRESH_TOKEN): not running the pixiv download test");
return;
}
let illustration = pixiv::fetch(126839080).await.unwrap();
let fetched: Fetched = illustration.into();
let url = match fetched.media.first() {
Some(crate::media::Media::Illustration { url, .. }) => url.clone(),
other => panic!("expected illustration media, got {other:?}"),
};
assert!(url.contains("i.pximg.net"));
let bytes = download_media_limited(&url, u64::MAX, DOWNLOAD_TOTAL_TIMEOUT)
.await
.unwrap();
assert!(!bytes.is_empty());
}
}
+10 -49
View File
@@ -4,7 +4,7 @@
use super::model; use super::model;
use crate::media::Media; use crate::media::Media;
use crate::site::{FetchError, Fetched, RenderData, Site, SiteFuture}; use crate::site::{FetchError, Fetched, RenderData, Site, SiteFuture};
use html_escape::{encode_double_quoted_attribute, encode_text}; use html_escape::encode_text;
use regex::Regex; use regex::Regex;
use std::sync::LazyLock; use std::sync::LazyLock;
@@ -34,10 +34,6 @@ impl Site for MisskeySite {
pub static PATTERN: LazyLock<Regex> = pub static PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(?:https?://)?misskey\.io/notes/([\w.\-~]+)").unwrap()); LazyLock::new(|| Regex::new(r"^(?:https?://)?misskey\.io/notes/([\w.\-~]+)").unwrap());
pub fn enabled() -> bool {
true
}
pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> { pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
let caps = PATTERN.captures(url).ok_or(FetchError::NotFound)?; let caps = PATTERN.captures(url).ok_or(FetchError::NotFound)?;
let note_id = caps.get(1).ok_or(FetchError::NotFound)?.as_str(); let note_id = caps.get(1).ok_or(FetchError::NotFound)?.as_str();
@@ -53,20 +49,10 @@ pub fn cache_key(url: &str) -> Option<String> {
.map(|caps| format!("misskey:{}", &caps[1])) .map(|caps| format!("misskey:{}", &caps[1]))
} }
/// Misskey's fetch-retry policy: transient classes only. Not-found, blocked
/// and parse failures are permanent.
pub fn is_retryable(err: &FetchError) -> bool {
matches!(err, FetchError::Http(_) | FetchError::Transient(_))
}
/// misskey.io media hosts need no extra headers (verified: direct GET works).
pub fn media_headers(_url: &str) -> Option<Vec<(&'static str, String)>> {
None
}
/// Fetches a note from misskey.io by id. The API answers client failures /// Fetches a note from misskey.io by id. The API answers client failures
/// with HTTP 400 + `{"error":{"code":...}}` (NO_SUCH_NOTE → NotFound); /// with HTTP 400 + `{"error":{"code":...}}` (NO_SUCH_NOTE → NotFound);
/// everything else non-success is transient and retried by [`crate::site::fetch`]. /// every other non-success status falls through to the shared classes in
/// [`crate::site::status_error`] — persistent 4xx permanent, 429/5xx retried.
pub async fn fetch(note_id: &str) -> Result<model::Note, FetchError> { pub async fn fetch(note_id: &str) -> Result<model::Note, FetchError> {
let response = crate::site::CLIENT let response = crate::site::CLIENT
.post(API_URL) .post(API_URL)
@@ -77,19 +63,19 @@ pub async fn fetch(note_id: &str) -> Result<model::Note, FetchError> {
if !status.is_success() { if !status.is_success() {
return Err(match status.as_u16() { return Err(match status.as_u16() {
400 => not_found_or_invalid(response).await, 400 => not_found_or_invalid(response).await,
_ => FetchError::Transient(format!("misskey status {status}")), // The local fallback used to disagree with the center: a misskey
// 404 came back Transient here and was fetched three more times
// for a note that is simply gone.
_ => crate::site::status_error("misskey", &response),
}); });
} }
response.json().await.map_err(|e| FetchError::Site { crate::site::response_json(response, "misskey").await
site: "misskey",
error: Box::new(e),
})
} }
/// Maps a 400 response: NO_SUCH_NOTE is permanent NotFound, any other 400 is /// Maps a 400 response: NO_SUCH_NOTE is permanent NotFound, any other 400 is
/// a site error (permanent — retrying a rejected request cannot succeed). /// a site error (permanent — retrying a rejected request cannot succeed).
async fn not_found_or_invalid(response: reqwest::Response) -> FetchError { async fn not_found_or_invalid(response: reqwest::Response) -> FetchError {
match response.json::<serde_json::Value>().await { match crate::site::response_json::<serde_json::Value>(response, "misskey").await {
Ok(v) if v["error"]["code"] == "NO_SUCH_NOTE" => FetchError::NotFound, Ok(v) if v["error"]["code"] == "NO_SUCH_NOTE" => FetchError::NotFound,
_ => FetchError::Site { _ => FetchError::Site {
site: "misskey", site: "misskey",
@@ -130,7 +116,7 @@ impl From<model::Note> for Fetched {
text.push_str(content.text.as_deref().unwrap_or_default().trim()); text.push_str(content.text.as_deref().unwrap_or_default().trim());
let text = text.trim().to_string(); let text = text.trim().to_string();
let caption = caption(&url, &author_url, &author, &text); let caption = crate::site::caption(&url, &author_url, &author, &text);
let sensitive = content.cw.is_some() || content.files.iter().any(|f| f.is_sensitive); let sensitive = content.cw.is_some() || content.files.iter().any(|f| f.is_sensitive);
let media: Vec<Media> = content.files.iter().filter_map(media_from_file).collect(); let media: Vec<Media> = content.files.iter().filter_map(media_from_file).collect();
@@ -144,7 +130,6 @@ impl From<model::Note> for Fetched {
sensitive, sensitive,
site_id: "misskey", site_id: "misskey",
render_data: Some(RenderData { render_data: Some(RenderData {
url,
author: encode_text(&author).into_owned(), author: encode_text(&author).into_owned(),
author_url: author_url.clone(), author_url: author_url.clone(),
title: String::new(), title: String::new(),
@@ -156,38 +141,21 @@ impl From<model::Note> for Fetched {
} }
} }
fn caption(url: &str, author_url: &str, author: &str, text: &str) -> String {
let url = encode_double_quoted_attribute(url);
let author_url = encode_double_quoted_attribute(author_url);
let author = encode_text(author);
if text.is_empty() {
return format!("{url}\n<a href=\"{author_url}\">{author}</a>");
}
format!(
"{url}\n<a href=\"{author_url}\">{author}</a>: {text}",
text = encode_text(text),
)
}
/// Maps a Misskey DriveFile to a [`Media`] item; unknown/audio/other types /// Maps a Misskey DriveFile to a [`Media`] item; unknown/audio/other types
/// are skipped (twitter's `_ => {}` precedent). GIF must be matched before /// are skipped (twitter's `_ => {}` precedent). GIF must be matched before
/// the generic image arm. /// the generic image arm.
fn media_from_file(file: &model::DriveFile) -> Option<Media> { fn media_from_file(file: &model::DriveFile) -> Option<Media> {
let title = file.name.clone();
match file.mime_type.as_str() { match file.mime_type.as_str() {
"image/gif" => Some(Media::Animated { "image/gif" => Some(Media::Animated {
title,
url: file.url.clone(), url: file.url.clone(),
thumbnail_url: file.thumbnail_url.clone().unwrap_or_default(), thumbnail_url: file.thumbnail_url.clone().unwrap_or_default(),
}), }),
mime if mime.starts_with("image/") => Some(Media::Illustration { mime if mime.starts_with("image/") => Some(Media::Illustration {
title,
url: file.url.clone(), url: file.url.clone(),
thumbnail_url: file.thumbnail_url.clone(), thumbnail_url: file.thumbnail_url.clone(),
fallback_url: None, fallback_url: None,
}), }),
mime if mime.starts_with("video/") => Some(Media::Video { mime if mime.starts_with("video/") => Some(Media::Video {
title,
url: file.url.clone(), url: file.url.clone(),
thumbnail_url: file.thumbnail_url.clone().unwrap_or_default(), thumbnail_url: file.thumbnail_url.clone().unwrap_or_default(),
}), }),
@@ -237,11 +205,6 @@ mod tests {
cache_key("https://misskey.io/notes/aotihl10lqrs015s"), cache_key("https://misskey.io/notes/aotihl10lqrs015s"),
Some("misskey:aotihl10lqrs015s".to_string()) Some("misskey:aotihl10lqrs015s".to_string())
); );
assert_eq!(x_media_site_id("misskey:abc"), "misskey");
}
fn x_media_site_id(key: &str) -> &'static str {
crate::site::site_id_from_key(key)
} }
#[test] #[test]
@@ -266,12 +229,10 @@ mod tests {
assert_eq!(fetched.media.len(), 1); assert_eq!(fetched.media.len(), 1);
match &fetched.media[0] { match &fetched.media[0] {
Media::Illustration { Media::Illustration {
title,
url, url,
thumbnail_url, thumbnail_url,
fallback_url, fallback_url,
} => { } => {
assert_eq!(title.as_deref(), Some("pic.webp"));
assert_eq!(url, "https://media.misskeyusercontent.jp/io/a.webp"); assert_eq!(url, "https://media.misskeyusercontent.jp/io/a.webp");
assert_eq!( assert_eq!(
thumbnail_url.as_deref(), thumbnail_url.as_deref(),
+1 -3
View File
@@ -1,6 +1,4 @@
mod interface; mod interface;
mod model; mod model;
pub use interface::{ pub use interface::{MisskeySite, PATTERN, cache_key, fetch_from_url};
MisskeySite, PATTERN, cache_key, enabled, fetch_from_url, is_retryable, media_headers,
};
-2
View File
@@ -30,6 +30,4 @@ pub(crate) struct DriveFile {
pub(crate) thumbnail_url: Option<String>, pub(crate) thumbnail_url: Option<String>,
#[serde(default, rename = "isSensitive")] #[serde(default, rename = "isSensitive")]
pub(crate) is_sensitive: bool, pub(crate) is_sensitive: bool,
#[serde(default)]
pub(crate) name: Option<String>,
} }
+434 -224
View File
@@ -15,12 +15,16 @@ use thiserror::Error;
pub mod bilibili; pub mod bilibili;
pub mod bsky; pub mod bsky;
mod download;
pub mod misskey; pub mod misskey;
pub mod pixiv; pub mod pixiv;
pub mod twitter; pub mod twitter;
pub use pixiv::PixivError; pub use pixiv::PixivError;
pub(crate) use download::{CLIENT, DOWNLOAD_TOTAL_TIMEOUT};
pub use download::{download_media_limited, download_media_to_file};
/// The result of fetching a post: canonical URL, HTML caption, the post's /// The result of fetching a post: canonical URL, HTML caption, the post's
/// title and body, media list and spoiler flag. Produced by [`fetch`]. /// title and body, media list and spoiler flag. Produced by [`fetch`].
#[derive(Debug)] #[derive(Debug)]
@@ -52,8 +56,11 @@ pub struct Fetched {
/// Raw values (pre-escaped) for user-customizable caption formats. /// Raw values (pre-escaped) for user-customizable caption formats.
pub(crate) render_data: Option<RenderData>, pub(crate) render_data: Option<RenderData>,
/// Keeps temp files (e.g. an encoded ugoira MP4) alive until the caller /// Keeps temp files (e.g. an encoded ugoira MP4) alive until the caller
/// finishes uploading; not part of the public contract. /// finishes uploading; not part of the public contract. Shared rather than
pub(crate) _keep_alive: Option<tempfile::TempDir>, /// owned because one fetched post can serve several sends — the bot shares
/// one in-flight fetch between concurrent duplicates of the same link — and
/// the files have to outlive every one of them.
pub(crate) _keep_alive: Option<std::sync::Arc<tempfile::TempDir>>,
} }
/// Values for the `{url} {author} {author_url} {title} {content} {tags}` /// Values for the `{url} {author} {author_url} {title} {content} {tags}`
@@ -62,13 +69,14 @@ pub struct Fetched {
/// ///
/// `author`, `title`, `content` and `tags` come from the site API (post /// `author`, `title`, `content` and `tags` come from the site API (post
/// text, display names, descriptions) and are HTML-escaped at construction. /// text, display names, descriptions) and are HTML-escaped at construction.
/// `url` and `author_url` stay raw: they are canonical URLs the adapter /// `author_url` stays raw: it is a canonical URL the adapter builds from
/// builds from numeric ids and API-constrained handles/DIDs, so they carry /// numeric ids and API-constrained handles/DIDs, so it carries no escapable
/// no escapable character — the bot's `/test` report relies on that when it /// character — the bot's `/test` report relies on that when it embeds it.
/// embeds them. /// `{url}` needs no copy here: [`Fetched::source_url`] is the same canonical
/// URL every adapter would have handed this struct, and `caption_with` reads
/// it from there.
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct RenderData { pub(crate) struct RenderData {
pub url: String,
pub author: String, pub author: String,
pub author_url: String, pub author_url: String,
pub title: String, pub title: String,
@@ -76,6 +84,24 @@ pub(crate) struct RenderData {
pub tags: String, pub tags: String,
} }
/// The built-in caption for a post that has no user-supplied format: the
/// canonical URL, the author as a link, then the post's text after a colon.
/// The two URLs are escaped for an HTML attribute and the text as HTML text,
/// so site-supplied content cannot inject markup. Shared by the adapters whose
/// captions have exactly this shape (bilibili, misskey).
pub fn caption(url: &str, author_url: &str, author: &str, text: &str) -> String {
let url = html_escape::encode_double_quoted_attribute(url);
let author_url = html_escape::encode_double_quoted_attribute(author_url);
let author = html_escape::encode_text(author);
if text.is_empty() {
return format!("{url}\n<a href=\"{author_url}\">{author}</a>");
}
format!(
"{url}\n<a href=\"{author_url}\">{author}</a>: {}",
html_escape::encode_text(text)
)
}
/// The post's text as one string: title and content joined by a line break, /// The post's text as one string: title and content joined by a line break,
/// each only when it is non-empty. This is what the sites' built-in captions /// each only when it is non-empty. This is what the sites' built-in captions
/// show after the author line, and what the bot quotes when it is long. /// show after the author line, and what the bot quotes when it is long.
@@ -89,13 +115,6 @@ pub fn compose_text(title: &str, content: &str) -> String {
} }
impl Fetched { impl Fetched {
/// The site this post came from (used for per-site format overrides).
/// A thin alias over [`Fetched::site_id`] kept for callers that read the
/// site off a fetched post.
pub fn site_name(&self) -> &'static str {
self.site_id
}
/// Renders a user-supplied caption format. The format string is /// Renders a user-supplied caption format. The format string is
/// HTML-escaped in full, then the (already-escaped) placeholder values /// HTML-escaped in full, then the (already-escaped) placeholder values
/// are substituted — users can structure text but never inject raw HTML /// are substituted — users can structure text but never inject raw HTML
@@ -107,7 +126,7 @@ impl Fetched {
(Some(data), false) => caption_from_fields( (Some(data), false) => caption_from_fields(
format, format,
"", "",
&data.url, &self.source_url,
&data.author, &data.author,
&data.author_url, &data.author_url,
&data.title, &data.title,
@@ -133,12 +152,14 @@ impl Fetched {
}) })
} }
/// Hands over the temp dir keeping locally produced media (ugoira MP4, /// A reference to the temp dir keeping locally produced media (ugoira MP4,
/// bsky remux MP4) alive. The bot keeps it while its task may still be /// bsky remux MP4) alive. The bot keeps it while its task may still be
/// retried by the queue, which runs after this [`Fetched`] is dropped and /// retried by the queue, which runs after this [`Fetched`] is dropped and
/// its temp files would otherwise be gone. `None` when no such dir exists. /// its temp files would otherwise be gone. `None` when no such dir exists;
pub fn take_keep_alive(&mut self) -> Option<tempfile::TempDir> { /// each clone keeps the directory alive for as long as it lives, so two
self._keep_alive.take() /// sends of one post can each hold the same files.
pub fn keep_alive(&self) -> Option<std::sync::Arc<tempfile::TempDir>> {
self._keep_alive.clone()
} }
} }
@@ -203,6 +224,36 @@ pub fn caption_from_fields(
) )
} }
/// Maximum decoded JSON response accepted from a site API. Metadata is
/// expected to be much smaller; this keeps a compromised or malformed API
/// from growing an unbounded `String` before serde gets a chance to reject it.
pub(crate) const MAX_SITE_JSON_BYTES: usize = 8 * 1024 * 1024;
/// Reads a successful site response as a bounded UTF-8 JSON value.
pub(crate) async fn response_json<T: serde::de::DeserializeOwned>(
response: reqwest::Response,
site: &'static str,
) -> Result<T, FetchError> {
let body = crate::site::download::send_json_response(response, site).await?;
serde_json::from_slice(&body).map_err(|e| FetchError::Site {
site,
error: Box::new(e),
})
}
/// Same bounded response reader for endpoints that need a text body before
/// classification or parsing.
pub(crate) async fn response_text(
response: reqwest::Response,
site: &'static str,
) -> Result<String, FetchError> {
let body = crate::site::download::send_json_response(response, site).await?;
String::from_utf8(body.to_vec()).map_err(|e| FetchError::Site {
site,
error: Box::new(e),
})
}
/// Stable per-post cache key derived from any supported URL, so variant /// Stable per-post cache key derived from any supported URL, so variant
/// domains (x.com / twitter.com / fxtwitter.com, mobile, `/photo/N` /// domains (x.com / twitter.com / fxtwitter.com, mobile, `/photo/N`
/// suffixes) map to the same post. Delegates to each registered site's /// suffixes) map to the same post. Delegates to each registered site's
@@ -211,19 +262,6 @@ pub fn cache_key(url: &str) -> Option<String> {
SITES.iter().find_map(|site| site.cache_key(url)) SITES.iter().find_map(|site| site.cache_key(url))
} }
/// The site id carried by a cache key (`"twitter:123"` → `"twitter"`).
/// Unknown prefixes fall back to `"unknown"`. The bot uses this on the
/// link-cache hit path, where no [`Fetched`] is available — the same value
/// a fresh fetch would read from [`Fetched::site_id`].
pub fn site_id_from_key(key: &str) -> &'static str {
let prefix = key.split(':').next().unwrap_or("");
SITES
.iter()
.map(|site| site.id())
.find(|id| *id == prefix)
.unwrap_or("unknown")
}
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum FetchError { pub enum FetchError {
#[error("http error: {0}")] #[error("http error: {0}")]
@@ -247,6 +285,12 @@ pub enum FetchError {
NotFound, NotFound,
#[error("blocked")] #[error("blocked")]
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 / /// The post exists but its content is withheld (twitter NSFW /
/// age-restricted tweets come back as an empty `{}` from syndication). /// age-restricted tweets come back as an empty `{}` from syndication).
#[error("content withheld (sensitive)")] #[error("content withheld (sensitive)")]
@@ -254,42 +298,76 @@ pub enum FetchError {
/// A download exceeded the caller's size cap (see [`download_media_limited`]). /// A download exceeded the caller's size cap (see [`download_media_limited`]).
#[error("media too large")] #[error("media too large")]
TooLarge, TooLarge,
/// The post was fetched, but its media could not be prepared locally — a
/// download or encode step that runs *after* the site's own response
/// (bsky's HLS remux, say). Deliberately not retryable: the retry would
/// replay the whole fetch, redoing the download work that just failed
/// instead of the request that failed.
#[error("media could not be prepared: {0}")]
MediaPrep(String),
/// A transient server-side failure (429 / 5xx); [`fetch`] retries these. /// A transient server-side failure (429 / 5xx); [`fetch`] retries these.
#[error("transient: {0}")] #[error("transient: {0}")]
Transient(String), Transient(String),
/// The source answered 429 *with* a `Retry-After` and named its own
/// delay: [`fetch`] sleeps at least that long instead of guessing one
/// (capped by [`MAX_RETRY_AFTER_SECS`] — the header is server-supplied
/// and must not park one of the fetch slots).
#[error("{site} rate limited, retry after {retry_after_secs}s")]
RateLimited {
site: &'static str,
retry_after_secs: u64,
},
/// A local I/O failure while streaming a download to disk /// A local I/O failure while streaming a download to disk
/// (see [`download_media_to_file`]). /// (see [`download_media_to_file`]).
#[error("io error: {0}")] #[error("io error: {0}")]
Io(std::io::Error), Io(std::io::Error),
} }
/// Shared HTTP client (browser User-Agent) for twitter/bsky fetches and /// Cap on a server-supplied `Retry-After`: honored so a retry stops hammering
/// [`download_media`]. /// a source that asked for air, bounded so the same untrusted header cannot
pub(crate) static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| { /// park a fetch slot for an hour.
let mut builder = reqwest::Client::builder() pub const MAX_RETRY_AFTER_SECS: u64 = 60;
.user_agent("Mozilla/5.0")
// reqwest has no total timeout by default; a stalled connection /// The error class for a non-success HTTP status, shared by the site
// would otherwise pin a fetch/handler forever. /// adapters, the media downloads and twitter's auth fallback: 404/410 mean
.timeout(Duration::from_secs(30)) /// the post is gone (permanent), any other client error the source answers
.connect_timeout(Duration::from_secs(10)); /// on sight is a refusal (permanent too — three retries only delay the same
// Route site fetches through the same proxy the Bot API uses, so a /// answer), 408/429/5xx are a bad moment retried by [`fetch`], and a 429
// network that needs TELOXIDE_PROXY (e.g. behind the GFW) does not /// that carries `Retry-After` keeps the delay the source asked for (the
// leave site fetches dead while the bot itself works. /// seconds form only — a HTTP-date value parses to `None` and falls back to
if let Some(proxy) = std::env::var("TELOXIDE_PROXY") /// the plain transient path).
.ok() /// `site` only names the adapter in the message (`"media"` for downloads);
.filter(|s| !s.is_empty()) /// a site whose statuses mean something else (bilibili's 412 risk control,
&& let Ok(p) = reqwest::Proxy::all(&proxy) /// misskey's 400 with `NO_SUCH_NOTE`) maps those before falling back here.
{ pub fn status_error(site: &'static str, response: &reqwest::Response) -> FetchError {
builder = builder.proxy(p); let retry_after = response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.trim().parse::<u64>().ok());
classify_status(site, response.status(), retry_after)
}
/// [`status_error`]'s table, split out so tests reach it without building an
/// HTTP response — the retry delay only ever shapes the 429 arm.
pub(crate) fn classify_status(
site: &'static str,
status: reqwest::StatusCode,
retry_after: Option<u64>,
) -> FetchError {
match status.as_u16() {
404 | 410 => FetchError::NotFound,
code if status.is_client_error() && !matches!(code, 408 | 429) => FetchError::Blocked,
429 => match retry_after {
Some(retry_after_secs) => FetchError::RateLimited {
site,
retry_after_secs: retry_after_secs.min(MAX_RETRY_AFTER_SECS),
},
None => FetchError::Transient(format!("{site} status {status}")),
},
_ => FetchError::Transient(format!("{site} status {status}")),
} }
// Each `#[tokio::test]` runs on its own runtime; the connection pool is }
// bound to the runtime that created it, so cross-runtime reuse of idle
// connections fails with DispatchGone. In test builds every request uses
// a fresh connection. Production runs on one runtime and keeps pooling.
#[cfg(test)]
let builder = builder.pool_max_idle_per_host(0);
builder.build().expect("failed to build HTTP client")
});
/// Whether a usable `ffmpeg` binary is on PATH (probed once). Shared by the /// Whether a usable `ffmpeg` binary is on PATH (probed once). Shared by the
/// pixiv ugoira encoder and the bsky HLS remuxer. /// pixiv ugoira encoder and the bsky HLS remuxer.
@@ -305,14 +383,17 @@ static FFMPEG_AVAILABLE: LazyLock<bool> = LazyLock::new(|| {
static FFMPEG_MISSING_LOGGED: AtomicBool = AtomicBool::new(false); static FFMPEG_MISSING_LOGGED: AtomicBool = AtomicBool::new(false);
pub(crate) fn ffmpeg_available() -> bool { /// Whether the encode step must be skipped: no ffmpeg on PATH, logged once
*FFMPEG_AVAILABLE /// per process. The one gate both encoders check — the probe and the
} /// log-once used to be two functions that only ever appeared together.
pub(crate) fn ffmpeg_missing() -> bool {
pub(crate) fn log_once_ffmpeg_missing() { if *FFMPEG_AVAILABLE {
return false;
}
if !FFMPEG_MISSING_LOGGED.swap(true, Ordering::Relaxed) { if !FFMPEG_MISSING_LOGGED.swap(true, Ordering::Relaxed) {
log::warn!("ffmpeg not found; ugoira and bsky video posts stay unsupported"); log::warn!("ffmpeg not found; ugoira and bsky video posts stay unsupported");
} }
true
} }
/// Site adapter: one impl per supported site (twitter / bsky / misskey / /// Site adapter: one impl per supported site (twitter / bsky / misskey /
@@ -342,9 +423,13 @@ pub trait Site: Send + Sync {
fn cache_key(&self, url: &str) -> Option<String>; fn cache_key(&self, url: &str) -> Option<String>;
/// Fetches and normalizes a post. /// Fetches and normalizes a post.
fn fetch_from_url<'a>(&'a self, url: &'a str) -> SiteFuture<'a, Fetched>; fn fetch_from_url<'a>(&'a self, url: &'a str) -> SiteFuture<'a, Fetched>;
/// Retry policy for fetch errors: transient classes only. /// Retry policy for fetch errors: transient classes only (a 429's
/// named `Retry-After` included — it is a bad moment, just a louder one).
fn is_retryable(&self, err: &FetchError) -> bool { fn is_retryable(&self, err: &FetchError) -> bool {
matches!(err, FetchError::Http(_) | FetchError::Transient(_)) matches!(
err,
FetchError::Http(_) | FetchError::Transient(_) | FetchError::RateLimited { .. }
)
} }
/// Extra headers for downloading this site's media (hotlink protection, /// Extra headers for downloading this site's media (hotlink protection,
/// e.g. pixiv's Referer for pximg.net). Matched on the media URL, not /// e.g. pixiv's Referer for pximg.net). Matched on the media URL, not
@@ -367,7 +452,7 @@ type SiteFuture<'a, T, E = FetchError> = Pin<Box<dyn Future<Output = Result<T, E
/// The one registry of supported sites, in dispatch order (twitter → bsky → /// The one registry of supported sites, in dispatch order (twitter → bsky →
/// misskey → pixiv → bilibili). Adding a site = new module + one /// misskey → pixiv → bilibili). Adding a site = new module + one
/// `Box::new(...)` entry here; the bot crate never lists sites itself. /// `Box::new(...)` entry here; the bot crate never lists sites itself.
static SITES: LazyLock<Vec<Box<dyn Site>>> = LazyLock::new(|| { pub(crate) static SITES: LazyLock<Vec<Box<dyn Site>>> = LazyLock::new(|| {
vec![ vec![
Box::new(twitter::TwitterSite), Box::new(twitter::TwitterSite),
Box::new(bsky::BskySite), Box::new(bsky::BskySite),
@@ -377,11 +462,13 @@ static SITES: LazyLock<Vec<Box<dyn Site>>> = LazyLock::new(|| {
] ]
}); });
/// The first enabled site whose pattern matches `url`, in dispatch order. /// The first site whose pattern matches `url`, in dispatch order — enabled
fn find_site(url: &str) -> Option<&'static dyn Site> { /// or not. The caller decides what a disabled match means; no match at all is
/// the "unsupported link" case.
fn matching_site(url: &str) -> Option<&'static dyn Site> {
SITES SITES
.iter() .iter()
.find(|site| site.enabled() && site.pattern().is_match(url)) .find(|site| site.pattern().is_match(url))
.map(|site| site.as_ref()) .map(|site| site.as_ref())
} }
@@ -408,7 +495,9 @@ pub async fn validate_all() -> Vec<(&'static str, String)> {
} }
/// Fetches a post from its URL. Returns `Ok(None)` when no site pattern /// 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. /// 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` /// What counts as transient is the matched site's own policy (`is_retryable`
@@ -427,132 +516,151 @@ pub async fn fetch_once(url: &str) -> Result<Option<Fetched>, FetchError> {
fetch_with_attempts(url, 1).await fetch_with_attempts(url, 1).await
} }
/// A small admission gate for `/test` and `/debug`, which run directly in
/// dispatcher handlers instead of the URL worker pool.
pub async fn acquire_command_fetch_slot() -> tokio::sync::OwnedSemaphorePermit {
std::sync::Arc::clone(&COMMAND_FETCH_SLOTS)
.acquire_owned()
.await
.expect("command fetch gate closed")
}
/// Total attempts of the retried [`fetch`] (3: the initial try plus two). /// Total attempts of the retried [`fetch`] (3: the initial try plus two).
const MAX_FETCH_ATTEMPTS: u32 = 3; const MAX_FETCH_ATTEMPTS: u32 = 3;
/// How many fetches may run at once, process-wide. The URL workers already
/// bound their own path (8 workers on a bounded channel), but inline queries,
/// `/debug` and `/test` reach [`fetch`]/[`fetch_once`] straight from handler
/// and debounce tasks with no limit at all — and the heavy part runs *inside*
/// the fetch: a ugoira encode is a 512 MiB download plus ffmpeg, a bsky video
/// an HLS remux, so N users meant N encodes. Every entry waits on this one
/// gate instead; the count matches `URL_WORKERS` so the bot's own pipeline
/// keeps its full width. A retry's backoff (1s, then 2s) holds its permit —
/// deliberately simple: the wait is bounded by the same retries.
static FETCH_SLOTS: LazyLock<tokio::sync::Semaphore> =
LazyLock::new(|| tokio::sync::Semaphore::new(8));
static COMMAND_FETCH_SLOTS: LazyLock<std::sync::Arc<tokio::sync::Semaphore>> =
LazyLock::new(|| std::sync::Arc::new(tokio::sync::Semaphore::new(2)));
async fn fetch_with_attempts(url: &str, attempts: u32) -> Result<Option<Fetched>, FetchError> { async fn fetch_with_attempts(url: &str, attempts: u32) -> Result<Option<Fetched>, FetchError> {
let Some(site) = find_site(url) else { // Wall time of the whole fetch, retry backoff included: the ugoira encode
return Ok(None); // and the HLS remux live inside it, so this is where a slow fetch shows.
let started = std::time::Instant::now();
let Some(site) = matching_site(url) else {
return Ok(None); // unsupported link: silently ignored
}; };
for attempt in 0..attempts.max(1) { // A registered-but-disabled site (pixiv without a token) is not an
match site.fetch_from_url(url).await { // unsupported link: report it, so the bot answers the user instead of
Ok(fetched) => { // ignoring the message. One match is the whole lookup — the site patterns
// Per-request detail: debug only, keyed by the post id. // are disjoint (one domain each), so "first enabled match" and "first
log::debug!( // match, then check" never disagree.
"fetched [key={}]: site {} returned {} media", if !site.enabled() {
cache_key(url).unwrap_or_else(|| "?".into()), return Err(FetchError::Disabled { site: site.id() });
fetched.site_name(), }
fetched.media.len() // Gate every network attempt process-wide (see FETCH_SLOTS).
); let _permit = FETCH_SLOTS.acquire().await.expect("fetch gate closed");
return Ok(Some(fetched)); let fetch = async {
} // One hard ceiling for the whole fetch, backoff naps included (the
Err(err) => { // timeout below): the idle timeouts restart on every chunk, so a
if site.is_retryable(&err) && attempt + 1 < attempts { // drip-feeding URL could otherwise pin one fetch slot effectively
tokio::time::sleep(Duration::from_secs(1 << attempt)).await; // forever. Generous for a genuinely large ugoira zip on a slow link
} else { // — minutes, not hours — and the deadline the audit's low finding
return Err(err); // asked for.
for attempt in 0..attempts.max(1) {
match site.fetch_from_url(url).await {
Ok(fetched) => {
// Per-request detail: debug only, keyed by the post id.
log::debug!(
"fetched [key={}]: site {} returned {} media in {}ms",
cache_key(url).unwrap_or_else(|| "?".into()),
fetched.site_id,
fetched.media.len(),
started.elapsed().as_millis()
);
return Ok(Some(fetched));
}
Err(err) => {
if site.is_retryable(&err) && attempt + 1 < attempts {
tokio::time::sleep(retry_wait(attempt, rand::random::<u64>(), &err)).await;
} else {
return Err(err);
}
} }
} }
} }
unreachable!("retry loop always returns")
};
match tokio::time::timeout(Duration::from_secs(900), fetch).await {
Ok(result) => result,
Err(_) => Err(FetchError::Transient(format!(
"fetch exceeded its 900s total budget [key={}]",
cache_key(url).unwrap_or_else(|| "?".into())
))),
} }
unreachable!("retry loop always returns")
} }
/// Applies every site's media-header rule to a download request (pixiv's /// How long to sleep before retrying `attempt` (0-based) after `err`: the
/// `Referer` for pximg.net hotlink protection). Sites contribute via their /// doubling base plus a random slice of it (roll in [0, base) → [base, 2×base))
/// `media_headers(url)` — the central download code carries no per-site logic. /// so workers that failed together do not recover together, floored at the
fn apply_media_headers(mut request: reqwest::RequestBuilder, url: &str) -> reqwest::RequestBuilder { /// delay a 429's `Retry-After` named — already capped by the classifier at
for site in SITES.iter() { /// [`MAX_RETRY_AFTER_SECS`], so an untrusted server cannot park a slot.
if let Some(headers) = site.media_headers(url) { pub(crate) fn retry_wait(attempt: u32, roll: u64, err: &FetchError) -> Duration {
for (name, value) in headers { let base = 1u64 << attempt.min(16);
request = request.header(name, value); let mut secs = base + roll % base;
} if let FetchError::RateLimited {
} retry_after_secs, ..
} } = err
request
}
/// Downloads media bytes for the bot's upload fallback: when Telegram's own
/// fetch of a media URL is blocked (hotlink protection), the bot downloads
/// the file itself and uploads it via multipart. Site-appropriate headers
/// come from each site's `media_headers` (pixiv image hosts need `Referer`).
/// Returns the Content-Length of a media URL, or `None` when the server does
/// not report one. Used to check whether a file fits Telegram's size limits
/// before downloading/uploading it.
pub async fn media_size(url: &str) -> Result<Option<u64>, FetchError> {
let response = apply_media_headers(CLIENT.get(url), url)
.send()
.await?
.error_for_status()?;
Ok(response.content_length())
}
/// Downloads a media file with a hard size cap: the body is streamed and the
/// download aborts with [`FetchError::TooLarge`] the moment the cap is
/// crossed (or when a declared Content-Length already exceeds it). Keeps the
/// bot from buffering arbitrarily large bodies into memory.
pub async fn download_media_limited(url: &str, max_bytes: u64) -> Result<bytes::Bytes, FetchError> {
let response = apply_media_headers(CLIENT.get(url), url)
.send()
.await?
.error_for_status()?;
if let Some(len) = response.content_length()
&& len > max_bytes
{ {
return Err(FetchError::TooLarge); secs = secs.max(*retry_after_secs);
} }
let mut response = response; Duration::from_secs(secs)
let mut buf = Vec::new();
while let Some(chunk) = response.chunk().await? {
buf.extend_from_slice(&chunk);
if buf.len() as u64 > max_bytes {
return Err(FetchError::TooLarge);
}
}
Ok(bytes::Bytes::from(buf))
} }
pub async fn download_media(url: &str) -> Result<bytes::Bytes, FetchError> { /// Whether fetching `url` requires site-specific headers (pixiv's `Referer`
download_media_limited(url, u64::MAX).await /// 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
/// Streams a download to `out`, aborting with [`FetchError::TooLarge`] the /// skip such media instead of shipping a broken item.
/// moment the body crosses `max_bytes` (or when a declared Content-Length pub fn needs_media_headers(url: &str) -> bool {
/// already exceeds it). Unlike [`download_media_limited`] the body is never SITES.iter().any(|site| site.media_headers(url).is_some())
/// buffered in memory — used for large files (e.g. the pixiv ugoira frame
/// zip, which can be hundreds of MB) that would otherwise spike RAM.
/// Returns the number of bytes written.
pub async fn download_media_to_file(
url: &str,
max_bytes: u64,
out: &mut std::fs::File,
) -> Result<u64, FetchError> {
use std::io::Write;
let response = apply_media_headers(CLIENT.get(url), url)
.send()
.await?
.error_for_status()?;
if let Some(len) = response.content_length()
&& len > max_bytes
{
return Err(FetchError::TooLarge);
}
let mut response = response;
let mut total: u64 = 0;
while let Some(chunk) = response.chunk().await? {
total += chunk.len() as u64;
if total > max_bytes {
return Err(FetchError::TooLarge);
}
out.write_all(&chunk).map_err(FetchError::Io)?;
}
Ok(total)
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
/// Locally produced media (ugoira MP4, bsky remux MP4) lives in a temp dir
/// whose lifetime is refcounted: one fetch result can serve several sends
/// (the bot shares one in-flight fetch between concurrent duplicates), and
/// the files must outlive all of them — but no longer than the last one.
#[test]
fn a_keep_alive_clone_outlives_the_fetched() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("media.mp4");
std::fs::write(&file, b"mp4").unwrap();
let fetched = Fetched {
source_url: "https://x.com/u/status/1".into(),
caption: String::new(),
title: String::new(),
content: String::new(),
media: Vec::new(),
sensitive: false,
site_id: "twitter",
render_data: None,
_keep_alive: Some(std::sync::Arc::new(dir)),
};
let shared = fetched.keep_alive().expect("a temp dir to share");
drop(fetched);
assert!(file.exists(), "the file must survive the fetched post");
let second = shared.clone();
drop(shared);
assert!(file.exists(), "another holder keeps it alive");
drop(second);
assert!(!file.exists(), "the last holder releases the directory");
}
#[test] #[test]
fn cache_key_normalizes_domain_variants() { fn cache_key_normalizes_domain_variants() {
assert_eq!( assert_eq!(
@@ -582,27 +690,17 @@ mod tests {
assert_eq!(cache_key("https://example.com/not-a-post"), None); assert_eq!(cache_key("https://example.com/not-a-post"), None);
} }
#[test]
fn site_id_from_key_parses_prefix() {
assert_eq!(site_id_from_key("twitter:123"), "twitter");
assert_eq!(site_id_from_key("pixiv:123"), "pixiv");
assert_eq!(site_id_from_key("bsky:handle.example/3lorem"), "bsky");
assert_eq!(site_id_from_key("bilibili:123"), "bilibili");
assert_eq!(site_id_from_key("unknown:1"), "unknown");
assert_eq!(site_id_from_key("no-colon"), "unknown");
}
#[test] #[test]
fn registry_lists_all_sites_in_dispatch_order() { fn registry_lists_all_sites_in_dispatch_order() {
assert_eq!( assert_eq!(
site_ids(), site_ids(),
vec!["twitter", "bsky", "misskey", "pixiv", "bilibili"] vec!["twitter", "bsky", "misskey", "pixiv", "bilibili"]
); );
// Enabled sites dispatch; unsupported URLs never match. // Patterns dispatch; unsupported URLs never match.
assert!(find_site("https://x.com/u/status/1").is_some()); assert!(matching_site("https://x.com/u/status/1").is_some());
assert!(find_site("https://misskey.io/notes/abc").is_some()); assert!(matching_site("https://misskey.io/notes/abc").is_some());
assert!(find_site("https://t.bilibili.com/1245284537985925159").is_some()); assert!(matching_site("https://t.bilibili.com/1245284537985925159").is_some());
assert!(find_site("https://example.com/x").is_none()); assert!(matching_site("https://example.com/x").is_none());
// Cache keys are pattern-driven, independent of the enabled() gate // Cache keys are pattern-driven, independent of the enabled() gate
// (pixiv is disabled in tests without PIXIV_REFRESH_TOKEN). // (pixiv is disabled in tests without PIXIV_REFRESH_TOKEN).
assert_eq!( assert_eq!(
@@ -620,8 +718,10 @@ mod tests {
}; };
assert_eq!(err.to_string(), "example error: boom"); assert_eq!(err.to_string(), "example error: boom");
assert!(err.source().is_some()); assert!(err.source().is_some());
// Permanent by default: no site's is_retryable matches it. // Permanent by default: no site's is_retryable matches it (the trait
assert!(!twitter::is_retryable(&err)); // default is the policy for every site that does not override it).
assert!(!twitter::TwitterSite.is_retryable(&err));
assert!(twitter::TwitterSite.is_retryable(&FetchError::Transient("429".into())));
} }
#[test] #[test]
@@ -679,13 +779,25 @@ mod tests {
#[test] #[test]
fn truncate_caption_does_not_split_an_html_entity() { fn truncate_caption_does_not_split_an_html_entity() {
// An entity crossing the cut must not be left half-open (&amp without ;). // The exact output is what pins the guard: a cut that keeps `&am` (no
let mut long = "a".repeat(MAX_CAPTION_CHARS - 4); // `;`) leaves a half-open entity that `!contains("&amp")` cannot see,
long.push_str("&amp;bbbb"); // so the old assertions stayed green with the guard deleted. Both
let out = truncate_caption(&long); // directions matter — an entity the cut falls inside is dropped whole,
assert!(out.chars().count() <= MAX_CAPTION_CHARS); // one the cut falls after is kept whole.
assert!(!out.contains("&amp"), "half entity left: {out:?}"); for (long, expected) in [
assert!(!out.ends_with('&')); (
"a".repeat(MAX_CAPTION_CHARS - 4) + "&amp;bbbb",
"a".repeat(MAX_CAPTION_CHARS - 4) + "…",
),
(
"a".repeat(MAX_CAPTION_CHARS - 6) + "&amp;bbbb",
"a".repeat(MAX_CAPTION_CHARS - 6) + "&amp;…",
),
] {
let out = truncate_caption(&long);
assert_eq!(out, expected);
assert!(out.chars().count() <= MAX_CAPTION_CHARS, "{out:?}");
}
} }
#[test] #[test]
@@ -697,39 +809,137 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn unsupported_url_returns_none() { async fn unsupported_urls_return_none() {
let result = fetch("https://example.com/some/article").await; // Neither a URL no site pattern matches nor a string that is no URL at
assert!(matches!(result, Ok(None)), "got {result:?}"); // all is an error: both answer `Ok(None)`, which is what keeps the bot
// silent on links it cannot handle (only a registered-but-disabled site
// gets a reply).
for url in ["https://example.com/some/article", "not a url at all"] {
let result = fetch(url).await;
assert!(matches!(result, Ok(None)), "{url}: 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}");
}
}
#[test]
fn persistent_client_statuses_are_permanent() {
use reqwest::StatusCode;
// The one table every caller shares now: only 408, 429 and 5xx can
// answer differently on a retry. A 400 used to be Transient here and
// in two local fallbacks — twitter syndication's broken-token 400, for
// one, burned three retries per link before saying the same thing.
assert!(matches!(
classify_status("x", StatusCode::NOT_FOUND, None),
FetchError::NotFound
));
assert!(matches!(
classify_status("x", StatusCode::BAD_REQUEST, None),
FetchError::Blocked
));
assert!(matches!(
classify_status("x", StatusCode::PAYLOAD_TOO_LARGE, None),
FetchError::Blocked
));
assert!(matches!(
classify_status("x", StatusCode::REQUEST_TIMEOUT, None),
FetchError::Transient(_)
));
assert!(matches!(
classify_status("x", StatusCode::TOO_MANY_REQUESTS, None),
FetchError::Transient(_)
));
assert!(matches!(
classify_status("x", StatusCode::INTERNAL_SERVER_ERROR, None),
FetchError::Transient(_)
));
// The download path delegates under its own name, same classes.
assert!(matches!(
classify_status("media", StatusCode::BAD_REQUEST, None),
FetchError::Blocked
));
// A 429 that named its delay keeps it — and the cap means the
// (server-supplied) header cannot park a fetch slot for an hour.
assert!(matches!(
classify_status("x", StatusCode::TOO_MANY_REQUESTS, Some(12)),
FetchError::RateLimited {
retry_after_secs: 12,
..
}
));
assert!(matches!(
classify_status("x", StatusCode::TOO_MANY_REQUESTS, Some(9999)),
FetchError::RateLimited {
retry_after_secs: crate::site::MAX_RETRY_AFTER_SECS,
..
}
));
}
#[test]
fn retry_wait_jitters_and_respects_a_named_delay() {
// Doubling base plus a random slice: attempt 0 → exactly 1 s (any
// slice of 1 is 0), attempt 2 with roll 3 → 4 + 3 s.
assert_eq!(
retry_wait(0, 0, &FetchError::Transient("x".into())),
Duration::from_secs(1)
);
assert_eq!(
retry_wait(2, 3, &FetchError::Transient("x".into())),
Duration::from_secs(7)
);
// A named delay floors the wait: roll 0 would sleep 1 s, the source said 60.
assert_eq!(
retry_wait(
0,
0,
&FetchError::RateLimited {
site: "x",
retry_after_secs: 60
}
),
Duration::from_secs(60)
);
} }
#[tokio::test] #[tokio::test]
async fn unknown_scheme_returns_none() { async fn disabled_site_is_reported_not_ignored() {
let result = fetch("not a url at all").await; // pixiv is the only token-gated site; with PIXIV_REFRESH_TOKEN set it
assert!(matches!(result, Ok(None)), "got {result:?}"); // is enabled and this link would hit the network, so skip then.
}
#[tokio::test]
async fn download_media_pixiv_original_with_referer() {
// Proves the Referer header is attached for i.pximg.net: a header-less
// GET to a pixiv original URL is rejected with 403.
// Empty-string check too: an unset CI secret arrives as "" (GitHub
// Actions), which would otherwise run the test tokenless and fail.
if std::env::var("PIXIV_REFRESH_TOKEN") if std::env::var("PIXIV_REFRESH_TOKEN")
.ok() .ok()
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.is_none() .is_some()
{ {
eprintln!("skipping: no PIXIV_REFRESH_TOKEN"); eprintln!("skipping: PIXIV_REFRESH_TOKEN is set");
return; return;
} }
let illustration = pixiv::fetch(126839080).await.unwrap(); let result = fetch("https://www.pixiv.net/artworks/1").await;
let fetched: Fetched = illustration.into(); assert!(
let url = match fetched.media.first() { matches!(result, Err(FetchError::Disabled { site: "pixiv" })),
Some(crate::media::Media::Illustration { url, .. }) => url.clone(), "got {result:?}"
other => panic!("expected illustration media, got {other:?}"), );
}; // The cache key still resolves: the bot keys the reply and the link
assert!(url.contains("i.pximg.net")); // cache off it even when the site is off.
let bytes = download_media(&url).await.unwrap(); assert_eq!(
assert!(!bytes.is_empty()); cache_key("https://www.pixiv.net/artworks/1"),
Some("pixiv:1".into())
);
} }
} }
+179 -76
View File
@@ -4,7 +4,7 @@
//! `app-api.pixiv.net`, deserialized with the kept `model.rs` types. //! `app-api.pixiv.net`, deserialized with the kept `model.rs` types.
use super::interface::Illustration; use super::interface::Illustration;
use super::model::{IllustrationModel, TypeModel, UgoiraMetadataModel}; use super::model::{IllustrationModel, UgoiraMetadataModel};
use crate::media::Media; use crate::media::Media;
use crate::site::FetchError; use crate::site::FetchError;
use std::env; use std::env;
@@ -23,6 +23,19 @@ const APP_USER_AGENT: &str = "PixivIOSApp/7.13.3 (iOS 14.6; iPhone13,2)";
/// Token refresh safe margin (seconds). /// Token refresh safe margin (seconds).
const TOKEN_REFRESH_SAFE_MARGIN: u64 = 300; const TOKEN_REFRESH_SAFE_MARGIN: u64 = 300;
const MAX_UGOIRA_FRAMES: usize = 5_000;
const MAX_UGOIRA_UNPACKED_BYTES: u64 = 512 * 1024 * 1024;
fn check_ugoira_archive_size(entries: usize, unpacked: u64) -> Result<(), &'static str> {
if entries > MAX_UGOIRA_FRAMES {
return Err("ugoira has too many frames");
}
if unpacked > MAX_UGOIRA_UNPACKED_BYTES {
return Err("ugoira exceeds total unpacked size cap");
}
Ok(())
}
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum PixivError { pub enum PixivError {
/// No refresh token available (PIXIV_REFRESH_TOKEN unset). /// No refresh token available (PIXIV_REFRESH_TOKEN unset).
@@ -39,6 +52,20 @@ pub enum PixivError {
Status(u16), Status(u16),
#[error("pixiv api error: {0}")] #[error("pixiv api error: {0}")]
Api(String), Api(String),
/// A bad moment while preparing media: a transient download status
/// (429 / 5xx), a stalled transfer or a temp-file write failure. A retry
/// can change the answer, so the pixiv retry policy re-fetches these.
#[error("transient pixiv error: {0}")]
Transient(String),
}
fn map_response_error(error: FetchError) -> PixivError {
match error {
FetchError::Http(e) => PixivError::Http(e),
FetchError::Transient(message) => PixivError::Transient(message),
FetchError::RateLimited { .. } => PixivError::Transient("rate limited".into()),
other => PixivError::Api(other.to_string()),
}
} }
/// Native pixiv app-API client. /// Native pixiv app-API client.
@@ -76,7 +103,12 @@ impl PixivAPI {
.header("User-Agent", AUTH_USER_AGENT) .header("User-Agent", AUTH_USER_AGENT)
.send() .send()
.await?; .await?;
let json: serde_json::Value = serde_json::from_str(&response.text().await?)?; if !response.status().is_success() {
return Err(PixivError::Status(response.status().as_u16()));
}
let json: serde_json::Value = crate::site::response_json(response, "pixiv")
.await
.map_err(map_response_error)?;
let access_token = json let access_token = json
.get("access_token") .get("access_token")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@@ -115,7 +147,9 @@ impl PixivAPI {
if !response.status().is_success() { if !response.status().is_success() {
return Err(PixivError::Status(response.status().as_u16())); return Err(PixivError::Status(response.status().as_u16()));
} }
let json: serde_json::Value = serde_json::from_str(&response.text().await?)?; let json: serde_json::Value = crate::site::response_json(response, "pixiv")
.await
.map_err(map_response_error)?;
if json.get("error").is_some() { if json.get("error").is_some() {
let message = json let message = json
.get("message") .get("message")
@@ -132,21 +166,26 @@ impl PixivAPI {
pub async fn fetch(&self, illust_id: u64) -> Result<Illustration, FetchError> { pub async fn fetch(&self, illust_id: u64) -> Result<Illustration, FetchError> {
let model = self.illust_detail(illust_id).await?; let model = self.illust_detail(illust_id).await?;
let mut illustration = Illustration::from_model(&model); let mut illustration = Illustration::from_model(&model);
if matches!(&model.r#type, TypeModel::Ugoira) { if model.r#type == "ugoira" {
// Real ugoira support: download the frame zip and encode an MP4. // Real ugoira support: download the frame zip and encode an MP4.
// Without ffmpeg (or on encode failure) the post stays // Without ffmpeg the post stays unsupported (empty media, like
// unsupported (empty media, like Python). // Python) — but a *failed* download/encode is reported instead:
// a ugoira post has no static image to fall back to, so
// swallowing it would present a transient zip-download error as
// "this post has no media", with the retries skipped.
match self.ugoira_video(illust_id).await { match self.ugoira_video(illust_id).await {
Ok(Some((mp4_path, _keep_alive))) => { Ok(Some((mp4_path, _keep_alive))) => {
illustration.media.push(Media::Video { illustration.media.push(Media::Video {
title: None,
url: mp4_path, url: mp4_path,
thumbnail_url: model.image_urls.medium.clone(), thumbnail_url: model.image_urls.medium.clone(),
}); });
illustration._keep_alive = Some(_keep_alive); illustration._keep_alive = Some(std::sync::Arc::new(_keep_alive));
} }
Ok(None) => {} Ok(None) => {}
Err(e) => log::error!("ugoira encode failed for {illust_id}: {e}"), Err(e) => {
log::error!("ugoira encode failed for {illust_id}: {e}");
return Err(FetchError::Pixiv(e));
}
} }
} }
Ok(illustration) Ok(illustration)
@@ -168,7 +207,9 @@ impl PixivAPI {
if !response.status().is_success() { if !response.status().is_success() {
return Err(PixivError::Status(response.status().as_u16())); return Err(PixivError::Status(response.status().as_u16()));
} }
let json: serde_json::Value = serde_json::from_str(&response.text().await?)?; let json: serde_json::Value = crate::site::response_json(response, "pixiv")
.await
.map_err(map_response_error)?;
if json.get("error").is_some() { if json.get("error").is_some() {
let message = json let message = json
.get("message") .get("message")
@@ -189,14 +230,15 @@ impl PixivAPI {
&self, &self,
illust_id: u64, illust_id: u64,
) -> Result<Option<(String, tempfile::TempDir)>, PixivError> { ) -> Result<Option<(String, tempfile::TempDir)>, PixivError> {
if !crate::site::ffmpeg_available() { if crate::site::ffmpeg_missing() {
crate::site::log_once_ffmpeg_missing();
return Ok(None); return Ok(None);
} }
let metadata = self.ugoira_metadata(illust_id).await?; let metadata = self.ugoira_metadata(illust_id).await?;
if metadata.frames.is_empty() { if metadata.frames.is_empty() {
return Ok(None); return Ok(None);
} }
check_ugoira_archive_size(metadata.frames.len(), 0)
.map_err(|e| PixivError::Api(e.to_string()))?;
let zip_url = metadata let zip_url = metadata
.zip_url .zip_url
.clone() .clone()
@@ -207,25 +249,50 @@ impl PixivAPI {
// Stream the frame zip to a temp file instead of buffering it in // Stream the frame zip to a temp file instead of buffering it in
// memory: ugoira zips can be hundreds of MB, and the old // memory: ugoira zips can be hundreds of MB, and the old
// download_media_limited path spiked RAM up to the size cap. // download_media_limited path spiked RAM up to the size cap.
let mut zip_file = tempfile::Builder::new() let zip_file = tempfile::Builder::new()
.prefix(crate::TEMP_FILE_PREFIX)
.suffix(".zip") .suffix(".zip")
.tempfile() .tempfile()
.map_err(|e| PixivError::Api(format!("temp zip failed: {e}")))?; .map_err(|e| PixivError::Api(format!("temp zip failed: {e}")))?;
crate::site::download_media_to_file(&zip_url, 512 * 1024 * 1024, zip_file.as_file_mut()) // Stream through a tokio handle: a sync write per chunk would stall
// an executor thread for the whole (up to 512 MiB) download. The
// clone shares the file offset with `zip_file`, so the extraction
// below reads what was written, and dropping it after the download
// hands every byte to the OS.
let mut zip_out = tokio::fs::File::from_std(
zip_file
.as_file()
.try_clone()
.map_err(|e| PixivError::Api(format!("temp zip clone failed: {e}")))?,
);
crate::site::download_media_to_file(&zip_url, 512 * 1024 * 1024, &mut zip_out)
.await .await
.map_err(|e| match e { .map_err(|e| match e {
FetchError::Http(e) => PixivError::Http(e), FetchError::Http(e) => PixivError::Http(e),
// A bad moment (429/5xx, a stalled transfer, a temp-file
// write failure) must stay retryable: folding it into Api
// made one hiccup permanently fail the whole ugoira post,
// while the bot's own upload downloads retry the same
// classes.
transient @ (FetchError::Transient(_)
| FetchError::RateLimited { .. }
| FetchError::Io(_)) => {
PixivError::Transient(format!("frame zip download failed: {transient}"))
}
other => PixivError::Api(format!("frame zip download failed: {other}")), other => PixivError::Api(format!("frame zip download failed: {other}")),
})?; })?;
drop(zip_out);
let frame_delays = metadata.frames.iter().map(|f| f.delay).collect::<Vec<_>>(); let frame_delays = metadata.frames.iter().map(|f| f.delay).collect::<Vec<_>>();
let result = let result =
tokio::task::spawn_blocking(move || -> Result<(String, tempfile::TempDir), String> { tokio::task::spawn_blocking(move || -> Result<(String, tempfile::TempDir), String> {
let frames_dir = tempfile::tempdir().map_err(|e| e.to_string())?; let frames_dir = tempfile::Builder::new()
let out_dir = tempfile::tempdir().map_err(|e| e.to_string())?; .prefix(crate::TEMP_FILE_PREFIX)
.tempdir()
// Extract frames to canonical zero-padded names; pixiv ugoira .map_err(|e| e.to_string())?;
// frames are uniformly jpg or png per artwork. The zip is read let out_dir = tempfile::Builder::new()
// from disk; `zip_file` stays alive for the whole extraction. .prefix(crate::TEMP_FILE_PREFIX)
.tempdir()
.map_err(|e| e.to_string())?;
let mut archive = zip::ZipArchive::new( let mut archive = zip::ZipArchive::new(
std::fs::File::open(zip_file.path()).map_err(|e| e.to_string())?, std::fs::File::open(zip_file.path()).map_err(|e| e.to_string())?,
) )
@@ -233,33 +300,12 @@ impl PixivAPI {
if archive.is_empty() { if archive.is_empty() {
return Err("empty frame zip".to_string()); return Err("empty frame zip".to_string());
} }
// Uniform jpg or png per artwork; sniff the first entry's check_ugoira_archive_size(archive.len(), 0)?;
// magic bytes instead of trusting its filename.
let first = archive.by_index(0).map_err(|e| e.to_string())?;
let mut first_bytes = Vec::new();
first
.take(64 * 1024 * 1024 + 1)
.read_to_end(&mut first_bytes)
.map_err(|e| e.to_string())?;
if first_bytes.len() > 64 * 1024 * 1024 {
return Err("frame exceeds size cap".to_string());
}
let extension = if first_bytes.starts_with(&[0xFF, 0xD8]) {
"jpg"
} else if first_bytes.starts_with(b"\x89PNG") {
"png"
} else {
"jpg"
};
let mut count = 0usize; let mut count = 0usize;
{ let mut unpacked = 0u64;
let path = frames_dir let mut extension = "jpg";
.path() for i in 0..archive.len() {
.join(format!("img_{count:05}.{extension}"));
std::fs::write(&path, &first_bytes).map_err(|e| e.to_string())?;
count += 1;
}
for i in 1..archive.len() {
let entry = archive.by_index(i).map_err(|e| e.to_string())?; let entry = archive.by_index(i).map_err(|e| e.to_string())?;
if entry.size() > 64 * 1024 * 1024 { if entry.size() > 64 * 1024 * 1024 {
return Err(format!("frame {i} exceeds size cap")); return Err(format!("frame {i} exceeds size cap"));
@@ -272,6 +318,19 @@ impl PixivAPI {
if bytes.len() > 64 * 1024 * 1024 { if bytes.len() > 64 * 1024 * 1024 {
return Err(format!("frame {i} exceeds size cap")); return Err(format!("frame {i} exceeds size cap"));
} }
unpacked = unpacked
.checked_add(bytes.len() as u64)
.ok_or_else(|| "ugoira unpacked size overflow".to_string())?;
check_ugoira_archive_size(archive.len(), unpacked)?;
if i == 0 {
extension = if bytes.starts_with(&[0xFF, 0xD8]) {
"jpg"
} else if bytes.starts_with(b"\x89PNG") {
"png"
} else {
"jpg"
};
}
let path = frames_dir let path = frames_dir
.path() .path()
.join(format!("img_{count:05}.{extension}")); .join(format!("img_{count:05}.{extension}"));
@@ -281,7 +340,6 @@ impl PixivAPI {
if count == 0 { if count == 0 {
return Err("empty frame zip".to_string()); return Err("empty frame zip".to_string());
} }
// Constant rate from the median frame delay (ms). // Constant rate from the median frame delay (ms).
let mut delays = frame_delays; let mut delays = frame_delays;
delays.sort_unstable(); delays.sort_unstable();
@@ -289,7 +347,7 @@ impl PixivAPI {
let framerate = 1000.0 / median as f64; let framerate = 1000.0 / median as f64;
let output = out_dir.path().join("ugoira.mp4"); let output = out_dir.path().join("ugoira.mp4");
let status = std::process::Command::new("ffmpeg") let mut child = std::process::Command::new("ffmpeg")
.args([ .args([
"-y", "-y",
"-framerate", "-framerate",
@@ -313,10 +371,27 @@ impl PixivAPI {
]) ])
.stdout(std::process::Stdio::null()) .stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null()) .stderr(std::process::Stdio::null())
.status() .spawn()
.map_err(|e| format!("ffmpeg spawn failed: {e}"))?; .map_err(|e| format!("ffmpeg spawn failed: {e}"))?;
if !status.success() { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300);
return Err(format!("ffmpeg exited with {status}")); loop {
match child
.try_wait()
.map_err(|e| format!("ffmpeg wait failed: {e}"))?
{
Some(status) => {
if !status.success() {
return Err(format!("ffmpeg exited with {status}"));
}
break;
}
None if std::time::Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
return Err("ffmpeg exceeded 300s".to_string());
}
None => std::thread::sleep(std::time::Duration::from_millis(50)),
}
} }
Ok((output.to_string_lossy().into_owned(), out_dir)) Ok((output.to_string_lossy().into_owned(), out_dir))
}) })
@@ -335,16 +410,23 @@ impl PixivAPI {
} }
} }
/// pixiv3-rs replacement: `None` when `PIXIV_REFRESH_TOKEN` is unset. /// pixiv3-rs replacement: `None` when `PIXIV_REFRESH_TOKEN` is unset or empty
static PIXIV_CLIENT: LazyLock<Option<PixivAPI>> = /// (compose injects an empty string for a blank `.env` value; an empty token
LazyLock::new(|| env::var("PIXIV_REFRESH_TOKEN").ok().map(PixivAPI::new)); /// must mean "not configured" instead of being sent to OAuth).
static PIXIV_CLIENT: LazyLock<Option<PixivAPI>> = LazyLock::new(|| {
env::var("PIXIV_REFRESH_TOKEN")
.ok()
.filter(|token| !token.is_empty())
.map(PixivAPI::new)
});
/// Set at startup when the login validation fails; pixiv stays disabled until /// Set at startup when the login validation fails; pixiv stays disabled until
/// the next process start. /// the next process start.
static DISABLED: AtomicBool = AtomicBool::new(false); static DISABLED: AtomicBool = AtomicBool::new(false);
pub fn enabled() -> bool { pub fn enabled() -> bool {
!DISABLED.load(Ordering::Relaxed) && env::var("PIXIV_REFRESH_TOKEN").is_ok() !DISABLED.load(Ordering::Relaxed)
&& env::var("PIXIV_REFRESH_TOKEN").is_ok_and(|token| !token.is_empty())
} }
/// Permanently disables pixiv until the next process start. /// Permanently disables pixiv until the next process start.
@@ -378,35 +460,56 @@ mod tests {
use super::*; use super::*;
use dotenv::dotenv; use dotenv::dotenv;
/// Skips when `PIXIV_REFRESH_TOKEN` is absent or empty (CI without the /// An empty `PIXIV_REFRESH_TOKEN` (what compose injects for a blank
/// secret must stay green; GitHub Actions exposes an unset secret as an /// `.env` value, and what an unset GitHub secret looks like) must read as
/// empty string, so `is_err()` alone is not enough). /// "not configured", exactly like unset — otherwise a default deployment
fn require_pixiv_token() -> bool { /// sends an empty refresh token to OAuth and fails login validation on
std::env::var("PIXIV_REFRESH_TOKEN") /// every boot.
.ok() #[test]
.filter(|s| !s.is_empty()) fn empty_refresh_token_reads_as_unset() {
.is_some() // SAFETY: the value is restored before returning; `enabled()` keys on
} // this variable alone and no other test mutates it. Concurrent readers
// see unset or empty, which this very fix makes the same answer.
#[tokio::test] let previous = env::var("PIXIV_REFRESH_TOKEN").ok();
async fn test_fetch() { unsafe { env::set_var("PIXIV_REFRESH_TOKEN", "") };
dotenv().ok(); let empty = enabled();
if !require_pixiv_token() { unsafe { env::remove_var("PIXIV_REFRESH_TOKEN") };
eprintln!("skipping: no PIXIV_REFRESH_TOKEN"); let unset = enabled();
return; match previous {
Some(value) => unsafe { env::set_var("PIXIV_REFRESH_TOKEN", value) },
None => unsafe { env::remove_var("PIXIV_REFRESH_TOKEN") },
} }
let result = fetch(126839080).await; assert!(!empty, "an empty token must not enable pixiv");
assert!(result.is_ok()); assert_eq!(empty, unset, "empty must read exactly like unset");
println!("{:#?}", result);
} }
#[tokio::test] #[tokio::test]
#[ignore = "live network: requires outbound HTTPS to oauth.secure.pixiv.net"] #[ignore = "live network: requires outbound HTTPS to oauth.secure.pixiv.net"]
async fn live_validate_with_bogus_token_fails() { async fn live_validate_with_bogus_token_fails() {
dotenv().ok(); dotenv().ok();
// A bogus token must surface as Api error (invalid_grant), not panic.
let client = PixivAPI::new("bogus_token_for_testing".to_string()); let client = PixivAPI::new("bogus_token_for_testing".to_string());
let result = client.get_access_token().await; let result = client.get_access_token().await;
assert!(matches!(result, Err(PixivError::Api(_))), "got {result:?}"); assert!(
matches!(result, Err(PixivError::Status(code)) if (400..500).contains(&code)),
"got {result:?}"
);
}
#[test]
fn response_read_transport_errors_stay_retryable() {
let mapped = map_response_error(FetchError::Transient("reset".into()));
assert!(matches!(mapped, PixivError::Transient(_)));
}
#[test]
fn ugoira_budget_rejects_too_many_frames() {
assert!(check_ugoira_archive_size(MAX_UGOIRA_FRAMES, 0).is_ok());
assert!(check_ugoira_archive_size(MAX_UGOIRA_FRAMES + 1, 0).is_err());
}
#[test]
fn ugoira_budget_rejects_too_many_unpacked_bytes() {
assert!(check_ugoira_archive_size(1, MAX_UGOIRA_UNPACKED_BYTES).is_ok());
assert!(check_ugoira_archive_size(1, MAX_UGOIRA_UNPACKED_BYTES + 1).is_err());
} }
} }
+127 -55
View File
@@ -1,4 +1,4 @@
use super::model::{IllustrationModel, TypeModel}; use super::model::{IllustrationModel, ImageUrlsModel};
use crate::media::Media; use crate::media::Media;
use crate::site::{FetchError, Fetched, PixivError, Site, SiteFuture}; use crate::site::{FetchError, Fetched, PixivError, Site, SiteFuture};
use html_escape::{encode_double_quoted_attribute, encode_text}; use html_escape::{encode_double_quoted_attribute, encode_text};
@@ -46,17 +46,25 @@ impl Site for PixivSite {
} }
fn validate(&self) -> SiteFuture<'static, (), String> { fn validate(&self) -> SiteFuture<'static, (), String> {
Box::pin(async { Box::pin(async { startup_validation(super::api::validate().await) })
match super::api::validate().await { }
Ok(()) => Ok(()), }
Err(e) => {
// Keep the old behavior: a failed login disables pixiv /// Turns the startup token exchange's outcome into what the bot reports, and
// for the rest of this process. /// disables pixiv only for a rejected credential. A bad *moment* — a 5xx or a
super::api::disable(); /// network error while the container comes up — must not disable it: disabling
Err(format!("{e}")) /// on any error turned every later pixiv link into "support is disabled".
} /// Separate from the network call so the decision is testable.
} fn startup_validation(result: Result<(), PixivError>) -> Result<(), String> {
}) match result {
Ok(()) => Ok(()),
Err(e) if pixiv_error_is_retryable(&e) => {
Err(format!("{e} (transient — pixiv stays enabled)"))
}
Err(e) => {
super::api::disable();
Err(format!("{e}"))
}
} }
} }
@@ -83,19 +91,25 @@ pub fn cache_key(url: &str) -> Option<String> {
/// API/auth errors, unparseable bodies and missing auth are not retried. /// API/auth errors, unparseable bodies and missing auth are not retried.
pub fn is_retryable(err: &FetchError) -> bool { pub fn is_retryable(err: &FetchError) -> bool {
match err { match err {
FetchError::Http(_) | FetchError::Transient(_) => true, FetchError::Http(_) | FetchError::Transient(_) | FetchError::RateLimited { .. } => true,
FetchError::Pixiv(e) => match e { FetchError::Pixiv(e) => pixiv_error_is_retryable(e),
PixivError::Http(_) => true,
PixivError::Status(code) if *code == 429 || *code >= 500 => true,
PixivError::Status(_)
| PixivError::Api(_)
| PixivError::Json(_)
| PixivError::NoAuth => false,
},
_ => false, _ => false,
} }
} }
/// The pixiv-specific half of the retry policy, shared with startup
/// validation: a bad moment (429/5xx, a network error) is retryable, a
/// rejected credential is not.
fn pixiv_error_is_retryable(err: &PixivError) -> bool {
match err {
PixivError::Http(_) | PixivError::Transient(_) => true,
PixivError::Status(code) if *code == 429 || *code >= 500 => true,
PixivError::Status(_) | PixivError::Api(_) | PixivError::Json(_) | PixivError::NoAuth => {
false
}
}
}
/// pximg.net is hotlink-protected: downloads must carry the pixiv Referer. /// pximg.net is hotlink-protected: downloads must carry the pixiv Referer.
/// The match is on the media host, not the site PATTERN — pixiv's PATTERN /// The match is on the media host, not the site PATTERN — pixiv's PATTERN
/// only matches `pixiv.net/artworks/...`, never `i.pximg.net`. /// only matches `pixiv.net/artworks/...`, never `i.pximg.net`.
@@ -167,7 +181,7 @@ pub struct Illustration {
pub(crate) media: Vec<Media>, pub(crate) media: Vec<Media>,
nsfw: bool, nsfw: bool,
/// Keeps a temp dir (ugoira MP4) alive until the send completes. /// Keeps a temp dir (ugoira MP4) alive until the send completes.
pub(crate) _keep_alive: Option<tempfile::TempDir>, pub(crate) _keep_alive: Option<std::sync::Arc<tempfile::TempDir>>,
} }
impl Illustration { impl Illustration {
@@ -211,34 +225,28 @@ impl Illustration {
tags.insert(0, "AI".to_string()); tags.insert(0, "AI".to_string());
} }
let mut media = vec![]; let mut media = vec![];
if matches!(&model.r#type, TypeModel::Ugoira) { if model.r#type == "ugoira" {
// No static images for ugoira; the fetch path encodes an MP4 via // No static images for ugoira; the fetch path encodes an MP4 via
// ffmpeg and appends it as a Video item (api.rs). This fallback // ffmpeg and appends it as a Video item (api.rs). This fallback
// keeps media empty when encoding fails or ffmpeg is missing. // keeps media empty when encoding fails or ffmpeg is missing.
} else if model.page_count > 1 { } else if model.page_count > 1 {
// Every page is kept: `original` is the only URL the API may leave
// out (typically the restricted ones), and a page without it used
// to be dropped whole — losing a page of the work while `large`
// sat right there.
media.extend(model.meta_pages.iter().filter_map(|page| { media.extend(model.meta_pages.iter().filter_map(|page| {
page.image_urls page_illustration(page.image_urls.original.clone(), &page.image_urls)
.original
.clone()
.map(|original| Media::Illustration {
title: None,
url: original,
thumbnail_url: Some(page.image_urls.medium.clone()),
fallback_url: Some(page.image_urls.large.clone()),
})
})); }));
} else if let Some(original) = model } else {
.meta_single_page // The single page names its original in one of two places, and
.original_image_url // `large` is the last resort.
.clone() let urls = &model.image_urls;
.or(model.image_urls.original.clone()) let original = model
{ .meta_single_page
media.push(Media::Illustration { .original_image_url
title: None, .clone()
url: original, .or_else(|| urls.original.clone());
thumbnail_url: Some(model.image_urls.medium.clone()), media.extend(page_illustration(original, urls));
fallback_url: Some(model.image_urls.large.clone()),
});
} }
let nsfw = model.sanity_level > 5; let nsfw = model.sanity_level > 5;
Self { Self {
@@ -255,6 +263,21 @@ impl Illustration {
} }
} }
/// One artwork page as a media item: `original` when the API sent one, else the
/// `large` variant (the same picture at a lower resolution), with `medium` as
/// the thumbnail. `None` when the API gave no usable URL at all.
fn page_illustration(original: Option<String>, urls: &ImageUrlsModel) -> Option<Media> {
let url = original.unwrap_or_else(|| urls.large.clone());
if url.is_empty() {
return None;
}
Some(Media::Illustration {
url,
thumbnail_url: Some(urls.medium.clone()),
fallback_url: Some(urls.large.clone()),
})
}
impl From<Illustration> for Fetched { impl From<Illustration> for Fetched {
fn from(illustration: Illustration) -> Self { fn from(illustration: Illustration) -> Self {
let url = illustration.url(); let url = illustration.url();
@@ -266,7 +289,6 @@ impl From<Illustration> for Fetched {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" "); .join(" ");
let render_data = Some(crate::site::RenderData { let render_data = Some(crate::site::RenderData {
url: url.clone(),
author: encode_text(&illustration.author).into_owned(), author: encode_text(&illustration.author).into_owned(),
author_url: author_url.clone(), author_url: author_url.clone(),
title: encode_text(&illustration.title).into_owned(), title: encode_text(&illustration.title).into_owned(),
@@ -410,13 +432,48 @@ mod tests {
} }
} }
#[test]
fn startup_validation_keeps_the_site_enabled_on_a_bad_moment() {
use super::super::api;
// The startup decision, not the retry policy: a 5xx/429 while the
// container comes up must leave pixiv enabled and say so in the message
// the admin gets. The rejected-credential half is not exercised here —
// it calls `disable()`, a process-wide flag with no reset, so a test
// touching it would order-couple every other pixiv test (the predicate
// it keys on is covered by the table below).
for err in [
PixivError::Status(429),
PixivError::Status(503),
PixivError::Transient("frame zip download failed: transient".into()),
] {
let enabled_before = api::enabled();
let message = startup_validation(Err(err)).unwrap_err();
assert!(message.contains("stays enabled"), "{message}");
assert_eq!(
api::enabled(),
enabled_before,
"a bad moment must not disable the site"
);
}
assert!(startup_validation(Ok(())).is_ok());
}
#[test] #[test]
fn is_retryable_classifies_transient_and_permanent() { fn is_retryable_classifies_transient_and_permanent() {
// Transient: network errors, explicit transient, pixiv 429/5xx. // Transient: network errors, explicit transient, pixiv 429/5xx, and a
// failed media download (the frame zip's own bad moment).
assert!(is_retryable(&FetchError::Transient("429".into()))); assert!(is_retryable(&FetchError::Transient("429".into())));
assert!(is_retryable(&FetchError::RateLimited {
site: "pixiv",
retry_after_secs: 30
}));
assert!(is_retryable(&FetchError::Pixiv(PixivError::Status(429)))); assert!(is_retryable(&FetchError::Pixiv(PixivError::Status(429))));
assert!(is_retryable(&FetchError::Pixiv(PixivError::Status(500)))); assert!(is_retryable(&FetchError::Pixiv(PixivError::Status(500))));
assert!(is_retryable(&FetchError::Pixiv(PixivError::Status(503)))); assert!(is_retryable(&FetchError::Pixiv(PixivError::Status(503))));
assert!(is_retryable(&FetchError::Pixiv(PixivError::Transient(
"frame zip download failed: transient: media status 429".into()
))));
// Permanent: pixiv 4xx (bad/expired token, forbidden, not found), // Permanent: pixiv 4xx (bad/expired token, forbidden, not found),
// api/auth errors, unparseable bodies, not-found/blocked/sensitive. // api/auth errors, unparseable bodies, not-found/blocked/sensitive.
assert!(!is_retryable(&FetchError::Pixiv(PixivError::Status(400)))); assert!(!is_retryable(&FetchError::Pixiv(PixivError::Status(400))));
@@ -505,14 +562,19 @@ mod tests {
} }
#[test] #[test]
fn single_page_without_any_original_is_empty() { fn single_page_without_any_original_falls_back_to_large() {
let v = illust_json("illust", 1, None, None, vec![], 0); let v = illust_json("illust", 1, None, None, vec![], 0);
let fetched: Fetched = parse(v).into(); let fetched: Fetched = parse(v).into();
assert!(fetched.media.is_empty()); // Neither `meta_single_page.original_image_url` nor `image_urls.
// original` is set: the work is still deliverable as `large`.
match fetched.media.as_slice() {
[Media::Illustration { url, .. }] => assert_eq!(url, "large.jpg"),
other => panic!("expected the large variant, got {other:?}"),
}
} }
#[test] #[test]
fn multi_page_skips_pages_without_original() { fn multi_page_keeps_pages_without_original() {
let v = illust_json( let v = illust_json(
"illust", "illust",
2, 2,
@@ -525,17 +587,27 @@ mod tests {
0, 0,
); );
let fetched: Fetched = parse(v).into(); let fetched: Fetched = parse(v).into();
assert_eq!(fetched.media.len(), 1); // Both pages arrive: the restricted one (no `original`) sends its
// `large` instead of vanishing — a dropped page is a missing picture.
let urls: Vec<&str> = fetched
.media
.iter()
.map(|media| match media {
Media::Illustration { url, .. } => url.as_str(),
other => panic!("expected Illustration, got {other:?}"),
})
.collect();
assert_eq!(urls, vec!["l1.jpg", "https://i.pximg.net/p2.jpg"]);
match &fetched.media[0] { match &fetched.media[0] {
Media::Illustration { Media::Illustration {
url,
thumbnail_url, thumbnail_url,
fallback_url, fallback_url,
.. ..
} => { } => {
assert_eq!(url, "https://i.pximg.net/p2.jpg"); assert_eq!(thumbnail_url.as_deref(), Some("m1.jpg"));
assert_eq!(thumbnail_url.as_deref(), Some("m2.jpg")); // `large` is the item itself here, so it is not also a
assert_eq!(fallback_url.as_deref(), Some("l2.jpg")); // smaller variant of itself.
assert_eq!(fallback_url.as_deref(), Some("l1.jpg"));
} }
other => panic!("expected Illustration, got {other:?}"), other => panic!("expected Illustration, got {other:?}"),
} }
@@ -567,7 +639,7 @@ mod tests {
); );
// Empty format falls back to the built-in caption. // Empty format falls back to the built-in caption.
assert_eq!(fetched.caption_with(""), fetched.caption); assert_eq!(fetched.caption_with(""), fetched.caption);
assert_eq!(fetched.site_name(), "pixiv"); assert_eq!(fetched.site_id, "pixiv");
} }
#[test] #[test]
+1 -1
View File
@@ -2,7 +2,7 @@ mod api;
mod interface; mod interface;
mod model; mod model;
pub use api::{PixivAPI, PixivError, disable, fetch, validate}; pub use api::{PixivError, disable, fetch, validate};
pub use interface::{ pub use interface::{
Illustration, PATTERN, PixivSite, cache_key, enabled, fetch_from_url, is_retryable, Illustration, PATTERN, PixivSite, cache_key, enabled, fetch_from_url, is_retryable,
media_headers, media_headers,
+4 -11
View File
@@ -10,7 +10,10 @@ pub struct IllustrationModel {
/// works (`<br />`, `<a href>`, sometimes `<p>`), empty for many. /// works (`<br />`, `<a href>`, sometimes `<p>`), empty for many.
#[serde(default)] #[serde(default)]
pub caption: String, pub caption: String,
pub r#type: TypeModel, /// `"illust"` / `"manga"` / `"ugoira"`; only ugoira changes how the
/// artwork is fetched (a zip of frames to encode), so the rest is kept as
/// the string the API sent rather than as variants nothing matches.
pub r#type: String,
pub image_urls: ImageUrlsModel, pub image_urls: ImageUrlsModel,
pub user: UserInfoModel, pub user: UserInfoModel,
pub tags: Vec<IllustrationTagModel>, pub tags: Vec<IllustrationTagModel>,
@@ -22,16 +25,6 @@ pub struct IllustrationModel {
pub meta_pages: Vec<MetaPageModel>, pub meta_pages: Vec<MetaPageModel>,
} }
#[derive(Deserialize, Debug)]
pub enum TypeModel {
#[serde(rename = "illust")]
Illust,
#[serde(rename = "manga")]
Manga,
#[serde(rename = "ugoira")]
Ugoira,
}
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct UserInfoModel { pub struct UserInfoModel {
pub id: u64, pub id: u64,
+5 -10
View File
@@ -130,14 +130,9 @@ pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
let status = response.status(); let status = response.status();
if !status.is_success() { if !status.is_success() {
log::warn!("twitter auth fetch {id}: HTTP {status}"); log::warn!("twitter auth fetch {id}: HTTP {status}");
return match status.as_u16() { return Err(crate::site::status_error("twitter auth", &response));
404 | 410 => Err(FetchError::NotFound),
_ => Err(FetchError::Transient(format!(
"twitter auth status {status}"
))),
};
} }
let text = response.text().await?; let text = crate::site::response_text(response, "twitter auth").await?;
let json: Value = serde_json::from_str(&text)?; let json: Value = serde_json::from_str(&text)?;
let result = parse_tweet_result(&json, id)?; let result = parse_tweet_result(&json, id)?;
let syndication_shape = to_syndication_shape(&result).ok_or_else(|| { let syndication_shape = to_syndication_shape(&result).ok_or_else(|| {
@@ -146,7 +141,7 @@ pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
"missing tweet fields in GraphQL response", "missing tweet fields in GraphQL response",
))) )))
})?; })?;
Tweet::from_syndication_json(&syndication_shape.to_string()).map_err(FetchError::Json) Tweet::from_syndication_value(syndication_shape).map_err(FetchError::Json)
} }
/// Locates the tweet for `id` in a `TweetDetail` response and unwraps /// Locates the tweet for `id` in a `TweetDetail` response and unwraps
@@ -220,7 +215,7 @@ fn normalize_tweet_result(result: &Value) -> Result<Value, FetchError> {
} }
/// Maps a GraphQL `{core, legacy, ...}` tweet onto the syndication JSON /// Maps a GraphQL `{core, legacy, ...}` tweet onto the syndication JSON
/// shape [`Tweet::from_syndication_json`] parses, so the existing text / /// shape [`Tweet::from_syndication_value`] parses, so the existing text /
/// media handling (t.co expansion, `name=orig`, mp4 variant) is reused. /// media handling (t.co expansion, `name=orig`, mp4 variant) is reused.
fn to_syndication_shape(tweet: &Value) -> Option<Value> { fn to_syndication_shape(tweet: &Value) -> Option<Value> {
let legacy = tweet.get("legacy")?; let legacy = tweet.get("legacy")?;
@@ -305,7 +300,7 @@ mod tests {
let json = conversation(tweet_result()); let json = conversation(tweet_result());
let result = parse_tweet_result(&json, "2083868672721039569").unwrap(); let result = parse_tweet_result(&json, "2083868672721039569").unwrap();
let shape = to_syndication_shape(&result).unwrap(); let shape = to_syndication_shape(&result).unwrap();
let tweet = Tweet::from_syndication_json(&shape.to_string()).unwrap(); let tweet = Tweet::from_syndication_value(shape).unwrap();
let fetched: crate::site::Fetched = tweet.into(); let fetched: crate::site::Fetched = tweet.into();
assert!(fetched.sensitive); assert!(fetched.sensitive);
+95 -172
View File
@@ -1,7 +1,7 @@
use super::model; use super::model;
use crate::media::Media; use crate::media::Media;
use crate::site::{FetchError, Fetched, Site, SiteFuture}; use crate::site::{FetchError, Fetched, Site, SiteFuture};
use html_escape::{decode_html_entities, encode_double_quoted_attribute, encode_text}; use html_escape::{decode_html_entities, encode_text};
use regex::Regex; use regex::Regex;
use std::sync::LazyLock; use std::sync::LazyLock;
@@ -30,8 +30,12 @@ pub static PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?:https?://)?(?:www\.|mobile\.)?(?:x|twitter|fixvx|vxtwitter|fixupx|fxtwitter)\.com/[^.]+/status/(\d+)").unwrap() Regex::new(r"^(?:https?://)?(?:www\.|mobile\.)?(?:x|twitter|fixvx|vxtwitter|fixupx|fxtwitter)\.com/[^.]+/status/(\d+)").unwrap()
}); });
pub fn enabled() -> bool { /// Cache key for a twitter URL: `"twitter:<id>"`. The prefix is the site id
true /// used for caption-format lookup and link-cache keys.
pub fn cache_key(url: &str) -> Option<String> {
PATTERN
.captures(url)
.map(|caps| format!("twitter:{}", &caps[1]))
} }
pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> { pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
@@ -43,71 +47,31 @@ pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
match fetch(id).await { match fetch(id).await {
Ok(tweet) => Ok(tweet.into()), Ok(tweet) => Ok(tweet.into()),
// Syndication withholds NSFW/age-restricted tweets (empty `{}`). // Syndication withholds NSFW/age-restricted tweets (empty `{}`).
// Retry as the logged-in user when TWITTER_AUTH_TOKEN is set; // Retry as the logged-in user when TWITTER_AUTH_TOKEN is set; without
// otherwise degrade to an empty result (the bot replies // the token the withholding is reported as `Sensitive`, so the bot can
// "No media found"). // answer "age-restricted / needs TWITTER_AUTH_TOKEN" instead of the
// misleading "No media found".
Err(FetchError::Sensitive) => { Err(FetchError::Sensitive) => {
if super::auth::enabled() { if super::auth::enabled() {
match super::auth::fetch(id).await { match super::auth::fetch(id).await {
Ok(tweet) => Ok(tweet.into()), Ok(tweet) => Ok(tweet.into()),
// The tweet is genuinely gone (deleted / suspended / // Deleted/suspended (tombstoned) and unexpected fallback
// tombstoned): report it instead of degrading to an // failures keep their own class: the bot reports what
// empty result ("No media found"). Only unexpected // actually happened rather than "No media found".
// fallback failures (network, parse) keep the NSFW
// placeholder.
Err(FetchError::NotFound) => Err(FetchError::NotFound),
Err(e) => { Err(e) => {
log::warn!("twitter auth fallback failed for {id}: {e}"); log::warn!("twitter auth fallback failed for {id}: {e}");
Ok(empty_fetched(url)) Err(e)
} }
} }
} else { } else {
log::debug!("tweet {id} is sensitive; set TWITTER_AUTH_TOKEN to fetch NSFW media"); 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), Err(e) => Err(e),
} }
} }
/// Cache key for a twitter URL: `"twitter:<id>"`. The prefix is the site id
/// used for caption-format lookup and link-cache keys.
pub fn cache_key(url: &str) -> Option<String> {
PATTERN
.captures(url)
.map(|caps| format!("twitter:{}", &caps[1]))
}
/// Twitter's fetch-retry policy: transient classes only. Not-found, blocked,
/// sensitive (NSFW withholding) and parse failures are permanent — retrying
/// them only wastes attempts against the syndication endpoint.
pub fn is_retryable(err: &FetchError) -> bool {
matches!(err, FetchError::Http(_) | FetchError::Transient(_))
}
/// twimg URLs need no extra headers (no hotlink protection).
pub fn media_headers(_url: &str) -> Option<Vec<(&'static str, String)>> {
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 /// Fetches a tweet from the syndication endpoint. Deleted/blocked tweets
/// surface as `FetchError::NotFound`; withheld content (empty tombstone, /// surface as `FetchError::NotFound`; withheld content (empty tombstone,
/// age-restricted) as `FetchError::Sensitive`. /// age-restricted) as `FetchError::Sensitive`.
@@ -123,19 +87,19 @@ pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
// 404/410 = gone (permanent); 429/5xx = transient and retried by fetch. // 404/410 = gone (permanent); 429/5xx = transient and retried by fetch.
let status = response.status(); let status = response.status();
if !status.is_success() { if !status.is_success() {
return match status.as_u16() { return Err(crate::site::status_error("twitter", &response));
404 | 410 => Err(FetchError::NotFound),
_ => Err(FetchError::Transient(format!("twitter status {status}"))),
};
} }
let text = response.text().await?; let text = crate::site::response_text(response, "twitter").await?;
// Classify before parsing the tweet (see [`parse_syndication_body`]). // Classify before building the tweet (see [`parse_syndication_body`]), and
parse_syndication_body(&text)?; // build it from the value that classification already parsed: this used to
Tweet::from_syndication_json(&text).map_err(FetchError::Json) // scan and allocate the whole body twice.
let body = parse_syndication_body(&text)?;
Tweet::from_syndication_value(body).map_err(FetchError::Json)
} }
/// Parses and classifies a syndication response body. `Ok` means the body is /// Parses and classifies a syndication response body. `Ok` carries the parsed
/// a real tweet payload; `Err` carries the permanent error class: /// body on for the caller to build the tweet from — the same value, so the
/// text is never parsed twice; `Err` carries the permanent error class:
/// - `NotFound`: an `errors` array (deleted/blocked) or a `TweetTombstone` /// - `NotFound`: an `errors` array (deleted/blocked) or a `TweetTombstone`
/// **with a reason** — "This Post was deleted by the Post author." / /// **with a reason** — "This Post was deleted by the Post author." /
/// "This Post is from a suspended account." (the tweet is gone). /// "This Post is from a suspended account." (the tweet is gone).
@@ -166,7 +130,18 @@ fn parse_syndication_body(text: &str) -> Result<serde_json::Value, FetchError> {
return Err(FetchError::NotFound); return Err(FetchError::NotFound);
} }
if body.get("id_str").is_none() { if body.get("id_str").is_none() {
return Err(FetchError::Sensitive); // Syndication answers an empty `{}` for withheld (NSFW /
// age-restricted) tweets: the documented case, kept as `Sensitive`
// because it is what triggers the logged-in auth fallback.
if body.as_object().is_some_and(|object| object.is_empty()) {
return Err(FetchError::Sensitive);
}
// Any other shape is not a tweet: an interstitial, a truncated body,
// a change on their side. Reporting that as withheld content told the
// user to set TWITTER_AUTH_TOKEN for something auth cannot fix.
return Err(FetchError::Transient(
"unexpected syndication body".to_string(),
));
} }
Ok(body) Ok(body)
} }
@@ -224,17 +199,16 @@ impl Tweet {
} }
pub fn caption(&self) -> String { pub fn caption(&self) -> String {
format!( crate::site::caption(&self.url(), &self.author_url(), &self.author, &self.text)
"{url}\n<a href=\"{author_url}\">{author}</a>: {text}",
url = encode_double_quoted_attribute(&self.url()),
author_url = encode_double_quoted_attribute(&self.author_url()),
author = encode_text(&self.author),
text = encode_text(&self.text),
)
} }
pub fn from_syndication_json(raw_json: &str) -> Result<Self, serde_json::Error> { /// Builds a tweet from an already-parsed syndication body. Takes the value
let json: model::SyndicationTweet = serde_json::from_str(raw_json)?; /// rather than JSON text so a caller that had to parse it anyway (the
/// fetch path classifies the raw shape; the auth fallback builds the shape
/// itself) does not pay for a second scan — `from_value` moves the strings
/// out instead.
pub fn from_syndication_value(body: serde_json::Value) -> Result<Self, serde_json::Error> {
let json: model::SyndicationTweet = serde_json::from_value(body)?;
let id = json.id_str; let id = json.id_str;
// Expand the user's t.co short links to their real destinations and // Expand the user's t.co short links to their real destinations and
// strip the appended media short link, mirroring FxEmbed's linkFixer // strip the appended media short link, mirroring FxEmbed's linkFixer
@@ -256,7 +230,6 @@ impl Tweet {
for item in json.media_details { for item in json.media_details {
match item.media_type.as_str() { match item.media_type.as_str() {
"photo" => media.push(Media::Illustration { "photo" => media.push(Media::Illustration {
title: None,
url: original_twimg_url(&item.media_url_https), url: original_twimg_url(&item.media_url_https),
thumbnail_url: None, thumbnail_url: None,
// The param-less base URL is a reduced-size variant; // The param-less base URL is a reduced-size variant;
@@ -264,12 +237,10 @@ impl Tweet {
fallback_url: Some(item.media_url_https.clone()), fallback_url: Some(item.media_url_https.clone()),
}), }),
"video" => media.push(Media::Video { "video" => media.push(Media::Video {
title: None,
url: mp4_variant(&item), url: mp4_variant(&item),
thumbnail_url: item.media_url_https, thumbnail_url: item.media_url_https,
}), }),
"animated_gif" => media.push(Media::Animated { "animated_gif" => media.push(Media::Animated {
title: None,
url: mp4_variant(&item), url: mp4_variant(&item),
thumbnail_url: item.media_url_https, thumbnail_url: item.media_url_https,
}), }),
@@ -355,7 +326,6 @@ impl From<Tweet> for Fetched {
let author_url = tweet.author_url(); let author_url = tweet.author_url();
// A tweet has no title: its text is all content. // A tweet has no title: its text is all content.
let render_data = Some(crate::site::RenderData { let render_data = Some(crate::site::RenderData {
url: url.clone(),
author: encode_text(&tweet.author).into_owned(), author: encode_text(&tweet.author).into_owned(),
author_url: author_url.clone(), author_url: author_url.clone(),
title: String::new(), title: String::new(),
@@ -436,7 +406,7 @@ mod tests {
"entities": { "urls": [] }, "entities": { "urls": [] },
"mediaDetails": [] "mediaDetails": []
}); });
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap(); let tweet = Tweet::from_syndication_value(raw).unwrap();
// The appended media short link is stripped, then entities decoded. // The appended media short link is stripped, then entities decoded.
assert_eq!(tweet.text, ">^ω^< & more 'quoted'"); assert_eq!(tweet.text, ">^ω^< & more 'quoted'");
assert_eq!(tweet.author, "O'Brien"); assert_eq!(tweet.author, "O'Brien");
@@ -466,20 +436,6 @@ mod tests {
assert_eq!(cache_key("https://example.com/1"), None); assert_eq!(cache_key("https://example.com/1"), None);
} }
#[test]
fn is_retryable_classifies_transient_and_permanent() {
// Transient: network errors and explicit transient statuses (the
// `Http` arm shares this match arm with `Transient`).
assert!(is_retryable(&FetchError::Transient("429".into())));
// Permanent: gone, blocked, withheld, oversized, unparseable.
assert!(!is_retryable(&FetchError::NotFound));
assert!(!is_retryable(&FetchError::Blocked));
assert!(!is_retryable(&FetchError::Sensitive));
assert!(!is_retryable(&FetchError::TooLarge));
let json_err = serde_json::from_str::<serde_json::Value>("x").unwrap_err();
assert!(!is_retryable(&FetchError::Json(json_err)));
}
#[test] #[test]
fn syndication_json_converts_to_fetched() { fn syndication_json_converts_to_fetched() {
let raw = fixture(serde_json::json!([ let raw = fixture(serde_json::json!([
@@ -495,7 +451,7 @@ mod tests {
} }
} }
])); ]));
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap(); let tweet = Tweet::from_syndication_value(raw).unwrap();
let fetched: Fetched = tweet.into(); let fetched: Fetched = tweet.into();
assert_eq!( assert_eq!(
fetched.source_url, fetched.source_url,
@@ -530,14 +486,6 @@ mod tests {
); );
} }
#[test]
fn syndication_text_only_has_no_media() {
let raw = fixture(serde_json::json!([]));
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap();
let fetched: Fetched = tweet.into();
assert!(fetched.media.is_empty());
}
#[test] #[test]
fn syndication_gif_maps_to_animated() { fn syndication_gif_maps_to_animated() {
let raw = fixture(serde_json::json!([ let raw = fixture(serde_json::json!([
@@ -549,61 +497,37 @@ mod tests {
} }
} }
])); ]));
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap(); let tweet = Tweet::from_syndication_value(raw).unwrap();
assert!(matches!(&tweet.media[0], Media::Animated { .. })); assert!(matches!(&tweet.media[0], Media::Animated { .. }));
} }
#[test] #[test]
fn syndication_text_strips_trailing_media_short_link() { fn syndication_text_strips_trailing_media_short_link() {
// Real syndication shape: the appended media short link sits after the // Real syndication shape: the appended media short link sits after the
// visible text; the unmapped t.co link is stripped by content. // visible text and there are no URL entities, so the unmapped t.co link
let raw = serde_json::json!({ // is stripped by content alone. The second row is real tweet
"__typename": "Tweet", // 2084567054481571919 (30 code points but 41 UTF-16 units, and the two
"id_str": "1", // endpoints historically reported `display_text_range` in different
"text": "hello world https://t.co/abc123", // units): a content-based strip cannot leave a partial link behind for
"user": { "name": "N", "screen_name": "h" }, // either unit system.
"mediaDetails": [] for (text, visible) in [
}); ("hello world https://t.co/abc123", "hello world"),
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap(); (
assert_eq!(tweet.text, "hello world"); "妄想𝑨𝒅𝒅𝒊𝒄𝒕𝒊𝒐𝒏…🩷💚❤️\n#ゼンゼロ #zzzero https://t.co/XnIi83EkEB",
assert!(!tweet.caption().contains("t.co")); "妄想𝑨𝒅𝒅𝒊𝒄𝒕𝒊𝒐𝒏…🩷💚❤️\n#ゼンゼロ #zzzero",
} ),
] {
#[test] let raw = serde_json::json!({
fn syndication_text_strips_trailing_link_regardless_of_index_units() { "__typename": "Tweet",
// Real tweet 2084567054481571919: the visible text is 30 code points "id_str": "1",
// but 41 UTF-16 units, and the two endpoints historically reported "text": text,
// display_text_range in different units (UTF-16 on syndication, code "user": { "name": "N", "screen_name": "h" },
// points on GraphQL). The FxEmbed-style content-based strip ignores "mediaDetails": []
// the range entirely, so the appended media link is removed for any });
// response shape. let tweet = Tweet::from_syndication_value(raw).unwrap();
let text = "妄想𝑨𝒅𝒅𝒊𝒄𝒕𝒊𝒐𝒏…🩷💚❤️\n#ゼンゼロ #zzzero https://t.co/XnIi83EkEB"; assert_eq!(tweet.text, visible, "left a partial link in {text:?}");
let visible = "妄想𝑨𝒅𝒅𝒊𝒄𝒕𝒊𝒐𝒏…🩷💚❤️\n#ゼンゼロ #zzzero"; assert!(!tweet.caption().contains("t.co"), "{text:?}");
let raw = serde_json::json!({ }
"__typename": "Tweet",
"id_str": "2084567054481571919",
"text": text,
"user": { "name": "N", "screen_name": "h" },
"mediaDetails": []
});
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap();
assert_eq!(tweet.text, visible, "left a partial link");
assert!(!tweet.caption().contains("t.co"));
}
#[test]
fn syndication_text_strips_trailing_short_link_without_entities() {
// No URL entities at all: the leftover t.co link is stripped by the
// content regex.
let raw = serde_json::json!({
"__typename": "Tweet",
"id_str": "1",
"text": "hello https://t.co/abc123",
"user": { "name": "N", "screen_name": "h" },
"mediaDetails": []
});
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap();
assert_eq!(tweet.text, "hello");
} }
#[test] #[test]
@@ -625,7 +549,7 @@ mod tests {
}, },
"mediaDetails": [] "mediaDetails": []
}); });
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap(); let tweet = Tweet::from_syndication_value(raw).unwrap();
assert_eq!( assert_eq!(
tweet.text, tweet.text,
"Test Tweet with @mentionThis $twtr http://bit.ly/2pUk4be #hashtag" "Test Tweet with @mentionThis $twtr http://bit.ly/2pUk4be #hashtag"
@@ -644,7 +568,7 @@ mod tests {
"user": { "name": "N", "screen_name": "h" }, "user": { "name": "N", "screen_name": "h" },
"mediaDetails": [] "mediaDetails": []
}); });
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap(); let tweet = Tweet::from_syndication_value(raw).unwrap();
assert_eq!(tweet.text, "check #tag"); assert_eq!(tweet.text, "check #tag");
} }
@@ -667,28 +591,11 @@ mod tests {
}, },
"mediaDetails": [] "mediaDetails": []
}); });
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap(); let tweet = Tweet::from_syndication_value(raw).unwrap();
assert_eq!(tweet.text, "see for context"); assert_eq!(tweet.text, "see for context");
assert!(!tweet.caption().contains("t.co")); assert!(!tweet.caption().contains("t.co"));
} }
#[test]
fn syndication_text_keeps_multibyte_text() {
// Text-only tweet: no short links, the multibyte text is untouched.
let text = "コミティア落ちたので、明日は行きません。🙏ごめんなさい";
let units: Vec<u16> = text.encode_utf16().collect();
assert_eq!(units.len(), 28);
let raw = serde_json::json!({
"__typename": "Tweet",
"id_str": "1",
"text": text,
"user": { "name": "N", "screen_name": "h" },
"mediaDetails": []
});
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap();
assert_eq!(tweet.text, text, "full text kept intact");
}
#[test] #[test]
fn original_twimg_url_rewrites_photo_urls() { fn original_twimg_url_rewrites_photo_urls() {
assert_eq!( assert_eq!(
@@ -712,9 +619,14 @@ mod tests {
#[test] #[test]
fn syndication_token_matches_js_formula() { fn syndication_token_matches_js_formula() {
// JS: ((861627479294746624 / 1e15) * PI).toString(36) == "236.vrsocvda" // JS: ((861627479294746624 / 1e15) * PI).toString(36) == "236.vrsocvda".
let token = syndication_token(861627479294746624); // This loop truncates ten base-36 fraction digits instead of rendering
assert!(token.starts_with("236.v"), "got {token}"); // the shortest round-tripping one, so it agrees with JS on the stem and
// diverges in the tail (`…d9ui` vs `…da`). Pinned exactly, because the
// token is a fixed function of the id: a stub or a wrong constant must
// not pass. The endpoint currently serves public tweets regardless of
// the token, which is why the tail is left as is.
assert_eq!(syndication_token(861627479294746624), "236.vrsocvd9ui");
} }
#[test] #[test]
@@ -782,6 +694,17 @@ mod tests {
)); ));
} }
#[test]
fn syndication_unexpected_shape_is_transient_not_withheld() {
// A 200 that is not a tweet at all (an interstitial, a truncated
// body) must not be reported as withheld content: that message tells
// the user to set TWITTER_AUTH_TOKEN, which cannot fix it.
match parse_syndication_body("{\"foo\":1}") {
Err(FetchError::Transient(_)) => {}
other => panic!("expected Transient, got {other:?}"),
}
}
#[test] #[test]
fn syndication_tweet_body_passes() { fn syndication_tweet_body_passes() {
let raw = fixture(serde_json::json!([])); let raw = fixture(serde_json::json!([]));
+1 -3
View File
@@ -2,6 +2,4 @@ mod auth;
mod interface; mod interface;
mod model; mod model;
pub use interface::{ pub use interface::{PATTERN, Tweet, TwitterSite, cache_key, fetch_from_url};
PATTERN, Tweet, TwitterSite, cache_key, enabled, fetch_from_url, is_retryable, media_headers,
};
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "xmedia-bot" name = "xmedia-bot"
version = "1.7.0" version = "1.9.2"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+144 -30
View File
@@ -34,29 +34,45 @@ impl Config {
fn parse_u64(name: &str, default: u64) -> u64 { fn parse_u64(name: &str, default: u64) -> u64 {
match env::var(name) { match env::var(name) {
Ok(v) => v.parse::<u64>().unwrap_or_else(|_| { Ok(v) => v.parse::<u64>().unwrap_or_else(|_| {
log::warn!("invalid {name}={v:?}; using default {default}"); log::warn!("invalid {name}; using default {default}");
default default
}), }),
Err(_) => default, Err(_) => default,
} }
} }
let admin_ids = match env::var("BOT_ADMIN") { /// A setting that must parse when it is set: an unparseable value warns
Ok(s) => { /// (naming the variable) and counts as unset.
let (ids, bad): (Vec<_>, Vec<_>) = s fn parse_opt<T: std::str::FromStr>(name: &str) -> Option<T> {
env::var(name).ok().and_then(|s| {
s.parse::<T>().ok().or_else(|| {
log::warn!("invalid {name}");
None
})
})
}
let admin_ids = env::var("BOT_ADMIN")
.map(|s| {
let mut bad = Vec::new();
let ids: Vec<i64> = s
.split(',') .split(',')
.map(str::trim) .map(str::trim)
.filter(|part| !part.is_empty()) .filter(|part| !part.is_empty())
.partition(|part| part.parse::<i64>().is_ok()); .filter_map(|part| match part.parse::<i64>() {
Ok(id) => Some(id),
Err(_) => {
bad.push(part);
None
}
})
.collect();
if !bad.is_empty() { if !bad.is_empty() {
log::warn!("BOT_ADMIN: ignoring non-numeric ids: {bad:?}"); log::warn!("BOT_ADMIN: ignoring {} non-numeric id(s)", bad.len());
} }
ids.into_iter() ids
.filter_map(|p| p.parse::<i64>().ok()) })
.collect() .unwrap_or_default();
}
Err(_) => Vec::new(),
};
let edit_message_ttl = let edit_message_ttl =
Duration::from_secs(parse_u64("EDIT_MESSAGE_TTL_SECONDS", 24 * 3600)); Duration::from_secs(parse_u64("EDIT_MESSAGE_TTL_SECONDS", 24 * 3600));
@@ -69,24 +85,9 @@ impl Config {
// The webhook settings are consumed by `.expect()` in main when // The webhook settings are consumed by `.expect()` in main when
// WEBHOOK=true, so an unparseable value fails fast at startup with a // WEBHOOK=true, so an unparseable value fails fast at startup with a
// clear message; still log here for the WEBHOOK=false case. // clear message; still log here for the WEBHOOK=false case.
let webhook_url = env::var("WEBHOOK_URL").ok().and_then(|s| { let webhook_url = parse_opt::<url::Url>("WEBHOOK_URL");
s.parse::<url::Url>().ok().or_else(|| { let webhook_listen = parse_opt::<IpAddr>("WEBHOOK_LISTEN");
log::warn!("invalid WEBHOOK_URL={s:?}"); let webhook_port = parse_opt::<u16>("WEBHOOK_PORT");
None
})
});
let webhook_listen = env::var("WEBHOOK_LISTEN").ok().and_then(|s| {
s.parse::<IpAddr>().ok().or_else(|| {
log::warn!("invalid WEBHOOK_LISTEN={s:?}");
None
})
});
let webhook_port = env::var("WEBHOOK_PORT").ok().and_then(|s| {
s.parse::<u16>().ok().or_else(|| {
log::warn!("invalid WEBHOOK_PORT={s:?}");
None
})
});
// Empty strings count as unset (e.g. `-e WEBHOOK_CERT=` to disable a // Empty strings count as unset (e.g. `-e WEBHOOK_CERT=` to disable a
// value that would otherwise come from `.env`). // value that would otherwise come from `.env`).
let webhook_cert = env::var("WEBHOOK_CERT").ok().filter(|s| !s.is_empty()); let webhook_cert = env::var("WEBHOOK_CERT").ok().filter(|s| !s.is_empty());
@@ -108,3 +109,116 @@ impl Config {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
/// The ids an operator's `BOT_ADMIN` yields: blanks dropped, non-numeric
/// entries warned about and skipped, the rest kept in order. Parsed once —
/// the split used to parse every entry twice.
#[test]
fn bot_admin_keeps_the_numeric_ids_in_order() {
// SAFETY: no other test reads BOT_ADMIN, and the value is restored
// before this test returns.
let previous = env::var("BOT_ADMIN").ok();
unsafe { env::set_var("BOT_ADMIN", " 7 ,abc,42, ,") };
let ids = Config::load().admin_ids;
match previous {
Some(value) => unsafe { env::set_var("BOT_ADMIN", value) },
None => unsafe { env::remove_var("BOT_ADMIN") },
}
assert_eq!(ids, vec![7, 42]);
}
/// The webhook truth table: `Config::load` enables webhook mode only for
/// a case-insensitive `true|yes|1`, and everything else — including the
/// classic misspelling "on", which an operator would expect to work — is
/// polling. Without this pin a typo silently ran a different transport
/// (with P0's fail-fast secret check, or with no listener at all).
#[test]
fn webhook_flag_is_a_case_insensitive_truth_table() {
// SAFETY: no other test *mutates* WEBHOOK, the value is restored
// before this test returns, and concurrent Config::load callers in
// other tests assert fields other than webhook_enabled.
let previous = env::var("WEBHOOK").ok();
for (value, expected) in [
("true", true),
("TRUE", true),
("Yes", true),
("1", true),
("false", false),
("on", false),
("", false),
] {
unsafe { env::set_var("WEBHOOK", value) };
assert_eq!(
Config::load().webhook_enabled,
expected,
"WEBHOOK={value:?}"
);
}
match previous {
Some(value) => unsafe { env::set_var("WEBHOOK", value) },
None => unsafe { env::remove_var("WEBHOOK") },
}
}
/// A malformed TTL warns and falls back to the default instead of being
/// parsed as 0 — the difference between a 24h edit-prompt expiry and a
/// prompt that expires instantly, which an operator would only notice
/// when the buttons stop working.
#[test]
fn invalid_ttl_falls_back_to_the_default() {
// SAFETY: no other test *mutates* EDIT_MESSAGE_TTL_SECONDS; restored
// below, and no other test asserts the TTL field.
let previous = env::var("EDIT_MESSAGE_TTL_SECONDS").ok();
unsafe { env::set_var("EDIT_MESSAGE_TTL_SECONDS", "not-a-number") };
let invalid = Config::load().edit_message_ttl;
unsafe { env::set_var("EDIT_MESSAGE_TTL_SECONDS", "120") };
let valid = Config::load().edit_message_ttl;
match previous {
Some(value) => unsafe { env::set_var("EDIT_MESSAGE_TTL_SECONDS", value) },
None => unsafe { env::remove_var("EDIT_MESSAGE_TTL_SECONDS") },
}
assert_eq!(
invalid,
Duration::from_secs(24 * 3600),
"an unparseable value falls back to the default"
);
assert_eq!(
valid,
Duration::from_secs(120),
"a valid value is taken as-is"
);
}
/// A blank WEBHOOK_CERT / WEBHOOK_SECRET_TOKEN counts as unset — compose
/// injects `${VAR:-}` as an empty string for a commented-out template
/// line — while a present value is kept (the `-e VAR=` disable idiom).
#[test]
fn blank_webhook_cert_and_secret_count_as_unset() {
// SAFETY: no other test *mutates* these two, both are restored
// below, and no other test asserts them.
let prev_cert = env::var("WEBHOOK_CERT").ok();
let prev_secret = env::var("WEBHOOK_SECRET_TOKEN").ok();
unsafe { env::set_var("WEBHOOK_CERT", "") };
unsafe { env::set_var("WEBHOOK_SECRET_TOKEN", "") };
let blank = Config::load();
unsafe { env::set_var("WEBHOOK_CERT", "/x/cert.pem") };
unsafe { env::set_var("WEBHOOK_SECRET_TOKEN", "s3cret") };
let present = Config::load();
match prev_cert {
Some(value) => unsafe { env::set_var("WEBHOOK_CERT", value) },
None => unsafe { env::remove_var("WEBHOOK_CERT") },
}
match prev_secret {
Some(value) => unsafe { env::set_var("WEBHOOK_SECRET_TOKEN", value) },
None => unsafe { env::remove_var("WEBHOOK_SECRET_TOKEN") },
}
assert_eq!(blank.webhook_cert, None, "blank must read as unset");
assert_eq!(blank.webhook_secret_token, None, "blank must read as unset");
assert_eq!(present.webhook_cert.as_deref(), Some("/x/cert.pem"));
assert_eq!(present.webhook_secret_token.as_deref(), Some("s3cret"));
}
}
+94
View File
@@ -49,7 +49,91 @@ pub static CONTEXT: LazyLock<AppContext<'static>> =
#[cfg(test)] #[cfg(test)]
pub(crate) mod test_support { pub(crate) mod test_support {
use super::*; use super::*;
use crate::link_cache::{CachedMedia, CachedMediaKind, CachedPost};
use crate::send::{MediaItemPayload, MediaRef};
use crate::state::EditMessage;
use std::sync::Arc; use std::sync::Arc;
use teloxide::{ApiError, RequestError};
/// The edit-before-forward prompt's message id, and the message the prompt
/// refers to (the one whose caption a reply swaps).
pub(crate) const PROMPT_ID: i64 = 7;
pub(crate) const FORWARDED_ID: i64 = 9;
/// A Telegram API error, for the tests that script a failure.
pub(crate) fn api_error(message: &str) -> RequestError {
RequestError::Api(ApiError::Unknown(message.to_string()))
}
/// The API error a caption edit that changes nothing answers with — what
/// the mocks script for a permanent send failure. A `fn` pointer, so it can
/// be handed to `MockSender::scripted` as-is.
pub(crate) fn permanent_error() -> RequestError {
api_error("Bad Request: message is not modified")
}
/// One photo payload item: `media` in the two flags the tests vary (no
/// smaller variant, since that is the field most tests leave alone —
/// `send`'s own tests build that case directly).
pub(crate) fn photo_item(media: &str, has_spoiler: bool, file_id: bool) -> MediaItemPayload {
MediaItemPayload::Photo {
media: if file_id {
MediaRef::FileId(media.to_string())
} else {
MediaRef::Source(media.to_string())
},
has_spoiler,
fallback_url: None,
}
}
/// The cached post every test that touches the link cache starts from: one
/// photo with a Telegram file id at the canonical URL (key `twitter:1`).
/// Tests that need another field mutate the returned value.
pub(crate) fn cached_photo() -> CachedPost {
CachedPost {
url: "https://x.com/u/status/1".into(),
caption: "cap".into(),
title: "t".into(),
content: "c".into(),
author: "a".into(),
author_url: "au".into(),
tags: String::new(),
sensitive: false,
media: vec![CachedMedia {
kind: CachedMediaKind::Photo,
file_id: "AgAC-file-id".into(),
url: "https://pbs.twimg.com/media/photo.jpg".into(),
}],
}
}
/// Seeds the live prompt a post-send leaves behind in chat 1: the chat's
/// template, a bound forward channel (the prompt's "forward" button
/// branches on it) and the record for [`PROMPT_ID`] pointing at
/// [`FORWARDED_ID`]. `template` is the record's template — what a reply
/// swaps the caption through, `""` for none — and `created_at` backdates
/// the record for the expiry cases.
pub(crate) async fn seed_prompt(ctx: &AppContext<'_>, template: &str, created_at: i64) {
ctx.chat_store
.update(1, |data| {
data.forward_channel_id = Some(2);
data.template
.insert("tpl".to_string(), "<b>[]</b>".to_string());
data.edit_message.insert(
PROMPT_ID,
EditMessage {
url: "https://x.com/u/status/1".into(),
chat_id: 1,
forward_message_ids: vec![FORWARDED_ID],
template: template.to_string(),
created_at,
},
);
})
.await
.expect("seed prompt state");
}
pub(crate) struct TestStores { pub(crate) struct TestStores {
_dir: tempfile::TempDir, _dir: tempfile::TempDir,
@@ -98,6 +182,16 @@ pub(crate) mod test_support {
&self.link_cache &self.link_cache
} }
pub(crate) fn task_queue(&self) -> &PersistentTaskQueue {
&self.task_queue
}
/// Path to the shared test database, for tests that need to corrupt or
/// inspect schema through a separate connection.
pub(crate) fn db_path(&self) -> &str {
self.pool.path()
}
/// Rows persisted in the task queue: what "queued for retry" looks like /// Rows persisted in the task queue: what "queued for retry" looks like
/// from the outside. /// from the outside.
pub(crate) async fn queued_tasks(&self) -> i64 { pub(crate) async fn queued_tasks(&self) -> i64 {
+207 -16
View File
@@ -17,9 +17,11 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
/// Upper bound on pooled (reused) connections and on concurrent DB /// Upper bound on pooled (reused) connections and on concurrent DB
/// operations per store. Small on purpose: the queue's `BEGIN IMMEDIATE` /// operations. Sized to cover every consumer at once — 4 queue workers +
/// leases serialize writes anyway, and WAL readers rarely need more. /// 8 URL workers, plus dispatcher handlers and the sweep — so the semaphore
const POOL_SIZE: usize = 4; /// stops queueing operations behind each other; SQLite's single writer
/// serializes writes regardless, and WAL readers rarely block.
const POOL_SIZE: usize = 16;
/// A tiny connection pool for one SQLite file. Connections are checked out /// A tiny connection pool for one SQLite file. Connections are checked out
/// on a blocking thread and returned afterwards; `acquire` opens a new /// on a blocking thread and returned afterwards; `acquire` opens a new
@@ -82,6 +84,26 @@ impl DbPool {
pub fn path(&self) -> &str { pub fn path(&self) -> &str {
&self.inner.path &self.inner.path
} }
/// [`with_conn`] for the many callers that answer a failed statement with
/// a default plus one log line: `what` names the operation and `level`
/// says how bad it is (`Error` when the failure loses work the caller
/// expected, `Warn` when the user is still served).
///
/// [`with_conn`]: DbPool::with_conn
pub async fn with_conn_or<T, F>(&self, level: log::Level, what: &str, default: T, f: F) -> T
where
T: Send + 'static,
F: FnOnce(&mut Connection) -> rusqlite::Result<T> + Send + 'static,
{
match self.with_conn(f).await {
Ok(value) => value,
Err(e) => {
log::log!(level, "{what}: {e}");
default
}
}
}
} }
impl PoolInner { impl PoolInner {
@@ -115,31 +137,60 @@ pub fn open_db(path: &str) -> rusqlite::Result<Connection> {
/// Opens the shared DB file, runs the merged schema for all three tables and /// Opens the shared DB file, runs the merged schema for all three tables and
/// returns a pool for it. One call per process in production (the stores /// returns a pool for it. One call per process in production (the stores
/// share the returned pool); tests call it per tempdir. /// share the returned pool); tests call it per tempdir. The file's directory
/// must exist already — [`crate::handlers::db_path`] is what creates it, and
/// it is the only caller that takes a path it did not get from a tempdir.
pub fn open_store(path: &str) -> rusqlite::Result<Arc<DbPool>> { pub fn open_store(path: &str) -> rusqlite::Result<Arc<DbPool>> {
if let Some(parent) = std::path::Path::new(path).parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(rusqlite_error)?;
}
let conn = open_db(path)?; let conn = open_db(path)?;
schema_init(&conn)?; schema_init(&conn)?;
migrate(&conn)?;
Ok(Arc::new(DbPool::new(path))) Ok(Arc::new(DbPool::new(path)))
} }
fn rusqlite_error(e: std::io::Error) -> rusqlite::Error { /// Schema migrations, applied in order and tracked by `PRAGMA user_version`
rusqlite::Error::ToSqlConversionFailure(Box::new(e)) /// (the index in this array + 1 is the version a statement brings the
/// database to). Append only — never edit or reorder an entry, or databases
/// already past it would skip or repeat work.
const MIGRATIONS: &[&str] = &[
// 1: lease fencing. A worker's write-backs (`delete`/`reschedule`/the
// lease heartbeat) are guarded by the token it was leased with, so a
// lease that expired and was re-leased by another worker can no longer be
// written by its former holder — which used to duplicate a send or drop
// the new holder's retry state, silently.
"ALTER TABLE tasks ADD COLUMN lease_token TEXT",
// 2: the 300 s sweep prunes the link cache by `created_at`
// (`DELETE FROM link_cache WHERE created_at < ?`). Without an index that
// is a full scan of every post sent inside the TTL window — up to a week
// of them — on every sweep; the `url` primary key cannot serve it.
"CREATE INDEX IF NOT EXISTS idx_link_cache_created_at ON link_cache(created_at)",
];
/// Brings an existing database up to [`MIGRATIONS`]. Idempotent: a database
/// already at the latest version does no work.
fn migrate(conn: &Connection) -> rusqlite::Result<()> {
let version: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
for (index, statement) in MIGRATIONS.iter().enumerate() {
let target = index as i64 + 1;
if version >= target {
continue;
}
let tx = conn.unchecked_transaction()?;
tx.execute_batch(statement)?;
tx.execute_batch(&format!("PRAGMA user_version = {target}"))?;
tx.commit()?;
}
Ok(())
} }
/// Creates the `tasks`, `chat_state` and `link_cache` tables (idempotent). /// Creates the `tasks`, `chat_state` and `link_cache` tables (idempotent).
/// The three stores used to own their own schema; keeping it in one place /// The three stores used to own their own schema; keeping it in one place
/// means one initialization for the whole database file. /// means one initialization for the whole database file.
/// ///
/// ⚠️ Schema-change reminder (deferred, see `docs/architecture-refactor.md` /// This is the **baseline** schema (version 0): a fresh database is created
/// §5): this is a plain `CREATE TABLE IF NOT EXISTS` with no versioning. /// exactly like this, and anything that must *change* an existing one is
/// Before any column/table change that must migrate existing databases, land /// appended to [`MIGRATIONS`] instead of being edited in here — otherwise a
/// the `PRAGMA user_version` migration chain first (`MIGRATIONS: &[&str]` + /// database created before the change would never gain the new column and a
/// `migrate(conn)`), then restructure this function. /// freshly created one would try to apply the migration a second time.
pub fn schema_init(conn: &Connection) -> rusqlite::Result<()> { pub fn schema_init(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch( conn.execute_batch(
"CREATE TABLE IF NOT EXISTS tasks (id TEXT PRIMARY KEY, payload TEXT NOT NULL, \ "CREATE TABLE IF NOT EXISTS tasks (id TEXT PRIMARY KEY, payload TEXT NOT NULL, \
@@ -166,3 +217,143 @@ pub fn now_f64() -> f64 {
pub fn unix_now() -> i64 { pub fn unix_now() -> i64 {
now_f64() as i64 now_f64() as i64
} }
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
/// The schema as it shipped *before* the first migration: what an existing
/// deployment has on disk when it starts on the new binary. Written out
/// literally rather than derived from `schema_init`, so an edit to the
/// baseline shows up here instead of being followed silently.
const V0_SCHEMA: &str = "CREATE TABLE tasks (id TEXT PRIMARY KEY, payload TEXT NOT NULL, \
run_after REAL NOT NULL, attempts INTEGER NOT NULL, status TEXT NOT NULL, \
locked_until REAL NOT NULL, created_at REAL NOT NULL); \
CREATE INDEX idx_tasks_pending ON tasks(status, run_after); \
CREATE TABLE chat_state (chat_id TEXT PRIMARY KEY, payload TEXT NOT NULL); \
CREATE TABLE link_cache (url TEXT PRIMARY KEY, payload TEXT NOT NULL, \
created_at REAL NOT NULL);";
/// The migrations that have already shipped, verbatim. Appending is the only
/// allowed change: editing one that a database has already applied leaves
/// deployments on different schemas with nothing to notice it — the version
/// counter says "done" and skips the new text.
const SHIPPED_MIGRATIONS: &[&str] = &["ALTER TABLE tasks ADD COLUMN lease_token TEXT"];
fn columns(conn: &Connection, table: &str) -> Vec<String> {
let mut stmt = conn
.prepare(&format!("PRAGMA table_info({table})"))
.unwrap();
let mut names: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.map(Result::unwrap)
.collect();
names.sort();
names
}
fn user_version(conn: &Connection) -> i64 {
conn.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap()
}
#[tokio::test]
async fn a_pre_migration_database_upgrades_and_keeps_its_rows() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("old.db");
{
let conn = Connection::open(&path).unwrap();
conn.execute_batch(V0_SCHEMA).unwrap();
conn.execute(
"INSERT INTO tasks (id, payload, run_after, attempts, status, locked_until, created_at) \
VALUES ('task_old', '{\"chat_id\":1}', 0, 0, 'pending', 0, 0)",
[],
)
.unwrap();
assert_eq!(user_version(&conn), 0, "the fixture starts un-migrated");
assert!(
!columns(&conn, "tasks").contains(&"lease_token".to_string()),
"the fixture is the pre-migration shape"
);
}
let pool = open_store(path.to_str().unwrap()).unwrap();
pool.with_conn(|conn| {
assert_eq!(user_version(conn), MIGRATIONS.len() as i64);
let mut expected = vec![
"id",
"payload",
"run_after",
"attempts",
"status",
"locked_until",
"created_at",
"lease_token",
];
expected.sort();
assert_eq!(
columns(conn, "tasks"),
expected,
"an upgrade must add the migration's column and nothing else"
);
let payload: String = conn
.query_row(
"SELECT payload FROM tasks WHERE id = 'task_old'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(payload, "{\"chat_id\":1}", "rows survive the upgrade");
// The link-cache prune's index arrives with the migrations (the
// baseline schema has none): without it every sweep scans the
// whole table.
let index: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master \
WHERE type = 'index' AND name = 'idx_link_cache_created_at'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(index, 1, "the migration's index must exist");
Ok(())
})
.await
.unwrap();
}
#[test]
fn shipped_migrations_are_frozen() {
assert!(
MIGRATIONS.len() >= SHIPPED_MIGRATIONS.len(),
"migrations were removed or reordered, not appended"
);
for (index, (shipped, current)) in SHIPPED_MIGRATIONS.iter().zip(MIGRATIONS).enumerate() {
assert_eq!(
shipped,
current,
"migration {} already shipped: append a new one instead of editing it",
index + 1
);
}
}
#[tokio::test]
async fn a_fresh_database_lands_at_the_latest_version() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("fresh.db");
let pool = open_store(path.to_str().unwrap()).unwrap();
// Every migration is applied on creation, so a deployment that only ever
// saw fresh databases is on the same schema as an upgraded one.
pool.with_conn(|conn| {
assert_eq!(user_version(conn), MIGRATIONS.len() as i64);
Ok(())
})
.await
.unwrap();
// Opening the same file again is a no-op (the version gate skips it).
open_store(path.to_str().unwrap()).unwrap();
}
}
+209 -73
View File
@@ -14,6 +14,8 @@ use teloxide::types::{CallbackQuery, CallbackQueryId, MessageId};
/// The `"forward"` button's data. /// The `"forward"` button's data.
const FORWARD: &str = "forward"; 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|<name>"`. /// Prefix of a template button's data: `"template|<name>"`.
const TEMPLATE_PREFIX: &str = "template|"; const TEMPLATE_PREFIX: &str = "template|";
@@ -57,7 +59,8 @@ async fn handle_callback(
}; };
// Lazy expiry: a stale record (past the TTL, not yet swept) is dropped. // Lazy expiry: a stale record (past the TTL, not yet swept) is dropped.
if edit.created_at + ttl_secs <= unix_now() { if edit.created_at + ttl_secs <= unix_now() {
ctx.chat_store let _ = ctx
.chat_store
.update(chat_id, |data| { .update(chat_id, |data| {
data.edit_message.remove(&prompt_message_id); data.edit_message.remove(&prompt_message_id);
}) })
@@ -69,7 +72,34 @@ async fn handle_callback(
return; return;
} }
log::info!("callback from {chat_id} on prompt {prompt_message_id}: {data}"); log::debug!(
"callback from {chat_id} on prompt {prompt_message_id}: {}",
super::log_escape(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");
let _ = 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 { if data == FORWARD {
match chat_data.forward_channel_id { match chat_data.forward_channel_id {
Some(channel_id) => { Some(channel_id) => {
@@ -77,6 +107,7 @@ async fn handle_callback(
from_chat_id: edit.chat_id, from_chat_id: edit.chat_id,
to_chat_id: channel_id, to_chat_id: channel_id,
message_ids: edit.forward_message_ids.clone(), message_ids: edit.forward_message_ids.clone(),
forward_offset: 0,
notify_chat_id: Some(chat_id), notify_chat_id: Some(chat_id),
notify_message_id: Some(prompt_message_id), notify_message_id: Some(prompt_message_id),
}; };
@@ -92,9 +123,24 @@ async fn handle_callback(
delay_seconds, delay_seconds,
task, task,
}) => { }) => {
log::info!("forward queued for retry in {delay_seconds:.1}s"); // The queued row owns the forward from here (it carries
send::enqueue_retry(ctx.task_queue, *task, delay_seconds).await; // the message ids itself), so the prompt is settled
("Forward queued for retry.".to_string(), false) // either way: leaving it live let a second Confirm copy
// the same messages to the channel twice, and let Skip
// answer "nothing was forwarded" while the row still
// delivered it.
let queued =
send::enqueue_retry(ctx.task_queue, &task, delay_seconds).await;
if queued {
log::info!("forward queued for retry in {delay_seconds:.1}s");
("Forward queued for retry.".to_string(), true)
} else {
log::error!("forward retry could not be queued");
(
"Forward failed and the retry could not be queued.".to_string(),
true,
)
}
} }
Err(send::SendError::Permanent { message, .. }) => { Err(send::SendError::Permanent { message, .. }) => {
log::error!("forward failed permanently: {message}"); log::error!("forward failed permanently: {message}");
@@ -107,7 +153,8 @@ async fn handle_callback(
.sender .sender
.delete_message(ChatId(chat_id), MessageId(prompt_message_id as i32)) .delete_message(ChatId(chat_id), MessageId(prompt_message_id as i32))
.await; .await;
ctx.chat_store let _ = ctx
.chat_store
.update(chat_id, |data| { .update(chat_id, |data| {
data.edit_message.remove(&prompt_message_id); data.edit_message.remove(&prompt_message_id);
}) })
@@ -133,30 +180,50 @@ async fn handle_callback(
} }
if let Some(name) = data.strip_prefix(TEMPLATE_PREFIX) { if let Some(name) = data.strip_prefix(TEMPLATE_PREFIX) {
let mut answer = None;
if let Some(template_html) = chat_data.template.get(name).cloned() if let Some(template_html) = chat_data.template.get(name).cloned()
&& let Some(first_forward_id) = edit.forward_message_ids.first().copied() && let Some(first_forward_id) = edit.forward_message_ids.first().copied()
{ {
// Raw template including the [] placeholder (Python parity). // Raw template including the [] placeholder (Python parity).
let _ = ctx match super::apply_caption_edit(
.sender ctx.sender,
.edit_message_caption( ChatId(chat_id),
ChatId(chat_id), MessageId(first_forward_id as i32),
MessageId(first_forward_id as i32), template_html,
template_html, )
) .await
.await; {
ctx.chat_store super::EditOutcome::Applied => {
.update(chat_id, |data| { let _ = ctx
if let Some(entry) = data.edit_message.get_mut(&prompt_message_id) { .chat_store
entry.template = name.to_string(); .update(chat_id, |data| {
} if let Some(entry) = data.edit_message.get_mut(&prompt_message_id) {
}) entry.template = name.to_string();
.await; }
log::info!("template '{name}' applied to prompt {prompt_message_id}"); })
.await;
log::info!(
"template '{}' applied to prompt {prompt_message_id}",
super::log_escape(name)
);
}
// Nothing was applied, so nothing is recorded either: the
// prompt keeps rendering through whatever it used before, and
// the toast says why (a silently "successful" press left the
// caption unchanged).
super::EditOutcome::Failed(reason) => {
log::error!(
"template '{}' could not be applied: {}",
super::log_escape(name),
super::log_escape(&reason)
);
answer = Some(format!("Could not apply the template: {reason}"));
}
}
} }
let _ = ctx let _ = ctx
.sender .sender
.answer_callback_query(callback_query_id, None) .answer_callback_query(callback_query_id, answer)
.await; .await;
} }
} }
@@ -164,52 +231,22 @@ async fn handle_callback(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::ctx::test_support::TestStores; use crate::ctx::test_support::{FORWARDED_ID, PROMPT_ID, TestStores, api_error, seed_prompt};
use crate::media_sender::test_support::{MockSender, Outcome}; use crate::media_sender::test_support::{MockSender, Outcome};
use crate::state::EditMessage;
use teloxide::ApiError;
/// The edit-before-forward prompt's message id in these tests. /// The Telegram wording the mocks answer with: a chat the bot cannot reach.
const PROMPT_ID: i64 = 7; const API_ERROR: &str = "Bad Request: chat not found";
/// The message the prompt refers to (the one whose caption is swapped).
const FORWARDED_ID: i64 = 9;
fn api_error() -> RequestError {
RequestError::Api(ApiError::Unknown("Bad Request: chat not found".into()))
}
fn callback_id() -> CallbackQueryId { fn callback_id() -> CallbackQueryId {
CallbackQueryId("cb-1".to_string()) CallbackQueryId("cb-1".to_string())
} }
/// Seeds a live prompt record plus a forward channel and a template;
/// `created_at` backdates the record for the expiry cases.
async fn seed_prompt(ctx: &AppContext<'_>, created_at: i64) {
ctx.chat_store
.update(1, |data| {
data.forward_channel_id = Some(2);
data.template
.insert("tpl".to_string(), "<b>[]</b>".to_string());
data.edit_message.insert(
PROMPT_ID,
EditMessage {
url: "https://x.com/u/status/1".into(),
chat_id: 1,
forward_message_ids: vec![FORWARDED_ID],
template: String::new(),
created_at,
},
);
})
.await;
}
#[tokio::test] #[tokio::test]
async fn template_button_swaps_the_caption_and_records_the_choice() { async fn template_button_swaps_the_caption_and_records_the_choice() {
let sender = MockSender::scripted(vec![Outcome::EditOk], api_error); let sender = MockSender::scripted(vec![Outcome::EditOk], || api_error(API_ERROR));
let stores = TestStores::new(); let stores = TestStores::new();
let ctx = stores.ctx(&sender); let ctx = stores.ctx(&sender);
seed_prompt(&ctx, crate::db::unix_now()).await; seed_prompt(&ctx, "", crate::db::unix_now()).await;
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "template|tpl").await; handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "template|tpl").await;
@@ -225,11 +262,35 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn forward_button_copies_then_clears_the_prompt() { async fn a_failed_template_swap_is_reported_in_the_toast() {
let sender = MockSender::scripted(vec![Outcome::CopyOk], api_error); let sender = MockSender::scripted(vec![Outcome::EditErr], || api_error(API_ERROR));
let stores = TestStores::new(); let stores = TestStores::new();
let ctx = stores.ctx(&sender); let ctx = stores.ctx(&sender);
seed_prompt(&ctx, crate::db::unix_now()).await; seed_prompt(&ctx, "", crate::db::unix_now()).await;
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "template|tpl").await;
// The caption never changed, so the toast says so and the record does
// not claim the template was applied.
let toast = sender.answers().last().cloned().flatten();
assert!(
toast
.as_deref()
.is_some_and(|t| t.contains("Could not apply the template")),
"{toast:?}"
);
assert_eq!(
ctx.chat_store.get(1).await.edit_message[&PROMPT_ID].template,
""
);
}
#[tokio::test]
async fn forward_button_copies_then_clears_the_prompt() {
let sender = MockSender::scripted(vec![Outcome::CopyOk], || api_error(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, "forward").await; handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await;
@@ -245,14 +306,67 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn forward_without_a_channel_is_reported() { async fn skip_drops_the_prompt_without_forwarding() {
let sender = MockSender::scripted(vec![], api_error); // "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(API_ERROR));
let stores = TestStores::new(); let stores = TestStores::new();
let ctx = stores.ctx(&sender); let ctx = stores.ctx(&sender);
seed_prompt(&ctx, crate::db::unix_now()).await; 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"
);
}
/// The whole callback path against a stand-in API through a real `Bot`:
/// copy, delete, toast, carrying the ids the prompt held. The scripted
/// mock records that a call happened; this records what the API received.
#[tokio::test]
async fn the_forward_button_talks_to_the_api_through_a_real_bot() {
use crate::media_sender::test_support::fake_api::FakeApi;
use teloxide::Bot;
let api = FakeApi::start().await;
let bot = Bot::new("42:TEST").set_api_url(api.url());
let stores = TestStores::new();
let ctx = stores.ctx(&bot);
seed_prompt(&ctx, "", crate::db::unix_now()).await;
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await;
assert_eq!(
api.methods(),
vec!["CopyMessages", "DeleteMessage", "AnswerCallbackQuery"]
);
let copy = api.body("CopyMessages");
assert_eq!(copy["chat_id"], 2, "the prompt's channel");
assert_eq!(copy["from_chat_id"], 1);
assert_eq!(copy["message_ids"], serde_json::json!([FORWARDED_ID]));
assert_eq!(api.body("AnswerCallbackQuery")["text"], "✅ Forwarded");
}
#[tokio::test]
async fn forward_without_a_channel_is_reported() {
let sender = MockSender::scripted(vec![], || api_error(API_ERROR));
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
seed_prompt(&ctx, "", crate::db::unix_now()).await;
ctx.chat_store ctx.chat_store
.update(1, |data| data.forward_channel_id = None) .update(1, |data| data.forward_channel_id = None)
.await; .await
.unwrap();
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await; handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await;
@@ -264,39 +378,61 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn retryable_forward_is_queued_and_keeps_the_prompt() { async fn retryable_forward_is_queued_and_settles_the_prompt() {
use teloxide::types::Seconds; use teloxide::types::Seconds;
let sender = MockSender::scripted(vec![Outcome::CopyErr], || { let sender = MockSender::scripted(vec![Outcome::CopyErr], || {
RequestError::RetryAfter(Seconds::from_seconds(7)) RequestError::RetryAfter(Seconds::from_seconds(7))
}); });
let stores = TestStores::new(); let stores = TestStores::new();
let ctx = stores.ctx(&sender); let ctx = stores.ctx(&sender);
seed_prompt(&ctx, crate::db::unix_now()).await; seed_prompt(&ctx, "", crate::db::unix_now()).await;
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await; handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await;
// The queued row carries the message ids itself, so it owns the
// forward from here and the prompt is closed with it. Keeping it live
// (the old behaviour) let a second Confirm copy the same messages to
// the channel twice, and let Skip answer "nothing was forwarded" while
// the row still delivered it.
assert_eq!( assert_eq!(
sender.calls(), sender.calls(),
vec!["copy_messages", "answer_callback_query"] vec!["copy_messages", "delete_message", "answer_callback_query"]
); );
assert_eq!( assert_eq!(
sender.answers(), sender.answers(),
vec![Some("Forward queued for retry.".to_string())] vec![Some("Forward queued for retry.".to_string())]
); );
assert_eq!(stores.queued_tasks().await, 1); assert_eq!(stores.queued_tasks().await, 1);
// The prompt is not settled: the queued retry still needs the record.
assert!( assert!(
ctx.chat_store !ctx.chat_store
.get(1) .get(1)
.await .await
.edit_message .edit_message
.contains_key(&PROMPT_ID) .contains_key(&PROMPT_ID),
"the record must be dropped so the prompt cannot be used again"
); );
// A second tap finds no record: it cannot enqueue a duplicate copy.
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await;
assert_eq!(
sender.calls(),
vec![
"copy_messages",
"delete_message",
"answer_callback_query",
"answer_callback_query"
]
);
assert_eq!(
sender.answers().last().map(|a| a.as_deref()),
Some(Some("Expired"))
);
assert_eq!(stores.queued_tasks().await, 1, "no second forward row");
} }
#[tokio::test] #[tokio::test]
async fn unknown_and_expired_prompts_answer_expired() { async fn unknown_and_expired_prompts_answer_expired() {
let sender = MockSender::scripted(vec![], api_error); let sender = MockSender::scripted(vec![], || api_error(API_ERROR));
let stores = TestStores::new(); let stores = TestStores::new();
let ctx = stores.ctx(&sender); let ctx = stores.ctx(&sender);
@@ -306,7 +442,7 @@ mod tests {
// A record past its TTL (nothing swept it yet) is dropped on use. // A record past its TTL (nothing swept it yet) is dropped on use.
let stale = crate::db::unix_now() - ctx.config.edit_message_ttl.as_secs() as i64 - 1; let stale = crate::db::unix_now() - ctx.config.edit_message_ttl.as_secs() as i64 - 1;
seed_prompt(&ctx, stale).await; seed_prompt(&ctx, "", stale).await;
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await; handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await;
assert_eq!( assert_eq!(
sender.answers(), sender.answers(),
File diff suppressed because it is too large Load Diff
+459 -118
View File
@@ -3,13 +3,16 @@
//! inline cache instead of re-fetching. //! inline cache instead of re-fetching.
use super::log_key; use super::log_key;
use crate::ctx::AppContext;
use crate::link_cache::{CachedMediaKind, CachedPost};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::LazyLock; use std::sync::LazyLock;
use teloxide::RequestError; use teloxide::RequestError;
use teloxide::prelude::*; use teloxide::prelude::*;
use teloxide::types::{ use teloxide::types::{
InlineQuery, InlineQueryResult, InlineQueryResultMpeg4Gif, InlineQueryResultPhoto, FileId, InlineQuery, InlineQueryResult, InlineQueryResultCachedMpeg4Gif,
InlineQueryResultVideo, ParseMode, InlineQueryResultCachedPhoto, InlineQueryResultCachedVideo, InlineQueryResultMpeg4Gif,
InlineQueryResultPhoto, InlineQueryResultVideo, ParseMode,
}; };
use x_media::media::Media; use x_media::media::Media;
@@ -20,6 +23,12 @@ use x_media::media::Media;
/// post id. Only answer once the query has been stable for this long. /// post id. Only answer once the query has been stable for this long.
const INLINE_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(800); const INLINE_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(800);
/// How long a debounce entry is worth keeping: the window Telegram caches an
/// inline answer for (`answer_inline_query` asks for `cache_time(300)`). Past
/// it a repeat is sent to the bot again and has to be answered fresh, so the
/// entry would only suppress a fetch the user is waiting for.
const INLINE_STATE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
/// Last seen inline query per user and whether it was already answered. /// Last seen inline query per user and whether it was already answered.
/// Guards the debounce timer: a repeat of an answered query is served by /// Guards the debounce timer: a repeat of an answered query is served by
/// Telegram's inline cache (see `cache_time`), not by another fetch. Keyed by /// Telegram's inline cache (see `cache_time`), not by another fetch. Keyed by
@@ -27,113 +36,151 @@ const INLINE_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(80
/// different user's query) cancel another user's pending answer. /// different user's query) cancel another user's pending answer.
struct InlineDebounceState { struct InlineDebounceState {
query: String, query: String,
generation: u64,
answered: bool, answered: bool,
last_seen: std::time::Instant,
} }
#[derive(Default)] #[derive(Default)]
struct DebounceStates(HashMap<u64, InlineDebounceState>); struct DebounceStates {
entries: HashMap<u64, InlineDebounceState>,
generation: u64,
}
impl DebounceStates { impl DebounceStates {
/// Records `query` as the user's newest query. Returns false when it is a /// Records `query` as the user's newest query. Returns false when it is a
/// repeat whose answer already went out (Telegram's inline cache serves /// repeat whose answer already went out (Telegram's inline cache serves
/// it; re-fetching would only hit the source site again). /// it; re-fetching would only hit the source site again).
fn note(&mut self, user_id: u64, query: &str) -> bool { fn note(&mut self, user_id: u64, query: &str) -> (bool, u64) {
if let Some(prev) = self.0.get(&user_id) if let Some(prev) = self.entries.get(&user_id)
&& prev.query == query && prev.query == query
&& prev.answered && prev.answered
{ {
return false; return (false, prev.generation);
} }
self.0.insert( self.generation = self.generation.wrapping_add(1);
let generation = self.generation;
self.entries.insert(
user_id, user_id,
InlineDebounceState { InlineDebounceState {
query: query.to_string(), query: query.to_string(),
generation,
answered: false, answered: false,
last_seen: std::time::Instant::now(),
}, },
); );
true (true, generation)
} }
/// Claims the answer for the user's newest query; false when a newer query /// Drops entries no query has touched for `idle_for`. Split from the clock
/// superseded it or the answer was already claimed. /// so the boundary is testable without ageing a monotonic instant.
fn claim(&mut self, user_id: u64, query: &str) -> bool { fn prune_idle_at(&mut self, now: std::time::Instant, idle_for: std::time::Duration) -> usize {
let Some(state) = self.0.get_mut(&user_id) else { let before = self.entries.len();
self.entries
.retain(|_, state| now.saturating_duration_since(state.last_seen) < idle_for);
before - self.entries.len()
}
fn claim(&mut self, user_id: u64, query: &str, generation: u64) -> bool {
let Some(state) = self.entries.get_mut(&user_id) else {
return false; return false;
}; };
if state.query != query || state.answered { if state.query != query || state.generation != generation || state.answered {
return false; return false;
} }
state.answered = true; state.answered = true;
state.last_seen = std::time::Instant::now();
true true
} }
/// Releases a claimed-but-unsent answer so a repeat can retry the fetch. fn release(&mut self, user_id: u64, query: &str, generation: u64) {
fn release(&mut self, user_id: u64, query: &str) { if let Some(state) = self.entries.get_mut(&user_id)
if let Some(state) = self.0.get_mut(&user_id)
&& state.query == query && state.query == query
&& state.generation == generation
{ {
state.answered = false; state.answered = false;
state.last_seen = std::time::Instant::now();
} }
} }
} }
/// Drops debounce entries idle for [`INLINE_STATE_TTL`]; the 300 s sweep calls
/// this next to the rate limiter's prune. Returns how many were dropped.
pub(crate) fn prune_idle_states() -> usize {
INLINE_DEBOUNCE_STATE
.lock()
.prune_idle_at(std::time::Instant::now(), INLINE_STATE_TTL)
}
static INLINE_DEBOUNCE_STATE: LazyLock<parking_lot::Mutex<DebounceStates>> = static INLINE_DEBOUNCE_STATE: LazyLock<parking_lot::Mutex<DebounceStates>> =
LazyLock::new(|| parking_lot::Mutex::new(DebounceStates::default())); LazyLock::new(|| parking_lot::Mutex::new(DebounceStates::default()));
pub async fn inline_query_handler(bot: Bot, query: InlineQuery) -> Result<(), RequestError> { pub async fn inline_query_handler(bot: Bot, query: InlineQuery) -> Result<(), RequestError> {
if query.query.is_empty() { let ctx = AppContext::from_statics(&bot);
return respond(()); if query.query.is_empty() || x_media::site::cache_key(&query.query).is_none() {
return answer_inline_query(&ctx, query).await.map(|_| ());
} }
// Only run a fetch for something that is actually a supported post URL.
if x_media::site::cache_key(&query.query).is_none() {
return respond(());
}
// Debounce: record the query and answer only after it has been stable for
// INLINE_DEBOUNCE (the timer below). An already-answered repeat of the
// same query is left to Telegram's inline cache instead of re-fetching.
let user_id = query.from.id.0; let user_id = query.from.id.0;
if !INLINE_DEBOUNCE_STATE.lock().note(user_id, &query.query) { let (should_answer, generation) = INLINE_DEBOUNCE_STATE.lock().note(user_id, &query.query);
if !should_answer {
return respond(()); return respond(());
} }
let query_text = query.query.clone(); let query_text = query.query.clone();
tokio::spawn(async move { tokio::spawn(async move {
tokio::time::sleep(INLINE_DEBOUNCE).await; tokio::time::sleep(INLINE_DEBOUNCE).await;
// Only the user's last query of a typing burst survives: earlier if !INLINE_DEBOUNCE_STATE
// timers see the query changed and give up without answering. .lock()
if !INLINE_DEBOUNCE_STATE.lock().claim(user_id, &query_text) { .claim(user_id, &query_text, generation)
{
return; return;
} }
match answer_inline_query(bot, query).await { let ctx = AppContext::from_statics(&bot);
match answer_inline_query(&ctx, query).await {
Ok(true) => {} Ok(true) => {}
// No results produced (or nothing to answer): let a repeat of the Ok(false) | Err(_) => {
// same query retry the fetch. INLINE_DEBOUNCE_STATE
Ok(false) | Err(_) => INLINE_DEBOUNCE_STATE.lock().release(user_id, &query_text), .lock()
.release(user_id, &query_text, generation);
}
} }
}); });
respond(()) respond(())
} }
/// Fetches the post behind an inline query and answers it. The caller has /// Answers the inline query behind a post URL. The caller has already applied
/// already applied the debounce. Returns `true` when an answer was sent. /// the debounce. Returns `true` when an answer was sent.
async fn answer_inline_query(bot: Bot, query: InlineQuery) -> Result<bool, RequestError> { async fn answer_inline_query(
log::debug!( ctx: &AppContext<'_>,
"inline query: {} [key={}]", query: InlineQuery,
query.query, ) -> Result<bool, RequestError> {
log_key(&query.query) // The query is user input: `debug` keeps only its normalized key, the
); // text itself is `trace` (same split as the message handler).
log::debug!("inline query [key={}]", log_key(&query.query));
log::trace!("inline query: {}", super::log_escape(&query.query));
let Some(key) = x_media::site::cache_key(&query.query) else {
answer(ctx.sender, query.id, Vec::new()).await?;
return Ok(true);
};
// A post that was already sent to some chat is answered from the link
// cache: its Telegram file ids make the answer instant, and — unlike a URL
// result, which Telegram must fetch itself — they carry media that a
// hotlink-protected host (pixiv's pximg.net) or a locally encoded file
// (ugoira MP4, bsky remux) can never serve inline. That media used to be
// skipped outright, so a pixiv link answered empty.
if let Some(cached) = ctx.link_cache.get(&key, ctx.config.link_cache_ttl).await {
let caption = inline_caption(&cached, ctx.config.caption_quote_text_chars);
let results = cached_inline_results(&cached, &caption);
answer(ctx.sender, query.id, results).await?;
return Ok(true);
}
// No retries: the debounce plus a 1s/2s backoff would outlast the inline // No retries: the debounce plus a 1s/2s backoff would outlast the inline
// query the answer belongs to. // query the answer belongs to.
match x_media::site::fetch_once(&query.query).await { match x_media::site::fetch_once(&query.query).await {
Ok(Some(fetched)) => { Ok(Some(fetched)) => {
let mut results: Vec<InlineQueryResult> = Vec::new();
// Inline results have the same 1024-char caption limit as regular // Inline results have the same 1024-char caption limit as regular
// messages; truncate once here for all items, then apply the same // messages; truncate once here for all items, then apply the same
// long-post quoting as the send paths. `answer_inline_query` has no // long-post quoting as the send paths. The built-in caption is what
// `AppContext` (the debounce spawns it), so the parsed config comes // an inline answer can use: there is no chat whose per-site format
// from the process-wide static, and the text is the *escaped* // could apply, so the render fields come from the fetch itself.
// title/content the built-in caption embeds (the raw
// `Fetched.title`/`content` differ whenever the post contains
// `<`/`&`).
let caption = x_media::site::truncate_caption(&fetched.caption); let caption = x_media::site::truncate_caption(&fetched.caption);
let text = fetched let text = fetched
.render_fields() .render_fields()
@@ -142,10 +189,19 @@ async fn answer_inline_query(bot: Bot, query: InlineQuery) -> Result<bool, Reque
let caption = crate::send::quote_long_caption( let caption = crate::send::quote_long_caption(
&caption, &caption,
&text, &text,
super::CONFIG.caption_quote_text_chars, ctx.config.caption_quote_text_chars,
); );
let mut results: Vec<InlineQueryResult> = Vec::new();
for (i, media) in fetched.media.iter().enumerate() { 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 {i}");
continue;
}
let Some(url) = url::Url::parse(media.url()).ok() else { let Some(url) = url::Url::parse(media.url()).ok() else {
continue; continue;
}; };
@@ -153,97 +209,382 @@ async fn answer_inline_query(bot: Bot, query: InlineQuery) -> Result<bool, Reque
.thumbnail_url() .thumbnail_url()
.and_then(|t| url::Url::parse(t).ok()) .and_then(|t| url::Url::parse(t).ok())
.unwrap_or_else(|| url.clone()); .unwrap_or_else(|| url.clone());
let caption = caption.clone().into_owned(); // Inline photo results have their own (smaller) size cap; use
let result = match media { // the reduced variant when one exists.
Media::Illustration { .. } => { let url = media
// Inline photo results have their own (smaller) size .smaller_url()
// cap; use the reduced variant when one exists. .and_then(|u| url::Url::parse(u).ok())
let photo_url = media .unwrap_or(url);
.smaller_url() results.push(url_result(
.and_then(|u| url::Url::parse(u).ok()) i.to_string(),
.unwrap_or_else(|| url.clone()); match media {
InlineQueryResult::Photo( Media::Illustration { .. } => CachedMediaKind::Photo,
InlineQueryResultPhoto::new(id, photo_url, thumbnail) Media::Video { .. } => CachedMediaKind::Video,
.caption(caption) Media::Animated { .. } => CachedMediaKind::Animation,
.parse_mode(ParseMode::Html), },
) url,
} thumbnail,
Media::Video { .. } => InlineQueryResult::Video( fetched.title.clone(),
InlineQueryResultVideo::new( caption.clone().into_owned(),
id, ));
url,
"video/mp4".parse().expect("valid mime"),
thumbnail,
fetched.title.clone(),
)
.caption(caption)
.parse_mode(ParseMode::Html),
),
Media::Animated { .. } => InlineQueryResult::Mpeg4Gif(
InlineQueryResultMpeg4Gif::new(id, url, thumbnail)
.caption(caption)
.parse_mode(ParseMode::Html),
),
};
results.push(result);
}
if !results.is_empty() {
// Explicit cache window: repeats of the same query within 5
// minutes are served by Telegram without hitting the bot.
bot.answer_inline_query(query.id, results)
.cache_time(300)
.await?;
return Ok(true);
} }
answer(ctx.sender, query.id, results).await?;
Ok(true)
}
Ok(None) | Err(_) => {
if let Err(e) = answer(ctx.sender, query.id, Vec::new()).await {
log::error!(
"inline empty answer failed for [key={}]: {e}",
log_key(&query.query)
);
return Err(e);
}
Ok(true)
} }
Ok(None) => {}
Err(e) => log::error!("inline fetch {}: {e}", query.query),
} }
Ok(false) }
/// The caption of an inline answer, from a cached post: the caption that was
/// sent (the site's built-in one, truncated) plus the long-post quoting the
/// send paths apply.
fn inline_caption(cached: &CachedPost, quote_chars: usize) -> String {
let text = x_media::site::compose_text(&cached.title, &cached.content);
crate::send::quote_long_caption(
&x_media::site::truncate_caption(&cached.caption),
&text,
quote_chars,
)
.into_owned()
}
/// One inline result pointing Telegram at a URL it fetches itself.
fn url_result(
id: String,
kind: CachedMediaKind,
url: url::Url,
thumbnail: url::Url,
title: String,
caption: String,
) -> InlineQueryResult {
let parse_mode = ParseMode::Html;
match kind {
CachedMediaKind::Photo => InlineQueryResult::Photo(
InlineQueryResultPhoto::new(id, url, thumbnail)
.caption(caption)
.parse_mode(parse_mode),
),
CachedMediaKind::Video => InlineQueryResult::Video(
InlineQueryResultVideo::new(
id,
url,
"video/mp4".parse().expect("valid mime"),
thumbnail,
title,
)
.caption(caption)
.parse_mode(parse_mode),
),
CachedMediaKind::Animation => InlineQueryResult::Mpeg4Gif(
InlineQueryResultMpeg4Gif::new(id, url, thumbnail)
.caption(caption)
.parse_mode(parse_mode),
),
}
}
/// One inline result served from a Telegram file id.
fn cached_result(
id: String,
kind: CachedMediaKind,
file_id: String,
title: String,
caption: String,
) -> InlineQueryResult {
let parse_mode = ParseMode::Html;
let file_id = FileId(file_id);
match kind {
CachedMediaKind::Photo => InlineQueryResult::CachedPhoto(
InlineQueryResultCachedPhoto::new(id, file_id)
.caption(caption)
.parse_mode(parse_mode),
),
CachedMediaKind::Video => InlineQueryResult::CachedVideo(
InlineQueryResultCachedVideo::new(id, file_id, title)
.caption(caption)
.parse_mode(parse_mode),
),
CachedMediaKind::Animation => InlineQueryResult::CachedMpeg4Gif(
InlineQueryResultCachedMpeg4Gif::new(id, file_id)
.caption(caption)
.parse_mode(parse_mode),
),
}
}
/// The inline results a cached post answers with, one per media item: from the
/// file id when the entry has one, else from the source URL (a degraded entry
/// keeps only URLs). A URL item that needs site headers is skipped as in the
/// fetch path; a *file id* needs no headers, which is what makes a pixiv post
/// answerable inline.
fn cached_inline_results(cached: &CachedPost, caption: &str) -> Vec<InlineQueryResult> {
cached
.media
.iter()
.enumerate()
.filter_map(|(i, media)| {
let id = i.to_string();
let caption = || caption.to_string();
if !media.file_id.is_empty() {
return Some(cached_result(
id,
media.kind,
media.file_id.clone(),
cached.title.clone(),
caption(),
));
}
if x_media::site::needs_media_headers(&media.url) {
log::debug!("inline: skipping hotlink-protected cached media {i}");
return None;
}
let url = url::Url::parse(&media.url).ok()?;
if matches!(media.kind, CachedMediaKind::Video) {
log::debug!("inline: skipping a cached video with no thumbnail {i}");
return None;
}
Some(url_result(
id,
media.kind,
url.clone(),
url,
cached.title.clone(),
caption(),
))
})
.collect()
}
/// Answers with `results` (an empty vec is a real answer: it stops the client
/// spinning and lets Telegram serve repeats itself) under the cache window
/// [`INLINE_STATE_TTL`] mirrors.
async fn answer(
sender: &dyn crate::media_sender::MediaSender,
id: teloxide::types::InlineQueryId,
mut results: Vec<InlineQueryResult>,
) -> Result<(), RequestError> {
results.truncate(50);
if results.is_empty() {
log::debug!("inline: nothing Telegram can serve for the query; answering empty");
}
sender.answer_inline_query(id, results, 300).await
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::DebounceStates; use super::{DebounceStates, INLINE_STATE_TTL, answer_inline_query, cached_inline_results};
use crate::ctx::test_support::{TestStores, api_error, cached_photo};
use crate::link_cache::{CachedMedia, CachedMediaKind};
use crate::media_sender::test_support::MockSender;
use teloxide::types::InlineQuery;
const URL_A: &str = "https://x.com/a/status/1"; const URL_A: &str = "https://x.com/a/status/1";
const URL_B: &str = "https://x.com/b/status/2"; const URL_B: &str = "https://x.com/b/status/2";
fn inline_query(url: &str) -> InlineQuery {
serde_json::from_value(serde_json::json!({
"id": "42",
"from": { "id": 5, "is_bot": false, "first_name": "u" },
"query": url,
"offset": "",
}))
.expect("a minimal inline query deserializes")
}
/// A post already in the link cache is answered from its file ids: no
/// fetch, and — unlike a URL result — media Telegram could never fetch
/// itself (a pixiv pximg URL) can be served.
#[tokio::test]
async fn a_cached_post_answers_from_its_file_ids() {
let sender = MockSender::scripted(vec![], || api_error("boom"));
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
let mut entry = cached_photo();
entry.media = vec![
CachedMedia {
kind: CachedMediaKind::Photo,
file_id: "AgAC-photo".into(),
url: "https://i.pximg.net/img-original/img/1.jpg".into(),
},
CachedMedia {
kind: CachedMediaKind::Animation,
file_id: "AgAC-gif".into(),
url: "https://i.pximg.net/img-original/img/1.gif".into(),
},
];
stores.link_cache().put("twitter:1", &entry).await;
let answered = answer_inline_query(&ctx, inline_query("https://x.com/u/status/1"))
.await
.unwrap();
assert!(answered);
assert_eq!(
sender.inline_answers(),
vec![vec!["cached_photo:AgAC-photo", "cached_gif:AgAC-gif"]],
"every item goes out as its cached file id, hotlink protection and all"
);
}
/// A degraded entry has no file ids left, so its URLs are used — and an
/// item Telegram must not fetch (needs site headers) or cannot render (a
/// video with no poster) is skipped. Nothing left means an *empty* answer:
/// leaving the query unanswered makes the client spin and re-fetch on every
/// keystroke.
#[tokio::test]
async fn a_degraded_cached_post_answers_with_urls_or_empty() {
let sender = MockSender::scripted(vec![], || api_error("boom"));
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
let mut entry = cached_photo();
entry.media = vec![
CachedMedia {
kind: CachedMediaKind::Photo,
file_id: String::new(),
url: "https://p/1.jpg".into(),
},
CachedMedia {
kind: CachedMediaKind::Video,
file_id: String::new(),
url: "https://v/1.mp4".into(),
},
];
stores.link_cache().put("twitter:1", &entry).await;
answer_inline_query(&ctx, inline_query("https://x.com/u/status/1"))
.await
.unwrap();
assert_eq!(
sender.inline_answers(),
vec![vec!["photo:https://p/1.jpg"]],
"the degradable photo goes out by URL, the poster-less video is skipped"
);
// Nothing servable: a pixiv original needs a Referer Telegram does not
// send.
stores.link_cache().remove("twitter:1").await;
let mut entry = cached_photo();
entry.media = vec![CachedMedia {
kind: CachedMediaKind::Photo,
file_id: String::new(),
url: "https://i.pximg.net/img-original/img/1.jpg".into(),
}];
stores.link_cache().put("twitter:1", &entry).await;
answer_inline_query(&ctx, inline_query("https://x.com/u/status/1"))
.await
.unwrap();
assert_eq!(
sender.inline_answers(),
vec![vec!["photo:https://p/1.jpg".to_string()], Vec::new()],
"a query with nothing servable is still answered, with no results"
);
}
#[tokio::test]
async fn unsupported_inline_query_answers_empty() {
let sender = MockSender::scripted(vec![], || api_error("boom"));
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
let answered = answer_inline_query(&ctx, inline_query("not a supported post"))
.await
.unwrap();
assert!(answered);
assert_eq!(sender.inline_answers(), vec![Vec::<String>::new()]);
}
#[tokio::test]
async fn answer_caps_cached_results_at_telegram_limit() {
let sender = MockSender::scripted(vec![], || api_error("boom"));
let mut entry = cached_photo();
entry.media = (0..51)
.map(|i| CachedMedia {
kind: CachedMediaKind::Photo,
file_id: format!("id-{i}"),
url: format!("https://p/{i}.jpg"),
})
.collect();
let results = cached_inline_results(&entry, "caption");
assert!(results.len() > 50, "the builder itself may keep all items");
super::answer(
&sender,
inline_query("https://x.com/u/status/1").id,
results,
)
.await
.unwrap();
assert_eq!(sender.inline_answers()[0].len(), 50);
}
#[test] #[test]
fn debounce_state_is_per_user() { fn debounce_state_is_per_user() {
let mut states = DebounceStates::default(); let mut states = DebounceStates::default();
// Two users query different links: both proceed, and neither timer assert!(states.note(1, URL_A).0);
// cancels the other (a single shared slot dropped one of them). assert!(states.note(2, URL_B).0);
assert!(states.note(1, URL_A)); let (_, generation_a) = states.note(1, URL_A);
assert!(states.note(2, URL_B)); let (_, generation_b) = states.note(2, URL_B);
assert!(states.claim(1, URL_A), "user 1's answer was cancelled"); assert!(states.claim(1, URL_A, generation_a));
assert!(states.claim(2, URL_B), "user 2's answer was cancelled"); assert!(states.claim(2, URL_B, generation_b));
} }
#[test] #[test]
fn answered_query_is_suppressed_per_user_only() { fn answered_query_is_suppressed_per_user_only() {
let mut states = DebounceStates::default(); let mut states = DebounceStates::default();
assert!(states.note(1, URL_A)); assert!(states.note(1, URL_A).0);
assert!(states.claim(1, URL_A)); let (_, generation) = states.note(1, URL_A);
// A repeat of the answered query by the same user is left to assert!(states.claim(1, URL_A, generation));
// Telegram's inline cache. assert!(!states.note(1, URL_A).0);
assert!(!states.note(1, URL_A)); assert!(states.note(2, URL_A).0);
// Another user pasting the same link still gets an answer. let (_, generation) = states.note(2, URL_A);
assert!(states.note(2, URL_A)); assert!(states.claim(2, URL_A, generation));
assert!(states.claim(2, URL_A)); }
#[test]
fn idle_states_are_pruned_and_live_ones_kept() {
let mut states = DebounceStates::default();
assert!(states.note(1, URL_A).0);
let first = states.entries[&1].last_seen;
std::thread::sleep(std::time::Duration::from_millis(2));
assert!(states.note(2, URL_B).0);
assert_eq!(
states.prune_idle_at(first + INLINE_STATE_TTL, INLINE_STATE_TTL),
1
);
assert!(!states.entries.contains_key(&1));
assert!(states.entries.contains_key(&2));
assert!(states.note(1, URL_A).0);
}
#[test]
fn stale_same_query_generation_cannot_claim_after_a_b_a() {
let mut states = DebounceStates::default();
assert!(states.note(1, URL_A).0);
assert!(states.note(1, URL_B).0);
let (_, stale_generation) = states.note(1, URL_A);
let (_, current_generation) = states.note(1, URL_A);
assert_ne!(stale_generation, current_generation);
assert!(!states.claim(1, URL_A, stale_generation));
assert!(states.claim(1, URL_A, current_generation));
} }
#[test] #[test]
fn newer_query_supersedes_and_failed_answer_is_released() { fn newer_query_supersedes_and_failed_answer_is_released() {
let mut states = DebounceStates::default(); let mut states = DebounceStates::default();
assert!(states.note(1, URL_A)); assert!(states.note(1, URL_A).0);
assert!(states.note(1, URL_B)); let (_, generation_a) = states.note(1, URL_B);
// The stale timer for the half-typed query gives up… assert!(!states.claim(1, URL_A, generation_a));
assert!(!states.claim(1, URL_A)); let (_, generation_b) = states.note(1, URL_B);
// …and the newest one answers. assert!(states.claim(1, URL_B, generation_b));
assert!(states.claim(1, URL_B)); states.release(1, URL_B, generation_b);
// No results → release so a repeat may retry the fetch. assert!(states.claim(1, URL_B, generation_b));
states.release(1, URL_B);
assert!(states.claim(1, URL_B));
} }
} }
+358 -85
View File
@@ -9,53 +9,43 @@
mod callback; mod callback;
mod commands; mod commands;
mod inline; mod inline;
mod repair;
mod statics; mod statics;
mod url_workers;
mod urls; mod urls;
pub use callback::callback_query_handler; pub use callback::callback_query_handler;
pub use commands::register_commands; pub use commands::register_commands;
pub use inline::inline_query_handler; pub use inline::inline_query_handler;
pub(crate) use inline::prune_idle_states;
pub(crate) use repair::repair_lost_local_media;
/// The resolved `$DATA_DIR/task_queue.db` path, for the startup config line.
pub(crate) use statics::db_path;
pub use statics::{CHAT_STORE, CONFIG, LINK_CACHE, TASK_QUEUE}; pub use statics::{CHAT_STORE, CONFIG, LINK_CACHE, TASK_QUEUE};
pub use urls::{start_url_workers, stop_url_workers}; pub use url_workers::{start_url_workers, stop_url_workers};
use crate::ctx::AppContext; use crate::ctx::AppContext;
use crate::media_sender::MediaSender; use crate::media_sender::MediaSender;
use commands::{Command, execute_command}; use commands::{Command, execute_command};
use teloxide::RequestError; use teloxide::RequestError;
use teloxide::prelude::*; use teloxide::prelude::*;
use teloxide::types::{ChatId, ChatKind, Message, MessageId, ParseMode, ReplyParameters}; use teloxide::types::{ChatId, Message, MessageId};
use teloxide::utils::command::BotCommands; use teloxide::utils::command::BotCommands;
use urls::{URL_JOBS, extract_urls}; use url_workers::URL_JOBS;
use urls::extract_urls;
/// Reply to a message by id, keeping the reply decoration even if the /// Reply to a message by id, keeping the reply decoration even if the
/// original was already deleted. Returns the reply's message id. /// original was already deleted.
pub(crate) async fn reply( pub(crate) async fn reply(
sender: &dyn MediaSender, sender: &dyn MediaSender,
chat_id: i64, chat_id: i64,
reply_to: MessageId, reply_to: MessageId,
text: impl Into<String>, text: impl Into<String>,
) -> Result<i64, RequestError> { ) -> Result<(), RequestError> {
sender sender
.send_message(ChatId(chat_id), text.into(), Some(reply_to), None) .send_message(ChatId(chat_id), text.into(), Some(reply_to), None)
.await .await
} .map(|_| ())
/// Reply to a message by id with HTML parse mode (same reply decoration as
/// [`reply`]). Used by `/test`, whose report is an HTML message (the caption
/// is wrapped in a `<blockquote>` to show it exactly as it will render).
pub(crate) async fn reply_html(
bot: &Bot,
chat_id: i64,
reply_to: MessageId,
text: String,
) -> Result<i64, RequestError> {
// `<Bot as Requester>::` disambiguates from the MediaSender trait's
// same-named method (see media_sender.rs).
<Bot as Requester>::send_message(bot, ChatId(chat_id), text)
.parse_mode(ParseMode::Html)
.reply_parameters(ReplyParameters::new(reply_to).allow_sending_without_reply())
.await
.map(|message| message.id.0 as i64)
} }
/// Log prefix tying the whole lifecycle of one link (fetch → send → cache → /// Log prefix tying the whole lifecycle of one link (fetch → send → cache →
@@ -66,6 +56,77 @@ pub fn log_key(url: &str) -> String {
x_media::site::cache_key(url).unwrap_or_else(|| "<unsupported>".to_string()) x_media::site::cache_key(url).unwrap_or_else(|| "<unsupported>".to_string())
} }
/// Makes user-supplied text (a display name, callback data, a channel handle)
/// fit one log line: newlines and other control characters are escaped, so a
/// crafted value cannot forge a second log entry or hide inside one. Tab is
/// kept — it cannot break the line.
pub(crate) fn log_escape(s: &str) -> std::borrow::Cow<'_, str> {
if !s.chars().any(|c| c.is_control() && c != '\t') {
return std::borrow::Cow::Borrowed(s);
}
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
c if c != '\t' && c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
std::borrow::Cow::Owned(out)
}
/// How long a caption edit may sleep before it gives up on retrying: the reply
/// (or button press) that carried the text is already consumed, so the update
/// must not stall the chat's queue behind a long flood-control wait — the user
/// is told to send it again instead.
const CAPTION_EDIT_MAX_RETRY_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
/// Whether a caption edit landed.
enum EditOutcome {
Applied,
/// The API's reason, for the message the user gets.
Failed(String),
}
/// Applies a caption edit, retrying once when the API names a short retryable
/// delay (`RetryAfter`/network/5xx). A failed edit used to be logged and
/// swallowed while the record was updated anyway: the user saw nothing, the
/// caption never changed, and the text they typed was gone. Callers report
/// [`EditOutcome::Failed`] instead.
async fn apply_caption_edit(
sender: &dyn MediaSender,
chat_id: ChatId,
message_id: MessageId,
caption: String,
) -> EditOutcome {
let mut attempt = 0;
loop {
match sender
.edit_message_caption(chat_id, message_id, caption.clone())
.await
{
Ok(()) => return EditOutcome::Applied,
Err(e) => {
let reason = e.to_string();
if attempt == 0
&& let crate::send::Classification::Retryable { delay_seconds } =
crate::send::classify_request_error(&e)
&& std::time::Duration::from_secs_f64(delay_seconds)
<= CAPTION_EDIT_MAX_RETRY_WAIT
{
attempt = 1;
log::debug!("caption edit failed ({reason}), retrying once");
tokio::time::sleep(std::time::Duration::from_secs_f64(delay_seconds)).await;
continue;
}
log::error!("edit_message_caption failed: {reason}");
return EditOutcome::Failed(reason);
}
}
}
}
/// Edit-before-forward: a reply to the prompt swaps the caption of the first /// Edit-before-forward: a reply to the prompt swaps the caption of the first
/// forwarded message. Returns true when the message was consumed as an edit. /// forwarded message. Returns true when the message was consumed as an edit.
/// Body of [`message_handler`]'s edit branch, without teloxide update types so /// Body of [`message_handler`]'s edit branch, without teloxide update types so
@@ -80,6 +141,18 @@ async fn edit_message_handler(
let Some(edit) = chat_data.edit_message.get(&reply_to_message_id) else { let Some(edit) = chat_data.edit_message.get(&reply_to_message_id) else {
return false; return false;
}; };
// Lazy expiry, the same rule a button press gets: a record past the TTL
// (not yet swept) is dropped and the reply falls through to the normal
// message flow instead of rewriting a caption from a dead prompt.
if edit.created_at + ctx.config.edit_message_ttl.as_secs() as i64 <= crate::db::unix_now() {
let _ = ctx
.chat_store
.update(chat_id, |data| {
data.edit_message.remove(&reply_to_message_id);
})
.await;
return false;
}
let Some(first_forward_id) = edit.forward_message_ids.first() else { let Some(first_forward_id) = edit.forward_message_ids.first() else {
return false; return false;
}; };
@@ -97,25 +170,49 @@ async fn edit_message_handler(
.map(|template| template.replace("[]", &link)) .map(|template| template.replace("[]", &link))
.unwrap_or(link) .unwrap_or(link)
}; };
match ctx match apply_caption_edit(
.sender ctx.sender,
.edit_message_caption( ChatId(chat_id),
ChatId(chat_id), MessageId(*first_forward_id as i32),
MessageId(*first_forward_id as i32), new_text,
new_text, )
) .await
.await
{ {
Ok(()) => log::info!( EditOutcome::Applied => log::info!(
"edit-before-forward: caption swapped on message {first_forward_id} for prompt {reply_to_message_id}" "edit-before-forward: caption swapped on message {first_forward_id} for prompt {reply_to_message_id}"
), ),
Err(e) => log::error!("edit_message_caption failed: {e}"), // The reply was a caption for this prompt, so it stays consumed either
// way — but the user is told the swap failed instead of losing it
// silently (and can send it again).
EditOutcome::Failed(reason) => {
let _ = reply(
ctx.sender,
chat_id,
MessageId(reply_to_message_id as i32),
format!("Could not update the caption ({reason}). Send it again to retry."),
)
.await;
}
} }
true true
} }
/// The `dptree` entry point: the process-wide context, plus the bot the
/// dispatcher handed us (used for the replies this module sends itself).
pub async fn message_handler(bot: Bot, message: Message) -> Result<(), RequestError> { pub async fn message_handler(bot: Bot, message: Message) -> Result<(), RequestError> {
let is_private = matches!(message.chat.kind, ChatKind::Private(_)); handle_message(&AppContext::from_statics(&bot), &bot, message).await
}
/// Body of [`message_handler`], taking its context. Every branch here — the
/// edit-reply interception, the command path, the private-chat link enqueue and
/// the group hint — is otherwise reachable only through the process-wide
/// statics, which is why none of them had a test.
pub(crate) async fn handle_message(
ctx: &AppContext<'_>,
bot: &Bot,
message: Message,
) -> Result<(), RequestError> {
let is_private = message.chat.is_private();
let sender = message let sender = message
.from .from
.as_ref() .as_ref()
@@ -125,41 +222,53 @@ pub async fn message_handler(bot: Bot, message: Message) -> Result<(), RequestEr
.text() .text()
.map(|t| { .map(|t| {
let end = t.floor_char_boundary(120.min(t.len())); let end = t.floor_char_boundary(120.min(t.len()));
&t[..end] log_escape(&t[..end])
}) })
.unwrap_or("<no text>"); .unwrap_or_else(|| std::borrow::Cow::Borrowed("<no text>"));
// Per-request detail: debug only (message text is user data). // Per-request detail: who and where at `debug`; the message text itself is
// user data and only ever appears at `trace`, so a `debug` log can be
// shared without leaking what people pasted.
log::debug!( log::debug!(
"message from {sender} in {} (private={is_private}): {text_preview}", "message from {} in {} (private={is_private})",
log_escape(&sender),
message.chat.id message.chat.id
); );
log::trace!("message text: {text_preview}");
// URL/edit flows only run in private chats; commands run in any chat. // URL/edit flows only run in private chats; commands run in any chat.
if is_private if is_private
&& let Some(reply) = message.reply_to_message() && let Some(reply) = message.reply_to_message()
&& let Some(text) = message.text() && let Some(text) = message.text()
&& edit_message_handler( && edit_message_handler(ctx, message.chat.id.0, reply.id.0 as i64, text).await
&AppContext::from_statics(&bot),
message.chat.id.0,
reply.id.0 as i64,
text,
)
.await
{ {
return respond(()); return respond(());
} }
if let Some(text) = message.text() if let Some(text) = message.text()
&& let Ok(command) = Command::parse(text, "") && let Ok(command) = Command::parse(text, "")
{ {
log::debug!("command from {}: {text_preview}", message.chat.id); // The command name is what the operator needs at `debug`; its argument
execute_command(&bot, &message, command).await?; // may be a user-supplied URL, which stays at `trace`.
log::debug!(
"command from {}: {}",
message.chat.id,
log_escape(text.split_whitespace().next().unwrap_or("<empty>"))
);
log::trace!("command text: {text_preview}");
execute_command(ctx, bot, &message, command).await?;
return respond(()); return respond(());
} }
if is_private { if is_private {
let urls = extract_urls(&message); // Only links a site adapter claims: an unsupported URL never gets a
// media message, so enqueuing it would spend a queue slot, a worker
// wake-up and (through `run_with_chat_action`) a Telegram call on
// nothing. Same test the group branch below makes for its hint.
let urls: Vec<String> = extract_urls(&message)
.into_iter()
.filter(|url| x_media::site::cache_key(url).is_some())
.collect();
if !urls.is_empty() { if !urls.is_empty() {
// Debug only, and echo the normalized keys instead of the raw URLs. // Debug only, and echo the normalized keys instead of the raw URLs.
let keys: Vec<String> = urls.iter().map(|u| log_key(u)).collect(); let keys: Vec<String> = urls.iter().map(|u| log_key(u)).collect();
log::debug!("extracted {} URL(s): {keys:?}", urls.len()); log::debug!("queuing {} supported URL(s): {keys:?}", urls.len());
} }
for url in urls { for url in urls {
// Clone out of the lock: the parking_lot guard is !Send and must // Clone out of the lock: the parking_lot guard is !Send and must
@@ -175,52 +284,79 @@ pub async fn message_handler(bot: Bot, message: Message) -> Result<(), RequestEr
break; break;
} }
} }
} else if (message.chat.is_group() || message.chat.is_supergroup())
&& 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(ctx.sender, message.chat.id.0, message.id, GROUP_LINK_HINT).await;
} }
respond(()) 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.";
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::ctx::test_support::TestStores; use crate::ctx::test_support::{FORWARDED_ID, PROMPT_ID, TestStores, api_error, seed_prompt};
use crate::media_sender::test_support::{MockSender, Outcome}; use crate::media_sender::test_support::{MockSender, Outcome};
use crate::state::EditMessage; use teloxide::RequestError;
use teloxide::ApiError;
const PROMPT_ID: i64 = 7; /// The Telegram wording the mocks answer with: a message the bot cannot
const FORWARDED_ID: i64 = 9; /// edit (the prompt was deleted).
const API_ERROR: &str = "Bad Request: message not found";
fn api_error() -> RequestError { #[test]
RequestError::Api(ApiError::Unknown("Bad Request: message not found".into())) fn log_escape_cannot_forge_a_second_log_line() {
let forged = log_escape("alice\nINFO injected entry");
assert!(!forged.contains('\n'), "no raw newline may survive");
assert!(
forged.contains("\\n"),
"the break stays visible as an escape"
);
// The common case (clean input) borrows — logging must not allocate.
assert!(matches!(
log_escape("plain text"),
std::borrow::Cow::Borrowed(_)
));
} }
/// Seeds a prompt record; `template` names the chat template used for it #[tokio::test]
/// (empty = none, the caption gets the bare link). async fn a_reply_to_an_expired_prompt_is_not_edited() {
async fn seed_prompt(ctx: &AppContext<'_>, template: &str) { // 90 000 s ago: past the TTL under any config a test can hold.
ctx.chat_store let sender = MockSender::scripted(vec![], || api_error(API_ERROR));
.update(1, |data| { let stores = TestStores::new();
data.template let ctx = stores.ctx(&sender);
.insert("tpl".to_string(), "<b>[]</b>".to_string()); seed_prompt(&ctx, "tpl", crate::db::unix_now() - 90_000).await;
data.edit_message.insert(
PROMPT_ID, let consumed = edit_message_handler(&ctx, 1, PROMPT_ID, "new caption").await;
EditMessage {
url: "https://x.com/u/status/1".into(), assert!(!consumed, "an expired prompt must not consume the reply");
chat_id: 1, assert!(
forward_message_ids: vec![FORWARDED_ID], sender.captions().is_empty(),
template: template.to_string(), "no caption edit may reach a dead prompt"
created_at: crate::db::unix_now(), );
}, assert!(
); stores.chat_store().get(1).await.edit_message.is_empty(),
}) "the stale record must be dropped for good"
.await; );
} }
#[tokio::test] #[tokio::test]
async fn reply_to_a_prompt_swaps_the_caption_through_its_template() { async fn reply_to_a_prompt_swaps_the_caption_through_its_template() {
let sender = MockSender::scripted(vec![Outcome::EditOk], api_error); let sender = MockSender::scripted(vec![Outcome::EditOk], || api_error(API_ERROR));
let stores = TestStores::new(); let stores = TestStores::new();
let ctx = stores.ctx(&sender); let ctx = stores.ctx(&sender);
seed_prompt(&ctx, "tpl").await; seed_prompt(&ctx, "tpl", crate::db::unix_now()).await;
let consumed = edit_message_handler(&ctx, 1, PROMPT_ID, "new caption").await; let consumed = edit_message_handler(&ctx, 1, PROMPT_ID, "new caption").await;
@@ -233,10 +369,10 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn reply_text_and_url_are_escaped_into_the_caption() { async fn reply_text_and_url_are_escaped_into_the_caption() {
let sender = MockSender::scripted(vec![Outcome::EditOk], api_error); let sender = MockSender::scripted(vec![Outcome::EditOk], || api_error(API_ERROR));
let stores = TestStores::new(); let stores = TestStores::new();
let ctx = stores.ctx(&sender); let ctx = stores.ctx(&sender);
seed_prompt(&ctx, "").await; seed_prompt(&ctx, "", crate::db::unix_now()).await;
edit_message_handler(&ctx, 1, PROMPT_ID, "<script>alert(1)</script>").await; edit_message_handler(&ctx, 1, PROMPT_ID, "<script>alert(1)</script>").await;
@@ -248,21 +384,62 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn a_failed_caption_swap_still_consumes_the_reply() { async fn a_failed_caption_swap_is_reported_and_consumed() {
let sender = MockSender::scripted(vec![Outcome::EditErr], api_error); // The script is per call, in order: the edit fails, the notice follows.
let sender = MockSender::scripted(vec![Outcome::EditErr, Outcome::MessageOk], || {
api_error(API_ERROR)
});
let stores = TestStores::new(); let stores = TestStores::new();
let ctx = stores.ctx(&sender); let ctx = stores.ctx(&sender);
seed_prompt(&ctx, "tpl").await; seed_prompt(&ctx, "tpl", crate::db::unix_now()).await;
// The edit failed (message deleted etc.); the reply must still be // The edit failed (message deleted etc.); the reply must still be
// swallowed instead of being treated as a link to fetch. // swallowed instead of being treated as a link to fetch — and the user
// must be told, because the text they sent is gone either way.
assert!(edit_message_handler(&ctx, 1, PROMPT_ID, "new caption").await); assert!(edit_message_handler(&ctx, 1, PROMPT_ID, "new caption").await);
assert_eq!(sender.calls(), vec!["edit_message_caption"]); assert_eq!(sender.calls(), vec!["edit_message_caption", "send_message"]);
let notice = sender.messages().join(" ");
assert!(notice.contains("Could not update the caption"), "{notice}");
}
#[tokio::test(start_paused = true)]
async fn a_short_retryable_caption_failure_is_retried_once() {
use teloxide::types::Seconds;
// A one-second flood-control wait is worth honouring: the retry lands
// and the user never hears about it.
let sender = MockSender::scripted(vec![Outcome::EditErr, Outcome::EditOk], || {
RequestError::RetryAfter(Seconds::from_seconds(1))
});
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
seed_prompt(&ctx, "tpl", crate::db::unix_now()).await;
assert!(edit_message_handler(&ctx, 1, PROMPT_ID, "new caption").await);
assert_eq!(
sender.calls(),
vec!["edit_message_caption", "edit_message_caption"]
);
}
#[tokio::test(start_paused = true)]
async fn a_long_retryable_caption_failure_is_not_retried() {
use teloxide::types::Seconds;
// A minute-long wait must not stall the chat's update queue behind it:
// the user is told to send the caption again instead.
let sender = MockSender::scripted(vec![Outcome::EditErr, Outcome::MessageOk], || {
RequestError::RetryAfter(Seconds::from_seconds(60))
});
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
seed_prompt(&ctx, "tpl", crate::db::unix_now()).await;
assert!(edit_message_handler(&ctx, 1, PROMPT_ID, "new caption").await);
assert_eq!(sender.calls(), vec!["edit_message_caption", "send_message"]);
} }
#[tokio::test] #[tokio::test]
async fn reply_to_an_unrelated_message_is_not_consumed() { async fn reply_to_an_unrelated_message_is_not_consumed() {
let sender = MockSender::scripted(vec![], api_error); let sender = MockSender::scripted(vec![], || api_error(API_ERROR));
let stores = TestStores::new(); let stores = TestStores::new();
let ctx = stores.ctx(&sender); let ctx = stores.ctx(&sender);
@@ -271,4 +448,100 @@ mod tests {
assert!(!edit_message_handler(&ctx, 1, PROMPT_ID, "hello").await); assert!(!edit_message_handler(&ctx, 1, PROMPT_ID, "hello").await);
assert!(sender.calls().is_empty()); assert!(sender.calls().is_empty());
} }
/// A reply driven through the real message entry point into a real `Bot`:
/// the routing (reply-to-prompt → caption swap, before the command and URL
/// branches) and the request teloxide builds.
#[tokio::test]
async fn a_prompt_reply_reaches_the_api_as_a_caption_edit() {
use crate::media_sender::test_support::fake_api::FakeApi;
use teloxide::Bot;
let api = FakeApi::start().await;
let bot = Bot::new("42:TEST").set_api_url(api.url());
let stores = TestStores::new();
let ctx = stores.ctx(&bot);
seed_prompt(&ctx, "", crate::db::unix_now()).await;
let message: Message = serde_json::from_value(serde_json::json!({
"message_id": PROMPT_ID + 1,
"date": 0,
"chat": { "id": 1, "type": "private" },
"from": { "id": 5, "is_bot": false, "first_name": "u" },
"reply_to_message": {
"message_id": PROMPT_ID,
"date": 0,
"chat": { "id": 1, "type": "private" },
"text": "prompt",
},
"text": "new caption",
}))
.expect("a minimal message deserializes");
handle_message(&ctx, &bot, message).await.unwrap();
assert_eq!(api.methods(), vec!["EditMessageCaption"]);
let body = api.body("EditMessageCaption");
assert_eq!(body["chat_id"], 1);
assert_eq!(body["message_id"], FORWARDED_ID);
assert_eq!(
body["caption"],
"<a href=\"https://x.com/u/status/1\">new caption</a>"
);
// The other branch of the same entry point: a supported link in a group
// gets the one explanatory reply (the link pipeline is private-chat only,
// and dropping it in silence reads as a broken bot).
let group: Message = serde_json::from_value(serde_json::json!({
"message_id": 2,
"date": 0,
"chat": { "id": -100, "type": "group", "title": "g" },
"from": { "id": 5, "is_bot": false, "first_name": "u" },
"text": "https://x.com/u/status/1",
"entities": [{ "type": "url", "offset": 0, "length": 24 }],
}))
.expect("a minimal group message deserializes");
handle_message(&ctx, &bot, group).await.unwrap();
assert_eq!(api.methods(), vec!["EditMessageCaption", "SendMessage"]);
assert_eq!(api.body("SendMessage")["text"], GROUP_LINK_HINT);
// A channel stays silent: the hint reply would be posted into the
// channel itself, so the same link must produce no further call.
let channel: Message = serde_json::from_value(serde_json::json!({
"message_id": 3,
"date": 0,
"chat": { "id": -1001234567890i64, "type": "channel", "title": "c" },
"text": "https://x.com/u/status/1",
"entities": [{ "type": "url", "offset": 0, "length": 24 }],
}))
.expect("a minimal channel message deserializes");
handle_message(&ctx, &bot, channel).await.unwrap();
assert_eq!(
api.methods(),
vec!["EditMessageCaption", "SendMessage"],
"a channel must not get the group hint"
);
// And an *unsupported* link in a group stays silent too: the hint is
// for links a site adapter claims (the branch's own filter).
let unsupported: Message = serde_json::from_value(serde_json::json!({
"message_id": 4,
"date": 0,
"chat": { "id": -100, "type": "group", "title": "g" },
"text": "https://example.com/x",
"entities": [{ "type": "url", "offset": 0, "length": 19 }],
}))
.expect("a minimal group message deserializes");
handle_message(&ctx, &bot, unsupported).await.unwrap();
assert_eq!(
api.methods(),
vec!["EditMessageCaption", "SendMessage"],
"an unsupported link must not get the hint"
);
}
} }
+376
View File
@@ -0,0 +1,376 @@
//! Startup repair, run before any queue worker exists: a queued retry whose
//! local media (a ugoira MP4, a bsky remux, a downloaded temp file) did not
//! survive the restart can never succeed, because the registry that kept those
//! files alive (`send::KEEP_ALIVE`) is in memory. Those rows are re-fetched
//! from their post instead of dead-lettering the user's link.
use super::log_key;
use super::urls::{cached_snapshot, media_to_payload};
use crate::ctx::AppContext;
use crate::link_cache::CachedPost;
use crate::send::{self, Delivery, MediaItemPayload, Task};
// ── Startup repair: queued retries whose local media did not survive ───────
/// A post's fresh media plus the caption and cache snapshot that go with them:
/// what [`refetch`] hands [`apply_refresh`]. Plain data, so the rewrite below
/// can be tested without a network fetch (which cannot be faked here:
/// [`x_media::site::Fetched`] keeps a private field and is not constructible
/// outside its crate).
struct Refetched {
caption: String,
items: Vec<MediaItemPayload>,
cache_data: Option<CachedPost>,
keep_alive: Option<std::sync::Arc<tempfile::TempDir>>,
}
/// Whether a queued task should have its post re-fetched, because it still
/// wants a local file (ugoira MP4, a bsky remux, a downloaded temp file) that is
/// gone. Those files live in the system temp dir and the registry that keeps
/// them alive for the retry (`send::KEEP_ALIVE`) is in memory, so a restart
/// takes all of them — a retry that needs one can only dead-letter.
///
/// A partially delivered album is left alone: its remaining batches cannot be
/// reconciled with a fresh media list without risking a second copy of what the
/// user already received.
fn needs_refetch(task: &Task) -> bool {
if let Task::SendMediaSequence {
batch_index,
sent_message_ids,
..
} = task
&& (*batch_index > 0 || !sent_message_ids.is_empty())
{
return false;
}
task.local_media_paths().iter().any(|path| !path.exists())
}
/// Rebuilds the task from the fresh media, keeping its delivery envelope (chat,
/// reply, forward/edit settings, notify targets): the retry that was queued must
/// still deliver the same way, whoever asked for it.
fn apply_refresh(task: &Task, fresh: &Refetched) -> Option<Task> {
let chat_id = task.chat_id()?;
let (edit_before_forward, forward_channel_id) = match task {
Task::SendMediaSequence {
edit_before_forward,
forward_channel_id,
..
}
| Task::SendAnimation {
edit_before_forward,
forward_channel_id,
..
} => (*edit_before_forward, *forward_channel_id),
Task::ForwardMessages { .. } => return None,
};
let reply_to_message_id = match task {
Task::SendMediaSequence {
reply_to_message_id,
..
}
| Task::SendAnimation {
reply_to_message_id,
..
} => *reply_to_message_id,
Task::ForwardMessages { .. } => return None,
};
let (notify_chat_id, notify_message_id) = task.notify_target();
Some(Task::from_items(
Delivery {
chat_id,
reply_to_message_id,
edit_before_forward,
forward_channel_id,
notify_chat_id,
notify_message_id,
},
task.source_url()?.to_string(),
fresh.caption.clone(),
fresh.items.clone(),
fresh.cache_data.clone(),
))
}
/// Fetches the post again and maps it into [`Refetched`]: the same mapping the
/// fresh-fetch path uses (per-site caption format from the chat, render fields
/// for the link-cache snapshot), so a repaired task looks like a first send.
async fn refetch(
ctx: &AppContext<'_>,
chat_id: i64,
url: &str,
) -> Result<Option<Refetched>, x_media::site::FetchError> {
let Some(fetched) = x_media::site::fetch(url).await? else {
return Ok(None);
};
if fetched.media.is_empty() {
return Ok(None);
}
let chat_data = ctx.chat_store.get(chat_id).await;
let format = chat_data.format_for(fetched.site_id);
let caption = fetched.caption_with(&format);
let cache_data = cached_snapshot(&fetched);
let items: Vec<MediaItemPayload> = fetched
.media
.iter()
.filter_map(|media| media_to_payload(media, fetched.sensitive))
.collect();
if items.is_empty() {
return Ok(None);
}
Ok(Some(Refetched {
caption,
items,
cache_data,
keep_alive: fetched.keep_alive(),
}))
}
/// Re-fetches every queued task whose local media did not survive the restart.
/// This runs at startup before queue workers exist, so any SQLite error is
/// returned to the caller and prevents workers from starting on unrepaired
/// state.
pub(crate) async fn repair_lost_local_media(
ctx: &AppContext<'_>,
) -> Result<usize, rusqlite::Error> {
let rows = ctx.task_queue.runnable_rows().await?;
let mut repaired = 0;
for (id, payload) in rows {
let Ok(task) = serde_json::from_str::<Task>(&payload) else {
continue;
};
if !needs_refetch(&task) {
continue;
}
let (Some(url), Some(chat_id)) = (task.source_url().map(str::to_string), task.chat_id())
else {
continue;
};
match refetch(ctx, chat_id, &url).await {
Ok(Some(fresh)) => {
let Some(updated) = apply_refresh(&task, &fresh) else {
continue;
};
let updated = serde_json::to_value(&updated).expect("task serializes");
match ctx.task_queue.replace_payload(&id, &updated).await {
Ok(true) => {
if let Some(dir) = fresh.keep_alive {
send::KEEP_ALIVE.lock().push(dir);
}
repaired += 1;
log::info!(
"startup repair: re-fetched [key={}] for chat={chat_id}",
log_key(&url)
);
}
Ok(false) => {
log::warn!("startup repair: queue row {id} disappeared before rewrite")
}
Err(e) => {
log::error!("startup repair: queue row {id} rewrite failed: {e}");
return Err(e);
}
}
}
Ok(None) | Err(_) => {
let (notify_chat_id, notify_message_id) = task.notify_target();
log::warn!(
"startup repair: [key={}] for chat={chat_id} needed a re-fetch and none was possible",
log_key(&url)
);
send::notify_failure(
ctx.sender,
notify_chat_id,
notify_message_id,
&format!(
"{} — the media held for retry was lost when the bot restarted and the post could not be fetched again. Please send the link again.",
log_key(&url)
),
)
.await;
}
}
}
Ok(repaired)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ctx::test_support::{TestStores, permanent_error, photo_item};
use crate::media_sender::test_support::MockSender;
use crate::send::MediaRef;
fn queued_task(media: &str, batch_index: usize, sent: Vec<i64>) -> Task {
Task::SendMediaSequence {
chat_id: 1,
reply_to_message_id: 2,
caption: "cap".into(),
media_batches: vec![vec![photo_item(media, false, false)]],
batch_index,
sent_message_ids: sent,
source_url: "https://x.com/u/status/1".into(),
edit_before_forward: true,
forward_channel_id: Some(2),
notify_chat_id: Some(1),
notify_message_id: Some(2),
cache_data: None,
}
}
#[test]
fn only_tasks_missing_a_local_file_need_a_refetch() {
// A URL send needs nothing.
assert!(!needs_refetch(&queued_task("https://cdn/1.jpg", 0, vec![])));
// A local path that is still there (a survived temp file) needs nothing.
let dir = tempfile::tempdir().unwrap();
let alive = dir.path().join("ugoira.mp4");
std::fs::write(&alive, b"x").unwrap();
assert!(!needs_refetch(&queued_task(
alive.to_str().unwrap(),
0,
vec![]
)));
// A local path the restart took away does.
assert!(needs_refetch(&queued_task(
"/nonexistent-ugoira.mp4",
0,
vec![]
)));
// A partially delivered album is left to its own retry path.
assert!(!needs_refetch(&queued_task(
"/nonexistent-ugoira.mp4",
1,
vec![7]
)));
assert!(!needs_refetch(&queued_task(
"/nonexistent-ugoira.mp4",
0,
vec![7]
)));
// A channel copy holds no media.
assert!(!needs_refetch(&Task::ForwardMessages {
from_chat_id: 1,
to_chat_id: 2,
message_ids: vec![3],
forward_offset: 0,
notify_chat_id: None,
notify_message_id: None,
}));
}
#[test]
fn apply_refresh_keeps_the_delivery_envelope() {
let task = queued_task("/nonexistent-ugoira.mp4", 0, vec![]);
let fresh = Refetched {
caption: "fresh caption".into(),
items: vec![photo_item("https://cdn/fresh.jpg", true, false)],
cache_data: None,
keep_alive: None,
};
match apply_refresh(&task, &fresh).expect("a repairable task") {
Task::SendMediaSequence {
chat_id,
reply_to_message_id,
caption,
media_batches,
batch_index,
sent_message_ids,
source_url,
edit_before_forward,
forward_channel_id,
notify_chat_id,
notify_message_id,
..
} => {
// Same delivery: chat, reply, forward/edit settings, notify.
assert_eq!((chat_id, reply_to_message_id), (1, 2));
assert!(edit_before_forward);
assert_eq!(forward_channel_id, Some(2));
assert_eq!((notify_chat_id, notify_message_id), (Some(1), Some(2)));
assert_eq!(source_url, "https://x.com/u/status/1");
// Fresh media, and nothing of it counted as sent yet.
assert_eq!(caption, "fresh caption");
assert!(
matches!(
media_batches[0][0].media_ref(),
MediaRef::Source(media) if media == "https://cdn/fresh.jpg"
),
"fresh media must replace the lost local file"
);
assert!(matches!(
media_batches[0][0],
MediaItemPayload::Photo {
has_spoiler: true,
..
}
));
assert_eq!((batch_index, sent_message_ids.len()), (0, 0));
}
other => panic!("expected a media sequence, got {other:?}"),
}
}
#[tokio::test]
async fn a_queue_scan_error_is_reported_to_the_startup_caller() {
let stores = TestStores::new();
let sender = MockSender::scripted(vec![], permanent_error);
let ctx = stores.ctx(&sender);
let raw = rusqlite::Connection::open(stores.db_path()).unwrap();
raw.execute_batch("DROP TABLE tasks").unwrap();
assert!(repair_lost_local_media(&ctx).await.is_err());
}
/// The whole repair against a real post: a queued row whose media is a local
/// file the restart took away is re-fetched from its `source_url` and
/// rewritten in place, so the retry can still deliver it.
#[tokio::test]
#[ignore = "live network: requires outbound HTTPS to public.api.bsky.app"]
async fn live_repair_refetches_a_lost_local_media_row() {
let stores = TestStores::new();
// An empty script: the repair must not need to tell the user anything.
let sender = MockSender::scripted(vec![], permanent_error);
let ctx = stores.ctx(&sender);
let mut task = queued_task("/nonexistent-ugoira.mp4", 0, vec![]);
if let Task::SendMediaSequence { source_url, .. } = &mut task {
*source_url = "https://bsky.app/profile/fu-futa.bsky.social/post/3laoveufjv224".into();
}
stores
.task_queue()
.enqueue(serde_json::to_value(&task).unwrap(), crate::db::now_f64())
.await
.unwrap();
assert_eq!(repair_lost_local_media(&ctx).await, Ok(1));
let updated: Task = serde_json::from_value(stores.queued_payload().await).unwrap();
match updated {
Task::SendMediaSequence {
media_batches,
batch_index,
sent_message_ids,
caption,
..
} => {
let media: Vec<&str> = media_batches
.iter()
.flatten()
.map(|item| match item.media_ref() {
MediaRef::Source(media) | MediaRef::FileId(media) => media.as_str(),
})
.collect();
assert!(!media.is_empty(), "the fresh fetch yielded no media");
assert!(
media.iter().all(|m| m.starts_with("http")),
"the retry must be uploadable from URLs again: {media:?}"
);
assert_eq!((batch_index, sent_message_ids.len()), (0, 0));
assert!(!caption.is_empty());
}
other => panic!("expected a repaired media sequence, got {other:?}"),
}
// The post was re-read, not re-delivered: nothing was sent.
assert!(sender.calls().is_empty(), "{:?}", sender.calls());
}
}
+3 -2
View File
@@ -23,8 +23,9 @@ static DB: LazyLock<Arc<db::DbPool>> = LazyLock::new(|| {
/// create parent dirs, so the old hardcoded `data/task_queue.db` failed with /// create parent dirs, so the old hardcoded `data/task_queue.db` failed with
/// a confusing error when started from a directory without `data/`, and a /// a confusing error when started from a directory without `data/`, and a
/// CWD-relative path is a footgun for systemd / cron deployments — `DATA_DIR` /// CWD-relative path is a footgun for systemd / cron deployments — `DATA_DIR`
/// lets them pin the state anywhere. /// lets them pin the state anywhere. Also read by the startup config line, so
fn db_path() -> std::path::PathBuf { /// the log says where the state actually landed.
pub(crate) fn db_path() -> std::path::PathBuf {
let dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "data".to_string()); let dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "data".to_string());
let dir_path = std::path::Path::new(&dir); let dir_path = std::path::Path::new(&dir);
std::fs::create_dir_all(dir_path).expect("failed to create data directory"); std::fs::create_dir_all(dir_path).expect("failed to create data directory");
@@ -0,0 +1,102 @@
//! The URL job channel and its worker pool: a bounded queue (backpressure
//! instead of unbounded spawns) drained by [`URL_WORKERS`] supervised workers.
//!
//! teloxide's per-chat workers are sequential, so a batch forward needs its own
//! concurrency: this is where a link handed over by `handlers::mod` actually
//! reaches the pipeline.
use super::urls::{PostSend, url_media};
use crate::ctx::CONTEXT;
use std::sync::LazyLock;
use teloxide::types::Message;
/// One URL job: the message + the extracted URL (the sender and stores come
/// from the shared [`AppContext`], assembled from statics inside the worker).
type UrlJob = (Message, String);
/// Bounded channel of URL jobs drained by [`start_url_workers`]. The bound
/// caps both queued memory and shutdown backlog; a full channel applies
/// backpressure to the per-chat handler instead of spawning unbounded tasks.
pub(crate) static URL_JOBS: LazyLock<
parking_lot::Mutex<Option<tokio::sync::mpsc::Sender<UrlJob>>>,
> = LazyLock::new(|| parking_lot::Mutex::new(None));
/// Set by main's shutdown sequence; workers stop pulling new jobs.
pub(crate) static URL_STOP: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// JoinHandles of the URL workers, awaited by [`stop_url_workers`].
static URL_WORKER_HANDLES: LazyLock<parking_lot::Mutex<Option<Vec<tokio::task::JoinHandle<()>>>>> =
LazyLock::new(|| parking_lot::Mutex::new(None));
/// Worker count draining URL jobs; keeps the old 8-permit concurrency cap
/// while bounding how many jobs can be queued at all.
const URL_WORKERS: usize = 8;
/// Starts the URL job workers (called once from main after the queue starts).
/// teloxide dispatches updates to a per-chat worker that handles them
/// sequentially, so a batch-forward of many messages would otherwise be
/// processed one at a time (fetch + send each, roughly a second per
/// message); the workers add throughput, and FIFO order preserves per-message
/// URL order.
pub async fn start_url_workers() {
let (tx, rx) = tokio::sync::mpsc::channel::<UrlJob>(256);
*URL_JOBS.lock() = Some(tx);
let rx = std::sync::Arc::new(tokio::sync::Mutex::new(rx));
let mut handles = Vec::with_capacity(URL_WORKERS);
for _ in 0..URL_WORKERS {
let rx = std::sync::Arc::clone(&rx);
handles.push(tokio::spawn(async move {
// Supervised like the queue workers: a panic inside a worker
// (a handler, a poisoned lock) used to kill it for good and
// silently shrink the pool — the remaining workers keep the
// channel drained, so nothing else surfaces the loss. The job the
// panicking worker held is lost; the panic is not.
while !URL_STOP.load(std::sync::atomic::Ordering::Relaxed) {
let rx = std::sync::Arc::clone(&rx);
if let Err(e) = tokio::spawn(async move {
while !URL_STOP.load(std::sync::atomic::Ordering::Relaxed) {
let job = rx.lock().await.recv().await;
match job {
Some((message, url)) => {
url_media(
&CONTEXT,
message.chat.id.0,
message.id.0 as i64,
&url,
PostSend::FromChat,
)
.await;
}
None => break,
}
}
})
.await
{
log::error!("url worker panicked, restarting: {e}");
}
}
}));
}
*URL_WORKER_HANDLES.lock() = Some(handles);
}
/// Stops the URL workers: sets the stop flag, drops the job channel (so
/// workers blocked in \`recv()\` wake with \`None\` and exit) and awaits the
/// worker tasks. Each worker finishes its in-flight job first; jobs still
/// queued in the channel are abandoned (the old implementation neither
/// drained them nor woke blocked workers — it only set a flag checked
/// between jobs).
pub async fn stop_url_workers() {
URL_STOP.store(true, std::sync::atomic::Ordering::Relaxed);
// Dropping the sender makes every worker's recv() return None.
*URL_JOBS.lock() = None;
// Take the handles first so the lock guard drops before the awaits.
let handles = URL_WORKER_HANDLES.lock().take();
if let Some(handles) = handles {
for handle in handles {
if let Err(e) = handle.await {
log::error!("url worker panicked at shutdown: {e}");
}
}
}
}
File diff suppressed because it is too large Load Diff
+97 -107
View File
@@ -9,12 +9,13 @@
//! by the periodic prune in `main`. //! by the periodic prune in `main`.
use crate::db::now_f64; use crate::db::now_f64;
use rusqlite::OptionalExtension;
use rusqlite::params; use rusqlite::params;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum CachedMediaKind { pub enum CachedMediaKind {
Photo, Photo,
@@ -26,6 +27,12 @@ pub enum CachedMediaKind {
pub struct CachedMedia { pub struct CachedMedia {
pub kind: CachedMediaKind, pub kind: CachedMediaKind,
pub file_id: String, pub file_id: String,
/// The media URL the send used, kept so an entry whose file ids stopped
/// working can still be re-sent without touching the source site (see the
/// bot's `invalidate_cache`). Empty for entries written before this field
/// existed — those can only be dropped and re-fetched.
#[serde(default)]
pub url: String,
} }
/// Everything needed to re-send a post without touching the source site: /// Everything needed to re-send a post without touching the source site:
@@ -67,136 +74,117 @@ impl LinkCache {
pub async fn get(&self, key: &str, ttl: Duration) -> Option<CachedPost> { pub async fn get(&self, key: &str, ttl: Duration) -> Option<CachedPost> {
let key = key.to_string(); let key = key.to_string();
let ttl = ttl.as_secs_f64(); let ttl = ttl.as_secs_f64();
let result = self self.pool
.pool .with_conn_or(
.with_conn(move |conn| { log::Level::Warn,
let mut stmt = "link cache read failed",
conn.prepare("SELECT payload, created_at FROM link_cache WHERE url = ?1")?; None,
let mut rows = stmt.query(params![key])?; move |conn| {
let Some(row) = rows.next()? else { let Some((payload, created_at)) = conn
return Ok(None); .query_row(
}; "SELECT payload, created_at FROM link_cache WHERE url = ?1",
let payload: String = row.get(0)?; params![key],
let created_at: f64 = row.get(1)?; |row| Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?)),
if now_f64() - created_at > ttl { )
conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key])?; .optional()?
return Ok(None); else {
} return Ok(None);
match serde_json::from_str::<CachedPost>(&payload) { };
Ok(post) => Ok(Some(post)), if now_f64() - created_at > ttl {
Err(e) => {
// Unreadable payload (e.g. an older schema): drop it
// instead of re-failing the parse on every later hit.
conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key])?; conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key])?;
Err(rusqlite::Error::ToSqlConversionFailure(Box::new(e))) return Ok(None);
} }
} match serde_json::from_str::<CachedPost>(&payload) {
}) Ok(post) => Ok(Some(post)),
.await; Err(e) => {
match result { // Unreadable payload (e.g. an older schema): drop it
Ok(v) => v, // instead of re-failing the parse on every later hit.
Err(e) => { conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key])?;
log::error!("link cache read failed: {e}"); Err(rusqlite::Error::ToSqlConversionFailure(Box::new(e)))
None }
} }
} },
)
.await
} }
pub async fn put(&self, key: &str, post: &CachedPost) { pub async fn put(&self, key: &str, post: &CachedPost) {
let key = key.to_string(); let key = key.to_string();
let payload = serde_json::to_string(post).expect("cached post serializes"); let payload = serde_json::to_string(post).expect("cached post serializes");
let result = self self.pool
.pool .with_conn_or(
.with_conn(move |conn| { log::Level::Warn,
conn.execute( "link cache write failed",
"INSERT OR REPLACE INTO link_cache (url, payload, created_at) VALUES (?1, ?2, ?3)", (),
params![key, payload, now_f64()], move |conn| {
)?; conn.execute(
Ok(()) "INSERT OR REPLACE INTO link_cache (url, payload, created_at) VALUES (?1, ?2, ?3)",
}) params![key, payload, now_f64()],
)?;
Ok(())
},
)
.await; .await;
if let Err(e) = result {
log::error!("link cache write failed: {e}");
}
} }
/// Drops an entry (e.g. a cached file id that turned out invalid). /// Drops an entry (e.g. a cached file id that turned out invalid).
pub async fn remove(&self, key: &str) { pub async fn remove(&self, key: &str) {
let key = key.to_string(); let key = key.to_string();
let result = self self.pool
.pool .with_conn_or(
.with_conn(move |conn| { log::Level::Warn,
conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key])?; "link cache delete failed",
Ok(()) (),
}) move |conn| {
conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key])?;
Ok(())
},
)
.await; .await;
if let Err(e) = result {
log::error!("link cache delete failed: {e}");
}
} }
/// Removes expired entries; returns how many were deleted. /// Removes expired entries; returns how many were deleted.
pub async fn prune(&self, ttl: Duration) -> usize { pub async fn prune(&self, ttl: Duration) -> usize {
let cutoff = now_f64() - ttl.as_secs_f64(); let cutoff = now_f64() - ttl.as_secs_f64();
let result = self self.pool
.pool .with_conn_or(
.with_conn(move |conn| { log::Level::Warn,
conn.execute( "link cache prune failed",
"DELETE FROM link_cache WHERE created_at < ?1", 0,
params![cutoff], move |conn| {
) conn.execute(
}) "DELETE FROM link_cache WHERE created_at < ?1",
.await; params![cutoff],
match result { )
Ok(n) => n, },
Err(e) => { )
log::error!("link cache prune failed: {e}"); .await
0
}
}
} }
/// Deletes one entry (by normalized cache key) or the whole cache when /// Deletes one entry (by normalized cache key) or the whole cache when
/// `key` is `None`. Returns how many rows were removed. /// `key` is `None`. Returns how many rows were removed.
pub async fn clear(&self, key: Option<&str>) -> usize { pub async fn clear(&self, key: Option<&str>) -> usize {
let key = key.map(str::to_string); let key = key.map(str::to_string);
let result = self self.pool
.pool .with_conn_or(
.with_conn(move |conn| match &key { log::Level::Warn,
Some(key) => conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key]), "link cache clear failed",
None => conn.execute("DELETE FROM link_cache", []), 0,
}) move |conn| match &key {
.await; Some(key) => {
match result { conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key])
Ok(n) => n, }
Err(e) => { None => conn.execute("DELETE FROM link_cache", []),
log::error!("link cache clear failed: {e}"); },
0 )
} .await
}
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::ctx::test_support::cached_photo;
fn entry() -> CachedPost {
CachedPost {
url: "https://x.com/u/status/1".into(),
caption: "cap".into(),
title: "t".into(),
content: "c".into(),
author: "a".into(),
author_url: "au".into(),
tags: "".into(),
sensitive: true,
media: vec![CachedMedia {
kind: CachedMediaKind::Photo,
file_id: "AgAC...".into(),
}],
}
}
/// A payload written before the title/content split has no `content` /// A payload written before the title/content split has no `content`
/// field. It must still read back — the cache deletes what it cannot /// field. It must still read back — the cache deletes what it cannot
@@ -247,12 +235,14 @@ mod tests {
let cache = LinkCache::new( let cache = LinkCache::new(
crate::db::open_store(dir.path().join("c.db").to_str().unwrap()).unwrap(), crate::db::open_store(dir.path().join("c.db").to_str().unwrap()).unwrap(),
); );
cache.put("twitter:1", &entry()).await; cache.put("twitter:1", &cached_photo()).await;
let got = cache.get("twitter:1", Duration::from_secs(3600)).await; let got = cache.get("twitter:1", Duration::from_secs(3600)).await;
assert!(got.is_some()); assert!(got.is_some());
let got = got.unwrap(); let got = got.unwrap();
assert_eq!(got.url, "https://x.com/u/status/1"); assert_eq!(got.url, "https://x.com/u/status/1");
assert_eq!(got.media[0].file_id, "AgAC..."); assert_eq!(got.media[0].file_id, "AgAC-file-id");
// The source URL rides along: it is what a degraded entry falls back to.
assert_eq!(got.media[0].url, "https://pbs.twimg.com/media/photo.jpg");
} }
#[tokio::test] #[tokio::test]
@@ -261,7 +251,7 @@ mod tests {
let cache = LinkCache::new( let cache = LinkCache::new(
crate::db::open_store(dir.path().join("c.db").to_str().unwrap()).unwrap(), crate::db::open_store(dir.path().join("c.db").to_str().unwrap()).unwrap(),
); );
cache.put("twitter:1", &entry()).await; cache.put("twitter:1", &cached_photo()).await;
// Force the row into the past so a 1s TTL expires it. // Force the row into the past so a 1s TTL expires it.
{ {
let conn = rusqlite::Connection::open(dir.path().join("c.db")).unwrap(); let conn = rusqlite::Connection::open(dir.path().join("c.db")).unwrap();
@@ -314,8 +304,8 @@ mod tests {
let cache = LinkCache::new( let cache = LinkCache::new(
crate::db::open_store(dir.path().join("c.db").to_str().unwrap()).unwrap(), crate::db::open_store(dir.path().join("c.db").to_str().unwrap()).unwrap(),
); );
cache.put("twitter:1", &entry()).await; cache.put("twitter:1", &cached_photo()).await;
cache.put("pixiv:2", &entry()).await; cache.put("pixiv:2", &cached_photo()).await;
cache.remove("twitter:1").await; cache.remove("twitter:1").await;
assert!( assert!(
cache cache
@@ -349,8 +339,8 @@ mod tests {
let cache = LinkCache::new( let cache = LinkCache::new(
crate::db::open_store(dir.path().join("c.db").to_str().unwrap()).unwrap(), crate::db::open_store(dir.path().join("c.db").to_str().unwrap()).unwrap(),
); );
cache.put("twitter:1", &entry()).await; cache.put("twitter:1", &cached_photo()).await;
cache.put("pixiv:2", &entry()).await; cache.put("pixiv:2", &cached_photo()).await;
// By key: only the matching row is removed. // By key: only the matching row is removed.
assert_eq!(cache.clear(Some("twitter:1")).await, 1); assert_eq!(cache.clear(Some("twitter:1")).await, 1);
assert!( assert!(
+339 -45
View File
@@ -1,4 +1,5 @@
use dotenv::dotenv; use dotenv::dotenv;
use std::time::Duration;
use teloxide::dptree::endpoint; use teloxide::dptree::endpoint;
use teloxide::prelude::*; use teloxide::prelude::*;
use teloxide::stop::StopToken; use teloxide::stop::StopToken;
@@ -20,7 +21,7 @@ mod send;
mod state; mod state;
use ctx::CONTEXT; use ctx::CONTEXT;
use handlers::{CHAT_STORE, CONFIG, LINK_CACHE, TASK_QUEUE}; use handlers::{CONFIG, TASK_QUEUE};
/// Docker `stop` / `compose down` delivers SIGTERM, which teloxide's ctrlc /// Docker `stop` / `compose down` delivers SIGTERM, which teloxide's ctrlc
/// handler (SIGINT only) never sees — without this the process would die /// handler (SIGINT only) never sees — without this the process would die
@@ -40,12 +41,85 @@ fn spawn_sigterm_handler(stop_token: StopToken) {
#[cfg(not(unix))] #[cfg(not(unix))]
fn spawn_sigterm_handler(_stop_token: StopToken) {} fn spawn_sigterm_handler(_stop_token: StopToken) {}
/// A leftover temp file must be at least this old before the startup sweep
/// touches it. Orphans come from a *previous* run; anything younger could
/// belong to a second instance sharing the temp directory (a misconfiguration,
/// but one that must not cost it its in-flight download).
const ORPHAN_TEMP_AGE: Duration = Duration::from_secs(3600);
/// Removes this project's own leftover temp entries (`x_media::TEMP_FILE_PREFIX`)
/// from `dir` once they are older than `older_than`. Returns how many were
/// removed. Entries that are not ours, or are too young, or cannot be dated,
/// are left alone: the OS temp directory is shared, and the marker prefix plus
/// the age gate are the only two things that make deleting here safe.
fn sweep_temp_dir(dir: &std::path::Path, older_than: Duration) -> usize {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let cutoff = std::time::SystemTime::now() - older_than;
let mut removed = 0;
for entry in entries.flatten() {
let name = entry.file_name();
if !name
.to_string_lossy()
.starts_with(x_media::TEMP_FILE_PREFIX)
{
continue;
}
let old_enough = entry
.metadata()
.and_then(|meta| meta.modified())
.is_ok_and(|modified| modified < cutoff);
if !old_enough {
continue;
}
let path = entry.path();
let result = if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
std::fs::remove_dir_all(&path)
} else {
std::fs::remove_file(&path)
};
match result {
Ok(()) => removed += 1,
// Not worth a warning per entry: a file another process removed
// first (or one we may not delete) is not a problem here.
Err(e) => log::debug!("could not remove orphaned temp entry {path:?}: {e}"),
}
}
removed
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
dotenv().ok(); dotenv().ok();
pretty_env_logger::init(); // Without RUST_LOG nothing at all was logged (env_logger falls back to
// `error`), so a deployment that forgot the variable looked like a bot
// with no logs; and at `debug` the HTTP client's own lines (hyper_util,
// reqwest) outnumbered the bot's by two to one. The timed builder adds
// the timestamp the plain `init` omitted, so a line can be compared with
// a user's report. An explicit RUST_LOG still wins outright — but a blank
// one (`RUST_LOG=` in `.env`, which is not "unset") must not silence the
// log the way its absence used to.
let filter = std::env::var("RUST_LOG")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "info,hyper_util=warn,reqwest=warn".to_string());
pretty_env_logger::formatted_timed_builder()
.parse_filters(&filter)
.init();
log::info!("Starting bot"); log::info!("Starting bot");
// Temp media (downloaded files, ugoira/remux dirs) is cleaned up by
// `TempDir`/`NamedTempFile` on drop — which a killed process never runs.
// Without this sweep every hard restart left its downloads behind (up to
// hundreds of MB each) and nothing could tell them apart from a live
// process's files or from anything else in the OS temp dir. See
// [`sweep_temp_dir`] for why the age gate makes that safe.
let orphans = sweep_temp_dir(&std::env::temp_dir(), ORPHAN_TEMP_AGE);
if orphans > 0 {
log::info!("swept {orphans} orphaned temp file(s) from a previous run");
}
let bot = Bot::from_env(); let bot = Bot::from_env();
// Force the queue workers' shared Bot to initialize now so a missing // Force the queue workers' shared Bot to initialize now so a missing
// token fails at startup, not on the first queued task. // token fails at startup, not on the first queued task.
@@ -56,11 +130,43 @@ async fn main() {
log::warn!("failed to register commands: {e}"); log::warn!("failed to register commands: {e}");
} }
// The effective tunables, so an operator can see what the process actually
// resolved (a mistyped DATA_DIR or a forgotten TTL override is otherwise
// invisible until it bites). The proxy URL is never printed — it may embed
// credentials — and admin ids are chat identifiers, so they stay at debug.
let quote_chars = match CONFIG.caption_quote_text_chars {
0 => "off".to_string(),
n => format!("{n} chars"),
};
log::info!( log::info!(
"config: {} admin(s), edit-message TTL {}s", "config: {} admin(s), state {}, edit-message TTL {}s, link cache TTL {}s, caption quote {quote_chars}, proxy={}",
CONFIG.admin_ids.len(), CONFIG.admin_ids.len(),
CONFIG.edit_message_ttl.as_secs() crate::handlers::db_path().display(),
CONFIG.edit_message_ttl.as_secs(),
CONFIG.link_cache_ttl.as_secs(),
if std::env::var("TELOXIDE_PROXY").is_ok() {
"yes"
} else {
"no"
}
); );
log::debug!("config: admin ids {:?}", CONFIG.admin_ids);
// Startup repair, before any worker runs: a queued retry whose media was a
// local file (ugoira MP4, bsky remux, a downloaded temp file) can never
// succeed after a restart — the registry that kept those files alive is in
// memory — so those rows are re-fetched from their post instead of
// dead-lettering the user's link.
let repaired = match handlers::repair_lost_local_media(&CONTEXT).await {
Ok(repaired) => repaired,
Err(e) => {
log::error!("startup repair failed: {e}; refusing to start queue workers");
return;
}
};
if repaired > 0 {
log::info!("startup repair: re-fetched {repaired} queued task(s)");
}
// Queue worker: handles typed tasks, dead-letters failed sends to the // Queue worker: handles typed tasks, dead-letters failed sends to the
// task's chat. Both closures use the shared context (the queue requires // task's chat. Both closures use the shared context (the queue requires
@@ -93,45 +199,17 @@ async fn main() {
} }
} }
// Edit-expiry sweep: clears the prompt's buttons once the record expires. // Background sweep: expires the edit prompts and prunes what has aged out.
log::info!( log::info!(
"edit-expiry sweep: every 300s, ttl {}", "edit-expiry sweep: every {}s, ttl {}",
SWEEP_INTERVAL.as_secs(),
CONFIG.edit_message_ttl.as_secs() CONFIG.edit_message_ttl.as_secs()
); );
let (stop_tx, stop_rx) = watch::channel(false); let (stop_tx, stop_rx) = watch::channel(false);
{ {
let bot = bot.clone(); let bot = bot.clone();
let mut stop_rx = stop_rx;
tokio::spawn(async move { tokio::spawn(async move {
loop { periodic_sweep(crate::ctx::AppContext::from_statics(&bot), stop_rx).await;
tokio::select! {
_ = stop_rx.changed() => break,
_ = tokio::time::sleep(std::time::Duration::from_secs(300)) => {}
}
let ttl = CONFIG.edit_message_ttl;
let removed = CHAT_STORE.prune_expired(ttl).await;
let pruned = LINK_CACHE.prune(CONFIG.link_cache_ttl).await;
if pruned > 0 {
log::info!("link cache: pruned {pruned} expired entr(ies)");
}
let idle_limiters = crate::rate_limit::prune_idle();
if idle_limiters > 0 {
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.
if let Err(e) = bot
.edit_message_reply_markup(
ChatId(chat_id),
MessageId(prompt_message_id as i32),
)
.await
{
log::info!("edit-expiry sweep: prompt message gone: {e}");
}
}
}
}); });
} }
@@ -141,7 +219,6 @@ async fn main() {
.branch(Update::filter_callback_query().branch(endpoint(handlers::callback_query_handler))); .branch(Update::filter_callback_query().branch(endpoint(handlers::callback_query_handler)));
let mut dispatcher = Dispatcher::builder(bot.clone(), handler) let mut dispatcher = Dispatcher::builder(bot.clone(), handler)
.dependencies(dptree::deps![""])
.enable_ctrlc_handler() .enable_ctrlc_handler()
.build(); .build();
@@ -152,13 +229,16 @@ async fn main() {
// secret token included) — no explicit registration here. // secret token included) — no explicit registration here.
let listen = CONFIG.webhook_listen.expect("WEBHOOK_LISTEN is not set"); let listen = CONFIG.webhook_listen.expect("WEBHOOK_LISTEN is not set");
let port = CONFIG.webhook_port.expect("WEBHOOK_PORT is not set"); let port = CONFIG.webhook_port.expect("WEBHOOK_PORT is not set");
let mut options = webhooks::Options::new((listen, port).into(), url); // No secret, no webhook: without one the axum listener accepts any
// POST, and a forged update can impersonate anyone — admins included.
let secret = CONFIG
.webhook_secret_token
.clone()
.expect("WEBHOOK_SECRET_TOKEN is not set (required in webhook mode)");
let mut options = webhooks::Options::new((listen, port).into(), url).secret_token(secret);
if let Some(cert) = &CONFIG.webhook_cert { if let Some(cert) = &CONFIG.webhook_cert {
options = options.certificate(InputFile::file(cert)); options = options.certificate(InputFile::file(cert));
} }
if let Some(secret) = &CONFIG.webhook_secret_token {
options = options.secret_token(secret.clone());
}
let mut listener = webhooks::axum(bot.clone(), options) let mut listener = webhooks::axum(bot.clone(), options)
.await .await
@@ -189,18 +269,19 @@ async fn main() {
.await; .await;
} }
// Graceful stop (Ctrl+C / SIGTERM): stop the sweep, notify the admin, // Graceful stop (Ctrl+C / SIGTERM): stop the queue first so no new
// drain the queue. Bounded: a worker mid-download (30 s timeout) or a // persistent task is leased while the URL workers drain. The two drains
// long ugoira encode must not hold the shutdown hostage forever. // share the bounded shutdown budget; URL work may legitimately outlive it,
// but the queue must not be left running until process exit.
log::info!("Stopping bot"); log::info!("Stopping bot");
const SHUTDOWN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); const SHUTDOWN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
let shutdown = async { let shutdown = async {
let _ = stop_tx.send(true); let _ = stop_tx.send(true);
TASK_QUEUE.stop().await;
handlers::stop_url_workers().await; handlers::stop_url_workers().await;
if let Some(admin) = CONFIG.admin_ids.first() { if let Some(admin) = CONFIG.admin_ids.first() {
let _ = bot.send_message(ChatId(*admin), "Shutting down...").await; let _ = bot.send_message(ChatId(*admin), "Shutting down...").await;
} }
TASK_QUEUE.stop().await;
}; };
if tokio::time::timeout(SHUTDOWN_TIMEOUT, shutdown) if tokio::time::timeout(SHUTDOWN_TIMEOUT, shutdown)
.await .await
@@ -211,3 +292,216 @@ async fn main() {
log::info!("Bot stopped"); log::info!("Bot stopped");
} }
} }
/// How often [`periodic_sweep`] runs.
const SWEEP_INTERVAL: Duration = Duration::from_secs(300);
/// The background sweep: rewrites the expired edit prompts in place, prunes the
/// link cache, the idle rate-limit buckets and the idle inline-query entries,
/// and reports the queue only when it is not empty.
///
/// Takes the shared [`crate::ctx::AppContext`] — the collaborators as one
/// bundle, production assembling it from the statics and tests from tempdir
/// stores — so a test can drive a tick with a paused clock: a sleeping task
/// nothing drives is how the queue's own sweep kept a missing worker wake-up.
async fn periodic_sweep(ctx: crate::ctx::AppContext<'_>, mut stop: watch::Receiver<bool>) {
loop {
tokio::select! {
_ = stop.changed() => break,
_ = tokio::time::sleep(SWEEP_INTERVAL) => {}
}
let removed = ctx
.chat_store
.prune_expired(ctx.config.edit_message_ttl)
.await;
let pruned = ctx.link_cache.prune(ctx.config.link_cache_ttl).await;
if pruned > 0 {
log::info!("link cache: pruned {pruned} expired entr(ies)");
}
let idle_limiters = crate::rate_limit::prune_idle();
if idle_limiters > 0 {
log::debug!("rate limiter: dropped {idle_limiters} idle bucket(s)");
}
// Entries past Telegram's own inline cache window: a repeat is sent to
// the bot again anyway, so keeping them would suppress a fetch the user
// is waiting for (and the map grew one entry per user, forever).
let idle_inline = handlers::prune_idle_states();
if idle_inline > 0 {
log::debug!("inline queries: dropped {idle_inline} idle entry(ies)");
}
// Only speaks up when the queue is not empty: a healthy bot has nothing
// to report, and a periodic "0 pending" line is noise that hides the
// lines that matter.
if let Some((pending, oldest_run_after)) = ctx.task_queue.pending_backlog().await {
let overdue = crate::db::now_f64() - oldest_run_after;
if overdue >= 0.0 {
log::info!("queue: {pending} pending task(s), oldest {overdue:.0}s overdue");
} else {
log::info!(
"queue: {pending} pending task(s), oldest retry in {:.0}s",
-overdue
);
}
}
for (chat_id, prompt_message_id) in removed {
// 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) = ctx
.sender
.edit_message_text(
ChatId(chat_id),
MessageId(prompt_message_id as i32),
send::EDIT_PROMPT_EXPIRED_TEXT.to_string(),
)
.await
{
log::info!("edit-expiry sweep: prompt message gone: {e}");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sweep_removes_only_our_old_temp_entries() {
let dir = tempfile::tempdir().unwrap();
let old = std::time::SystemTime::now() - Duration::from_secs(7200);
let make = |name: &str, aged: bool| {
let path = dir.path().join(name);
std::fs::write(&path, b"x").unwrap();
if aged {
let file = std::fs::File::options().write(true).open(&path).unwrap();
file.set_modified(old).unwrap();
}
path
};
let ours_old = make(&format!("{}photo-old.jpg", x_media::TEMP_FILE_PREFIX), true);
let ours_fresh = make(
&format!("{}photo-new.jpg", x_media::TEMP_FILE_PREFIX),
false,
);
let theirs = make("someone-elses-file", true);
assert_eq!(sweep_temp_dir(dir.path(), Duration::from_secs(3600)), 1);
assert!(!ours_old.exists(), "an old leftover of ours is removed");
assert!(ours_fresh.exists(), "a fresh file may belong to a live run");
assert!(
theirs.exists(),
"files without our prefix are never touched"
);
// A caller with no age gate also reaches the directory branch (aging a
// *directory* is not portable, so the gate is what the first half
// above proves): the fresh dir and file go, the unrelated file stays.
let leftover_dir = dir
.path()
.join(format!("{}ugoira", x_media::TEMP_FILE_PREFIX));
std::fs::create_dir(&leftover_dir).unwrap();
std::fs::write(leftover_dir.join("frame.png"), b"x").unwrap();
assert_eq!(sweep_temp_dir(dir.path(), Duration::ZERO), 2);
assert!(
!leftover_dir.exists(),
"leftover dirs go with their contents"
);
assert!(!ours_fresh.exists(), "no age gate: ours, however fresh");
assert!(theirs.exists());
}
/// The sweep's tick: an expired prompt is rewritten in place (buttons
/// dropped) while a live one is left alone. Driven through the loop's own
/// timer on a paused clock — the loop is what a hand-called helper would
/// leave untested, which is how the queue's sweep kept a missing wake-up.
#[tokio::test(start_paused = true)]
async fn the_sweep_expires_only_the_prompts_past_their_ttl() {
use crate::ctx::test_support::{
FORWARDED_ID, PROMPT_ID, TestStores, api_error, seed_prompt,
};
use crate::media_sender::test_support::MockSender;
use crate::state::EditMessage;
// The interval is pinned here because no assertion on the edits can see
// it: a shorter interval produces the same single edit (the record is
// gone after the first tick), and the paused clock can jump past the
// boundary while a tick's DB work is in flight.
assert_eq!(SWEEP_INTERVAL, Duration::from_secs(300));
let config = crate::config::Config::load();
let stores = TestStores::new();
let sender = MockSender::scripted(vec![], || {
api_error("Bad Request: message to edit not found")
});
let ctx = stores.ctx(&sender);
// Chat 1 holds a prompt past its ttl; chat 2 a live one.
let stale = crate::db::unix_now() - config.edit_message_ttl.as_secs() as i64 - 1;
seed_prompt(&ctx, "", stale).await;
stores
.chat_store()
.update(2, |data| {
data.edit_message.insert(
PROMPT_ID,
EditMessage {
url: "https://x.com/u/status/1".into(),
chat_id: 2,
forward_message_ids: vec![FORWARDED_ID],
template: String::new(),
created_at: crate::db::unix_now(),
},
);
})
.await
.unwrap();
let (stop_tx, stop_rx) = watch::channel(false);
let sweep = periodic_sweep(stores.ctx(&sender), stop_rx);
tokio::pin!(sweep);
// One second short of the interval: nothing has been touched. The
// select is what polls the loop (a pinned future nobody awaits never
// runs), and the paused clock makes this the loop's own timer.
tokio::select! {
_ = &mut sweep => unreachable!("the sweep only returns on stop"),
_ = tokio::time::sleep(SWEEP_INTERVAL - Duration::from_secs(1)) => {}
}
assert!(
sender.edited_texts().is_empty(),
"the sweep ran before its interval"
);
// The second that crosses the interval: the tick fires.
tokio::select! {
_ = &mut sweep => unreachable!("the sweep only returns on stop"),
_ = tokio::time::sleep(Duration::from_secs(2)) => {}
}
assert_eq!(
sender.edited_texts(),
vec![(1, PROMPT_ID, send::EDIT_PROMPT_EXPIRED_TEXT.to_string())],
"exactly the expired prompt, rewritten in place"
);
assert!(
!ctx.chat_store
.get(1)
.await
.edit_message
.contains_key(&PROMPT_ID),
"the expired record is gone"
);
assert!(
ctx.chat_store
.get(2)
.await
.edit_message
.contains_key(&PROMPT_ID),
"a live prompt keeps its record and its buttons"
);
stop_tx.send(true).unwrap();
sweep.await;
}
}
-453
View File
@@ -1,453 +0,0 @@
//! Send abstraction: the message-sending surface [`send`](crate::send)
//! needs, so the send pipeline can be tested with a scripted mock instead of
//! a live teloxide `Bot`.
use std::future::Future;
use std::pin::Pin;
use teloxide::RequestError;
use teloxide::prelude::Requester;
use teloxide::prelude::*;
use teloxide::types::{
CallbackQueryId, ChatAction, ChatId, InlineKeyboardMarkup, InputFile, InputMedia, Message,
MessageId, ParseMode, ReplyParameters,
};
/// Boxed, `Send` future returned by a [`MediaSender`] method (`async fn` in
/// traits is not dyn-compatible).
type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
/// The message-sending surface the send pipeline uses. The production
/// implementation is teloxide's [`Bot`]; tests inject a scripted mock to
/// cover the fallback and classification logic without touching the
/// Telegram API.
pub trait MediaSender: Send + Sync {
/// Sends a media group, replying to `reply_to`.
fn send_media_group(
&self,
chat_id: ChatId,
reply_to: MessageId,
items: Vec<InputMedia>,
) -> BoxFuture<'_, Result<Vec<Message>, RequestError>>;
/// Sends a lone animation, replying to `reply_to`.
fn send_animation<'a>(
&'a self,
chat_id: ChatId,
reply_to: MessageId,
caption: &'a str,
spoiler: bool,
file: InputFile,
) -> BoxFuture<'a, Result<Message, RequestError>>;
/// Copies messages between chats (forward to channel).
fn copy_messages(
&self,
to: ChatId,
from: ChatId,
ids: Vec<MessageId>,
) -> BoxFuture<'_, Result<Vec<MessageId>, RequestError>>;
/// Sends a plain text message, optionally replying to `reply_to` and
/// attaching `reply_markup`. Returns the sent message's id: the bot only
/// ever needs that (the edit-before-forward prompt's record is keyed by
/// it), and returning the whole `Message` would force every test mock to
/// construct one.
fn send_message(
&self,
chat_id: ChatId,
text: String,
reply_to: Option<MessageId>,
reply_markup: Option<InlineKeyboardMarkup>,
) -> BoxFuture<'_, Result<i64, RequestError>>;
/// Answers a callback query, optionally with a toast `text` shown to the
/// user who pressed the button.
fn answer_callback_query(
&self,
id: CallbackQueryId,
text: Option<String>,
) -> BoxFuture<'_, Result<(), RequestError>>;
/// Rewrites a message's caption, always with HTML parse mode (every caller
/// in this bot renders escaped HTML: templates and edit-before-forward
/// links).
fn edit_message_caption(
&self,
chat_id: ChatId,
message_id: MessageId,
caption: String,
) -> BoxFuture<'_, Result<(), RequestError>>;
/// Deletes a message (the edit-before-forward prompt after a forward).
fn delete_message(
&self,
chat_id: ChatId,
message_id: MessageId,
) -> BoxFuture<'_, Result<(), RequestError>>;
/// Sets the chat's "typing / uploading …" indicator (cosmetic).
fn send_chat_action(
&self,
chat_id: ChatId,
action: ChatAction,
) -> BoxFuture<'_, Result<(), RequestError>>;
}
impl MediaSender for Bot {
fn send_media_group(
&self,
chat_id: ChatId,
reply_to: MessageId,
items: Vec<InputMedia>,
) -> BoxFuture<'_, Result<Vec<Message>, RequestError>> {
Box::pin(async move {
// Pace media sends per chat (one token per item) so bursts do not
// trip Telegram's flood control.
crate::rate_limit::limiter_for(chat_id.0)
.acquire(items.len() as f64)
.await;
// `<Bot as Requester>::` disambiguates from this trait's same-named
// method (teloxide's API lives in the `Requester` trait).
<Bot as Requester>::send_media_group(self, chat_id, items)
.reply_parameters(ReplyParameters::new(reply_to).allow_sending_without_reply())
.await
})
}
fn send_animation<'a>(
&'a self,
chat_id: ChatId,
reply_to: MessageId,
caption: &'a str,
spoiler: bool,
file: InputFile,
) -> BoxFuture<'a, Result<Message, RequestError>> {
Box::pin(async move {
crate::rate_limit::limiter_for(chat_id.0).acquire(1.0).await;
let mut request = <Bot as Requester>::send_animation(self, chat_id, file)
.caption(caption)
.parse_mode(ParseMode::Html)
.reply_parameters(ReplyParameters::new(reply_to).allow_sending_without_reply());
if spoiler {
request = request.has_spoiler(true);
}
request.await
})
}
fn copy_messages(
&self,
to: ChatId,
from: ChatId,
ids: Vec<MessageId>,
) -> BoxFuture<'_, Result<Vec<MessageId>, RequestError>> {
Box::pin(async move {
// Channel forwards are the burstiest path (batch copies); pace
// them per message against the channel's budget.
crate::rate_limit::limiter_for(to.0)
.acquire(ids.len() as f64)
.await;
<Bot as Requester>::copy_messages(self, to, from, ids).await
})
}
fn send_message(
&self,
chat_id: ChatId,
text: String,
reply_to: Option<MessageId>,
reply_markup: Option<InlineKeyboardMarkup>,
) -> BoxFuture<'_, Result<i64, RequestError>> {
Box::pin(async move {
let mut request = <Bot as Requester>::send_message(self, chat_id, text);
if let Some(reply_to) = reply_to {
request = request
.reply_parameters(ReplyParameters::new(reply_to).allow_sending_without_reply());
}
if let Some(markup) = reply_markup {
request = request.reply_markup(markup);
}
request.await.map(|message| message.id.0 as i64)
})
}
fn answer_callback_query(
&self,
id: CallbackQueryId,
text: Option<String>,
) -> BoxFuture<'_, Result<(), RequestError>> {
Box::pin(async move {
let mut request = <Bot as Requester>::answer_callback_query(self, id);
if let Some(text) = text {
request = request.text(text);
}
request.await.map(|_| ())
})
}
fn edit_message_caption(
&self,
chat_id: ChatId,
message_id: MessageId,
caption: String,
) -> BoxFuture<'_, Result<(), RequestError>> {
Box::pin(async move {
<Bot as Requester>::edit_message_caption(self, chat_id, message_id)
.caption(caption)
.parse_mode(ParseMode::Html)
.await
.map(|_| ())
})
}
fn delete_message(
&self,
chat_id: ChatId,
message_id: MessageId,
) -> BoxFuture<'_, Result<(), RequestError>> {
Box::pin(async move {
<Bot as Requester>::delete_message(self, chat_id, message_id)
.await
.map(|_| ())
})
}
fn send_chat_action(
&self,
chat_id: ChatId,
action: ChatAction,
) -> BoxFuture<'_, Result<(), RequestError>> {
Box::pin(async move {
// teloxide's `send_chat_action` returns `Result<True, _>` (its
// unit marker type); map the success to `()`.
<Bot as Requester>::send_chat_action(self, chat_id, action)
.await
.map(|_| ())
})
}
}
/// Test support: a scripted [`MediaSender`] mock (no Telegram API involved).
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
use parking_lot::Mutex;
/// One scripted outcome, consumed front-to-back; the last entry repeats
/// for further calls of the same method kind.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Outcome {
GroupOk,
GroupErr,
AnimationErr,
CopyOk,
CopyErr,
/// An error from `send_message` (replies are fire-and-forget, so an
/// error is fine for tests).
MessageErr,
/// A successful `send_message`, returning message id [`MockSender::SENT_ID`].
MessageOk,
EditOk,
EditErr,
}
/// Replays a script and records what was sent, so tests can assert the
/// user-visible text a path produced.
pub(crate) struct MockSender {
script: Mutex<Vec<Outcome>>,
cursor: Mutex<usize>,
calls: Mutex<Vec<&'static str>>,
messages: Mutex<Vec<String>>,
captions: Mutex<Vec<String>>,
answers: Mutex<Vec<Option<String>>>,
/// Builds the error every `*Err` outcome returns (RequestError is not
/// cloneable, so the factory recreates it per call).
error: Box<dyn Fn() -> RequestError + Send + Sync>,
}
impl MockSender {
/// The message id a successful `send_message` reports.
pub(crate) const SENT_ID: i64 = 1;
pub(crate) fn scripted(
script: Vec<Outcome>,
error: impl Fn() -> RequestError + Send + Sync + 'static,
) -> Self {
MockSender {
script: Mutex::new(script),
cursor: Mutex::new(0),
calls: Mutex::new(Vec::new()),
messages: Mutex::new(Vec::new()),
captions: Mutex::new(Vec::new()),
answers: Mutex::new(Vec::new()),
error: Box::new(error),
}
}
/// Method names in call order (e.g. `["send_media_group",
/// "send_media_group"]` proves the fallback re-sent).
pub(crate) fn calls(&self) -> Vec<&'static str> {
self.calls.lock().clone()
}
/// Texts of the plain messages sent, in order.
pub(crate) fn messages(&self) -> Vec<String> {
self.messages.lock().clone()
}
/// Captions passed to `edit_message_caption`, in order.
pub(crate) fn captions(&self) -> Vec<String> {
self.captions.lock().clone()
}
/// Toast texts of the answered callback queries, in order.
pub(crate) fn answers(&self) -> Vec<Option<String>> {
self.answers.lock().clone()
}
fn next(&self, kind: &'static str) -> Outcome {
self.calls.lock().push(kind);
let script = self.script.lock();
let mut cursor = self.cursor.lock();
if script.is_empty() {
panic!("mock script exhausted: {kind}");
}
let idx = (*cursor).min(script.len() - 1);
*cursor = idx + 1;
script[idx]
}
fn error(&self) -> RequestError {
(self.error)()
}
}
impl MediaSender for MockSender {
fn send_media_group(
&self,
_chat_id: ChatId,
_reply_to: MessageId,
items: Vec<InputMedia>,
) -> BoxFuture<'_, Result<Vec<Message>, RequestError>> {
Box::pin(async move {
// Record the captions exactly as Telegram receives them (only
// the first item of a group carries one), so tests can assert
// what a recipient sees.
self.captions
.lock()
.extend(items.iter().filter_map(|item| match item {
InputMedia::Photo(photo) => photo.caption.clone(),
InputMedia::Video(video) => video.caption.clone(),
InputMedia::Animation(animation) => animation.caption.clone(),
_ => None,
}));
match self.next("send_media_group") {
Outcome::GroupOk => Ok(Vec::new()),
Outcome::GroupErr => Err(self.error()),
other => panic!("unexpected outcome {other:?} for send_media_group"),
}
})
}
fn send_animation<'a>(
&'a self,
_chat_id: ChatId,
_reply_to: MessageId,
_caption: &'a str,
_spoiler: bool,
_file: InputFile,
) -> BoxFuture<'a, Result<Message, RequestError>> {
Box::pin(async move {
match self.next("send_animation") {
Outcome::AnimationErr => Err(self.error()),
other => panic!("unexpected outcome {other:?} for send_animation"),
}
})
}
fn copy_messages(
&self,
_to: ChatId,
_from: ChatId,
_ids: Vec<MessageId>,
) -> BoxFuture<'_, Result<Vec<MessageId>, RequestError>> {
Box::pin(async move {
match self.next("copy_messages") {
Outcome::CopyOk => Ok(vec![MessageId(1)]),
Outcome::CopyErr => Err(self.error()),
other => panic!("unexpected outcome {other:?} for copy_messages"),
}
})
}
fn send_message(
&self,
_chat_id: ChatId,
text: String,
_reply_to: Option<MessageId>,
_reply_markup: Option<InlineKeyboardMarkup>,
) -> BoxFuture<'_, Result<i64, RequestError>> {
Box::pin(async move {
self.messages.lock().push(text);
match self.next("send_message") {
Outcome::MessageOk => Ok(MockSender::SENT_ID),
Outcome::MessageErr => Err(self.error()),
other => panic!("unexpected outcome {other:?} for send_message"),
}
})
}
fn answer_callback_query(
&self,
_id: CallbackQueryId,
text: Option<String>,
) -> BoxFuture<'_, Result<(), RequestError>> {
// Always succeeds: the toast is cosmetic, so the script stays
// focused on the outcomes a test cares about.
Box::pin(async move {
self.calls.lock().push("answer_callback_query");
self.answers.lock().push(text);
Ok(())
})
}
fn edit_message_caption(
&self,
_chat_id: ChatId,
_message_id: MessageId,
caption: String,
) -> BoxFuture<'_, Result<(), RequestError>> {
Box::pin(async move {
self.captions.lock().push(caption);
match self.next("edit_message_caption") {
Outcome::EditOk => Ok(()),
Outcome::EditErr => Err(self.error()),
other => panic!("unexpected outcome {other:?} for edit_message_caption"),
}
})
}
fn delete_message(
&self,
_chat_id: ChatId,
_message_id: MessageId,
) -> BoxFuture<'_, Result<(), RequestError>> {
// Deletion is fire-and-forget in every caller; always succeeds.
Box::pin(async move {
self.calls.lock().push("delete_message");
Ok(())
})
}
fn send_chat_action(
&self,
_chat_id: ChatId,
_action: ChatAction,
) -> BoxFuture<'_, Result<(), RequestError>> {
Box::pin(async move {
self.calls.lock().push("send_chat_action");
Ok(())
})
}
}
}
+323
View File
@@ -0,0 +1,323 @@
//! Send abstraction: the message-sending surface [`send`](crate::send)
//! needs, so the send pipeline can be tested with a scripted mock instead of
//! a live teloxide `Bot`.
use std::future::Future;
use std::pin::Pin;
use teloxide::RequestError;
use teloxide::prelude::Requester;
use teloxide::prelude::*;
use teloxide::types::{
CallbackQueryId, ChatAction, ChatId, InlineKeyboardMarkup, InlineQueryId, InlineQueryResult,
InputFile, InputMedia, Message, MessageId, ParseMode, ReplyParameters,
};
/// Boxed, `Send` future returned by a [`MediaSender`] method (`async fn` in
/// traits is not dyn-compatible).
type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
/// The message-sending surface the send pipeline uses. The production
/// implementation is teloxide's [`Bot`]; tests inject a scripted mock to
/// cover the fallback and classification logic without touching the
/// Telegram API.
pub trait MediaSender: Send + Sync {
/// Sends a media group, replying to `reply_to`.
fn send_media_group(
&self,
chat_id: ChatId,
reply_to: MessageId,
items: Vec<InputMedia>,
) -> BoxFuture<'_, Result<Vec<Message>, RequestError>>;
/// Sends a lone animation, replying to `reply_to`.
fn send_animation<'a>(
&'a self,
chat_id: ChatId,
reply_to: MessageId,
caption: &'a str,
spoiler: bool,
file: InputFile,
) -> BoxFuture<'a, Result<Message, RequestError>>;
/// Copies messages between chats (forward to channel).
fn copy_messages(
&self,
to: ChatId,
from: ChatId,
ids: Vec<MessageId>,
) -> BoxFuture<'_, Result<Vec<MessageId>, RequestError>>;
/// Sends a plain text message, optionally replying to `reply_to` and
/// attaching `reply_markup`. Returns the sent message's id: the bot only
/// ever needs that (the edit-before-forward prompt's record is keyed by
/// it), and returning the whole `Message` would force every test mock to
/// construct one.
fn send_message(
&self,
chat_id: ChatId,
text: String,
reply_to: Option<MessageId>,
reply_markup: Option<InlineKeyboardMarkup>,
) -> BoxFuture<'_, Result<i64, RequestError>>;
/// Sends an HTML-formatted plain message.
fn send_html_message(
&self,
chat_id: ChatId,
text: String,
reply_to: Option<MessageId>,
) -> BoxFuture<'_, Result<i64, RequestError>>;
/// Answers an inline query with `results`, cached by Telegram for
/// `cache_time` seconds. An empty `results` answers *empty*, which is a
/// real answer: it stops the client spinning and lets Telegram serve a
/// repeat itself instead of the bot re-running the query.
fn answer_inline_query(
&self,
id: InlineQueryId,
results: Vec<InlineQueryResult>,
cache_time: u32,
) -> BoxFuture<'_, Result<(), RequestError>>;
/// Answers a callback query, optionally with a toast `text` shown to the
/// user who pressed the button.
fn answer_callback_query(
&self,
id: CallbackQueryId,
text: Option<String>,
) -> BoxFuture<'_, Result<(), RequestError>>;
/// Rewrites a message's text and drops its inline keyboard: the
/// edit-expiry sweep rewriting a prompt whose record expired (a button left
/// behind could only answer "Expired").
fn edit_message_text(
&self,
chat_id: ChatId,
message_id: MessageId,
text: String,
) -> BoxFuture<'_, Result<(), RequestError>>;
/// Rewrites a message's caption, always with HTML parse mode (every caller
/// in this bot renders escaped HTML: templates and edit-before-forward
/// links).
fn edit_message_caption(
&self,
chat_id: ChatId,
message_id: MessageId,
caption: String,
) -> BoxFuture<'_, Result<(), RequestError>>;
/// Deletes a message (the edit-before-forward prompt after a forward).
fn delete_message(
&self,
chat_id: ChatId,
message_id: MessageId,
) -> BoxFuture<'_, Result<(), RequestError>>;
/// Sets the chat's "typing / uploading …" indicator (cosmetic).
fn send_chat_action(
&self,
chat_id: ChatId,
action: ChatAction,
) -> BoxFuture<'_, Result<(), RequestError>>;
}
impl MediaSender for Bot {
fn send_media_group(
&self,
chat_id: ChatId,
reply_to: MessageId,
items: Vec<InputMedia>,
) -> BoxFuture<'_, Result<Vec<Message>, RequestError>> {
Box::pin(async move {
// Pace media sends per chat (one token per item) so bursts do not
// trip Telegram's flood control.
crate::rate_limit::limiter_for(chat_id.0)
.acquire(items.len() as f64)
.await;
// Same spend against the bot-wide budget: a fan-out over chats is
// invisible to the per-chat buckets.
crate::rate_limit::acquire_global(items.len() as f64).await;
// `<Bot as Requester>::` disambiguates from this trait's same-named
// method (teloxide's API lives in the `Requester` trait).
<Bot as Requester>::send_media_group(self, chat_id, items)
.reply_parameters(ReplyParameters::new(reply_to).allow_sending_without_reply())
.await
})
}
fn send_animation<'a>(
&'a self,
chat_id: ChatId,
reply_to: MessageId,
caption: &'a str,
spoiler: bool,
file: InputFile,
) -> BoxFuture<'a, Result<Message, RequestError>> {
Box::pin(async move {
crate::rate_limit::limiter_for(chat_id.0).acquire(1.0).await;
crate::rate_limit::acquire_global(1.0).await;
let mut request = <Bot as Requester>::send_animation(self, chat_id, file)
.caption(caption)
.parse_mode(ParseMode::Html)
.reply_parameters(ReplyParameters::new(reply_to).allow_sending_without_reply());
if spoiler {
request = request.has_spoiler(true);
}
request.await
})
}
fn copy_messages(
&self,
to: ChatId,
from: ChatId,
ids: Vec<MessageId>,
) -> BoxFuture<'_, Result<Vec<MessageId>, RequestError>> {
Box::pin(async move {
// Channel forwards are the burstiest path (batch copies); pace
// them per message against the channel's budget.
crate::rate_limit::limiter_for(to.0)
.acquire(ids.len() as f64)
.await;
crate::rate_limit::acquire_global(ids.len() as f64).await;
<Bot as Requester>::copy_messages(self, to, from, ids).await
})
}
fn send_message(
&self,
chat_id: ChatId,
text: String,
reply_to: Option<MessageId>,
reply_markup: Option<InlineKeyboardMarkup>,
) -> BoxFuture<'_, Result<i64, RequestError>> {
Box::pin(async move {
crate::rate_limit::limiter_for(chat_id.0).acquire(1.0).await;
crate::rate_limit::acquire_global(1.0).await;
let mut request = <Bot as Requester>::send_message(self, chat_id, text);
if let Some(reply_to) = reply_to {
request = request
.reply_parameters(ReplyParameters::new(reply_to).allow_sending_without_reply());
}
if let Some(markup) = reply_markup {
request = request.reply_markup(markup);
}
request.await.map(|message| message.id.0 as i64)
})
}
fn send_html_message(
&self,
chat_id: ChatId,
text: String,
reply_to: Option<MessageId>,
) -> BoxFuture<'_, Result<i64, RequestError>> {
Box::pin(async move {
crate::rate_limit::limiter_for(chat_id.0).acquire(1.0).await;
crate::rate_limit::acquire_global(1.0).await;
let mut request =
<Bot as Requester>::send_message(self, chat_id, text).parse_mode(ParseMode::Html);
if let Some(reply_to) = reply_to {
request = request
.reply_parameters(ReplyParameters::new(reply_to).allow_sending_without_reply());
}
request.await.map(|message| message.id.0 as i64)
})
}
fn answer_inline_query(
&self,
id: InlineQueryId,
results: Vec<InlineQueryResult>,
cache_time: u32,
) -> BoxFuture<'_, Result<(), RequestError>> {
Box::pin(async move {
<Bot as Requester>::answer_inline_query(self, id, results)
.cache_time(cache_time)
.await
.map(|_| ())
})
}
fn answer_callback_query(
&self,
id: CallbackQueryId,
text: Option<String>,
) -> BoxFuture<'_, Result<(), RequestError>> {
Box::pin(async move {
let mut request = <Bot as Requester>::answer_callback_query(self, id);
if let Some(text) = text {
request = request.text(text);
}
request.await.map(|_| ())
})
}
fn edit_message_text(
&self,
chat_id: ChatId,
message_id: MessageId,
text: String,
) -> BoxFuture<'_, Result<(), RequestError>> {
Box::pin(async move {
crate::rate_limit::limiter_for(chat_id.0).acquire(1.0).await;
crate::rate_limit::acquire_global(1.0).await;
<Bot as Requester>::edit_message_text(self, chat_id, message_id, text)
.reply_markup(InlineKeyboardMarkup::default())
.await
.map(|_| ())
})
}
fn edit_message_caption(
&self,
chat_id: ChatId,
message_id: MessageId,
caption: String,
) -> BoxFuture<'_, Result<(), RequestError>> {
Box::pin(async move {
crate::rate_limit::limiter_for(chat_id.0).acquire(1.0).await;
crate::rate_limit::acquire_global(1.0).await;
<Bot as Requester>::edit_message_caption(self, chat_id, message_id)
.caption(caption)
.parse_mode(ParseMode::Html)
.await
.map(|_| ())
})
}
fn delete_message(
&self,
chat_id: ChatId,
message_id: MessageId,
) -> BoxFuture<'_, Result<(), RequestError>> {
Box::pin(async move {
crate::rate_limit::limiter_for(chat_id.0).acquire(1.0).await;
crate::rate_limit::acquire_global(1.0).await;
<Bot as Requester>::delete_message(self, chat_id, message_id)
.await
.map(|_| ())
})
}
fn send_chat_action(
&self,
chat_id: ChatId,
action: ChatAction,
) -> BoxFuture<'_, Result<(), RequestError>> {
Box::pin(async move {
// Same gap as send_message: actions count against the bot-wide
// budget too (see there); the refresh loop behind
// `run_with_chat_action` makes them frequent enough to matter.
crate::rate_limit::acquire_global(1.0).await;
// teloxide's `send_chat_action` returns `Result<True, _>` (its
// unit marker type); map the success to `()`.
<Bot as Requester>::send_chat_action(self, chat_id, action)
.await
.map(|_| ())
})
}
}
#[cfg(test)]
pub(crate) mod test_support;
@@ -0,0 +1,537 @@
//! Test support: a scripted [`MediaSender`] mock (no Telegram API involved)
//! and the stand-in Telegram API the real-`Bot` tests talk to.
//!
//! [`MediaSender`]: super::MediaSender
use super::*;
use parking_lot::Mutex;
use teloxide::types::InlineQueryResult;
/// One scripted outcome, consumed front-to-back; the last entry repeats
/// for further calls of the same method kind.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Outcome {
GroupOk,
GroupErr,
AnimationOk,
AnimationErr,
CopyOk,
CopyErr,
/// An error from `send_message` (replies are fire-and-forget, so an
/// error is fine for tests).
MessageErr,
/// A successful `send_message`, returning message id [`MockSender::SENT_ID`].
MessageOk,
EditOk,
EditErr,
}
/// A stand-in for `api.telegram.org` for the tests that must drive a real
/// `Bot` — its request building, the per-chat limiter, the bot-wide budget
/// — which the scripted mock bypasses entirely. Records every call and
/// answers the smallest result each method needs.
pub(crate) mod fake_api {
use parking_lot::Mutex;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
pub(crate) struct FakeApi {
url: url::Url,
calls: Arc<Mutex<Vec<(String, serde_json::Value)>>>,
server: tokio::task::JoinHandle<()>,
}
impl FakeApi {
/// Binds an ephemeral port and serves until dropped.
pub(crate) async fn start() -> FakeApi {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let calls = Arc::new(Mutex::new(Vec::new()));
let recorded = Arc::clone(&calls);
let server = tokio::spawn(async move {
while let Ok((mut socket, _)) = listener.accept().await {
let recorded = Arc::clone(&recorded);
tokio::spawn(async move {
let Some((method, body)) = read_request(&mut socket).await else {
return;
};
recorded.lock().push((method.clone(), body));
let payload = serde_json::json!({
"ok": true,
"result": canned_result(&method),
})
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
content-length: {}\r\nconnection: close\r\n\r\n{}",
payload.len(),
payload
);
let _ = socket.write_all(response.as_bytes()).await;
let _ = socket.flush().await;
});
}
});
FakeApi {
// Trailing slash: teloxide appends `bot<token>/<method>`.
url: url::Url::parse(&format!("http://{addr}/")).unwrap(),
calls,
server,
}
}
/// Where to point a `Bot`: `Bot::new(token).set_api_url(api.url())`.
pub(crate) fn url(&self) -> url::Url {
self.url.clone()
}
/// Method names in call order.
pub(crate) fn methods(&self) -> Vec<String> {
self.calls.lock().iter().map(|(m, _)| m.clone()).collect()
}
/// The JSON body of the first call to `method` (`Null` for a body
/// that is not JSON, i.e. a multipart upload).
pub(crate) fn body(&self, method: &str) -> serde_json::Value {
self.calls
.lock()
.iter()
.find(|(m, _)| m == method)
.map(|(_, body)| body.clone())
.unwrap_or(serde_json::Value::Null)
}
}
impl Drop for FakeApi {
fn drop(&mut self) {
self.server.abort();
}
}
/// The smallest result teloxide can deserialize for a method. The names
/// arrive as the payload type's own — `SendMediaGroup`, not
/// `sendMediaGroup`: teloxide builds the URL from that, and the Bot API
/// accepts the spelling.
fn canned_result(method: &str) -> serde_json::Value {
match method {
"CopyMessages" => serde_json::json!([{ "message_id": 11 }]),
"SendMediaGroup" => serde_json::json!([minimal_message()]),
"SendMessage" | "SendAnimation" | "EditMessageCaption" => minimal_message(),
_ => serde_json::Value::Bool(true),
}
}
fn minimal_message() -> serde_json::Value {
serde_json::json!({
"message_id": 1,
"date": 0,
"chat": { "id": 1, "type": "private" },
})
}
/// One HTTP/1.1 request: the head up to the blank line, then
/// `content-length` bytes of body — JSON for most methods, multipart
/// for the media ones (teloxide sends `SendMediaGroup` that way).
async fn read_request(socket: &mut TcpStream) -> Option<(String, serde_json::Value)> {
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
loop {
let n = socket.read(&mut chunk).await.ok()?;
if n == 0 {
return None;
}
buf.extend_from_slice(&chunk[..n]);
let Some(headers_end) = find(&buf, b"\r\n\r\n") else {
continue;
};
let head = String::from_utf8_lossy(&buf[..headers_end]).to_string();
let length: usize = head
.lines()
.find_map(|line| {
line.to_ascii_lowercase()
.strip_prefix("content-length:")
.and_then(|v| v.trim().parse().ok())
})
.unwrap_or(0);
let body_start = headers_end + 4;
while buf.len() < body_start + length {
let n = socket.read(&mut chunk).await.ok()?;
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
}
let method = head
.lines()
.next()
// `POST /bot<token>/<method>`
.and_then(|line| line.split(' ').nth(1))
.and_then(|path| path.rsplit('/').next())
.unwrap_or_default()
.to_string();
let body = parse_body(&buf[body_start..], &head);
return Some((method, body));
}
}
/// The request body as JSON: either the JSON body itself, or a
/// multipart form flattened into an object (each part's value parsed as
/// JSON when it is one, so `media` comes back as its array).
fn parse_body(body: &[u8], head: &str) -> serde_json::Value {
let content_type = head
.lines()
.find(|line| line.to_ascii_lowercase().starts_with("content-type:"))
.unwrap_or_default()
.to_ascii_lowercase();
let Some(boundary) = content_type
.split("boundary=")
.nth(1)
.map(|b| b.trim().trim_matches('"').to_string())
else {
return serde_json::from_slice(body).unwrap_or_default();
};
let text = String::from_utf8_lossy(body);
let mut fields = serde_json::Map::new();
for part in text.split(&format!("--{boundary}")).skip(1) {
let Some((part_head, value)) = part.split_once("\r\n\r\n") else {
continue;
};
let Some(name) = part_head
.split("name=\"")
.nth(1)
.and_then(|rest| rest.split('"').next())
else {
continue;
};
let value = value.trim_end_matches("\r\n");
fields.insert(
name.to_string(),
serde_json::from_str(value).unwrap_or_else(|_| value.into()),
);
}
serde_json::Value::Object(fields)
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
}
/// Replays a script and records what was sent, so tests can assert the
/// user-visible text a path produced.
pub(crate) struct MockSender {
script: Mutex<Vec<Outcome>>,
cursor: Mutex<usize>,
calls: Mutex<Vec<&'static str>>,
messages: Mutex<Vec<String>>,
captions: Mutex<Vec<String>>,
answers: Mutex<Vec<Option<String>>>,
/// `(chat, message, text)` of every text rewrite, in order.
edited_texts: Mutex<Vec<(i64, i64, String)>>,
/// What each `send_animation` handed Telegram: a URL or a file id as that
/// string, an upload as `attach://<id>`.
animation_files: Mutex<Vec<String>>,
/// What every `answer_inline_query` answered with, one entry per result:
/// `cached_photo:<file id>`, `photo:<url>`, and so on. An answer with no
/// results is recorded as an empty inner vec.
inline_answers: Mutex<Vec<Vec<String>>>,
/// Builds the error every `*Err` outcome returns (RequestError is not
/// cloneable, so the factory recreates it per call).
error: Box<dyn Fn() -> RequestError + Send + Sync>,
}
/// A one-string description of an inline result: the kind plus the file id it
/// is served from, or the URL it points Telegram at.
fn inline_result_tag(result: &InlineQueryResult) -> String {
match result {
InlineQueryResult::CachedPhoto(r) => format!("cached_photo:{}", r.photo_file_id.0),
InlineQueryResult::CachedVideo(r) => format!("cached_video:{}", r.video_file_id.0),
InlineQueryResult::CachedMpeg4Gif(r) => format!("cached_gif:{}", r.mpeg4_file_id.0),
InlineQueryResult::Photo(r) => format!("photo:{}", r.photo_url),
InlineQueryResult::Video(r) => format!("video:{}", r.video_url),
InlineQueryResult::Mpeg4Gif(r) => format!("gif:{}", r.mpeg4_url),
other => format!("{other:?}"),
}
}
/// The smallest `Message` the send paths accept, for the outcomes that must
/// report one (`send_animation` reads its id, and its media for the cache).
pub(crate) fn mock_message(id: i64) -> Message {
serde_json::from_value(serde_json::json!({
"message_id": id,
"date": 0,
"chat": { "id": 1, "type": "private" },
}))
.expect("a minimal message deserializes")
}
impl MockSender {
/// The message id a successful `send_message` reports.
pub(crate) const SENT_ID: i64 = 1;
pub(crate) fn scripted(
script: Vec<Outcome>,
error: impl Fn() -> RequestError + Send + Sync + 'static,
) -> Self {
MockSender {
script: Mutex::new(script),
cursor: Mutex::new(0),
calls: Mutex::new(Vec::new()),
messages: Mutex::new(Vec::new()),
captions: Mutex::new(Vec::new()),
answers: Mutex::new(Vec::new()),
edited_texts: Mutex::new(Vec::new()),
animation_files: Mutex::new(Vec::new()),
inline_answers: Mutex::new(Vec::new()),
error: Box::new(error),
}
}
/// Method names in call order (e.g. `["send_media_group",
/// "send_media_group"]` proves the fallback re-sent).
pub(crate) fn calls(&self) -> Vec<&'static str> {
self.calls.lock().clone()
}
/// Texts of the plain messages sent, in order.
pub(crate) fn messages(&self) -> Vec<String> {
self.messages.lock().clone()
}
/// Captions passed to `edit_message_caption`, in order.
pub(crate) fn captions(&self) -> Vec<String> {
self.captions.lock().clone()
}
/// Toast texts of the answered callback queries, in order.
pub(crate) fn answers(&self) -> Vec<Option<String>> {
self.answers.lock().clone()
}
/// `(chat, message, text)` of every `edit_message_text`, in order.
pub(crate) fn edited_texts(&self) -> Vec<(i64, i64, String)> {
self.edited_texts.lock().clone()
}
/// What every `send_animation` handed Telegram, in order.
pub(crate) fn animation_files(&self) -> Vec<String> {
self.animation_files.lock().clone()
}
/// What every `answer_inline_query` answered with, in call order.
pub(crate) fn inline_answers(&self) -> Vec<Vec<String>> {
self.inline_answers.lock().clone()
}
fn next(&self, kind: &'static str) -> Outcome {
self.calls.lock().push(kind);
let script = self.script.lock();
let mut cursor = self.cursor.lock();
if script.is_empty() {
panic!("mock script exhausted: {kind}");
}
let idx = (*cursor).min(script.len() - 1);
*cursor = idx + 1;
script[idx]
}
fn error(&self) -> RequestError {
(self.error)()
}
}
impl MediaSender for MockSender {
fn send_media_group(
&self,
_chat_id: ChatId,
_reply_to: MessageId,
items: Vec<InputMedia>,
) -> BoxFuture<'_, Result<Vec<Message>, RequestError>> {
Box::pin(async move {
// Record the captions exactly as Telegram receives them (only
// the first item of a group carries one), so tests can assert
// what a recipient sees.
self.captions
.lock()
.extend(items.iter().filter_map(|item| match item {
InputMedia::Photo(photo) => photo.caption.clone(),
InputMedia::Video(video) => video.caption.clone(),
InputMedia::Animation(animation) => animation.caption.clone(),
_ => None,
}));
match self.next("send_media_group") {
Outcome::GroupOk => Ok(Vec::new()),
Outcome::GroupErr => Err(self.error()),
other => panic!("unexpected outcome {other:?} for send_media_group"),
}
})
}
fn send_animation<'a>(
&'a self,
_chat_id: ChatId,
_reply_to: MessageId,
_caption: &'a str,
_spoiler: bool,
file: InputFile,
) -> BoxFuture<'a, Result<Message, RequestError>> {
// Record what Telegram was handed: a URL or a file id serializes as
// that string, an upload as `attach://<id>`. Enough to tell a cached
// send (which must not re-upload) from a fresh one.
self.animation_files.lock().push(
serde_json::to_value(&file)
.ok()
.and_then(|value| value.as_str().map(str::to_string))
.unwrap_or_default(),
);
Box::pin(async move {
match self.next("send_animation") {
Outcome::AnimationOk => Ok(mock_message(MockSender::SENT_ID)),
Outcome::AnimationErr => Err(self.error()),
other => panic!("unexpected outcome {other:?} for send_animation"),
}
})
}
fn copy_messages(
&self,
_to: ChatId,
_from: ChatId,
_ids: Vec<MessageId>,
) -> BoxFuture<'_, Result<Vec<MessageId>, RequestError>> {
Box::pin(async move {
match self.next("copy_messages") {
Outcome::CopyOk => Ok(vec![MessageId(1)]),
Outcome::CopyErr => Err(self.error()),
other => panic!("unexpected outcome {other:?} for copy_messages"),
}
})
}
fn send_message(
&self,
_chat_id: ChatId,
text: String,
_reply_to: Option<MessageId>,
_reply_markup: Option<InlineKeyboardMarkup>,
) -> BoxFuture<'_, Result<i64, RequestError>> {
Box::pin(async move {
self.messages.lock().push(text);
match self.next("send_message") {
Outcome::MessageOk => Ok(MockSender::SENT_ID),
Outcome::MessageErr => Err(self.error()),
other => panic!("unexpected outcome {other:?} for send_message"),
}
})
}
fn send_html_message(
&self,
_chat_id: ChatId,
text: String,
_reply_to: Option<MessageId>,
) -> BoxFuture<'_, Result<i64, RequestError>> {
Box::pin(async move {
self.messages.lock().push(text);
match self.next("send_html_message") {
Outcome::MessageOk => Ok(MockSender::SENT_ID),
Outcome::MessageErr => Err(self.error()),
other => panic!("unexpected outcome {other:?} for send_html_message"),
}
})
}
fn answer_inline_query(
&self,
_id: InlineQueryId,
results: Vec<InlineQueryResult>,
cache_time: u32,
) -> BoxFuture<'_, Result<(), RequestError>> {
// Records what the answer was made of, so a test can tell a cached
// (file-id) result from a URL one. Always succeeds: the debounce's
// release path is covered by `DebounceStates` directly.
assert_eq!(
cache_time, 300,
"the inline cache window is what the tests pin"
);
self.calls.lock().push("answer_inline_query");
self.inline_answers
.lock()
.push(results.iter().map(inline_result_tag).collect());
Box::pin(async move { Ok(()) })
}
fn answer_callback_query(
&self,
_id: CallbackQueryId,
text: Option<String>,
) -> BoxFuture<'_, Result<(), RequestError>> {
// Always succeeds: the toast is cosmetic, so the script stays
// focused on the outcomes a test cares about.
Box::pin(async move {
self.calls.lock().push("answer_callback_query");
self.answers.lock().push(text);
Ok(())
})
}
fn edit_message_text(
&self,
chat_id: ChatId,
message_id: MessageId,
text: String,
) -> BoxFuture<'_, Result<(), RequestError>> {
// Always succeeds: the only caller is the expiry sweep, which
// tolerates a failure (a prompt the user already deleted), so the
// script stays free for the call the test is about.
Box::pin(async move {
self.calls.lock().push("edit_message_text");
self.edited_texts
.lock()
.push((chat_id.0, message_id.0 as i64, text));
Ok(())
})
}
fn edit_message_caption(
&self,
_chat_id: ChatId,
_message_id: MessageId,
caption: String,
) -> BoxFuture<'_, Result<(), RequestError>> {
Box::pin(async move {
self.captions.lock().push(caption);
match self.next("edit_message_caption") {
Outcome::EditOk => Ok(()),
Outcome::EditErr => Err(self.error()),
other => panic!("unexpected outcome {other:?} for edit_message_caption"),
}
})
}
fn delete_message(
&self,
_chat_id: ChatId,
_message_id: MessageId,
) -> BoxFuture<'_, Result<(), RequestError>> {
// Deletion is fire-and-forget in every caller; always succeeds.
Box::pin(async move {
self.calls.lock().push("delete_message");
Ok(())
})
}
fn send_chat_action(
&self,
_chat_id: ChatId,
_action: ChatAction,
) -> BoxFuture<'_, Result<(), RequestError>> {
Box::pin(async move {
self.calls.lock().push("send_chat_action");
Ok(())
})
}
}
+300 -42
View File
@@ -12,6 +12,7 @@
//! and 24-bit RGB have no alpha channel). //! and 24-bit RGB have no alpha channel).
use std::io::Write; use std::io::Write;
use std::sync::LazyLock;
use fast_image_resize as fir; use fast_image_resize as fir;
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
@@ -22,13 +23,137 @@ use tempfile::NamedTempFile;
pub const PHOTO_MAX_DIMENSION_SUM: u32 = 10000; pub const PHOTO_MAX_DIMENSION_SUM: u32 = 10000;
/// Resize target with a safety margin so rounding cannot cross the cap. /// Resize target with a safety margin so rounding cannot cross the cap.
pub const PHOTO_TARGET_DIMENSION_SUM: u32 = 9900; pub const PHOTO_TARGET_DIMENSION_SUM: u32 = 9900;
/// Upload cap (bytes): files above this are not uploaded; the bot falls back /// Photo upload cap (bytes): Telegram rejects a larger `sendPhoto`, so the bot
/// to a smaller media URL instead. /// falls back to a smaller media URL instead. Videos and animations have their
/// own, larger cap — `send::upload::MAX_MEDIA_UPLOAD_BYTES` — and never become
/// photos.
pub const MAX_UPLOAD_BYTES: u64 = 10 * 1024 * 1024; pub const MAX_UPLOAD_BYTES: u64 = 10 * 1024 * 1024;
/// Decode budget (bytes): a larger intermediate buffer is not worth the peak /// Decode budget (bytes): a larger intermediate buffer is not worth the peak
/// memory; the photo degrades to the smaller URL instead. Also the cap for /// memory; the photo degrades to the smaller URL instead.
/// downloading photos in the send fallback (they must be downloaded whole).
pub(crate) const MAX_DECODE_BYTES: u64 = 512 * 1024 * 1024; pub(crate) const MAX_DECODE_BYTES: u64 = 512 * 1024 * 1024;
/// Cap for *downloading* a photo in the send fallback, kept separate from the
/// decode budget above: the whole body is buffered before it is processed, once
/// per download slot in flight, while the decode budget is about a single
/// buffer. Telegram's *photo* upload cap is 10 MiB, so a photo this large can
/// only be sent after a downscale that its reduced variant serves just as
/// well — over the cap the item degrades to the smaller URL
/// (`FallbackError::MediaTooLarge`), it is never an error.
pub(crate) const MAX_PHOTO_DOWNLOAD_BYTES: u64 = 32 * 1024 * 1024;
/// Size of one memory-budget unit. Small enough that ordinary photos do not
/// queue behind each other, coarse enough that the semaphore is not a counter
/// per megabyte.
const MEMORY_UNIT_BYTES: u64 = 64 * 1024 * 1024;
/// Process-wide memory budget for photo preparation, in [`MEMORY_UNIT_BYTES`]
/// units: 512 MiB. `PREP_SLOTS` bounds how many items are prepared at once but
/// not how much memory they hold — one photo's decode buffer can be up to
/// [`MAX_DECODE_BYTES`] (512 MiB), and the guard that refuses a bigger one is
/// per photo, so six concurrent photos could peak near 3 GiB on a host sized
/// for a fraction of that. Each item charges what it actually holds (its
/// downloaded bytes plus the decode buffer its header predicts), so a 10-image
/// album of ordinary photos still runs several at a time while huge ones
/// serialize.
const MEMORY_UNITS: u32 = 8;
static MEMORY_BUDGET: LazyLock<std::sync::Arc<tokio::sync::Semaphore>> =
LazyLock::new(|| std::sync::Arc::new(tokio::sync::Semaphore::new(MEMORY_UNITS as usize)));
/// The buffer `w`×`h` needs in `channels` output channels — the one number the
/// per-photo guards and the reservation below both use, so they cannot drift.
fn decode_bytes(w: u32, h: u32, channels: usize) -> u64 {
(w as u64) * (h as u64) * channels as u64
}
fn memory_units(bytes: u64) -> u32 {
bytes
.div_ceil(MEMORY_UNIT_BYTES)
.clamp(1, MEMORY_UNITS as u64) as u32
}
/// Conservative peak estimate for one photo preparation. The source bytes,
/// decoded pixels, any RGBA-to-RGB copy, resize output, and encoded output
/// can coexist briefly; charging only `w*h*channels` under-counts the real
/// process peak.
fn processing_peak_bytes(downloaded: u64, decode: u64) -> u64 {
downloaded
.saturating_add(decode)
.saturating_add(decode / 2)
.saturating_add(decode)
.saturating_add(MAX_UPLOAD_BYTES)
}
/// Reserves `bytes` of the preparation budget until the returned permit drops.
pub(crate) async fn reserve_memory(bytes: u64) -> tokio::sync::OwnedSemaphorePermit {
reserve(std::sync::Arc::clone(&MEMORY_BUDGET), bytes).await
}
/// [`reserve_memory`] against a caller-chosen budget; the tests pass their own
/// so they do not fight over the process-wide one.
async fn reserve(
budget: std::sync::Arc<tokio::sync::Semaphore>,
bytes: u64,
) -> tokio::sync::OwnedSemaphorePermit {
budget
.acquire_many_owned(memory_units(bytes))
.await
.expect("memory budget semaphore closed")
}
/// The processing decision for one downloaded photo, taken from its header
/// alone — the one place the within-limits test and the decode-size guard
/// live, so the memory reservation and the branch that acts on it cannot
/// drift.
enum PhotoPlan {
/// Already within Telegram's limits (dimension sum and upload cap): the
/// downloaded file is uploaded untouched, no decode buffer.
AsIs,
/// Needs processing: the decode buffer it will allocate, in bytes.
Decode(u64),
/// Processing would need a decode buffer over [`MAX_DECODE_BYTES`]: the
/// caller falls back to the item's smaller URL.
TooLarge,
}
/// [`PhotoPlan`] for a photo whose header said `w`×`h` in `channels` output
/// channels, `len` bytes long.
fn plan_photo(w: u32, h: u32, len: usize, channels: usize) -> PhotoPlan {
if (w as u64) + (h as u64) <= PHOTO_MAX_DIMENSION_SUM as u64 && len as u64 <= MAX_UPLOAD_BYTES {
return PhotoPlan::AsIs;
}
let bytes = decode_bytes(w, h, channels);
if bytes > MAX_DECODE_BYTES {
PhotoPlan::TooLarge
} else {
PhotoPlan::Decode(bytes)
}
}
/// The memory budget for one photo preparation, from its header alone. The
/// result includes the already-buffered download and the conservative decode,
/// transform, resize, and encoding peak; the caller holds that reservation
/// through the whole preparation.
pub(crate) fn prepare_budget_bytes(bytes: &[u8]) -> u64 {
let plan = if let Some((w, h, _depth, color)) = parse_png_header(bytes) {
plan_photo(w, h, bytes.len(), output_channels(color))
} else if let Some((w, h)) = jpeg_dims(bytes) {
plan_photo(w, h, bytes.len(), 3)
} else {
PhotoPlan::AsIs
};
match plan {
PhotoPlan::Decode(decode) => processing_peak_bytes(bytes.len() as u64, decode),
PhotoPlan::AsIs | PhotoPlan::TooLarge => bytes.len() as u64,
}
}
/// JPEG dimensions from the headers, without decoding any pixels.
fn jpeg_dims(bytes: &[u8]) -> Option<(u32, u32)> {
let mut decoder = zune_jpeg::JpegDecoder::new(std::io::Cursor::new(bytes));
decoder.decode_headers().ok()?;
let info = decoder.info()?;
Some((info.width as u32, info.height as u32))
}
/// JPEG output quality (1-100). /// JPEG output quality (1-100).
const JPEG_QUALITY: u8 = 90; const JPEG_QUALITY: u8 = 90;
@@ -80,31 +205,15 @@ pub fn prepare_photo(file: NamedTempFile, bytes: &[u8]) -> Result<PhotoPrep, Str
} }
} }
/// Parses the PNG IHDR (bytes 8..26: signature + length + "IHDR" + width + /// The PNG's IHDR as the crate reads it (signature through the first IDAT):
/// height + bit depth + color type). /// width/height/depth/color decide the plan and the decode channels, without
/// decoding any pixels.
fn parse_png_header(bytes: &[u8]) -> Option<(u32, u32, png::BitDepth, png::ColorType)> { fn parse_png_header(bytes: &[u8]) -> Option<(u32, u32, png::BitDepth, png::ColorType)> {
if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 26 { let reader = png::Decoder::new(std::io::Cursor::new(bytes))
return None; .read_info()
} .ok()?;
let w = u32::from_be_bytes(bytes.get(16..20)?.try_into().ok()?); let info = reader.info();
let h = u32::from_be_bytes(bytes.get(20..24)?.try_into().ok()?); Some((info.width, info.height, info.bit_depth, info.color_type))
let depth = match *bytes.get(24)? {
1 => png::BitDepth::One,
2 => png::BitDepth::Two,
4 => png::BitDepth::Four,
8 => png::BitDepth::Eight,
16 => png::BitDepth::Sixteen,
_ => return None,
};
let color = match *bytes.get(25)? {
0 => png::ColorType::Grayscale,
2 => png::ColorType::Rgb,
3 => png::ColorType::Indexed,
4 => png::ColorType::GrayscaleAlpha,
6 => png::ColorType::Rgba,
_ => return None,
};
Some((w, h, depth, color))
} }
/// Output channels of a decoded frame for the given color type (post /// Output channels of a decoded frame for the given color type (post
@@ -199,6 +308,7 @@ fn encode_jpeg(pix: &PixBuf, w: u32, h: u32) -> Result<Vec<u8>, String> {
fn write_temp(bytes: &[u8], ext: &str) -> Result<NamedTempFile, String> { fn write_temp(bytes: &[u8], ext: &str) -> Result<NamedTempFile, String> {
let mut file = tempfile::Builder::new() let mut file = tempfile::Builder::new()
.prefix(x_media::TEMP_FILE_PREFIX)
.suffix(&format!(".{ext}")) .suffix(&format!(".{ext}"))
.tempfile() .tempfile()
.map_err(|e| format!("temp file failed: {e}"))?; .map_err(|e| format!("temp file failed: {e}"))?;
@@ -209,7 +319,7 @@ fn write_temp(bytes: &[u8], ext: &str) -> Result<NamedTempFile, String> {
} }
fn target_dims(w: u32, h: u32) -> (u32, u32) { fn target_dims(w: u32, h: u32) -> (u32, u32) {
let scale = PHOTO_TARGET_DIMENSION_SUM as f64 / (w + h) as f64; let scale = PHOTO_TARGET_DIMENSION_SUM as f64 / ((w as u64) + (h as u64)) as f64;
( (
((w as f64 * scale).round() as u32).max(1), ((w as f64 * scale).round() as u32).max(1),
((h as f64 * scale).round() as u32).max(1), ((h as f64 * scale).round() as u32).max(1),
@@ -221,17 +331,16 @@ fn target_dims(w: u32, h: u32) -> (u32, u32) {
/// over the upload cap afterwards becomes JPEG. /// over the upload cap afterwards becomes JPEG.
fn prepare_png(file: NamedTempFile, bytes: &[u8]) -> Result<PhotoPrep, String> { fn prepare_png(file: NamedTempFile, bytes: &[u8]) -> Result<PhotoPrep, String> {
let (w, h, _bit_depth, color_type) = parse_png_header(bytes).ok_or("invalid PNG header")?; let (w, h, _bit_depth, color_type) = parse_png_header(bytes).ok_or("invalid PNG header")?;
let size_over = bytes.len() as u64 > MAX_UPLOAD_BYTES; let channels = output_channels(color_type);
if w + h <= PHOTO_MAX_DIMENSION_SUM && !size_over { let plan = plan_photo(w, h, bytes.len(), channels);
if let PhotoPlan::AsIs = plan {
return Ok(PhotoPrep::Upload(file)); return Ok(PhotoPrep::Upload(file));
} }
log::debug!( log::debug!(
"photo {w}x{h} ({_bit_depth:?} {color_type:?}, {} bytes) needs processing", "photo {w}x{h} ({_bit_depth:?} {color_type:?}, {} bytes) needs processing",
bytes.len() bytes.len()
); );
if let PhotoPlan::TooLarge = plan {
let channels = output_channels(color_type);
if (w as u64) * (h as u64) * channels as u64 > MAX_DECODE_BYTES {
log::warn!("photo decode buffer exceeds the memory budget; falling back to smaller media"); log::warn!("photo decode buffer exceeds the memory budget; falling back to smaller media");
return Ok(PhotoPrep::UseFallback); return Ok(PhotoPrep::UseFallback);
} }
@@ -267,7 +376,7 @@ fn prepare_png(file: NamedTempFile, bytes: &[u8]) -> Result<PhotoPrep, String> {
}; };
let (mut w, mut h) = (out_w, out_h); let (mut w, mut h) = (out_w, out_h);
if w + h > PHOTO_MAX_DIMENSION_SUM { if (w as u64) + (h as u64) > PHOTO_MAX_DIMENSION_SUM as u64 {
let (nw, nh) = target_dims(w, h); let (nw, nh) = target_dims(w, h);
pix = resize_pix(pix, w, h, nw, nh)?; pix = resize_pix(pix, w, h, nw, nh)?;
(w, h) = (nw, nh); (w, h) = (nw, nh);
@@ -298,18 +407,18 @@ fn prepare_jpeg(file: NamedTempFile, bytes: &[u8]) -> Result<PhotoPrep, String>
.map_err(|e| format!("jpeg headers: {e}"))?; .map_err(|e| format!("jpeg headers: {e}"))?;
let info = decoder.info().ok_or("jpeg info unavailable")?; let info = decoder.info().ok_or("jpeg info unavailable")?;
let (w, h) = (info.width as u32, info.height as u32); let (w, h) = (info.width as u32, info.height as u32);
let size_over = bytes.len() as u64 > MAX_UPLOAD_BYTES; let plan = plan_photo(w, h, bytes.len(), 3);
if w + h <= PHOTO_MAX_DIMENSION_SUM && !size_over { if let PhotoPlan::AsIs = plan {
return Ok(PhotoPrep::Upload(file)); return Ok(PhotoPrep::Upload(file));
} }
if (w as u64) * (h as u64) * 3 > MAX_DECODE_BYTES { if let PhotoPlan::TooLarge = plan {
log::warn!("photo decode buffer exceeds the memory budget; falling back to smaller media"); log::warn!("photo decode buffer exceeds the memory budget; falling back to smaller media");
return Ok(PhotoPrep::UseFallback); return Ok(PhotoPrep::UseFallback);
} }
let pixels = decoder.decode().map_err(|e| format!("jpeg decode: {e}"))?; let pixels = decoder.decode().map_err(|e| format!("jpeg decode: {e}"))?;
let mut pix = PixBuf::Rgb(pixels); let mut pix = PixBuf::Rgb(pixels);
let (mut w, mut h) = (w, h); let (mut w, mut h) = (w, h);
if w + h > PHOTO_MAX_DIMENSION_SUM { if (w as u64) + (h as u64) > PHOTO_MAX_DIMENSION_SUM as u64 {
let (nw, nh) = target_dims(w, h); let (nw, nh) = target_dims(w, h);
pix = resize_pix(pix, w, h, nw, nh)?; pix = resize_pix(pix, w, h, nw, nh)?;
(w, h) = (nw, nh); (w, h) = (nw, nh);
@@ -326,15 +435,154 @@ fn prepare_jpeg(file: NamedTempFile, bytes: &[u8]) -> Result<PhotoPrep, String>
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::time::Duration;
fn png_header(w: u32, h: u32, depth: u8, color: u8) -> Vec<u8> { fn png_header(w: u32, h: u32, depth: u8, color: u8) -> Vec<u8> {
let mut bytes = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".to_vec(); let mut bytes = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".to_vec();
bytes.extend(w.to_be_bytes()); bytes.extend(w.to_be_bytes());
bytes.extend(h.to_be_bytes()); bytes.extend(h.to_be_bytes());
bytes.extend([depth, color, 0, 0, 0]); bytes.extend([depth, color, 0, 0, 0]);
// A correct IHDR CRC plus an IDAT chunk header: `png::Decoder` verifies
// the CRC and `read_info` stops at the first IDAT — all the header
// read needs. The hand-rolled parser this fixture used to feed stopped
// four bytes earlier and checked neither.
bytes.extend(crc32(&bytes[12..]).to_be_bytes());
bytes.extend(0u32.to_be_bytes()); // IDAT payload length (never read)
bytes.extend(b"IDAT");
bytes bytes
} }
/// CRC-32 as PNG chunks use it (IEEE, reflected).
fn crc32(bytes: &[u8]) -> u32 {
let mut crc = !0u32;
for &b in bytes {
crc ^= b as u32;
for _ in 0..8 {
crc = (crc >> 1) ^ (0xEDB8_8320 & (crc & 1).wrapping_neg());
}
}
!crc
}
/// The budget is a *process-wide* memory bound: `PREP_SLOTS` (6) caps how
/// many photos are prepared at once, but six max-size photos would still
/// hold six decode buffers of up to 512 MiB each.
#[tokio::test]
async fn huge_decodes_cannot_overlap_but_do_run_alone() {
let budget = std::sync::Arc::new(tokio::sync::Semaphore::new(MEMORY_UNITS as usize));
let max_photo = processing_peak_bytes(MAX_PHOTO_DOWNLOAD_BYTES, MAX_DECODE_BYTES);
// One max-size photo fits (clamped to the whole budget), so it can
// never wait for budget that cannot exist.
let first = tokio::time::timeout(
Duration::from_millis(50),
reserve(budget.clone(), max_photo),
)
.await
.expect("a max-size photo must not wait");
// A second one of the same size has to wait for the first to finish.
assert!(
tokio::time::timeout(
Duration::from_millis(50),
reserve(budget.clone(), max_photo)
)
.await
.is_err(),
"two max-size decodes overlapped"
);
drop(first);
assert!(
tokio::time::timeout(
Duration::from_millis(50),
reserve(budget.clone(), max_photo)
)
.await
.is_ok(),
"the budget was not released"
);
}
/// A 10-image album of ordinary photos must not serialize: the conservative
/// peak still allows several small/medium photos to run together.
#[tokio::test]
async fn ordinary_photos_share_the_budget() {
let budget = std::sync::Arc::new(tokio::sync::Semaphore::new(MEMORY_UNITS as usize));
// A 4 MiB photo that decodes to ~36 MiB (4000x3000 RGB): its peak
// costs two 64 MiB units, so four fit in the 512 MiB process budget.
let ordinary = processing_peak_bytes(4 * 1024 * 1024, 36 * 1024 * 1024);
assert_eq!(memory_units(ordinary), 2);
let mut held = Vec::new();
for i in 0..4 {
held.push(
tokio::time::timeout(Duration::from_millis(50), reserve(budget.clone(), ordinary))
.await
.unwrap_or_else(|_| panic!("ordinary photo {i} waited for budget")),
);
}
assert!(
tokio::time::timeout(Duration::from_millis(50), reserve(budget, ordinary))
.await
.is_err(),
"the budget should reject a fifth two-unit photo"
);
}
#[test]
fn memory_units_round_up_and_clamp() {
assert_eq!(memory_units(1), 1);
assert_eq!(memory_units(MEMORY_UNIT_BYTES), 1);
assert_eq!(memory_units(MEMORY_UNIT_BYTES + 1), 2);
// Never more than exists, or the item waits for itself forever.
assert_eq!(memory_units(u64::MAX), MEMORY_UNITS);
// One item's worst case (a max download plus a max decode) takes the
// whole budget by itself.
assert_eq!(
memory_units(MAX_DECODE_BYTES + MAX_PHOTO_DOWNLOAD_BYTES),
MEMORY_UNITS
);
}
#[test]
fn a_wrapping_dimension_sum_never_reads_as_within_limits() {
// u32::MAX + 2 wraps to 1: the pre-u64 sum advertised AsIs here and
// handed the absurd dimensions to Telegram untouched.
assert!(matches!(
plan_photo(u32::MAX, 2, 16, 3),
PhotoPlan::TooLarge
));
}
/// What the reservation is charged is decided by the header, and it has to
/// agree with what the pipeline does: a photo uploaded as-is costs only
/// its buffered bytes, while a processed one costs its conservative peak.
#[test]
fn prepare_budget_follows_the_processing_decision() {
// 9999x2 (sum 10001) is over the dimension cap → processed → charged.
let oversized = png_header(9999, 2, 8, 2); // 8-bit RGB
assert_eq!(
prepare_budget_bytes(&oversized),
processing_peak_bytes(oversized.len() as u64, 9999 * 2 * 3)
);
// Inside the limits (dimensions *and* bytes) → uploaded as-is.
let small = png_header(100, 100, 8, 2);
assert_eq!(prepare_budget_bytes(&small), small.len() as u64);
// An unsupported format still keeps its already-buffered bytes alive.
let unsupported = b"GIF89a not a photo";
assert_eq!(prepare_budget_bytes(unsupported), unsupported.len() as u64);
// JPEG: 9999x2 is over the cap, so its RGB decode buffer is charged.
let (w, h) = (9999u16, 2u16);
let rgb = vec![90u8; w as usize * h as usize * 3];
let mut bytes = Vec::new();
jpeg_encoder::Encoder::new(&mut bytes, 90)
.encode(&rgb, w, h, jpeg_encoder::ColorType::Rgb)
.unwrap();
assert_eq!(
prepare_budget_bytes(&bytes),
processing_peak_bytes(bytes.len() as u64, 9999 * 2 * 3)
);
}
#[test] #[test]
fn parses_png_header() { fn parses_png_header() {
let bytes = png_header(8979, 5316, 16, 6); // 16-bit RGBA let bytes = png_header(8979, 5316, 16, 6); // 16-bit RGBA
@@ -459,7 +707,10 @@ mod tests {
#[test] #[test]
fn pipeline_resizes_oversized_jpeg() { fn pipeline_resizes_oversized_jpeg() {
// Build a small over-dimension JPEG with jpeg-encoder. // Build a small over-dimension JPEG with jpeg-encoder: 9999x2 sums to
// one over the cap. The output's own headers are what must show the
// resize — a copy-through is a perfectly valid JPEG, so magic bytes
// and a non-empty buffer used to pass for nothing.
let (w, h) = (9999u16, 2u16); let (w, h) = (9999u16, 2u16);
let rgb = vec![90u8; (w as usize) * (h as usize) * 3]; let rgb = vec![90u8; (w as usize) * (h as usize) * 3];
let mut bytes = Vec::new(); let mut bytes = Vec::new();
@@ -475,8 +726,15 @@ mod tests {
PhotoPrep::Upload(file) => { PhotoPrep::Upload(file) => {
let out = std::fs::read(file.path()).unwrap(); let out = std::fs::read(file.path()).unwrap();
assert!(out.starts_with(&[0xFF, 0xD8]), "output must stay jpeg"); assert!(out.starts_with(&[0xFF, 0xD8]), "output must stay jpeg");
// 9999x2 downscaled: the buffer length tells the new dims. let mut decoder = zune_jpeg::JpegDecoder::new(std::io::Cursor::new(out.as_slice()));
assert!(out.len() > 100); decoder.decode_headers().unwrap();
let info = decoder.info().unwrap();
let (nw, nh) = (info.width as u32, info.height as u32);
assert!(
nw + nh <= PHOTO_MAX_DIMENSION_SUM,
"still over the cap: {nw}x{nh}"
);
assert_ne!((nw, nh), (w as u32, h as u32), "output was not resized");
} }
PhotoPrep::UseFallback => panic!("over-dimension JPEG should have been resized"), PhotoPrep::UseFallback => panic!("over-dimension JPEG should have been resized"),
} }
File diff suppressed because it is too large Load Diff
+55 -10
View File
@@ -1,12 +1,13 @@
//! Per-chat token-bucket rate limiting. //! Per-chat token-bucket rate limiting.
//! //!
//! Telegram throttles bots that burst past a chat's message budget //! Telegram throttles bots on two budgets: one per chat (roughly 20
//! (roughly 20 messages/min for channels/groups); today the bot absorbs //! messages/min for channels/groups) and a bot-wide one (~30 messages per
//! those 429s with queue retries. This limiter smooths the burst *before* //! second). Both are smoothed here *before* the burst reaches the API — the
//! it reaches the API: media sends to a chat consume one token per //! per-chat bucket charges one token per message, and [`acquire_global`]
//! message, refilled at [`REFILL_PER_SEC`], so a batch forward paces itself //! charges the same spend against the bot-wide budget, which no per-chat
//! instead of tripping flood control. The queue retry stays as the safety //! bucket can see (a forward fanned out over many chats spends one token in
//! net for limits this bucket does not model (global per-bot limits etc.). //! each and nothing anywhere). The queue retry stays as the safety net for
//! whatever neither bucket models.
use parking_lot::Mutex; use parking_lot::Mutex;
use std::collections::HashMap; use std::collections::HashMap;
@@ -19,6 +20,12 @@ const CAPACITY: f64 = 20.0;
/// Sustained refill: ~20 messages per minute. /// Sustained refill: ~20 messages per minute.
const REFILL_PER_SEC: f64 = 20.0 / 60.0; const REFILL_PER_SEC: f64 = 20.0 / 60.0;
/// The bot-wide budget: Telegram allows roughly 30 messages per second for a
/// bot in total, independently of the per-chat limits. Set to the documented
/// ceiling, so it only ever binds on a cross-chat burst.
const GLOBAL_CAPACITY: f64 = 30.0;
const GLOBAL_REFILL_PER_SEC: f64 = 30.0;
struct State { struct State {
/// Current token balance; may go negative (debt from an acquire larger /// Current token balance; may go negative (debt from an acquire larger
/// than the capacity, repaid by subsequent refills). /// than the capacity, repaid by subsequent refills).
@@ -84,12 +91,24 @@ impl TokenBucket {
tokio::time::sleep(Duration::from_secs_f64(wait)).await; tokio::time::sleep(Duration::from_secs_f64(wait)).await;
} }
/// Current balance, refilled to now.
fn balance(&self) -> f64 {
let mut state = self.state.lock();
self.refill(&mut state);
state.tokens
}
/// Current balance, for the tests that assert a call site charged the
/// bucket (a charge is otherwise only observable as a delay).
#[cfg(test)]
pub(crate) fn tokens(&self) -> f64 {
self.balance()
}
/// True when the bucket has refilled to capacity: no debt outstanding, so /// True when the bucket has refilled to capacity: no debt outstanding, so
/// the chat has not sent anything recently. /// the chat has not sent anything recently.
fn is_idle(&self) -> bool { fn is_idle(&self) -> bool {
let mut state = self.state.lock(); self.balance() >= self.capacity
self.refill(&mut state);
state.tokens >= self.capacity
} }
} }
@@ -107,6 +126,17 @@ pub fn limiter_for(chat_id: i64) -> Arc<TokenBucket> {
.clone() .clone()
} }
/// The one bucket every chat shares: Telegram's bot-wide budget.
static GLOBAL_LIMITER: LazyLock<TokenBucket> =
LazyLock::new(|| TokenBucket::new(GLOBAL_CAPACITY, GLOBAL_REFILL_PER_SEC));
/// Waits for `n` messages' worth of the bot-wide budget. Called by the send
/// paths next to their per-chat [`limiter_for`]: at ~30/s it does not bind on
/// a single chat, but a batch fanned out over many chats has no other guard.
pub async fn acquire_global(n: f64) {
GLOBAL_LIMITER.acquire(n).await;
}
/// Drops limiters that are idle (refilled to capacity, so the chat has not /// Drops limiters that are idle (refilled to capacity, so the chat has not
/// sent recently) and are not still held by an in-flight sender. The map /// sent recently) and are not still held by an in-flight sender. The map
/// would otherwise keep one bucket per chat that ever sent media, forever. /// would otherwise keep one bucket per chat that ever sent media, forever.
@@ -160,6 +190,21 @@ mod tests {
); );
} }
#[tokio::test(start_paused = true)]
async fn the_global_budget_is_paced_and_shared() {
// Drain the process-wide budget (no other test touches it: the send
// paths that use it are mocked), then prove the next message waits for
// the refill instead of going out instantly.
acquire_global(GLOBAL_CAPACITY).await;
let start = tokio::time::Instant::now();
acquire_global(1.0).await;
assert!(
start.elapsed() >= Duration::from_secs_f64(1.0 / GLOBAL_REFILL_PER_SEC),
"a fanned-out burst must be paced: elapsed {:?}",
start.elapsed()
);
}
#[tokio::test(start_paused = true)] #[tokio::test(start_paused = true)]
async fn prune_idle_drops_full_unheld_buckets_only() { async fn prune_idle_drops_full_unheld_buckets_only() {
// Held by this task: kept even at full capacity, a sender has it. // Held by this task: kept even at full capacity, a sender has it.
+161
View File
@@ -0,0 +1,161 @@
//! The Telegram error policy: which failures the send paths retry, which are
//! permanent, and which the download-and-reupload fallback owns. A status a
//! *site* answers with is classified in `x_media::site`; this is the Bot API's
//! side of the same question.
use super::upload::FallbackError;
use super::{Task, retry_delay_seconds};
use teloxide::{ApiError, RequestError};
/// Telegram's servers failed to fetch a media URL (hotlink protection etc.):
/// these errors are handled by the download-and-reupload fallback, NOT by a
/// queue retry (resending the URL cannot succeed).
pub fn is_media_fetch_failure(e: &ApiError) -> bool {
const MARKERS: [&str; 7] = [
"webpage_media_empty",
"media_empty",
"empty_web_media",
"webpage_curl_failed",
"timeout",
// Oversized photos (width + height > 10000 px) are rejected on URL
// sends too; route them to the download-and-resize fallback.
"photo_invalid_dimensions",
// Telegram refused to fetch the URL it was handed. Single-media URL
// sends answer with this one (the media-group verbs use the
// `webpage_*`/`media_empty` markers above), and it is exactly the
// case the download-and-reupload fallback exists for.
"failed to get http url content",
];
let description = e.to_string().to_lowercase();
MARKERS.iter().any(|marker| description.contains(marker))
}
/// Telegram reported the media file as too large (HTTP 413 on multipart
/// upload, or a "too large" message for URL-fetched media). These errors are
/// handled by the size-check fallback (use a smaller media URL), NOT by a
/// queue retry.
pub fn is_size_error(e: &ApiError) -> bool {
if matches!(e, ApiError::RequestEntityTooLarge) {
return true;
}
let description = e.to_string().to_lowercase();
["too large", "too big"]
.iter()
.any(|marker| description.contains(marker))
}
/// Task-free classification of a Telegram request error. The callers attach
/// the (updated) task when building a [`SendError`].
pub enum Classification {
Retryable {
delay_seconds: f64,
},
Permanent {
message: String,
},
/// Handled by the download fallback, not a queue retry.
MediaFetchFailure,
}
pub fn classify_request_error(e: &RequestError) -> Classification {
match e {
RequestError::RetryAfter(seconds) => Classification::Retryable {
delay_seconds: seconds.seconds() as f64,
},
RequestError::Network(_) => Classification::Retryable {
delay_seconds: retry_delay_seconds(0),
},
// A 5xx from the API — or from a proxy in front of it — is transient.
// teloxide only sleeps 10s on a server error and then parses whatever
// body came back, so by the time we see the error the HTTP status is
// gone: a JSON 5xx body arrives as an unknown description, an HTML
// error page as `InvalidJson`. Both used to be Permanent, which
// dead-lettered a post over a Telegram-side blip.
RequestError::Api(api) if is_server_error_text(&api.to_string()) => {
Classification::Retryable {
delay_seconds: retry_delay_seconds(0),
}
}
RequestError::Api(api) if is_media_fetch_failure(api) => Classification::MediaFetchFailure,
RequestError::Api(api) => Classification::Permanent {
message: api.to_string(),
},
// An unparsable body can only come from something that is not the Bot
// API (which always answers JSON): a 5xx/error page from an
// intermediary, cut off mid-response. A JSON body that merely does not
// match the expected type cannot be fixed by retrying, so that case
// stays permanent.
RequestError::InvalidJson { raw, .. } if !raw.trim_start().starts_with('{') => {
Classification::Retryable {
delay_seconds: retry_delay_seconds(0),
}
}
RequestError::MigrateToChatId(_)
| RequestError::InvalidJson { .. }
| RequestError::Io(_) => Classification::Permanent {
message: e.to_string(),
},
}
}
/// Descriptions a 5xx carries when its body *is* JSON (teloxide keeps only the
/// description text, never the status code). Matched like the media-fetch
/// markers below; anything unmatched stays permanent, so a new permanent API
/// error is not retried just because it is unfamiliar.
fn is_server_error_text(description: &str) -> bool {
const MARKERS: [&str; 4] = [
"server error",
"bad gateway",
"gateway timeout",
"service unavailable",
];
let description = description.to_lowercase();
MARKERS.iter().any(|marker| description.contains(marker))
}
/// Task boxed to keep the error size within `result_large_err` limits.
#[derive(Debug)]
pub enum SendError {
Retryable { delay_seconds: f64, task: Box<Task> },
Permanent { message: String, task: Box<Task> },
}
pub(crate) fn classify_to_send_error(
e: &RequestError,
task: Task,
fetch_failure_label: &str,
) -> SendError {
match classify_request_error(e) {
Classification::Retryable { delay_seconds } => SendError::Retryable {
delay_seconds,
task: Box::new(task),
},
Classification::Permanent { message } => SendError::Permanent {
message,
task: Box::new(task),
},
Classification::MediaFetchFailure => SendError::Permanent {
message: fetch_failure_label.into(),
task: Box::new(task),
},
}
}
impl SendError {
/// Attaches the (updated) task to a task-free [`FallbackError`] from the
/// download/upload pipeline. [`FallbackError::MediaTooLarge`] never
/// escapes the pipeline (it is handled by falling back to the smaller
/// URL), so it is unreachable here.
pub(super) fn from_fallback(f: FallbackError, task: Task) -> SendError {
match f {
FallbackError::Retryable { delay_seconds } => SendError::Retryable {
delay_seconds,
task: Box::new(task),
},
FallbackError::Permanent { message } => SendError::Permanent {
message,
task: Box::new(task),
},
FallbackError::MediaTooLarge => unreachable!("handled inside the upload fallback"),
}
}
}
+61 -77
View File
@@ -2,20 +2,16 @@
//! URL / local path), the per-kind `InputMedia` builders and the media-group //! URL / local path), the per-kind `InputMedia` builders and the media-group
//! assembly with its caption rule. //! assembly with its caption rule.
use super::MediaItemPayload; use super::{MediaItemPayload, MediaRef};
use teloxide::types::{ use teloxide::types::{
InputFile, InputMedia, InputMediaAnimation, InputMediaPhoto, InputMediaVideo, ParseMode, InputFile, InputMedia, InputMediaAnimation, InputMediaPhoto, InputMediaVideo, ParseMode,
}; };
fn parse_media_url(s: &str) -> Result<url::Url, String> { /// The item's media string, whether it is a URL/path or a file id — callers
url::Url::parse(s).map_err(|e| format!("invalid media URL: {e}")) /// that need the distinction match on [`MediaRef`] themselves.
}
pub(super) fn item_url(item: &MediaItemPayload) -> &str { pub(super) fn item_url(item: &MediaItemPayload) -> &str {
match item { match item.media_ref() {
MediaItemPayload::Photo { media, .. } MediaRef::Source(media) | MediaRef::FileId(media) => media,
| MediaItemPayload::Video { media, .. }
| MediaItemPayload::Animation { media, .. } => media,
} }
} }
@@ -23,7 +19,8 @@ pub(super) fn item_url(item: &MediaItemPayload) -> &str {
/// (e.g. a locally encoded ugoira MP4) is uploaded directly. /// (e.g. a locally encoded ugoira MP4) is uploaded directly.
pub(super) fn input_file_for(media: &str) -> Result<InputFile, String> { pub(super) fn input_file_for(media: &str) -> Result<InputFile, String> {
if media.starts_with("http://") || media.starts_with("https://") { if media.starts_with("http://") || media.starts_with("https://") {
Ok(InputFile::url(parse_media_url(media)?)) let url = url::Url::parse(media).map_err(|e| format!("invalid media URL: {e}"))?;
Ok(InputFile::url(url))
} else if !std::path::Path::new(media).exists() { } else if !std::path::Path::new(media).exists() {
// A retried task may reference a temp file the original send's // A retried task may reference a temp file the original send's
// TempDir already cleaned up; fail fast and permanent instead of // TempDir already cleaned up; fail fast and permanent instead of
@@ -38,59 +35,64 @@ impl MediaItemPayload {
/// The input for a send: a cached file id goes out as `InputFile::file_id` /// The input for a send: a cached file id goes out as `InputFile::file_id`
/// (no fetch, no upload), URLs go to Telegram, anything else is a local /// (no fetch, no upload), URLs go to Telegram, anything else is a local
/// path (transient upload fallback). /// path (transient upload fallback).
fn input_file(&self) -> Result<InputFile, String> { pub(super) fn input_file(&self) -> Result<InputFile, String> {
match self { match self.media_ref() {
MediaItemPayload::Photo { MediaRef::FileId(id) => Ok(InputFile::file_id(id.clone().into())),
media, MediaRef::Source(media) => input_file_for(media),
file_id: true,
..
}
| MediaItemPayload::Video {
media,
file_id: true,
..
}
| MediaItemPayload::Animation {
media,
file_id: true,
..
} => Ok(InputFile::file_id(media.clone().into())),
_ => input_file_for(item_url(self)),
} }
} }
} }
pub(super) fn photo_media(file: InputFile, caption: Option<&str>, spoiler: bool) -> InputMedia { /// Builds one media-group item around an already-selected file: the per-kind
let mut photo = InputMediaPhoto::new(file).parse_mode(ParseMode::Html); /// `InputMedia` (same spoiler/caption handling) plus the video's thumbnail,
if let Some(caption) = caption { /// which Telegram takes as a separate upload/URL. The one place that dispatch
photo = photo.caption(caption); /// is written; callers only choose the `InputFile`.
pub(super) fn media_from(
item: &MediaItemPayload,
file: InputFile,
caption: Option<&str>,
) -> Result<InputMedia, String> {
let media = match item {
MediaItemPayload::Photo { has_spoiler, .. } => {
let mut media = InputMediaPhoto::new(file).parse_mode(ParseMode::Html);
if let Some(caption) = caption {
media = media.caption(caption);
}
if *has_spoiler {
media = media.spoiler();
}
InputMedia::Photo(media)
}
MediaItemPayload::Video { has_spoiler, .. } => {
let mut media = InputMediaVideo::new(file).parse_mode(ParseMode::Html);
if let Some(caption) = caption {
media = media.caption(caption);
}
if *has_spoiler {
media = media.spoiler();
}
InputMedia::Video(media)
}
MediaItemPayload::Animation { has_spoiler, .. } => {
let mut media = InputMediaAnimation::new(file).parse_mode(ParseMode::Html);
if let Some(caption) = caption {
media = media.caption(caption);
}
if *has_spoiler {
media = media.spoiler();
}
InputMedia::Animation(media)
}
};
// The thumbnail comes off the item itself — every caller passed exactly
// that, and only a video uses it (Telegram takes it as a separate
// upload/URL).
match (item.thumbnail_url(), media) {
(Some(thumb), InputMedia::Video(video)) => {
Ok(InputMedia::Video(video.thumbnail(input_file_for(thumb)?)))
}
(_, media) => Ok(media),
} }
if spoiler {
photo = photo.spoiler();
}
InputMedia::Photo(photo)
}
pub(super) fn video_media(file: InputFile, caption: Option<&str>, spoiler: bool) -> InputMedia {
let mut video = InputMediaVideo::new(file).parse_mode(ParseMode::Html);
if let Some(caption) = caption {
video = video.caption(caption);
}
if spoiler {
video = video.spoiler();
}
InputMedia::Video(video)
}
pub(super) fn animation_media(file: InputFile, caption: Option<&str>, spoiler: bool) -> InputMedia {
let mut animation = InputMediaAnimation::new(file).parse_mode(ParseMode::Html);
if let Some(caption) = caption {
animation = animation.caption(caption);
}
if spoiler {
animation = animation.spoiler();
}
InputMedia::Animation(animation)
} }
/// Builds a media group from payloads; only the first item of the batch gets /// Builds a media group from payloads; only the first item of the batch gets
@@ -104,25 +106,7 @@ pub(super) fn build_media_group(
.enumerate() .enumerate()
.map(|(i, item)| { .map(|(i, item)| {
let item_caption = if i == 0 { caption } else { None }; let item_caption = if i == 0 { caption } else { None };
Ok(match item { media_from(item, item.input_file()?, item_caption)
MediaItemPayload::Photo { has_spoiler, .. } => {
photo_media(item.input_file()?, item_caption, *has_spoiler)
}
MediaItemPayload::Video {
has_spoiler,
thumbnail,
..
} => {
let mut video = video_media(item.input_file()?, item_caption, *has_spoiler);
if let (Some(thumb), InputMedia::Video(v)) = (thumbnail, &mut video) {
*v = v.clone().thumbnail(input_file_for(thumb)?);
}
video
}
MediaItemPayload::Animation { has_spoiler, .. } => {
animation_media(item.input_file()?, item_caption, *has_spoiler)
}
})
}) })
.collect() .collect()
} }
File diff suppressed because it is too large Load Diff
+289 -69
View File
@@ -7,21 +7,28 @@ use super::{SendError, Task, forward_messages, send_animation, send_media_sequen
use crate::ctx::AppContext; use crate::ctx::AppContext;
use crate::db::{now_f64, unix_now}; use crate::db::{now_f64, unix_now};
use crate::handlers::log_key; use crate::handlers::log_key;
use crate::link_cache::{CachedMedia, CachedMediaKind, LinkCache}; use crate::link_cache::{CachedMedia, CachedMediaKind};
use crate::media_sender::MediaSender; use crate::media_sender::MediaSender;
use crate::queue::{PersistentTaskQueue, QueueError}; use crate::queue::{PersistentTaskQueue, QueueError};
use crate::state::EditMessage; use crate::state::EditMessage;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::LazyLock; use std::sync::LazyLock;
use teloxide::types::{ChatId, InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageId}; use teloxide::types::{
ChatId, InlineKeyboardButton, InlineKeyboardButtonKind, InlineKeyboardMarkup, Message,
MessageId,
};
/// Persists a successful send under the post's cache key. Only runs for a /// Persists a successful send under the post's cache key. Skips a send that was
/// fresh (non-resumed) task that carried raw cache data with no file ids yet. /// served from the cache — its entry already holds the file ids the next repeat
/// wants — *unless* the entry was degraded (no file ids left, see
/// `invalidate_cache`): then the ids this send just produced are written back,
/// which is what returns a degraded entry to the fast path instead of leaving
/// it to re-upload the media on every repeat.
pub(super) async fn cache_sent_task(ctx: &AppContext<'_>, task: &Task, media: Vec<CachedMedia>) { pub(super) async fn cache_sent_task(ctx: &AppContext<'_>, task: &Task, media: Vec<CachedMedia>) {
let Some(cache_data) = task.cache_data() else { let Some(cache_data) = task.cache_data() else {
return; return;
}; };
if !cache_data.media.is_empty() || media.is_empty() { if cache_data.media.iter().any(|m| !m.file_id.is_empty()) || media.is_empty() {
return; return;
} }
let mut post = cache_data.clone(); let mut post = cache_data.clone();
@@ -33,7 +40,12 @@ pub(super) async fn cache_sent_task(ctx: &AppContext<'_>, task: &Task, media: Ve
} }
/// Persists a lone animation send under the post's cache key. /// Persists a lone animation send under the post's cache key.
pub(super) async fn cache_animation_send(ctx: &AppContext<'_>, task: &Task, message: &Message) { pub(super) async fn cache_animation_send(
ctx: &AppContext<'_>,
task: &Task,
message: &Message,
source_url: &str,
) {
if let Some(file_id) = message.animation().map(|a| a.file.id.to_string()) { if let Some(file_id) = message.animation().map(|a| a.file.id.to_string()) {
cache_sent_task( cache_sent_task(
ctx, ctx,
@@ -41,6 +53,7 @@ pub(super) async fn cache_animation_send(ctx: &AppContext<'_>, task: &Task, mess
vec![CachedMedia { vec![CachedMedia {
kind: CachedMediaKind::Animation, kind: CachedMediaKind::Animation,
file_id, file_id,
url: super::replayable_cache_url(source_url),
}], }],
) )
.await; .await;
@@ -57,75 +70,160 @@ pub(crate) enum Settled {
/// Every path that ends a task's life — sent, permanently failed, or /// Every path that ends a task's life — sent, permanently failed, or
/// dead-lettered after the last retry — funnels through here, so the cleanup a /// dead-lettered after the last retry — funnels through here, so the cleanup a
/// settled task owes cannot be forgotten by a new path: release the keep-alive /// settled task owes cannot be forgotten by a new path: release the keep-alive
/// temp media (retryable tasks keep it, they will be resent) and drop the /// temp media (retryable tasks keep it, they will be resent) and deal with the
/// link-cache entry that a failed send's stale file ids would keep poisoning. /// link-cache entry a failed send's stale file ids would keep poisoning
/// (degraded to its source URLs, dropped once those fail too).
pub(crate) async fn settle_task(ctx: &AppContext<'_>, task: &Task, outcome: Settled) { pub(crate) async fn settle_task(ctx: &AppContext<'_>, task: &Task, outcome: Settled) {
if matches!(outcome, Settled::Failed) { if matches!(outcome, Settled::Failed) {
invalidate_cache(ctx.link_cache, task).await; invalidate_cache(ctx, task).await;
} }
release_keep_alive(task); release_keep_alive(task);
} }
/// A cached Telegram file id failed permanently (stale/expired); drop the /// A cached Telegram file id failed permanently (stale/expired). The media
/// cache entry so the next request re-fetches instead of repeating it. /// itself is usually fine, so the entry is *degraded* rather than dropped: its
async fn invalidate_cache(cache: &LinkCache, task: &Task) { /// file ids go away and the source URLs stay, and the next request re-sends the
if task.is_cached_send() /// post from those — no source request, no ugoira encode, no HLS remux — with
&& let Some(url) = task.source_url() /// the media fetched by Telegram (or by the upload fallback). An entry that is
&& let Some(key) = x_media::site::cache_key(url) /// already degraded, or whose older rows carry no URLs, is removed instead: its
{ /// URLs did not work either, and the next request should fetch the post again
log::debug!("removing stale link cache entry for [key={}]", log_key(url)); /// and report what the source says.
cache.remove(&key).await; async fn invalidate_cache(ctx: &AppContext<'_>, task: &Task) {
if !task.is_cached_send() {
return;
} }
let Some(url) = task.source_url() else {
return;
};
let Some(key) = x_media::site::cache_key(url) else {
return;
};
let Some(mut entry) = ctx.link_cache.get(&key, ctx.config.link_cache_ttl).await else {
return;
};
let degradable = entry.media.iter().all(|m| !m.url.is_empty())
&& entry.media.iter().any(|m| !m.file_id.is_empty());
if !degradable {
log::debug!("removing stale link cache entry for [key={}]", log_key(url));
ctx.link_cache.remove(&key).await;
return;
}
log::debug!(
"degrading stale link cache entry to its source URLs for [key={}]",
log_key(url)
);
for media in &mut entry.media {
media.file_id.clear();
}
ctx.link_cache.put(&key, &entry).await;
} }
/// Locally produced media files (ugoira MP4, bsky remux MP4) whose temp dirs /// Locally produced media files (ugoira MP4, bsky remux MP4) whose temp dirs
/// must stay alive while their task may be retried by the queue. The fetch /// must stay alive while their task may be retried by the queue. The fetch
/// pipeline hands ownership here via /// pipeline hands a reference here via [`x_media::site::Fetched::keep_alive`]
/// [`x_media::site::Fetched::take_keep_alive`] before that /// before that [`x_media::site::Fetched`] is dropped; a queued retry runs after
/// [`x_media::site::Fetched`] is dropped; a queued retry runs after that drop, /// that drop, so without this the local file would be gone by the time the
/// so without this the local file would be gone by the time the retry sends /// retry sends it. `Arc` because one fetch can serve several tasks (a
/// it. Entries are removed when the task settles (see [`release_keep_alive`]). /// concurrent duplicate of the same link shares it): each holder keeps the
pub(crate) static KEEP_ALIVE: LazyLock<parking_lot::Mutex<Vec<tempfile::TempDir>>> = /// directory alive until its own task settles. Its entry is removed when that
/// task settles (see [`release_keep_alive`]).
pub(crate) static KEEP_ALIVE: LazyLock<parking_lot::Mutex<Vec<std::sync::Arc<tempfile::TempDir>>>> =
LazyLock::new(|| parking_lot::Mutex::new(Vec::new())); LazyLock::new(|| parking_lot::Mutex::new(Vec::new()));
/// Drops the keep-alive temp dirs holding media referenced by `task` (matched /// Drops the keep-alive reference this task's pipeline pushed (one entry,
/// by path prefix). Called once a task settles — sent or permanently failed — /// matched by path prefix). Called once a task settles — sent or permanently
/// so retry-only temp files do not leak; retryable tasks keep them alive. /// failed — so retry-only temp files do not leak; retryable tasks keep theirs.
/// Exactly one entry goes per call: a shared fetch pushes one per pipeline, so
/// clearing every holder would delete the directory out from under a
/// concurrent duplicate's queued retry.
pub(crate) fn release_keep_alive(task: &Task) { pub(crate) fn release_keep_alive(task: &Task) {
let paths = task.local_media_paths(); let paths = task.local_media_paths();
if paths.is_empty() { if paths.is_empty() {
return; return;
} }
let mut alive = KEEP_ALIVE.lock(); let mut alive = KEEP_ALIVE.lock();
alive.retain(|dir| { if let Some(index) = alive
let dir_path = dir.path(); .iter()
!paths.iter().any(|p| p.starts_with(dir_path)) .position(|dir| paths.iter().any(|p| p.starts_with(dir.path())))
}); {
alive.remove(index);
}
} }
/// One button per template name (column layout), then the confirm button. /// The edit-before-forward prompt's text. It names both controls and the TTL,
/// Sorted by name: the templates live in a `HashMap`, so an unsorted walk /// because the buttons alone left users waiting for a forward that never came
/// would reshuffle the buttons between prompts. /// (nothing is forwarded until Confirm).
pub(super) fn build_edit_markup(templates: &HashMap<String, String>) -> InlineKeyboardMarkup { pub(super) fn edit_prompt_text(ttl: std::time::Duration) -> String {
let mut names: Vec<&String> = templates.keys().collect(); format!(
names.sort(); "Reply to edit the caption, or tap a template, then ↩️ Confirm to forward. \
let mut rows = Vec::with_capacity(names.len() + 1); Expires in {}. Nothing is forwarded until you confirm.",
for name in names { coarsest_unit(ttl)
rows.push(vec![InlineKeyboardButton::callback( )
name.clone(), }
format!("template|{name}"),
)]); /// 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")
} }
rows.push(vec![InlineKeyboardButton::callback( }
"↩️ Confirm",
"forward", /// 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;
const MAX_CALLBACK_DATA_BYTES: usize = 64;
const TEMPLATE_CALLBACK_PREFIX: &str = "template|";
/// 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. A name that cannot fit Telegram's callback-data
/// limit is omitted; legacy/imported state cannot poison the whole prompt.
pub(super) fn build_edit_markup(templates: &HashMap<String, String>) -> InlineKeyboardMarkup {
let mut names: Vec<&String> = templates
.keys()
.filter(|name| TEMPLATE_CALLBACK_PREFIX.len() + name.len() <= MAX_CALLBACK_DATA_BYTES)
.collect();
names.sort();
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_CALLBACK_PREFIX}{name}"),
)
})
.collect(),
);
}
rows.push(vec![
InlineKeyboardButton::callback("↩️ Confirm", "forward"),
InlineKeyboardButton::callback("🛑 Skip", "skip"),
]);
InlineKeyboardMarkup::new(rows) InlineKeyboardMarkup::new(rows)
} }
/// Notifies a chat about a dead-lettered task (skips when `notify_chat_id` is /// Notifies a chat about a dead-lettered task (skips when `notify_chat_id` is
/// absent). /// absent).
pub(super) async fn notify_failure( pub(crate) async fn notify_failure(
sender: &dyn MediaSender, sender: &dyn MediaSender,
chat_id: Option<i64>, chat_id: Option<i64>,
message_id: Option<i64>, message_id: Option<i64>,
@@ -185,12 +283,29 @@ pub(crate) async fn post_send_actions(ctx: &AppContext<'_>, task: &Task, message
}; };
if edit_before_forward { 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 shown = keyboard
.inline_keyboard
.iter()
.flatten()
.filter(|button| {
matches!(&button.kind, InlineKeyboardButtonKind::CallbackData(data) if data.starts_with(TEMPLATE_CALLBACK_PREFIX))
})
.count();
let hidden = templates.len().saturating_sub(shown);
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 let prompt = ctx
.sender .sender
.send_message( .send_message(
ChatId(chat_id), ChatId(chat_id),
"Reply to edit message.".to_string(), text,
Some(MessageId(reply_to as i32)), Some(MessageId(reply_to as i32)),
Some(keyboard), Some(keyboard),
) )
@@ -198,11 +313,13 @@ pub(crate) async fn post_send_actions(ctx: &AppContext<'_>, task: &Task, message
match prompt { match prompt {
Ok(prompt_id) => { Ok(prompt_id) => {
log::info!( log::info!(
"edit-before-forward prompt {prompt_id} opened for {} message(s)", "edit-before-forward prompt {prompt_id} opened for {} message(s) [key={}] chat={chat_id}",
message_ids.len() message_ids.len(),
log_key(&source_url)
); );
let source_url = source_url.clone(); let source_url = source_url.clone();
ctx.chat_store let saved = match ctx
.chat_store
.update(chat_id, move |data| { .update(chat_id, move |data| {
data.edit_message.insert( data.edit_message.insert(
prompt_id, prompt_id,
@@ -215,22 +332,55 @@ pub(crate) async fn post_send_actions(ctx: &AppContext<'_>, task: &Task, message
}, },
); );
}) })
.await
{
Ok((_, true)) => true,
Ok((_, false)) | Err(()) => false,
};
if !saved {
log::error!("edit prompt {prompt_id} could not be persisted; removing it");
let _ = ctx
.sender
.delete_message(ChatId(chat_id), MessageId(prompt_id as i32))
.await;
notify_failure(
ctx.sender,
notify_chat_id,
notify_message_id,
"Could not save the edit-before-forward prompt — nothing was forwarded.",
)
.await; .await;
}
}
Err(e) => {
log::error!("failed to send edit prompt: {e}");
// Nothing is forwarded until the prompt is confirmed, so a
// prompt that never arrived means this post is never forwarded.
// Tell the chat instead of letting it wait for a prompt that
// will not come.
notify_failure(
ctx.sender,
notify_chat_id,
notify_message_id,
"Could not open the edit-before-forward prompt — nothing was forwarded.",
)
.await;
} }
Err(e) => log::error!("failed to send edit prompt: {e}"),
} }
return; return;
} }
if let Some(channel_id) = forward_channel_id { if let Some(channel_id) = forward_channel_id {
log::info!( log::info!(
"forwarding {} message(s) to channel {channel_id}", "forwarding {} message(s) to channel {channel_id} from chat {chat_id} [key={}]",
message_ids.len() message_ids.len(),
log_key(&source_url)
); );
let forward_task = Task::ForwardMessages { let forward_task = Task::ForwardMessages {
from_chat_id: chat_id, from_chat_id: chat_id,
to_chat_id: channel_id, to_chat_id: channel_id,
message_ids, message_ids,
forward_offset: 0,
notify_chat_id, notify_chat_id,
notify_message_id, notify_message_id,
}; };
@@ -240,14 +390,24 @@ pub(crate) async fn post_send_actions(ctx: &AppContext<'_>, task: &Task, message
delay_seconds, delay_seconds,
task, task,
}) => { }) => {
enqueue_retry(ctx.task_queue, *task, delay_seconds).await; // The forward is already committed from the user's side; if it
// cannot be queued, say so rather than going quiet.
if !enqueue_retry(ctx.task_queue, &task, delay_seconds).await {
notify_failure(
ctx.sender,
notify_chat_id,
notify_message_id,
&failure_text(task.source_url(), "retry could not be queued"),
)
.await;
}
} }
Err(SendError::Permanent { message, .. }) => { Err(SendError::Permanent { message, .. }) => {
notify_failure( notify_failure(
ctx.sender, ctx.sender,
notify_chat_id, notify_chat_id,
notify_message_id, notify_message_id,
&format!("Task failed after retries: {message}"), &failure_text(None, &message),
) )
.await; .await;
} }
@@ -255,16 +415,24 @@ pub(crate) async fn post_send_actions(ctx: &AppContext<'_>, task: &Task, message
} }
} }
/// Enqueues a task for a later attempt (retry / forward resume). When the /// Enqueues a task for a later attempt (retry / forward resume). Returns
/// enqueue itself fails the task can never be sent again, so its keep-alive /// whether the retry is actually persisted: when the enqueue itself fails the
/// temp media is released instead of leaking until process exit. /// task can never run again, so its keep-alive temp media is released instead
pub(crate) async fn enqueue_retry(queue: &PersistentTaskQueue, task: Task, delay_seconds: f64) { /// of leaking until process exit — and the caller must not tell the user a
let payload = serde_json::to_value(&task).expect("task serializes"); /// retry is coming (nothing would ever deliver it).
pub(crate) async fn enqueue_retry(
queue: &PersistentTaskQueue,
task: &Task,
delay_seconds: f64,
) -> bool {
let payload = serde_json::to_value(task).expect("task serializes");
let run_after = now_f64() + delay_seconds; let run_after = now_f64() + delay_seconds;
if let Err(e) = queue.enqueue(payload, run_after).await { if let Err(e) = queue.enqueue(payload, run_after).await {
log::error!("failed to enqueue retry: {e}"); log::error!("failed to enqueue retry: {e}");
release_keep_alive(&task); release_keep_alive(task);
return false;
} }
true
} }
/// Queue entry point: parses the stored task and dispatches. /// Queue entry point: parses the stored task and dispatches.
@@ -295,7 +463,10 @@ pub(crate) async fn handle_task(
}); });
} }
Err(SendError::Permanent { message, task }) => { Err(SendError::Permanent { message, task }) => {
settle_task(ctx, &task, Settled::Failed).await; // The queue dead-letters this payload into
// `dead_letter_notify`, which settles the task — settling
// here as well would release a shared keep-alive
// directory twice.
return Err(QueueError::Permanent { return Err(QueueError::Permanent {
message, message,
payload: serde_json::to_value(task).expect("task serializes"), payload: serde_json::to_value(task).expect("task serializes"),
@@ -323,7 +494,8 @@ pub(crate) async fn handle_task(
payload: serde_json::to_value(task).expect("task serializes"), payload: serde_json::to_value(task).expect("task serializes"),
}), }),
Err(SendError::Permanent { message, task }) => { Err(SendError::Permanent { message, task }) => {
settle_task(ctx, &task, Settled::Failed).await; // Settled by `dead_letter_notify`, which the queue invokes for
// this payload.
Err(QueueError::Permanent { Err(QueueError::Permanent {
message, message,
payload: serde_json::to_value(task).expect("task serializes"), payload: serde_json::to_value(task).expect("task serializes"),
@@ -341,6 +513,36 @@ 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(source_url: Option<&str>, message: &str) -> String {
match source_url.map(log_key) {
Some(key) => format!("Send failed permanently for {key}: {message}"),
// `ForwardMessages` carries no source URL (and neither does an
// unparsable payload): that failure is about the channel copy, not
// about a post.
None => format!("Forward failed permanently: {message}"),
}
}
/// The post a stored payload is about, without parsing it into a [`Task`]:
/// used when the payload no longer deserializes (written by an older version,
/// or corrupted) but its identity fields are still readable.
fn payload_source_url(payload: &serde_json::Value) -> Option<&str> {
payload.get("source_url").and_then(|v| v.as_str())
}
/// Whether a stored payload was a *cached* send (see `Task::is_cached_send`),
/// read straight off the JSON — the unparsable case still has to know whether
/// a link-cache entry may be holding the media that failed.
fn payload_is_cached_send(payload: &serde_json::Value) -> bool {
payload
.get("cache_data")
.and_then(|data| data.get("media"))
.and_then(|media| media.as_array())
.is_some_and(|media| !media.is_empty())
}
/// Dead-letter callback wired to the queue in main: settles the task and /// Dead-letter callback wired to the queue in main: settles the task and
/// notifies its chat. /// notifies its chat.
pub(crate) async fn dead_letter_notify( pub(crate) async fn dead_letter_notify(
@@ -351,8 +553,21 @@ pub(crate) async fn dead_letter_notify(
// A dead-lettered task never runs again, and the queue dead-letters retry // 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 // exhaustion itself (the handler is not called again), so this is the only
// place that sees the final payload. // place that sees the final payload.
if let Ok(task) = serde_json::from_value::<Task>(payload.clone()) { let task = serde_json::from_value::<Task>(payload.clone()).ok();
settle_task(ctx, &task, Settled::Failed).await; if let Some(task) = &task {
settle_task(ctx, task, Settled::Failed).await;
} else {
// A payload that no longer parses (an older version's row shape, a
// corrupted one) still says which post it was about: drop the stale
// cache entry the same way, instead of leaving a bad file id to be
// re-sent forever — and name the post in the notification rather than
// reporting a *forward* failure for a send task.
if payload_is_cached_send(&payload)
&& let Some(key) = payload_source_url(&payload).and_then(x_media::site::cache_key)
{
log::debug!("removing stale link cache entry for [key={key}]");
ctx.link_cache.remove(&key).await;
}
} }
let notify_chat_id = payload.get("notify_chat_id").and_then(|v| v.as_i64()); 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()); let notify_message_id = payload.get("notify_message_id").and_then(|v| v.as_i64());
@@ -360,7 +575,12 @@ pub(crate) async fn dead_letter_notify(
ctx.sender, ctx.sender,
notify_chat_id, notify_chat_id,
notify_message_id, notify_message_id,
&format!("Task failed after retries: {message}"), &failure_text(
task.as_ref()
.and_then(|task| task.source_url())
.or_else(|| payload_source_url(&payload)),
&message,
),
) )
.await; .await;
} }
+256 -112
View File
@@ -2,15 +2,49 @@
//! itself (hotlink protection), the bot downloads the file, shrinks photos //! itself (hotlink protection), the bot downloads the file, shrinks photos
//! that exceed Telegram's limits and uploads the batch via multipart. //! that exceed Telegram's limits and uploads the batch via multipart.
use super::input_media::{animation_media, input_file_for, item_url, photo_media, video_media}; use super::input_media::{input_file_for, item_url, media_from};
use super::{MediaItemPayload, SendError, Task, classify_to_send_error, retry_delay_seconds}; use super::{
MediaItemPayload, MediaRef, SendError, Task, classify_to_send_error, retry_delay_seconds,
};
use crate::media_sender::MediaSender; use crate::media_sender::MediaSender;
use crate::photo::{self, MAX_UPLOAD_BYTES, PhotoPrep}; use crate::photo::{self, PhotoPrep};
use std::sync::LazyLock;
use teloxide::prelude::*; use teloxide::prelude::*;
use teloxide::types::{ChatId, InputFile, InputMedia, MessageId}; use teloxide::types::{ChatId, InputFile, InputMedia, MessageId};
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
use x_media::site::FetchError; use x_media::site::FetchError;
/// How many fallback items may be downloaded and processed at once, across the
/// whole process. A per-batch bound is not a memory bound: `URL_WORKERS` (8)
/// and the queue's workers (4) can each be inside a batch, so a per-batch three
/// allowed two dozen downloads in flight, each buffering a whole photo
/// (up to [`photo::MAX_PHOTO_DOWNLOAD_BYTES`]) before it is processed. This is
/// the only admission control on the media path; the send itself is paced by
/// the rate limiter.
const PREP_CONCURRENCY: usize = 6;
static PREP_SLOTS: LazyLock<tokio::sync::Semaphore> =
LazyLock::new(|| tokio::sync::Semaphore::new(PREP_CONCURRENCY));
/// Telegram's multipart upload limit for everything that is not a photo:
/// its own docs on `sendVideo`/`sendAnimation`/`sendDocument` say 50 MB
/// (`RequestEntityTooLarge` is "larger than 50 MB"), while photos are the
/// 10 MiB [`photo::MAX_UPLOAD_BYTES`] case. Using the photo cap here refused
/// to even download a 10–50 MB video that Telegram itself would have
/// accepted, and a video has no smaller variant to fall back to — so the
/// post was lost.
pub(super) const MAX_MEDIA_UPLOAD_BYTES: u64 = 50 * 1024 * 1024;
/// Whole-transfer budget for one fallback download. The prep slot (and the
/// non-photo memory reservation) is held while this runs, and the idle window
/// alone lets a server drip one byte every 29 s forever — so this path caps
/// its own transfers well below the in-fetch default: 50 MiB in 300 s needs
/// about 1.4 Mbit/s, and a much slower link is better served by the retry
/// path toward the item's smaller fallback URL than by pinning a slot for
/// ten minutes.
/// ponytail: if slow-link reports show up, move the download out of the prep
/// slot (slot = decode/upload only) instead of raising this again.
const FALLBACK_DOWNLOAD_TOTAL: std::time::Duration = std::time::Duration::from_secs(300);
/// Infers a file extension from magic bytes so Telegram detects the mime type /// Infers a file extension from magic bytes so Telegram detects the mime type
/// on multipart uploads. /// on multipart uploads.
pub(super) fn sniff_ext(bytes: &[u8]) -> &'static str { pub(super) fn sniff_ext(bytes: &[u8]) -> &'static str {
@@ -55,74 +89,109 @@ pub(super) enum FallbackError {
/// errors are not. /// errors are not.
async fn download_to_temp( async fn download_to_temp(
item: &MediaItemPayload, item: &MediaItemPayload,
media_url: &str,
) -> Result<(NamedTempFile, bytes::Bytes), FallbackError> { ) -> Result<(NamedTempFile, bytes::Bytes), FallbackError> {
let media_url = match item { // The caller narrows the media to a source URL before calling (its entry
MediaItemPayload::Photo { media, .. } // guard rejects a file id), so there is nothing to match on here.
| MediaItemPayload::Video { media, .. }
| MediaItemPayload::Animation { media, .. } => media,
};
// Photos are downloaded even over the upload cap so `prepare_photo` can // Photos are downloaded even over the upload cap so `prepare_photo` can
// downscale / transcode them (cap = decode budget); videos/animations // downscale / transcode them, up to their own download cap; videos and
// abort as soon as the upload cap is crossed mid-stream. // animations are refused as soon as the declared size crosses their own
let limit = if matches!(item, MediaItemPayload::Photo { .. }) { // (larger) upload cap. The limit is that cap, not `cap + 1`: a file of
photo::MAX_DECODE_BYTES // exactly the cap is admitted (`len > max_bytes` is false), and one byte
// over is not — the same boundary the size probe this replaced drew.
let is_photo = matches!(item, MediaItemPayload::Photo { .. });
let limit = if is_photo {
photo::MAX_PHOTO_DOWNLOAD_BYTES
} else { } else {
MAX_UPLOAD_BYTES + 1 MAX_MEDIA_UPLOAD_BYTES
}; };
let bytes = match x_media::site::download_media_limited(media_url, limit).await { // A non-photo body is buffered whole and charges the process-wide budget
// for as long as this function holds it (one 64 MiB unit covers the cap):
// `PREP_SLOTS` bounds how many are in flight, this bounds what they add
// up to. Photos charge their own download cap for the same window — their
// real cost (header probe + decode buffer) is charged again by the
// prepare step right after, where both are actually held together.
let _budget = Some(
photo::reserve_memory(if is_photo {
photo::MAX_PHOTO_DOWNLOAD_BYTES
} else {
MAX_MEDIA_UPLOAD_BYTES
})
.await,
);
let bytes = match x_media::site::download_media_limited(
media_url,
limit,
FALLBACK_DOWNLOAD_TOTAL,
)
.await
{
Ok(bytes) => bytes, Ok(bytes) => bytes,
Err(FetchError::Http(_)) => { Err(e) => return Err(classify_download_error(e)),
return Err(FallbackError::Retryable {
delay_seconds: retry_delay_seconds(0),
});
}
Err(FetchError::TooLarge) => {
return Err(FallbackError::MediaTooLarge);
}
Err(e) => {
return Err(FallbackError::Permanent {
message: format!("download failed: {e}"),
});
}
}; };
let ext = sniff_ext(&bytes); let ext = sniff_ext(&bytes);
let mut file = tempfile::Builder::new() let mut file = tempfile::Builder::new()
.prefix(x_media::TEMP_FILE_PREFIX)
.suffix(&format!(".{ext}")) .suffix(&format!(".{ext}"))
.tempfile() .tempfile()
.map_err(|e| FallbackError::Permanent { .map_err(|e| FallbackError::Permanent {
message: format!("temp file failed: {e}"), message: format!("temp file failed: {e}"),
})?; })?;
use std::io::Write; // The write runs on a blocking thread: up to 50 MiB of sync disk I/O on
file.as_file_mut() // an executor thread would stall whatever else that worker runs (six prep
.write_all(&bytes) // tasks could stall six threads at once on a slow volume). A write
.map_err(|e| FallbackError::Permanent { // failure is resource exhaustion far more often than a broken temp
message: format!("temp file write failed: {e}"), // dir (ENOSPC / EDQUOT), and that clears on its own — worth an attempt
})?; // instead of dropping the post on the first try. Creating the file (above)
// stays permanent: a temp dir that cannot be created at all is a
// deployment fault that should fail loudly and immediately. `Retryable`
// carries no message, so the cause is logged here.
let (written, file, bytes) = tokio::task::spawn_blocking(move || {
use std::io::Write;
let written = file.as_file_mut().write_all(&bytes);
(written, file, bytes)
})
.await
.map_err(|e| FallbackError::Permanent {
message: format!("upload write worker panicked: {e}"),
})?;
written.map_err(|e| {
log::error!("temp file write failed: {e}");
FallbackError::Retryable {
delay_seconds: retry_delay_seconds(0),
}
})?;
Ok((file, bytes)) Ok((file, bytes))
} }
/// Which failure class a media download belongs to. Transport errors and
/// server-side hiccups (429/5xx, see `download_media_limited`) are worth
/// another attempt; a 4xx means the media itself is gone or refused, and a
/// retry could only ask the same URL again.
fn classify_download_error(err: FetchError) -> FallbackError {
match err {
FetchError::RateLimited {
retry_after_secs, ..
} => FallbackError::Retryable {
delay_seconds: retry_after_secs as f64,
},
FetchError::Http(_) | FetchError::Transient(_) => FallbackError::Retryable {
delay_seconds: retry_delay_seconds(0),
},
FetchError::TooLarge => FallbackError::MediaTooLarge,
e => FallbackError::Permanent {
message: format!("download failed: {e}"),
},
}
}
/// Builds the media group item from an uploaded file. /// Builds the media group item from an uploaded file.
fn media_from_file( fn media_from_file(
item: &MediaItemPayload, item: &MediaItemPayload,
path: std::path::PathBuf, path: std::path::PathBuf,
caption: Option<&str>, caption: Option<&str>,
thumbnail: Option<&str>,
) -> Result<InputMedia, String> { ) -> Result<InputMedia, String> {
let mut media = match item { media_from(item, InputFile::file(path), caption)
MediaItemPayload::Photo { has_spoiler, .. } => {
photo_media(InputFile::file(path), caption, *has_spoiler)
}
MediaItemPayload::Video { has_spoiler, .. } => {
video_media(InputFile::file(path), caption, *has_spoiler)
}
MediaItemPayload::Animation { has_spoiler, .. } => {
animation_media(InputFile::file(path), caption, *has_spoiler)
}
};
if let (Some(thumb), InputMedia::Video(v)) = (thumbnail, &mut media) {
*v = v.clone().thumbnail(input_file_for(thumb)?);
}
Ok(media)
} }
/// Builds the media group item from a (smaller) URL. /// Builds the media group item from a (smaller) URL.
@@ -130,23 +199,8 @@ fn media_from_url(
item: &MediaItemPayload, item: &MediaItemPayload,
url: &str, url: &str,
caption: Option<&str>, caption: Option<&str>,
thumbnail: Option<&str>,
) -> Result<InputMedia, String> { ) -> Result<InputMedia, String> {
let mut media = match item { media_from(item, input_file_for(url)?, caption)
MediaItemPayload::Photo { has_spoiler, .. } => {
photo_media(input_file_for(url)?, caption, *has_spoiler)
}
MediaItemPayload::Video { has_spoiler, .. } => {
video_media(input_file_for(url)?, caption, *has_spoiler)
}
MediaItemPayload::Animation { has_spoiler, .. } => {
animation_media(input_file_for(url)?, caption, *has_spoiler)
}
};
if let (Some(thumb), InputMedia::Video(v)) = (thumbnail, &mut media) {
*v = v.clone().thumbnail(input_file_for(thumb)?);
}
Ok(media)
} }
/// One item prepared for the upload fallback: the ready-to-send media plus /// One item prepared for the upload fallback: the ready-to-send media plus
@@ -166,40 +220,34 @@ pub(super) async fn prepare_upload_item(
index: usize, index: usize,
caption: Option<&str>, caption: Option<&str>,
) -> Result<PreparedItem, FallbackError> { ) -> Result<PreparedItem, FallbackError> {
// A file id is already Telegram's copy of an uploaded file: there is no
// URL to re-fetch, and without this guard `item_url` presents the id as
// a *path*, which fails at upload time with a confusing open error
// instead of a classification. Re-upload cannot apply to it.
if matches!(item.media_ref(), MediaRef::FileId(_)) {
return Err(FallbackError::Permanent {
message: "file id reached the upload fallback".into(),
});
}
let media_url = item_url(&item);
// Locally produced files (ugoira / bsky remux MP4): nothing to download // Locally produced files (ugoira / bsky remux MP4): nothing to download
// or shrink — upload the file directly. The send is a multipart upload, // or shrink — upload the file directly. The send is a multipart upload,
// so the only remaining failure is an upload-cap error, which is // so the only remaining failure is an upload-cap error, which is
// permanent (a video cannot be re-encoded here). // permanent (a video cannot be re-encoded here).
let media_url = item_url(&item);
if !media_url.starts_with("http://") && !media_url.starts_with("https://") { if !media_url.starts_with("http://") && !media_url.starts_with("https://") {
let media = media_from_file( let path = std::path::Path::new(media_url);
&item, let size = tokio::fs::metadata(path)
std::path::PathBuf::from(media_url), .await
caption, .map_err(|e| FallbackError::Permanent {
item.thumbnail_url(), message: format!("local media unavailable: {e}"),
) })?
.map_err(|message| FallbackError::Permanent { message })?; .len();
return Ok(PreparedItem { if size > MAX_MEDIA_UPLOAD_BYTES {
index, return Err(FallbackError::Permanent {
media, message: "local media exceeds Telegram upload limit".into(),
keep_alive: None, });
}); }
} let media = media_from_file(&item, path.to_path_buf(), caption)
// Size check before downloading/uploading: over the cap, use the
// smaller URL instead of the file. Photos are exempt — they are
// downloaded and processed (downscale / PNG→JPEG) before uploading.
let too_large = match x_media::site::media_size(media_url).await {
Ok(Some(size)) => size > MAX_UPLOAD_BYTES,
_ => false,
};
let too_large = too_large && !matches!(item, MediaItemPayload::Photo { .. });
if too_large {
let url = item
.fallback_url()
.ok_or_else(|| FallbackError::Permanent {
message: "media too large".into(),
})?;
let media = media_from_url(&item, url, caption, item.thumbnail_url())
.map_err(|message| FallbackError::Permanent { message })?; .map_err(|message| FallbackError::Permanent { message })?;
return Ok(PreparedItem { return Ok(PreparedItem {
index, index,
@@ -207,7 +255,14 @@ pub(super) async fn prepare_upload_item(
keep_alive: None, keep_alive: None,
}); });
} }
match download_to_temp(&item).await { // Whether a file is over the cap is settled by the download itself:
// `download_media_limited` reads the declared Content-Length before any
// body byte and aborts with `FetchError::TooLarge`, which arrives here as
// `FallbackError::MediaTooLarge` — turned into the item's smaller URL by
// the match below. A separate size probe used to issue a second GET of the
// same URL for an answer this path already has (and issued it for photos,
// whose answer was discarded one line later).
match download_to_temp(&item, media_url).await {
Ok((file, bytes)) => { Ok((file, bytes)) => {
if matches!(item, MediaItemPayload::Photo { .. }) { if matches!(item, MediaItemPayload::Photo { .. }) {
// Telegram rejects photos wider+taller than 10000 px combined // Telegram rejects photos wider+taller than 10000 px combined
@@ -215,16 +270,33 @@ pub(super) async fn prepare_upload_item(
// before uploading; photos that cannot be brought within the // before uploading; photos that cannot be brought within the
// limits degrade to the smaller URL. CPU-heavy work runs off // limits degrade to the smaller URL. CPU-heavy work runs off
// the async executor thread. // the async executor thread.
let prep = tokio::task::spawn_blocking(move || photo::prepare_photo(file, &bytes)) //
.await // Keep the permit inside the blocking closure. If the async
.map_err(|e| FallbackError::Permanent { // future is cancelled while `spawn_blocking` is still
message: format!("photo worker panicked: {e}"), // decoding, dropping the permit here would undercount the
})? // process memory bound until that closure finishes.
.map_err(|message| FallbackError::Permanent { message })?; let (bytes, budget_bytes) = tokio::task::spawn_blocking(move || {
let budget_bytes = photo::prepare_budget_bytes(&bytes);
(bytes, budget_bytes)
})
.await
.map_err(|e| FallbackError::Permanent {
message: format!("photo worker panicked: {e}"),
})?;
let budget = photo::reserve_memory(budget_bytes).await;
let prep = tokio::task::spawn_blocking(move || {
let _budget = budget;
photo::prepare_photo(file, &bytes)
})
.await
.map_err(|e| FallbackError::Permanent {
message: format!("photo worker panicked: {e}"),
})?
.map_err(|message| FallbackError::Permanent { message })?;
match prep { match prep {
PhotoPrep::Upload(upload) => { PhotoPrep::Upload(upload) => {
let path = upload.path().to_path_buf(); let path = upload.path().to_path_buf();
let media = media_from_file(&item, path, caption, item.thumbnail_url()) let media = media_from_file(&item, path, caption)
.map_err(|message| FallbackError::Permanent { message })?; .map_err(|message| FallbackError::Permanent { message })?;
Ok(PreparedItem { Ok(PreparedItem {
index, index,
@@ -237,7 +309,7 @@ pub(super) async fn prepare_upload_item(
message: "photo dimensions exceed Telegram limits and no smaller variant is available" message: "photo dimensions exceed Telegram limits and no smaller variant is available"
.into(), .into(),
})?; })?;
let media = media_from_url(&item, url, caption, item.thumbnail_url()) let media = media_from_url(&item, url, caption)
.map_err(|message| FallbackError::Permanent { message })?; .map_err(|message| FallbackError::Permanent { message })?;
Ok(PreparedItem { Ok(PreparedItem {
index, index,
@@ -248,7 +320,7 @@ pub(super) async fn prepare_upload_item(
} }
} else { } else {
let path = file.path().to_path_buf(); let path = file.path().to_path_buf();
let media = media_from_file(&item, path, caption, item.thumbnail_url()) let media = media_from_file(&item, path, caption)
.map_err(|message| FallbackError::Permanent { message })?; .map_err(|message| FallbackError::Permanent { message })?;
Ok(PreparedItem { Ok(PreparedItem {
index, index,
@@ -263,7 +335,7 @@ pub(super) async fn prepare_upload_item(
.ok_or_else(|| FallbackError::Permanent { .ok_or_else(|| FallbackError::Permanent {
message: "media too large".into(), message: "media too large".into(),
})?; })?;
let media = media_from_url(&item, url, caption, item.thumbnail_url()) let media = media_from_url(&item, url, caption)
.map_err(|message| FallbackError::Permanent { message })?; .map_err(|message| FallbackError::Permanent { message })?;
Ok(PreparedItem { Ok(PreparedItem {
index, index,
@@ -277,10 +349,12 @@ pub(super) async fn prepare_upload_item(
/// Download-and-reupload fallback for one media batch. Files over the upload /// Download-and-reupload fallback for one media batch. Files over the upload
/// cap are not downloaded/uploaded; the item falls back to its smaller URL /// cap are not downloaded/uploaded; the item falls back to its smaller URL
/// (which Telegram fetches itself). Items are prepared concurrently (bounded) /// (which Telegram fetches itself). Items are prepared concurrently because the
/// because the downloads are network-bound; the batch is then uploaded in its /// downloads are network-bound, under one process-wide bound ([`PREP_SLOTS`] —
/// original order. Returns the fallback-error without the task attached; /// the URL and queue workers can each be inside a batch, so a per-batch bound
/// callers wrap it with the updated task state. /// would multiply); the batch is then uploaded in its original order. Returns
/// the fallback-error without the task attached; callers wrap it with the
/// updated task state.
pub(super) async fn send_batch_via_upload( pub(super) async fn send_batch_via_upload(
sender: &dyn MediaSender, sender: &dyn MediaSender,
chat_id: i64, chat_id: i64,
@@ -289,7 +363,6 @@ pub(super) async fn send_batch_via_upload(
caption: Option<&str>, caption: Option<&str>,
task: Task, task: Task,
) -> Result<Vec<Message>, SendError> { ) -> Result<Vec<Message>, SendError> {
let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(3));
let mut set = tokio::task::JoinSet::new(); let mut set = tokio::task::JoinSet::new();
for (i, item) in batch.iter().enumerate() { for (i, item) in batch.iter().enumerate() {
let item_caption = if i == 0 { let item_caption = if i == 0 {
@@ -298,9 +371,8 @@ pub(super) async fn send_batch_via_upload(
None None
}; };
let item = item.clone(); let item = item.clone();
let sem = std::sync::Arc::clone(&sem);
set.spawn(async move { set.spawn(async move {
let _permit = sem.acquire().await.expect("upload semaphore closed"); let _permit = PREP_SLOTS.acquire().await.expect("upload semaphore closed");
prepare_upload_item(item, i, item_caption.as_deref()).await prepare_upload_item(item, i, item_caption.as_deref()).await
}); });
} }
@@ -343,3 +415,75 @@ pub(super) async fn send_batch_via_upload(
Err(e) => Err(classify_to_send_error(&e, task, "upload failed")), Err(e) => Err(classify_to_send_error(&e, task, "upload failed")),
} }
} }
#[cfg(test)]
mod download_class_tests {
use super::*;
#[test]
fn download_errors_split_by_whether_a_retry_can_help() {
// Transport failure and a server-side hiccup: try again.
assert!(matches!(
classify_download_error(FetchError::Transient("media status 503".into())),
FallbackError::Retryable { .. }
));
// The media is gone / the host refuses us: a retry repeats the 4xx.
assert!(matches!(
classify_download_error(FetchError::NotFound),
FallbackError::Permanent { .. }
));
assert!(matches!(
classify_download_error(FetchError::Blocked),
FallbackError::Permanent { .. }
));
// Over the cap: degrade to the smaller URL, never retry.
assert!(matches!(
classify_download_error(FetchError::TooLarge),
FallbackError::MediaTooLarge
));
}
#[test]
fn rate_limited_media_keeps_the_server_delay() {
match classify_download_error(FetchError::RateLimited {
site: "media",
retry_after_secs: 60,
}) {
FallbackError::Retryable { delay_seconds } => assert_eq!(delay_seconds, 60.0),
_ => panic!("expected retryable rate limit"),
}
}
#[tokio::test]
async fn a_file_id_item_is_refused_before_any_download() {
let item = MediaItemPayload::Photo {
media: MediaRef::FileId("AgACAgIAAx".into()),
has_spoiler: false,
fallback_url: None,
};
match prepare_upload_item(item, 0, None).await {
Err(FallbackError::Permanent { .. }) => {}
Err(_) => panic!("expected a permanent classification, got a different error"),
Ok(_) => panic!("a file id must be refused, not prepared"),
}
}
#[tokio::test]
async fn oversized_local_media_is_refused_before_upload() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("video.mp4");
let handle = std::fs::File::create(&file).unwrap();
handle.set_len(MAX_MEDIA_UPLOAD_BYTES + 1).unwrap();
drop(handle);
let item = MediaItemPayload::Video {
media: MediaRef::Source(file.to_string_lossy().into_owned()),
has_spoiler: false,
thumbnail: None,
fallback_url: None,
};
assert!(matches!(
prepare_upload_item(item, 0, None).await,
Err(FallbackError::Permanent { .. })
));
}
}
+324 -52
View File
@@ -3,6 +3,7 @@
use crate::db::unix_now; use crate::db::unix_now;
use parking_lot::Mutex; use parking_lot::Mutex;
use rusqlite::OptionalExtension;
use rusqlite::params; use rusqlite::params;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
@@ -22,6 +23,14 @@ pub struct ChatData {
pub message_format: HashMap<String, String>, pub message_format: HashMap<String, String>,
} }
impl ChatData {
/// The chat's caption format for `site`, empty when it has none — the
/// built-in caption then applies (`caption_from_fields`).
pub fn format_for(&self, site: &str) -> String {
self.message_format.get(site).cloned().unwrap_or_default()
}
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)] #[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct EditMessage { pub struct EditMessage {
pub url: String, pub url: String,
@@ -53,53 +62,78 @@ impl ChatStore {
} }
} }
pub async fn get(&self, chat_id: i64) -> ChatData { /// Loads persisted state, distinguishing a missing row and a failed read
/// from valid default settings. Only the ordinary read-only `get` path is
/// allowed to degrade to defaults; mutations must not write those defaults
/// back over a real row.
async fn load(&self, chat_id: i64) -> Result<ChatData, ()> {
if let Some(data) = self.cache.lock().get(&chat_id) { if let Some(data) = self.cache.lock().get(&chat_id) {
return data.clone(); return Ok(data.clone());
} }
let chat_key = chat_id.to_string(); let chat_key = chat_id.to_string();
let payload = self let payload = self
.pool .pool
.with_conn(move |conn| { .with_conn(move |conn| {
// Concurrent handler tasks (batch-forwards) may write chat_state // Concurrent handler tasks (batch-forwards) may write
// while this read runs; the shared busy timeout handles the // chat_state while this read runs; the shared busy timeout
// write-lock collision instead of failing the query. // handles the write-lock collision instead of failing the
let mut stmt = conn.prepare("SELECT payload FROM chat_state WHERE chat_id = ?1")?; // query.
let mut rows = stmt.query(params![chat_key])?; conn.query_row(
match rows.next()? { "SELECT payload FROM chat_state WHERE chat_id = ?1",
Some(row) => Ok(Some(row.get::<_, String>(0)?)), params![chat_key],
None => Ok(None), |row| row.get::<_, String>(0),
} )
.optional()
}) })
.await .await
.unwrap_or_else(|e| { .map_err(|e| {
log::error!("chat_state read failed: {e}"); log::warn!("chat_state read failed: {e}");
None })?;
}) let payload = payload.unwrap_or_default();
.unwrap_or_default(); let data = if payload.is_empty() {
let data: ChatData = serde_json::from_str(&payload).unwrap_or_default(); ChatData::default()
self.cache.lock().insert(chat_id, data.clone()); } else {
data serde_json::from_str(&payload).map_err(|e| {
log::warn!("chat_state payload is invalid: {e}");
})?
};
// Only fill a miss: an unconditional insert would let this (possibly
// stale) snapshot overwrite what a concurrent set just wrote.
self.cache
.lock()
.entry(chat_id)
.or_insert_with(|| data.clone());
Ok(data)
} }
/// Write-through: update the cache and the DB. /// Read-only access may degrade to defaults for display and control flow.
pub async fn set(&self, chat_id: i64, data: &ChatData) { /// Mutating callers use [`Self::update`], which refuses a failed load.
pub async fn get(&self, chat_id: i64) -> ChatData {
self.load(chat_id).await.unwrap_or_default()
}
/// Write-through: update the cache and the DB. Returns whether the DB
/// write landed: the cache is updated either way, so `false` means the
/// change lives only until the next restart and the caller has to say so
/// instead of reporting a save that did not happen.
pub async fn set(&self, chat_id: i64, data: &ChatData) -> bool {
self.cache.lock().insert(chat_id, data.clone()); self.cache.lock().insert(chat_id, data.clone());
let payload = serde_json::to_string(data).expect("chat state serializes"); let payload = serde_json::to_string(data).expect("chat state serializes");
let chat_id = chat_id.to_string(); let chat_id = chat_id.to_string();
let result = self self.pool
.pool .with_conn_or(
.with_conn(move |conn| { log::Level::Warn,
conn.execute( "chat_state write failed",
"INSERT OR REPLACE INTO chat_state (chat_id, payload) VALUES (?1, ?2)", false,
params![chat_id, payload], move |conn| {
)?; conn.execute(
Ok(()) "INSERT OR REPLACE INTO chat_state (chat_id, payload) VALUES (?1, ?2)",
}) params![chat_id, payload],
.await; )?;
if let Err(e) = result { Ok(true)
log::error!("chat_state write failed: {e}"); },
} )
.await
} }
/// The per-chat async lock serializing get→mutate→set cycles. /// The per-chat async lock serializing get→mutate→set cycles.
@@ -115,14 +149,19 @@ impl ChatStore {
/// (the batch-forward design spawns several per chat) each snapshot the /// (the batch-forward design spawns several per chat) each snapshot the
/// same `ChatData` and last-writer-wins would silently drop mutations, /// same `ChatData` and last-writer-wins would silently drop mutations,
/// e.g. a second `edit_message` record. The per-chat lock makes the /// e.g. a second `edit_message` record. The per-chat lock makes the
/// cycle atomic. Returns the closure's result. /// cycle atomic. Returns the closure's result plus whether the DB write
pub async fn update<R>(&self, chat_id: i64, f: impl FnOnce(&mut ChatData) -> R) -> R { /// landed (see [`Self::set`]); callers that do not care ignore the flag.
pub async fn update<R>(
&self,
chat_id: i64,
f: impl FnOnce(&mut ChatData) -> R,
) -> Result<(R, bool), ()> {
let lock = self.lock_for(chat_id); let lock = self.lock_for(chat_id);
let _guard = lock.lock().await; let _guard = lock.lock().await;
let mut data = self.get(chat_id).await; let mut data = self.load(chat_id).await?;
let r = f(&mut data); let r = f(&mut data);
self.set(chat_id, &data).await; let saved = self.set(chat_id, &data).await;
r Ok((r, saved))
} }
/// Removes edit-before-forward records whose `created_at + ttl` is in the /// Removes edit-before-forward records whose `created_at + ttl` is in the
@@ -131,22 +170,70 @@ impl ChatStore {
pub async fn prune_expired(&self, ttl: Duration) -> Vec<(i64, i64)> { pub async fn prune_expired(&self, ttl: Duration) -> Vec<(i64, i64)> {
let now = unix_now(); let now = unix_now();
let ttl_secs = ttl.as_secs() as i64; let ttl_secs = ttl.as_secs() as i64;
// Chats that may have an expired record, from a cache snapshot; the // Chats worth looking at, from a cache snapshot: the ones with an
// pruning itself re-reads and writes under the per-chat lock below // expired record, plus the ones holding no record at all. The latter
// (see the eviction note). Takes no lock of its own, so a chat // used to be left alone for the process lifetime — every chat that ever
// appearing later is simply picked up by the next sweep. // sent a message or ran a command stayed in the cache and in the
let candidates: Vec<i64> = { // per-chat lock map — even though a chat with no live prompt is exactly
// what the eviction below is for. The pruning itself re-reads and
// writes under the per-chat lock below; taking no lock here means a
// chat appearing later is simply picked up by the next sweep.
let mut candidates: Vec<i64> = {
let cache = self.cache.lock(); let cache = self.cache.lock();
cache cache
.iter() .iter()
.filter(|(_, data)| { .filter(|(_, data)| {
data.edit_message data.edit_message.is_empty()
.values() || data
.any(|entry| entry.created_at + ttl_secs <= now) .edit_message
.values()
.any(|entry| entry.created_at + ttl_secs <= now)
}) })
.map(|(chat_id, _)| *chat_id) .map(|(chat_id, _)| *chat_id)
.collect() .collect()
}; };
let persisted = self
.pool
.with_conn_or(
log::Level::Warn,
"expired prompt scan failed",
Vec::<i64>::new(),
move |conn| {
let mut stmt = conn.prepare("SELECT chat_id, payload FROM chat_state")?;
let rows = stmt.query_map([], |row| {
let id: String = row.get(0)?;
let id: i64 = id.parse().map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
0,
rusqlite::types::Type::Text,
Box::new(e),
)
})?;
let payload: String = row.get(1)?;
let data: ChatData = serde_json::from_str(&payload).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
0,
rusqlite::types::Type::Text,
Box::new(e),
)
})?;
Ok((id, data))
})?;
Ok(rows
.filter_map(Result::ok)
.filter(|(_, data)| {
data.edit_message
.values()
.any(|entry| entry.created_at + ttl_secs <= now)
})
.map(|(id, _)| id)
.collect())
},
)
.await;
candidates.extend(persisted);
candidates.sort_unstable();
candidates.dedup();
let mut removed = Vec::new(); let mut removed = Vec::new();
let mut evicted_chats = Vec::new(); let mut evicted_chats = Vec::new();
for chat_id in candidates { for chat_id in candidates {
@@ -173,12 +260,20 @@ impl ChatStore {
} }
if !evicted_chats.is_empty() { if !evicted_chats.is_empty() {
let mut cache = self.cache.lock(); let mut cache = self.cache.lock();
let mut locks = self.locks.lock();
for chat_id in &evicted_chats { for chat_id in &evicted_chats {
cache.remove(chat_id); cache.remove(chat_id);
locks.remove(chat_id);
} }
} }
// Per-chat locks go only while uncontended (the same rule as
// rate_limit's prune): pulling a lock out from under an in-flight
// update — between its `lock_for` clone and its `lock().await` —
// would let a second writer `lock_for` a fresh one and enter the
// critical section concurrently. A contended lock stays until a later
// sweep, and dropping the uncontended ones also catches chats an
// earlier sweep had to skip, so the map stays bounded.
self.locks
.lock()
.retain(|_, lock| Arc::strong_count(lock) > 1);
if !removed.is_empty() { if !removed.is_empty() {
log::info!( log::info!(
"pruned {} expired edit-before-forward record(s)", "pruned {} expired edit-before-forward record(s)",
@@ -215,7 +310,8 @@ mod tests {
}, },
); );
}) })
.await; .await
.unwrap();
})); }));
} }
for h in handles { for h in handles {
@@ -251,7 +347,8 @@ mod tests {
data.edit_message.insert(1, edit_entry(7, now - 3600)); data.edit_message.insert(1, edit_entry(7, now - 3600));
data.edit_message.insert(2, edit_entry(7, now)); data.edit_message.insert(2, edit_entry(7, now));
}) })
.await; .await
.unwrap();
let removed = store.prune_expired(Duration::from_secs(60)).await; let removed = store.prune_expired(Duration::from_secs(60)).await;
@@ -265,6 +362,85 @@ mod tests {
); );
} }
#[tokio::test]
async fn persisted_expired_prompts_are_pruned_after_restart() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cold.db");
let pool = crate::db::open_store(path.to_str().unwrap()).unwrap();
let raw = rusqlite::Connection::open(&path).unwrap();
let data = ChatData {
edit_message: [(1, edit_entry(7, unix_now() - 3600))]
.into_iter()
.collect(),
..ChatData::default()
};
raw.execute(
"INSERT INTO chat_state (chat_id, payload) VALUES ('7', ?1)",
rusqlite::params![serde_json::to_string(&data).unwrap()],
)
.unwrap();
let store = ChatStore::new(pool);
assert!(store.cache.lock().get(&7).is_none());
assert_eq!(
store.prune_expired(Duration::from_secs(60)).await,
vec![(7, 1)]
);
}
#[tokio::test]
async fn an_idle_chat_is_evicted_and_its_state_reloads() {
let dir = tempfile::tempdir().unwrap();
let pool = crate::db::open_store(dir.path().join("e.db").to_str().unwrap()).unwrap();
let store = ChatStore::new(pool);
// Durable settings and no prompt at all: this chat used to sit in the
// cache (and in the per-chat lock map) for the process lifetime,
// because the sweep only ever looked at chats with an *expired* record.
store
.update(9, |data| {
data.forward_channel_id = Some(-100);
data.message_format.insert("twitter".into(), "{url}".into());
})
.await
.unwrap();
assert!(store.cache.lock().contains_key(&9));
let removed = store.prune_expired(Duration::from_secs(60)).await;
assert!(removed.is_empty(), "nothing had expired");
assert!(
!store.cache.lock().contains_key(&9),
"a chat with no live prompt must leave the cache"
);
assert!(!store.locks.lock().contains_key(&9), "…and its lock");
// The DB kept the row, so the next use reloads everything it held.
let data = store.get(9).await;
assert_eq!(data.forward_channel_id, Some(-100));
assert_eq!(
data.message_format.get("twitter").map(String::as_str),
Some("{url}")
);
}
#[tokio::test]
async fn a_live_prompt_keeps_its_chat_cached() {
let dir = tempfile::tempdir().unwrap();
let pool = crate::db::open_store(dir.path().join("k.db").to_str().unwrap()).unwrap();
let store = ChatStore::new(pool);
store
.update(10, |data| {
data.edit_message.insert(1, edit_entry(10, unix_now()));
})
.await
.unwrap();
store.prune_expired(Duration::from_secs(3600)).await;
assert!(
store.cache.lock().contains_key(&10),
"a live prompt holds its chat in the cache"
);
}
#[tokio::test] #[tokio::test]
async fn prune_eviction_keeps_the_persisted_state() { async fn prune_eviction_keeps_the_persisted_state() {
// Every record expires → the chat is evicted from the cache; the // Every record expires → the chat is evicted from the cache; the
@@ -277,7 +453,8 @@ mod tests {
data.template.insert("keep".into(), "[]".into()); data.template.insert("keep".into(), "[]".into());
data.edit_message.insert(1, edit_entry(8, 0)); data.edit_message.insert(1, edit_entry(8, 0));
}) })
.await; .await
.unwrap();
let removed = store.prune_expired(Duration::from_secs(60)).await; let removed = store.prune_expired(Duration::from_secs(60)).await;
@@ -290,4 +467,99 @@ mod tests {
"eviction dropped state the DB never received" "eviction dropped state the DB never received"
); );
} }
#[tokio::test]
async fn a_failed_read_is_not_cached() {
// A read that errors (busy, IO, a missing table) answers the default;
// caching that answer would make the next get return it blind and the
// next update write it back over the chat's real settings.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("f.db");
let pool = crate::db::open_store(path.to_str().unwrap()).unwrap();
let raw = rusqlite::Connection::open(&path).unwrap();
raw.execute_batch("DROP TABLE chat_state").unwrap();
let store = ChatStore::new(pool);
let first = store.get(7).await;
assert!(first.forward_channel_id.is_none());
assert!(
!store.cache.lock().contains_key(&7),
"a failed read must not poison the cache"
);
// The next get retries the DB and sees the real row.
raw.execute_batch(
"CREATE TABLE chat_state (chat_id TEXT PRIMARY KEY, payload TEXT NOT NULL)",
)
.unwrap();
let real = ChatData {
forward_channel_id: Some(42),
..ChatData::default()
};
raw.execute(
"INSERT INTO chat_state (chat_id, payload) VALUES ('7', ?1)",
rusqlite::params![serde_json::to_string(&real).unwrap()],
)
.unwrap();
assert_eq!(store.get(7).await.forward_channel_id, Some(42));
}
#[tokio::test]
async fn a_failed_read_does_not_overwrite_existing_state() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("update.db");
let pool = crate::db::open_store(path.to_str().unwrap()).unwrap();
let raw = rusqlite::Connection::open(&path).unwrap();
let stored = "{\"forward_channel_id\":";
raw.execute(
"INSERT INTO chat_state (chat_id, payload) VALUES ('7', ?1)",
rusqlite::params![stored],
)
.unwrap();
let store = ChatStore::new(pool);
let mut called = false;
let result = store
.update(7, |data| {
called = true;
data.message_format.insert("twitter".into(), "{url}".into());
})
.await;
assert!(result.is_err());
assert!(!called, "a failed load must not run a destructive mutation");
assert!(!store.cache.lock().contains_key(&7));
let payload: String = raw
.query_row(
"SELECT payload FROM chat_state WHERE chat_id='7'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(payload, stored, "the original row must remain unchanged");
}
#[tokio::test]
async fn prune_spares_a_lock_someone_still_holds() {
// The sweep evicts uncontended locks only: removing one an update
// still holds (its `lock_for` clone alive) would let a second writer
// create a fresh lock and enter the critical section concurrently.
let dir = tempfile::tempdir().unwrap();
let pool = crate::db::open_store(dir.path().join("l.db").to_str().unwrap()).unwrap();
let store = ChatStore::new(pool);
store.set(1, &ChatData::default()).await;
let held = store.lock_for(1); // an update between lock_for and lock().await
store.prune_expired(Duration::from_secs(60)).await;
assert!(
store.locks.lock().contains_key(&1),
"a contended lock must survive the sweep"
);
drop(held);
store.prune_expired(Duration::from_secs(60)).await;
assert!(
!store.locks.lock().contains_key(&1),
"the next sweep drops it once uncontended"
);
}
} }
+109
View File
@@ -0,0 +1,109 @@
# Deployment reference for the Docker Hub image. Instance values (token, admins,
# site credentials, domain) live in `.env` next to this file — `docker compose`
# substitutes every `${VAR}` from it automatically — so this file stays in the
# repository unmodified. A variable that is not listed here is not passed into
# the container at all.
#
# JSON-file logs grow without limit by default: a long-running bot (and the
# proxy in front of it) will fill the disk. One cap, applied to every service
# below via the anchor.
x-logging: &default-logging
driver: json-file
options:
max-size: '10m'
max-file: '3'
services:
nginx-proxy:
image: nginxproxy/nginx-proxy:1.11.6-alpine
restart: always
environment:
# Routes requests with an unknown Host (i.e. plain IP access) here; set
# DEFAULT_HOST in .env to use it.
DEFAULT_HOST: '${DEFAULT_HOST:-}'
ports:
- '80:80'
- '443:443'
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
- certs:/etc/nginx/certs:ro
- html:/usr/share/nginx/html:ro
networks: [proxy]
labels:
- 'com.github.nginx-proxy.nginx'
container_name: nginx-proxy
logging: *default-logging
acme-companion:
image: nginxproxy/acme-companion:2.8.2
restart: always
environment:
DEFAULT_EMAIL: '${DEFAULT_EMAIL:-}'
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- certs:/etc/nginx/certs:rw
- html:/usr/share/nginx/html:rw
- acme:/etc/acme.sh
networks: [proxy]
container_name: acme-companion
depends_on:
- nginx-proxy
logging: *default-logging
tgxmb:
image: yoursfunny/telegram-twitter-media-bot:latest
restart: always
environment:
# From .env (the instance's own values; see the env table in README.md).
TELOXIDE_TOKEN: '${TELOXIDE_TOKEN:-}'
BOT_ADMIN: '${BOT_ADMIN:-}'
PIXIV_REFRESH_TOKEN: '${PIXIV_REFRESH_TOKEN:-}'
TWITTER_AUTH_TOKEN: '${TWITTER_AUTH_TOKEN:-}'
BILIBILI_COOKIE: '${BILIBILI_COOKIE:-}'
VIRTUAL_HOST: '${VIRTUAL_HOST:-}'
WEBHOOK_URL: '${WEBHOOK_URL:-}'
WEBHOOK_SECRET_TOKEN: '${WEBHOOK_SECRET_TOKEN:-}'
# Defaults, listed so they are discoverable; override in .env when needed.
LOCAL_USER_ID: '${LOCAL_USER_ID:-9001}'
RUST_LOG: '${RUST_LOG:-info}'
EDIT_MESSAGE_TTL_SECONDS: '${EDIT_MESSAGE_TTL_SECONDS:-86400}'
LINK_CACHE_TTL_SECONDS: '${LINK_CACHE_TTL_SECONDS:-604800}'
CAPTION_QUOTE_TEXT_CHARS: '${CAPTION_QUOTE_TEXT_CHARS:-200}'
VIRTUAL_PORT: '${VIRTUAL_PORT:-8443}'
WEBHOOK: '${WEBHOOK:-true}'
WEBHOOK_LISTEN: '${WEBHOOK_LISTEN:-0.0.0.0}'
WEBHOOK_PORT: '${WEBHOOK_PORT:-8443}'
# For a certificate on a bare IP: uncomment and set ACME_HOST in .env.
# ACME_HOST: '${ACME_HOST:-}'
#
# Not listed on purpose: TELOXIDE_PROXY. Docker Desktop reaches a host
# proxy through host.docker.internal (a loopback address inside the
# container is the container itself), and teloxide panics on an *empty*
# value, so add the line deliberately when this deployment needs one:
# TELOXIDE_PROXY: '${TELOXIDE_PROXY}'
volumes:
- ./data:/app/data
networks: [proxy]
depends_on:
- nginx-proxy
container_name: tgxmb
logging: *default-logging
# Probes the listener only when WEBHOOK=true (compose interpolates the
# value from .env); a polling deployment has no listener and must not be
# reported unhealthy. nginx-proxy shows 502s while webhook mode is down,
# so surface that to the orchestrator.
healthcheck:
test: ["CMD-SHELL", "test '${WEBHOOK:-true}' != true || bash -c 'exec 3<>/dev/tcp/127.0.0.1/${WEBHOOK_PORT:-8443}'"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
certs:
html:
acme:
networks:
proxy:
name: proxy
-74
View File
@@ -1,74 +0,0 @@
services:
nginx-proxy:
image: nginxproxy/nginx-proxy:1.11.6-alpine
restart: always
ports:
- '80:80'
- '443:443'
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
- certs:/etc/nginx/certs:ro
- html:/usr/share/nginx/html:ro
networks: [proxy]
labels:
- 'com.github.nginx-proxy.nginx'
container_name: nginx-proxy
acme-companion:
image: nginxproxy/acme-companion
restart: always
environment:
DEFAULT_EMAIL: ''
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- certs:/etc/nginx/certs:rw
- html:/usr/share/nginx/html:rw
- acme:/etc/acme.sh
networks: [proxy]
container_name: acme-companion
depends_on:
- nginx-proxy
tgxmb:
image: yoursfunny/telegram-twitter-media-bot:latest
restart: always
environment:
LOCAL_USER_ID: '1000'
TELOXIDE_TOKEN: ''
BOT_ADMIN: ''
PIXIV_REFRESH_TOKEN: ''
TWITTER_AUTH_TOKEN: ''
EDIT_MESSAGE_TTL_SECONDS: '86400'
LINK_CACHE_TTL_SECONDS: '604800'
RUST_LOG: 'info'
VIRTUAL_HOST: '<YOUR_DOMAIN>'
VIRTUAL_PORT: '8443'
# ACME_HOST: 'your.domain.com'
WEBHOOK: 'true'
WEBHOOK_LISTEN: '0.0.0.0'
WEBHOOK_PORT: '8443'
WEBHOOK_URL: 'https://<YOUR_DOMAIN>/'
WEBHOOK_SECRET_TOKEN: ''
volumes:
- ./data:/app/data
networks: [proxy]
depends_on:
- nginx-proxy
container_name: tgxmb
# Webhook mode only: the bot listens on WEBHOOK_PORT; nginx-proxy shows
# 502s while this is down, so surface it to the orchestrator.
healthcheck:
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8443'"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
certs:
html:
acme:
networks:
proxy:
name: proxy
+20 -4
View File
@@ -5,6 +5,22 @@ if [ "$(id -u)" -eq '0' ]
then then
USER_ID=${LOCAL_USER_ID:-9001} USER_ID=${LOCAL_USER_ID:-9001}
# A non-numeric id breaks useradd/usermod in confusing ways, and uid 0
# would sail straight through the privilege drop below (`setpriv
# --reuid=0` keeps the bot root while looking configured) — refuse both
# up front.
case $USER_ID in
''|*[!0-9]*)
echo "docker-entrypoint: LOCAL_USER_ID must be a numeric uid, got '$USER_ID'" >&2
exit 1
;;
esac
if [ "$USER_ID" -eq 0 ]
then
echo "docker-entrypoint: LOCAL_USER_ID=0 would keep the bot root; refusing" >&2
exit 1
fi
# `docker compose restart` / `docker restart` reuse the same container, so # `docker compose restart` / `docker restart` reuse the same container, so
# the overlay fs keeps the user created on first boot. A second `useradd` # the overlay fs keeps the user created on first boot. A second `useradd`
# then fails with exit code 9, which would trip `set -e` and kill the # then fails with exit code 9, which would trip `set -e` and kill the
@@ -12,18 +28,18 @@ then
# otherwise so LOCAL_USER_ID changes still apply. # otherwise so LOCAL_USER_ID changes still apply.
if ! id user > /dev/null 2>&1 if ! id user > /dev/null 2>&1
then then
useradd --shell /bin/bash -u ${USER_ID} -o -c "" -m user > /dev/null 2>&1 || true useradd --shell /bin/bash -u "${USER_ID}" -o -c "" -m user > /dev/null 2>&1 || true
else else
usermod -u ${USER_ID} -o user > /dev/null 2>&1 || true usermod -u "${USER_ID}" -o user > /dev/null 2>&1 || true
fi fi
# Bind-mounted volumes may not support chown; a failure here must not kill # Bind-mounted volumes may not support chown; a failure here must not kill
# the container either. # the container either.
chown -R `id -u user`:`id -u user` /app > /dev/null 2>&1 || true chown -R "$(id -u user):$(id -g user)" /app > /dev/null 2>&1 || true
export HOME=/home/user export HOME=/home/user
# setpriv (util-linux, present in bookworm-slim) replaces gosu: drop to the # setpriv (util-linux, present in bookworm-slim) replaces gosu: drop to the
# target user and exec, keeping the process as PID 1. # target user and exec, keeping the process as PID 1.
exec setpriv --reuid=`id -u user` --regid=`id -g user` --init-groups "$@" exec setpriv --reuid="$(id -u user)" --regid="$(id -g user)" --init-groups "$@"
fi fi
exec "$@" exec "$@"