347 Commits
Author SHA1 Message Date
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 v1.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 v1.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 v1.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
YoursFunny fb601f4d5d chore: bump version to 1.7.0 v1.7.0 2026-09-18 01:17:45 +08:00
YoursFunny d8dd4fa91e test(cache): pin that pre-split entries still parse
A payload written before the title/content split has no `content` field;
`#[serde(default)]` is what keeps it readable, and the cache deletes any
payload it cannot parse — so dropping that default would silently evict
entries rather than degrade them. The test inserts the literal pre-split
JSON and asserts it comes back with its text left in `title` (no
migration: the entry lives one TTL and moving the text would only
reshuffle `/set_format` placeholders until it expires) and its stored
caption untouched.
2026-09-18 01:14:09 +08:00
YoursFunny af96caff40 feat(send): quote a long post's text in an expandable blockquote
A post whose text (the split `title` plus `content`, joined by
`site::compose_text`) reaches `CAPTION_QUOTE_TEXT_CHARS` — default 200,
`0` disables — now has that text wrapped in `<blockquote expandable>`
inside its caption, leaving the URL and author line outside the quote.

Applied at the send boundary (`send_media_sequence`, `send_animation` and
the inline answers), where the caption is already truncated and the same
cache snapshot supplies the text, so a fresh send, a link-cache resend
and a queued retry all decide identically. The text is located as what
follows the author link, with the visible prefix accepted as a match
because `truncate_caption` may cut inside it — that keeps the longest
posts, the ones that most need folding, quoted. Captions whose layout
moves the text elsewhere (pixiv's title-inside-a-link, a `/set_format`
that puts `{title}`/`{content}` first) stay unquoted rather than risking
a blockquote nested in a tag, and a caption that already carries one is
never wrapped again.

Telegram measures a caption *after entities parsing*, so the tags cost no
length and the 1024-character limit cannot be breached; retries replay
the unwrapped caption, so a threshold change takes effect immediately.
The edit-before-forward rewrite stays unquoted by design.

Verified against Telegram: a media-group caption built this way comes
back with `caption_entities` `url` @0, `text_link` @50,
`expandable_blockquote` @56 — the quote starts after the author line.
2026-09-18 00:50:30 +08:00
YoursFunny 52184ba6fb refactor(x-media): split the post title from its content
`Fetched.title` carried whatever text the platform had — a tweet's body,
a bilibili dynamic's body, a pixiv artwork's title — which was enough
while x/twitter (no title at all) set the shape. The platforms actually
disagree: pixiv has a title *and* a description, bilibili has an opus
headline *and* a body. Posts now carry both:

- `title`: the platform's title (a pixiv artwork title, a bilibili opus
  headline or video card title), empty on text-only platforms;
- `content`: the body (tweet / bsky / misskey text, bilibili dynamic
  body, and pixiv's description — fetched for the first time here and
  flattened from the app API's HTML to plain text).

`{content}` joins the caption-format placeholders, so a custom
`/set_format` can include a pixiv description. The built-in captions keep
producing byte-identical output: `compose_text` joins the two fields the
same way the single field already was, and bilibili's forward marker
(`//@author:`) now lands in `content` behind the head line's `title`.

`CachedPost.content` is `#[serde(default)]`, so link-cache entries and
queued task payloads written before the split still parse, their text
living in `title`.
2026-09-18 00:44:49 +08:00
YoursFunny 0eb4e5c78d fix(sites): request the opus serialization so bilibili posts keep their text
An image/text post fetched without `features=itemOpusStyle` comes back in
bilibili's legacy shape, where the post's body and headline are gone
completely — `desc: null`, no `major.opus` — so `title` (and `{title}`)
stayed empty for exactly the posts that do have content
(`opus/1248857553488576532`: legacy `desc: null`, flagged
`major.opus.summary.text = "[doge_金箍]黑白搭配"`). The same flag also
moves the pictures to `major.opus.pics` (key `url`, not `src`).

Text now falls back opus (headline + body) → `desc.text` → archive card
title; media falls back `opus.pics` → `draw.items` → archive cover, so
the legacy shapes keep working if the flag is ever retired.

Verified live: the reported link now yields title "[doge_金箍]黑白搭配"
with its picture; AV dynamics keep their card title; forwards keep
`desc.text` and gain `//@` composition unchanged.
2026-09-17 22:34:11 +08:00