Compare commits

...
273 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 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
YoursFunny fb601f4d5d chore: bump version to 1.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
YoursFunny 5c51de217a fix(sites): use the archive title when a bilibili dynamic has no body
A 视频投稿动态 (`DYNAMIC_TYPE_AV`) carries no body at all: the API
answers `desc: null` and the content is the archive card, so `title`
(and `{title}` in caption formats) stayed empty for the most common
dynamic type. Audited 24 live dynamics: every dynamic that *has* text
(a 图文 post, a forward, a text post) keeps it in
`module_dynamic.desc.text` — only the AV card has none, so the video
title now stands in, mirroring pixiv whose `title` is the artwork title
rather than post text.

Also records two API observations in comments/docs: an id that cannot
exist answers `4101105 请求数据发生错误` (kept on the permanent arm), and
the feed endpoints strip `desc.text` so only the detail endpoint shows
whether a post has text.
2026-09-17 22:12:53 +08:00
YoursFunny c1f5d3ca54 feat(sites): add bilibili dynamic support (images and animated images)
Fetch `t.bilibili.com/<id>`, `www.bilibili.com/opus/<id>`,
`t.bilibili.com/h5/dynamic/detail/<id>` and `m.bilibili.com/dynamic/<id>`
through the anonymous `/x/polymer/web-dynamic/v1/detail` endpoint (no
cookie, no WBI signature; the site adds the device cookies
`/x/frontend/finger/spi` hands out, which is what lifts bilibili's
`-352` risk control).

Media: the `major.draw` grid (`.gif` sources become animations, the rest
photos with a downscaled `@518w.jpg` thumbnail used both as preview and
as the oversized fallback), an attached video's cover, and the quoted
dynamic's media for forwards. The video stream itself is not resolved;
`b23.tv` short links stay unmatched (they mostly point at videos, so
matching them would turn a silently ignored link into a failure reply).

`-352`/`-412` map to a retryable error so the queue backs off instead of
dropping the post; a removed dynamic (`500`) is permanent.

Registry-driven, so no bot-side code changes beyond the site lists in the
command replies; found while researching nazurin and
telegram-bili-feed-helper (see BILIBILI_PLAN.md).
2026-09-17 20:48:15 +08:00
YoursFunny 1bb6968108 Merge pull request #3 from TheFunny/dependabot/cargo/rand-0.10.2
build(deps): bump rand from 0.8.8 to 0.10.2
2026-09-17 17:05:30 +08:00
YoursFunny 14b444d109 Merge master into the rand 0.10 migration
Resolve the manifest and lockfile overlap with the rusqlite 0.40 and zip 8.6
bumps that landed on master after this branch was opened:
- crates/xmedia-bot/Cargo.toml: keep rusqlite 0.40 and rand 0.10
- Cargo.lock: re-point x-media/xmedia-bot at rand 0.10.2; teloxide keeps its
  own rand 0.8.8 (it requires ^0.8.5), and cargo pruned the now-unused
  version_check entry

Verified on the merged tree: cargo metadata --locked, cargo fmt --check,
cargo clippy --workspace --all-targets --locked -- -D warnings,
cargo test --workspace --locked (69 + 73 tests).
2026-09-17 16:59:27 +08:00
YoursFunny de3105d4cd Merge pull request #2 from TheFunny/dependabot/cargo/zip-8.6.0
build(deps): bump zip from 2.4.2 to 8.6.0
2026-09-17 16:54:44 +08:00
YoursFunny c46103a23f Merge pull request #1 from TheFunny/dependabot/cargo/rusqlite-0.40.2
build(deps): bump rusqlite from 0.32.1 to 0.40.2
2026-09-17 16:54:00 +08:00
YoursFunny 9529f64b41 fix(send): migrate rand usage to the 0.10 API
rand 0.10 renamed the entry points dependabot's version bump alone cannot
follow: `thread_rng()` -> `rng()`, `gen_range()` -> `random_range()` and the
`Rng` trait -> `RngExt`.

The jitter only needs one float, so use the free function
`rand::random_range(0.2..0.8)` and drop the now-unused trait import instead
of importing `RngExt`.

Verified: cargo fmt --check, cargo clippy --workspace --all-targets -- -D
warnings, cargo test --workspace --locked (retry_delay_seconds_bounds keeps
the 0.2..0.8 jitter window).
2026-09-17 16:43:01 +08:00
dependabot[bot] d0de17329d build(deps): bump rand from 0.8.8 to 0.10.2
Bumps [rand](https://github.com/rust-random/rand) from 0.8.8 to 0.10.2.
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/rand/compare/0.8.8...0.10.2)

---
updated-dependencies:
- dependency-name: rand
  dependency-version: 0.10.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-17 03:43:17 +00:00
dependabot[bot] 9af37e92b4 build(deps): bump zip from 2.4.2 to 8.6.0
Bumps [zip](https://github.com/zip-rs/zip2) from 2.4.2 to 8.6.0.
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v2.4.2...v8.6.0)

---
updated-dependencies:
- dependency-name: zip
  dependency-version: 8.6.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-17 03:43:10 +00:00
dependabot[bot] e68a1dbd30 build(deps): bump rusqlite from 0.32.1 to 0.40.2
Bumps [rusqlite](https://github.com/rusqlite/rusqlite) from 0.32.1 to 0.40.2.
- [Release notes](https://github.com/rusqlite/rusqlite/releases)
- [Changelog](https://github.com/rusqlite/rusqlite/blob/master/Changelog.md)
- [Commits](https://github.com/rusqlite/rusqlite/compare/v0.32.1...v0.40.2)

---
updated-dependencies:
- dependency-name: rusqlite
  dependency-version: 0.40.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-17 03:43:05 +00:00
YoursFunny b9c6d16ff0 docs(ci): note why the docker job skips actions/checkout
build-push-action defaults to the Git context, so BuildKit clones the
repo itself and the job never needs the workspace.
2026-09-17 11:38:49 +08:00
YoursFunny ec65c3ce74 chore: bump version to 1.6.0
Bump both crates (x-media, xmedia-bot) and refresh the lockfile to the
latest semver-compatible releases. No direct dependency or manifest
requirement changed.
2026-09-17 11:21:58 +08:00
YoursFunny 893ab7a1e0 feat(commands): /test sends the media, /debug takes over the parse report
- `/test <url>` now runs the ordinary link pipeline and actually sends the
  media, but with the chat's post-send actions suppressed: no channel forward,
  no edit-before-forward prompt. It is the same code path as a normal link
  (same caption/format handling, link cache, retries, dead-letter
  notification), so "does this link work?" is answered by the send itself.
- `/debug <url>` keeps what `/test` used to do: fetch and reply with the HTML
  parse report, sending/caching/forwarding nothing.
- `urls::url_media` takes a `PostSend` mode (`FromChat` for the URL workers,
  `Suppressed` for `/test`); `build_send_task` maps it to the task's
  `edit_before_forward`/`forward_channel_id`. Notification ids stay set in both
  modes, so a queued retry still reports a dead-letter to the chat.
- `/test` rejects an unsupported URL with the same message the old parse-only
  command used (the URL flow would otherwise ignore it silently).
- Report builder renamed `test_parse_report` -> `debug_report` (with the cap
  constant), `parse_test_arg` -> `parse_arg_remainder` (now shared by both
  commands). README/README.en command tables and AGENTS.md updated; `/help`
  descriptions come from the enum.

Tests: +3 (normal flow still honours the chat's settings, `/test` sends with
them suppressed and keeps the cache entry, `build_send_task` mode mapping). The
suppression test was verified to fail when the mode is ignored.
fmt/clippy clean, 73 + 69 tests pass.
2026-09-17 02:25:42 +08:00
YoursFunny bd032e3d68 ci: lock the dependency set, verify the build inputs on PRs, harden the jobs
- `--locked` on every cargo invocation (ci.yml clippy/test/build, both
  Dockerfile builds). The version bump edits Cargo.lock by hand, so a stale
  lock must fail loudly instead of being silently re-resolved: CI would
  otherwise test a different dependency set than the one committed — and than
  the one the released image is built from.
- docker.yml: build the image (no push, no registry login, read-only build
  cache) on pull requests touching the build inputs. The Dockerfile's
  stub-source machinery, the ffmpeg download and the entrypoint previously
  only ran at release time. Also: a release tag must equal both crate versions
  before anything is built (the binary carries no version, so `v1.5.1` with
  manifests at 1.5.0 used to publish silently wrong tags), `FFMPEG_URL`/
  `FFMPEG_SHA256` are taken from repository variables when set, and the
  unused `setup-qemu-action` step is gone (single-arch build; the comment says
  what arm64 would need).
- ci.yml: `concurrency` cancels superseded runs, `permissions: contents: read`,
  `RUST_BACKTRACE=1`, job timeouts, and a release-profile build of the same
  package the Dockerfile builds (the profile was otherwise never compiled
  before a merge). The `live` job narrows to `-p x-media`: every network- or
  secret-gated test lives there, and the bot crate's offline suite already ran
  in the `test` job. Timeout is 45 min because the release build is cold on
  the first run — a timeout there would kill the job before rust-cache could
  save its cache, leaving every later run cold too.
- Actions pinned to commit SHAs (Dependabot keeps them current);
  `dtolnay/rust-toolchain` stays on its channel ref by design.
- .github/dependabot.yml: crates (patch bumps grouped), action pins, Docker
  base images — the audit gate reports advisories, this is what moves them.
- tokio's `sync` feature is now declared instead of arriving transitively via
  teloxide; `.dockerignore` drops docs and markdown.

Verified locally: `cargo fmt --check`, `cargo clippy --workspace
--all-targets --locked`, `cargo test --workspace --locked` (70 + 69 pass),
`cargo build --release --locked` (6m03s cold, the 15.9 MB stripped binary
starts and registers 10 commands), the tag/version gate against both a
matching and a mismatching tag, and YAML parsing of all three workflow files.
2026-09-17 02:01:43 +08:00
YoursFunny dbda6ec1c2 refactor(send): split the 2100-line module by concern
send.rs had grown back into the shape handlers.rs was split out of: payload
types, error classification, the download-and-reupload fallback, the senders
and the whole post-send/queue shell in one file. Split by concern, leaving
call sites (`crate::send::x`) unchanged:

- `send/input_media.rs`: payload → `InputFile`/`InputMedia` selection and
  `build_media_group` (with its caption-on-first-item rule).
- `send/upload.rs`: the fallback pipeline (download with the upload cap,
  photo downscale handoff, smaller-URL fallback, multipart upload).
- `send/post_send.rs`: link-cache write, the `KEEP_ALIVE` registry for locally
  produced media, `settle_task`, the post-send actions and the queue entry
  points; the parts other modules call are re-exported.
- `send/mod.rs`: payloads, error classification, classification helpers and
  the senders themselves, plus the test module.

No behaviour change: 128 + 1326 + 366 + 345 lines, 70 tests still pass.
AGENTS.md updated for the new layout and for `ctx.rs`.
2026-09-17 01:41:28 +08:00
YoursFunny 0a9ff58a69 refactor: cover the edit/answer surface in MediaSender, test the button flows
docs/architecture-refactor.md §3 sketched the trait with "按需扩展:
edit_message_caption / delete_message / answer_callback_query …", but only the
five send methods landed, so `callback.rs` and the edit-before-forward caption
swap were stuck on the concrete `Bot` and remained untested (AGENTS.md still
lists callback.rs as untestable).

- `MediaSender` gains `answer_callback_query`, `edit_message_caption` (HTML
  parse mode baked in, every caller uses it) and `delete_message`; the mock
  records call order plus the texts, captions and answer toasts, so tests can
  assert what the user saw.
- `send_message` now returns the sent message id instead of the whole
  `Message`: the only consumer of the value is the edit-before-forward prompt
  (which keys its record by it), and returning a `Message` forced every mock
  to build a teloxide type. `reply`/`reply_html` follow.
- `callback.rs`: the dptree entry only unpacks the update; `handle_callback`
  takes plain values + `&AppContext`. `handlers/mod.rs::edit_message_handler`
  likewise takes the values the reply carries. Admin/setup APIs
  (`get_chat`, `get_chat_administrators`, `get_me`, `set_my_commands`) stay on
  the concrete `Bot`: they are not user flows worth a trait.
- The scripted mock moves to `parking_lot::Mutex` (no poisoning unwraps).

Tests: +11 (template button, forward ok/no-channel/retryable, expired+unknown
prompt, caption swap via template, escaping of user text into the caption,
failed swap still consuming the reply, prompt record written by post_send).
fmt/clippy clean, 70 + 69 tests pass.
2026-09-17 01:31:30 +08:00
YoursFunny c2d7c8406e refactor: finish the phase-B seam for the post-send path, funnel settlement
docs/architecture-refactor.md §3 stopped half-done: `url_media` got an injected
`AppContext`, but `send.rs`'s post-send half kept reaching for the process-wide
`CHAT_STORE`/`TASK_QUEUE`/`LINK_CACHE` statics, so the whole shell after a
successful send (edit-before-forward prompt, channel forward, retry enqueue,
cache write) had no test and no way to get one.

- `ctx.rs` now owns `AppContext` (sender + the three stores + config) with
  `from_statics` for production and a `CONTEXT` static for the spawned worker
  closures; `handlers/urls.rs` drops its private copy and the duplicated
  assembler, and the queue handler/dead-letter callbacks take the context
  (main wires them with `CONTEXT`).
- `send_media_sequence`/`send_animation`/`forward_messages`/`post_send_actions`
  take `&AppContext`; the cache write goes through the injected cache.
- New `settle_task(ctx, task, Sent|Failed)` is the single place that ends a
  task: release its keep-alive temp media, and drop the link-cache entry only
  on failure. All five former call sites funnel through it — the earlier
  keep-alive leak existed precisely because one of them had to remember.
  `invalidate_cache`/`invalidate_cache_with` (static + injected pair, the
  latter only existing because of the former) collapse into one private fn.
- `ctx::test_support::TestStores` gives tests a tempdir store set + context;
  `handlers/urls.rs` tests use it instead of hand-rolled setup.

Tests: +5 (post-send forward ok / queued / notified, settle Sent/Failed); the
post-send and settle paths were previously untested. fmt/clippy clean,
60 + 69 tests pass.
2026-09-17 01:27:05 +08:00
YoursFunny abdc27ed5e docs: resync AGENTS.md and the doc comments with the code
AGENTS.md:
- db.rs row claimed a per-store connection pool; there is one shared pool for
  all three tables (statics.rs builds it once).
- retry enqueue moved to send.rs, noted in both handlers rows.
- queue row now names both notifies (workers' + the sweep's).
- Retries bullet documents fetch vs fetch_once.
- test count ~125 -> ~135, the untested-files list no longer claims state.rs
  and handlers.rs are untested, and the live-test inventory mentions the
  token-gated, not-#[ignore]d pixiv download test that makes a local
  `cargo test --workspace` hit the network.
- /bot_dict is admin-only now.

Code docs:
- site/mod.rs: the module doc pointed new sites at `fetch_once` (a name that
  did not exist then and now means a single-attempt fetch) -> `SITES`; the
  cache_key/SITES/Site docs still said "twitter -> bsky -> pixiv" (misskey
  is registered third); RenderData now documents which fields are escaped
  and why url/author_url are not.
- state.rs, callback.rs: drop the pre-misskey site list and the `<name>`
  that rustdoc read as an HTML tag.
- Fixed the remaining rustdoc links/warnings: `cargo doc --workspace
  --no-deps` is now warning-free (was 8).
- docs/site-registry-refactor.md: §1 describes the pre-refactor state; said so.

No behavior change. fmt/clippy clean, 55 + 68 tests pass (the live pixiv
download test flaked on a CDN body timeout, as before).
2026-09-16 23:28:46 +08:00
YoursFunny 3fb8421c3a perf: stop the sweep stealing worker wakeups, retry-free inline fetch
- queue: the lease-expiry sweep waited on the workers' `Notify`. `notify_one`
  stores a permit, so a sweep wakeup could consume the one meant for a worker,
  which then blocked on `notified()` (it only waits when the table looked
  empty, i.e. indefinitely) with a due row sitting there. The sweep now has
  its own notify, woken only by stop.
- x-media: split fetch's retry loop into `fetch` (3 attempts, unchanged) and
  `fetch_once` (1 attempt); inline queries use the latter — the 800ms debounce
  plus 1s/2s backoffs were outlasting the answer window of the query.
- send: chunk_media_items now moves items out of the input Vec instead of
  requiring `T: Clone` and copying every payload.

fmt/clippy clean, 55 + 69 tests pass.
2026-09-16 21:23:53 +08:00
YoursFunny 475cfd18f9 fix: harden the debug command, link cache and rate limiter
- commands: /bot_dict dumped the whole chat state to any member of the chat
  and could exceed Telegram's 4096-char message limit (the send then failed
  and bubbled up as a handler error). It is now admin-only and capped at
  MAX_DEBUG_DUMP_CHARS; README, README.en and the /help description updated.
- send: the edit-before-forward template buttons were built from a HashMap
  walk, so their order changed between prompts. Now sorted by name.
- link_cache: an unparseable payload (older schema) was reported as a miss
  but left in place, re-failing the parse on every later hit; the row is
  dropped on read.
- handlers: a link handed to the URL workers after the channel closed
  (shutdown) was discarded silently; it is now logged.
- rate_limit: LIMITERS kept one bucket per chat that ever sent media,
  forever. The periodic sweep now drops buckets that are idle (refilled to
  capacity) and not held by an in-flight sender; acquire's refill was
  factored into a shared helper used by the idle check.

Tests: +3 (corrupted row dropped, sorted markup, idle-bucket pruning); the
cache one was verified to fail before the fix. fmt/clippy clean, 55 + 69.
2026-09-16 21:16:14 +08:00
YoursFunny 0a577600fd fix: repair four correctness defects in the send/state/handler paths
- send: the PHOTO_INVALID_DIMENSIONS marker never matched (the description is
  lower-cased, the marker was not), so oversized photos sent by URL were
  classified Permanent instead of taking the download-and-downscale fallback.
- inline: the debounce state was one global slot, so a second user's query
  cancelled the first user's pending answer entirely; it is now per user.
- state: prune_expired wrote back a stale snapshot without the per-chat lock,
  clobbering a concurrent update() (lost edit-message record -> "Expired");
  it now re-reads and prunes under the same lock update() uses.
- send/queue: a task dead-lettered on retry exhaustion kept its keep-alive
  temp media (ugoira MP4) alive until process exit; dead_letter_notify now
  releases it, and enqueue_retry releases when the enqueue itself fails.

Also folds the duplicated retry enqueue in post_send_actions into
send::enqueue_retry (single clock source, single place that releases).

Tests: +6 (marker, per-user debounce x3, keep-alive release, prune contract
x2); the marker and keep-alive cases were verified to fail before the fix.
cargo fmt/clippy clean, 52 + 69 tests pass.
2026-09-16 21:11:39 +08:00
YoursFunny 32254fa807 chore: bump version to 1.5.0 2026-09-07 21:26:24 +08:00
YoursFunny 11c04b66dc feat: add Misskey (misskey.io) fetch support
Fourth site adapter: POST /api/notes/show, renote-aware caption and
media normalization, DriveFile type → Illustration/Animated/Video.
Empty thumbnailUrl strings filtered out in thumbnail_for.
2026-09-07 21:25:52 +08:00
YoursFunny 2f741e5f4b refactor(send): apply ponytail audit cuts 2, 4, 6
- updated_sequence_task: clone the Task and mutate the two fields
  instead of rebuilding all 12 by hand (-22 lines; new fields no
  longer need a sync here)
- unify unix_now with db::now_f64 (unix_now() = now_f64() as i64),
  moved to db.rs next to its clock source
- classify_to_send_error takes the MediaFetchFailure label, folding
  the duplicated inline match in send_batch_via_upload (-8 lines)
2026-09-07 19:19:56 +08:00
YoursFunny 89c4642e1c fix(lint): resolve clippy warnings from rust 1.98
- photo.rs: chunks_exact(4)/(2) -> as_chunks::<N>().0
  (chunks_exact_to_as_chunks, the new lint prefers the
  compile-time-checked slice split)
- send.rs: box the Task inside SendError so the error fits the
  result_large_err limit (Task is ~400 bytes; the error now moves
  through Result as a pointer); unbox with *task at the two
  enqueue_retry call sites (handlers/urls.rs, handlers/callback.rs)

cargo clippy --workspace --all-targets is now warning-free; the
remaining proc-macro-error2 future-incompat note is upstream
(teloxide -> aquamarine) and unfixable locally. Full test suite passes.
2026-09-07 17:00:38 +08:00
YoursFunny 3f6a0f034a chore: bump version to 1.4.0
1.3.0 → 1.4.0: new features (DATA_DIR config, /test blockquote HTML
report) plus the twitter entity-decode, post-send-actions and queue
lease-heartbeat fixes.
2026-08-16 17:43:54 +08:00
YoursFunny 90a011e978 feat(commands): wrap the /test caption in a blockquote (HTML report)
Replaces the strip-tags plain-text rendering: the /test reply is now an
HTML message (reply_html helper with ParseMode::Html). Raw fields (url,
source_url, title, author_url, media urls) are escaped, the pre-escaped
render fields are embedded as-is, and the caption is wrapped in
<blockquote>...</blockquote> so the report shows it exactly as it will
render in the sent media caption — escaped text and clickable links
included, no literal &amp;/&lt;/&gt; and no raw markup.
2026-08-16 17:31:54 +08:00
YoursFunny 12a065846c feat(statics): make the SQLite path configurable via DATA_DIR
The DB file was hardcoded to CWD-relative data/task_queue.db — a footgun
for systemd/cron deployments and a confusing startup failure when the
data/ dir did not exist (SQLite never creates parent dirs).

db_path() now reads DATA_DIR (default data, CWD-relative, unchanged for
local runs and the docker-compose ./data mount) and creates the
directory automatically. README/README.en.md env tables and AGENTS.md
document the new variable.
2026-08-16 17:07:33 +08:00
YoursFunny dca1eff1c9 ci: add a cargo-audit dependency vulnerability gate
Runs actions-rust-lang/audit after the offline tests in the test job: a
crate in Cargo.lock with an unfixed security advisory fails the build.
Verified locally against the current lockfile (0 vulnerabilities; the 3
warnings — unmaintained dotenv/proc-macro-error2 and transitive anyhow
unsoundness — do not fail by default).
2026-08-16 17:06:28 +08:00
YoursFunny 894a9ebf4a docs: correct the user-facing string language claim in AGENTS.md
AGENTS.md claimed user-facing bot strings are Chinese, but every
reply/send_message string in the code is English (Hello!, Send failed,
No media found, Reply to edit message, ...). README stays Chinese;
update both the overview line and the convention line to state the
actual split.
2026-08-16 17:02:05 +08:00
YoursFunny c968891ff6 fix(commands): render the /test caption as plain text
The report's caption line still showed the raw HTML markup
(<a href="...">...</a>). strip_html_tags now drops the tags (keeping
the visible text; the links are already reported via source_url /
author_url) and the remaining entity-encoded text is decoded — the
strip runs on the escaped caption so a tweet text like >^ω^< survives
instead of being eaten as markup. Custom-format captions contain no
tags and pass through unchanged.
2026-08-16 17:01:47 +08:00
YoursFunny 0087bd01ac fix(queue): heartbeat the lease so long tasks are not re-processed
The lease was set once to now + LOCK_TTL_SECONDS (120 s) with no
renewal. Tasks that legitimately take longer — slow CDN downloads,
ugoira encodes, rate-limited batch forwards (a 100-message channel copy
waits ~4 min on the per-chat token bucket) — had their lease expire
mid-run; the 30 s expiry sweep flipped the row back to pending and
another worker processed it again, double-sending.

run_with_lease now drives the handler through tokio::select! and
refreshes locked_until every 30 s while it runs. The heartbeat lives in
the same future as the handler, so a panicking worker still lets the
sweep recover the row (no leaked task keeping the lease fresh forever).
2026-08-16 17:00:38 +08:00
YoursFunny 4cb40909c5 fix(send): run post_send_actions after retried sends
A task only reaches the queue after a failed send, so the fresh attempt
never ran post_send_actions (edit-before-forward prompt / channel
forward) — it failed before that point. The old guard skipped
post_send_actions for resumed tasks (batch_index > 0 or sent ids
present), which meant any send that needed a retry after partial
progress silently lost its forward and edit prompt.

post_send_actions is now run unconditionally on a successful queue send;
it executes exactly once, after the whole sequence completed.
2026-08-16 17:00:31 +08:00
YoursFunny f260f41755 docs: align AGENTS.md and READMEs with the current code
AGENTS.md: document the twitter API entity decode and the /test report
HTML-decoded display; add the missing db.rs / media_sender.rs /
rate_limit.rs module rows; fix the statics location (handlers/statics.rs);
refresh test counts (~115, twitter live 5, pixiv api.rs 1, photo heavy
test); versioning convention now includes README.en.md.
README.md / README.en.md: add TELOXIDE_PROXY to the env variable list.
2026-08-16 16:31:22 +08:00
YoursFunny af901caddb fix(twitter): decode API HTML entities so captions escape exactly once
Twitter's syndication and GraphQL APIs return tweet text and display
names pre-escaped for HTML (&gt; &lt; &amp; &#39;); the caption builder
escaped the text again, so sent messages showed literal entities (e.g.
>^ω^< came back as &gt;^ω^&lt;). from_syndication_json now decodes the
API text before storing it — both the syndication path and the
TWITTER_AUTH_TOKEN GraphQL fallback route through it — so the caption
escapes exactly once and renders correctly.

The /test report is a plain-text message but printed the pre-escaped
caption and render fields; it now HTML-decodes them for display so the
report shows the rendered text.
2026-08-16 16:31:16 +08:00
YoursFunny c0af42b1cc chore: bump version to 1.3.0 2026-08-15 15:49:40 +08:00
YoursFunny d0810217b4 feat(commands): add /test debug command that reports link parse results only 2026-08-15 15:48:57 +08:00
YoursFunny e6ba178983 feat(send): add per-chat token bucket rate limiting 2026-08-15 00:34:59 +08:00
YoursFunny ae69d72930 refactor(handlers): inject AppContext into url_media; cover the full URL pipeline 2026-08-14 23:38:41 +08:00
YoursFunny 1e30815a10 docs: mark architecture refactor phases A and B implemented 2026-08-14 22:04:30 +08:00
YoursFunny 50206a9056 refactor(send): introduce MediaSender seam; add mock-based fallback tests 2026-08-14 22:04:16 +08:00
YoursFunny c9e72fda70 refactor(handlers): split monolithic handlers.rs into modules 2026-08-14 21:50:40 +08:00
YoursFunny f6845b1b5c chore: bump version to 1.2.2 2026-08-14 21:45:48 +08:00
YoursFunny fae8dc6f2d fix(twitter): treat empty tombstone as withheld content, not deleted 2026-08-14 21:21:15 +08:00
YoursFunny ac72e414c3 docs: add architecture refactor design 2026-08-14 21:21:15 +08:00
YoursFunny 8b3b2a246b refactor(errors): derive FetchError and PixivError with thiserror 2026-08-14 20:23:14 +08:00
YoursFunny 6f6898c245 refactor(send): share the upload fallback pipeline between group and animation sends 2026-08-14 19:39:58 +08:00
YoursFunny 69698992d5 refactor(send): fold FallbackError into SendError via from_fallback 2026-08-14 19:38:53 +08:00
YoursFunny 1e77bb0478 refactor(db): share one DbPool across stores; merge schema init 2026-08-14 19:37:18 +08:00
YoursFunny 8f2b0a1dcb docs: note AFIT dyn retest on rustc 1.97.1 2026-08-14 19:00:00 +08:00
YoursFunny b65fb967c4 docs: cite official AFIDT goal for the AFIT dyn limitation 2026-08-14 18:45:19 +08:00
YoursFunny a8fd685777 docs: update site adapter convention in AGENTS.md 2026-08-14 18:27:05 +08:00
YoursFunny 5679a8c172 refactor(site): genericize FetchError::Site 2026-08-14 18:26:01 +08:00
YoursFunny bf4e6159b3 refactor(site): introduce Site trait and SITES registry 2026-08-14 18:24:03 +08:00
YoursFunny 5e23916b40 refactor(site): move cache_key/is_retryable/media_headers into site modules 2026-08-14 18:19:39 +08:00
YoursFunny 7ca8fd1da2 refactor(site): carry site_id on Fetched; unify cache-key site lookup 2026-08-14 18:17:22 +08:00
YoursFunny 96c11becb9 docs: prefer native async fn in trait (AFIT) for the site registry 2026-08-14 18:05:06 +08:00
YoursFunny 5830a3f013 chore: bump version to 1.2.1 2026-08-14 17:55:26 +08:00
YoursFunny 183bb7e435 docs: add site registry refactor design 2026-08-14 17:55:10 +08:00
YoursFunny 2a8433a8d2 fix(twitter): map syndication TweetTombstone to NotFound
Deleted tweets answer the syndication endpoint with HTTP 200 and a
TweetTombstone (no `errors`, no `id_str`). The body classifier only
knew the `errors` shape, so tombstones fell through to the
`no id_str -> Sensitive` branch and degraded to an empty result,
making the bot reply "No media found" for a deleted tweet.

- extract parse_syndication_body(); tombstone shape -> NotFound
- propagate NotFound from the TWITTER_AUTH_TOKEN GraphQL fallback
  instead of swallowing it into empty_fetched
- unit tests for all body classes + live regression test on a real
  tombstoned tweet
2026-08-14 12:10:36 +08:00
YoursFunny 6b3e61881d logging: re-level, redact user data at info, and key links by post id
P0 — level rework + redaction:
- info now carries only lifecycle, per-post business results (sent /
  forwarded / copied / template applied), admin actions and anomalies
  (upload fallback, retry enqueue; dead-letter stays error).
- Per-request detail moved to debug: message/command logging, URL
  extraction, fetching/fetched, link-cache hits, media-group batch sends,
  queue processing (enqueue/processing/completed/rescheduled), photo
  processing (downscale/transcode), inline queries, sensitive-tweet note.
- Full user-submitted URLs and message text now appear only at debug; at
  info and above links are printed via the normalized cache key.

P1 — request correlation:
- handlers::log_key() maps a URL to its normalized post key
  (twitter:<id> / pixiv:<id> / bsky:<handle>/<rkey>). The whole lifecycle
  of one link (fetch -> send -> cache -> fallback) now logs [key=...], so
  multi-worker logs can be correlated by grepping the key.

Convention documented in AGENTS.md.
2026-08-13 23:34:06 +08:00
YoursFunny 47935dd7c6 fix(pixiv): stop retrying permanent 4xx API errors
site::fetch retried every PixivError, so a bad/expired token (403) or a
deleted artwork (404) burned all 3 attempts with backoff against pixiv's
API for nothing. Add PixivError::Status(u16) — the app-API calls now
surface the HTTP status — and retry only the transient classes: network
errors, 429 and 5xx. 4xx / Api (token errors) / Json / NoAuth are
returned immediately. The classification is a pure helper
(fetch_error_is_retryable) with unit tests.
2026-08-13 23:17:52 +08:00
YoursFunny 6911e9146e fix(handlers): stop URL workers by closing the job channel
The old stop only set an atomic flag checked between jobs: a worker
blocked in recv() never woke (the channel was never closed), and queued
jobs were neither drained nor abandoned in a defined way despite the
"drains up to 256 jobs" comment. Now stop_url_workers sets the flag,
drops the sender so blocked recv() calls wake with None, and awaits the
worker JoinHandles (each finishes its in-flight job first). main awaits
it inside the existing 30s shutdown timeout.
2026-08-13 23:15:14 +08:00
YoursFunny aa705aef90 chore: bump version to 1.2.0
New in 1.2.0:
- feat: debounced inline queries, bounded graceful shutdown, config
  fail-fast on invalid env values
- fix: local-media tasks (ugoira/bsky MP4) survive queue retries,
  photo-first ordering in mixed media groups, caption truncation to
  Telegram's 1024-char limit
- perf: SQLite connection pool, concurrent upload-fallback downloads,
  ugoira zip streamed to disk, photo processing without re-reading the
  temp file, release profile LTO
- ci: test/clippy gate + layered live/token job
- docs: English README (README.en.md)
2026-08-13 22:52:38 +08:00
YoursFunny 39260a8817 style: rustfmt config.rs and main.rs from the last two features 2026-08-13 22:43:35 +08:00
YoursFunny 6b9640aa48 build: enable thin LTO and single codegen units for release
Smaller/faster production binary (verified: cargo build --release -p
xmedia-bot builds clean with the new profile). panic=abort is
intentionally not set — queue workers and db closures rely on JoinHandle
catching panics, which abort would defeat.
2026-08-13 22:42:42 +08:00
YoursFunny ad59f518ff docs: add English README (README.en.md)
Full English translation of README.md — features, quick start, webhook
deployment (domain + IP-only with acme.sh), env table, command table.
The Chinese README stays the primary one.
2026-08-13 22:37:12 +08:00
YoursFunny e21643063e feat(config): fail fast on misspelled env values
A typo in EDIT_MESSAGE_TTL_SECONDS / WEBHOOK_PORT etc. used to silently
fall back to a default, so the bot ran with different behavior than the
operator intended (or failed much later on a bare .expect). Unparseable
values now log a loud warning naming the variable; invalid webhook
settings still surface as a hard .expect in webhook mode.
2026-08-13 22:26:23 +08:00
YoursFunny 246fc989f0 feat(main): bound graceful shutdown with a 30s timeout
The stop sequence awaited the queue workers, which can be mid-download
(30s client timeout) or mid-ugoira encode (minutes). A stuck worker would
hold shutdown forever; now the process logs and exits after 30s.
2026-08-13 22:26:16 +08:00
YoursFunny 2e2d1b3506 style: rustfmt the DbPool call sites from the connection-pool change
Formatting-only; the pool commit landed before cargo fmt was run.
2026-08-13 22:25:13 +08:00
YoursFunny 1747d321d8 perf(photo): stop re-reading the downloaded temp file
download_to_temp buffered the full bytes, wrote them to a temp file, and
prepare_photo then read the whole file back from disk. The bytes are
already in memory — pass them through (download_to_temp now returns
(file, bytes)) so photo processing never touches the disk for input.
Adds the bytes dependency to xmedia-bot (already in the lock via x-media).
2026-08-13 22:24:58 +08:00
YoursFunny 505990e49e perf(pixiv): stream the ugoira frame zip to disk instead of RAM
download_media_limited buffered the whole frame zip (cap 512 MB) in
memory before extraction, spiking RAM for large ugoira. New
site::download_media_to_file streams chunks straight to a temp file with
the same Content-Length / stream cap checks, and ugoira_video now opens
the zip from disk inside spawn_blocking. Adds FetchError::Io for local
write failures (hand-rolled error pattern preserved).
2026-08-13 22:22:13 +08:00
YoursFunny 95b475ff08 perf(send): prepare upload-fallback items concurrently
The download-and-reupload fallback downloaded each batch item serially,
so a 9-item batch took 9× the slowest download. Items are now prepared
concurrently (bounded to 3 in-flight downloads + photo processing) via a
JoinSet, then the group is uploaded in its original order; the per-item
logic moved into prepare_upload_item. Temp files stay alive until the
group request completes. A failing item still aborts the batch (the
JoinSet drop cancels the remaining prep tasks, as before).
2026-08-13 22:19:39 +08:00
YoursFunny 2297fdc91c fix(site): truncate captions to Telegram's 1024-char limit
A long tweet text or a pixiv artwork with many tags can exceed Telegram's
1024-char caption cap for HTML parse mode, turning an otherwise fine send
into a permanent 400. truncate_caption() cuts at a char boundary (never
splitting a multi-byte rune or an HTML entity like &amp;) and appends an
ellipsis. Applied in caption_with / caption_from_fields, the link-cache
re-send path and the inline-query captions.
2026-08-13 22:15:33 +08:00
YoursFunny 4580b79d4f fix(send): order photos first in mixed media groups
Telegram's sendMediaGroup requires the first item to be a photo when a
group mixes photos and videos; the previous code kept the source-site
order, so a mixed post with a video first (twitter media order is not
guaranteed) would 400 permanently. photos_first() stable-sorts photos
ahead of videos/animations before chunking; within-kind order is kept.
2026-08-13 22:14:07 +08:00
YoursFunny edb32c23b4 perf(db): reuse SQLite connections via a small per-store pool
Every DB operation (queue lease/enqueue, chat_state get/set, link_cache
read/write) used to open a fresh connection — including the busy timeout
and WAL pragma — then close it, on every message, URL job and callback.

Replace with DbPool: a tiny pool (4 connections max, semaphore-bounded
concurrency for backpressure) whose with_conn() method runs the closure on
a pooled connection inside spawn_blocking. Steady-state cost of an
operation is a list pop + semaphore acquire instead of a connection open.
2026-08-13 22:12:34 +08:00
YoursFunny 4a467641aa ci: add test/clippy workflow and gate live/token tests
The docker workflow only builds/pushes; tests were a local responsibility.
Add .github/workflows/ci.yml with two layers:

- test: cargo fmt --check + cargo clippy --workspace --all-targets -D
  warnings + cargo test --workspace (fully offline, no secrets) on every
  push/PR, including forks.
- live: the #[ignore]d live-network tests plus the pixiv token-gated
  tests, run on schedule / manual dispatch / tag pushes only (fork PRs
  cannot read repository secrets), with PIXIV_REFRESH_TOKEN /
  TWITTER_AUTH_TOKEN injected and continue-on-error for flaky sites.

Test gating (documented in AGENTS.md):
- live-network tests now carry #[ignore = "live network: ..."] (twitter 3,
  bsky 2, pixiv bogus-token 1) and run via -- --ignored live.
- pixiv token tests early-return when PIXIV_REFRESH_TOKEN is absent or
  empty (an unset GitHub secret arrives as ""); test_fetch previously
  failed without a token.

Also fixes the three clippy assertions_on_constants warnings in send.rs
(required for -D warnings).
2026-08-13 22:07:32 +08:00
YoursFunny bd43a12dee fix(send): keep local media (ugoira/bsky remux MP4) alive across queue retries
A task whose media is a locally produced file (pixiv ugoira MP4, bsky HLS
remux MP4) references a path inside a tempfile TempDir owned by Fetched.
The retry ran after that TempDir was dropped, so the file was already gone
and the retry always failed permanently ("local media file missing") — and
the upload fallback even tried to GET the local path as a URL.

Keep the temp dirs in a process-wide registry (Fetched::take_keep_alive ->
send::KEEP_ALIVE) that is only released when the task settles (sent or
permanently failed); the upload fallback now uploads local files directly
instead of attempting to download them.

Restart-mid-queue still loses the files (documented behavior in
input_file_for) — only the in-process retry path is fixed here.
2026-08-13 22:05:07 +08:00
YoursFunny c496e41c55 feat(handlers): debounce inline queries to stop fetch storms while typing
Telegram fires an inline query on every keystroke and every prefix of a
pasted URL (status/12, status/123, ...) matches the site patterns, so
typing used to trigger a full 3-attempt fetch per keystroke. Answer only
after the query has been stable for 800ms, dedupe repeats through
Telegram's inline cache (explicit cache_time 300), and let a repeat of a
query that produced no answer retry the fetch.
2026-08-13 21:57:07 +08:00
YoursFunny 68f026c990 docs: note version-bump convention in AGENTS.md 2026-08-10 19:29:21 +08:00
YoursFunny 62d80c8905 docs: sync README and AGENTS.md with 1.1.1 state 2026-08-10 19:16:56 +08:00
YoursFunny 78c9c841c6 chore: bump version to 1.1.1 2026-08-10 18:19:16 +08:00
YoursFunny e49d500d23 deps: switch TLS to rustls, drop libssl from the runtime image
OpenSSL came from two removable defaults: teloxide's `default` feature
(native-tls) and x-media's reqwest default features (default-tls). Switch
both to rustls (webpki-roots) so the binary links no system TLS libs:

- teloxide: default-features=false + rustls + ctrlc_handler (was part of
  the removed default)
- reqwest (x-media): default-features=false + rustls-tls

Verified: openssl-sys/native-tls gone from the tree, cargo check clean,
all 5 live twitter/bsky fetches pass over rustls, full container startup
works. The runtime image now needs no libssl.so.3/libcrypto.so.3 or CA
bundle (ffmpeg only processes local files; all downloads go through
reqwest), saving ~8MB.
2026-08-10 17:07:23 +08:00
YoursFunny 6feabd723b docker: fix 1.1.0 container startup (stub binary + missing libssl)
c40b074 broke the image two ways:
- `cargo clean -p` removes 0 files, so the real sources (host mtimes
  older than the step-1 stub build) were never recompiled and the image
  shipped the 337KB fn-main stub, exiting 0 on start. Restore the
  touch-based rebuild, which forces cargo to see every .rs as newer.
- bookworm-slim does not ship libssl3 despite the old comment; the bot
  links OpenSSL via teloxide/reqwest native-tls, so restore the
  libssl/libcrypto copies from the builder (same Debian release).
2026-08-10 16:37:12 +08:00
YoursFunny ea72516d5c chore: bump version to 1.1.0 2026-08-10 10:32:54 +08:00
YoursFunny 755330e585 chore: clippy and rustfmt cleanup on new code 2026-08-08 20:35:43 +08:00
YoursFunny c40b074b3c docker: fail-closed rebuild, ffmpeg checksum, smaller runtime
- Replace the mtime-touch stub-rebuild hack with cargo clean -p (a
  future-dated host file could silently ship the stub binary)
- Optional FFMPEG_SHA256 build arg verified before extraction
- Drop the redundant libssl3/libcrypto copies and the root
  supplementary group; strip the release binary; add a webhook-mode
  healthcheck to the compose example
2026-08-08 20:34:21 +08:00
YoursFunny 9910da2914 deps: drop unused regex, unify reqwest on 0.12
x-media's reqwest 0.13 dragged in quinn/rustls/aws-lc-rs (cmake C
build) alongside teloxide's 0.12; unifying on 0.12 removes the whole
second TLS/QUIC stack from the build and image. The unused regex dep in
xmedia-bot is gone; x-media keeps url (the bsky HLS remux uses it).
2026-08-08 20:31:03 +08:00
YoursFunny ebc0122264 db: enable WAL and index the pending-task lease query
The lease/earliest_run_after queries full-scanned tasks, and the
rollback journal blocked readers behind worker writes. journal_mode=WAL
(persistent, idempotent) plus idx_tasks_pending(status, run_after)
covers both without a schema migration.
2026-08-08 20:26:07 +08:00
YoursFunny 44cba8abe0 send: reuse one process-wide Bot for queue workers
handle_task and dead_letter_notify built a fresh Bot (env parse + HTTP
client) per queue item. A single LazyLock<Bot> is forced at startup so
a missing TELOXIDE_TOKEN fails fast instead of on the first task.
2026-08-08 20:25:22 +08:00
YoursFunny 99009aae9a db: share one now_f64() instead of four private copies
handlers, queue, send and link_cache each carried the same SystemTime
helper; a single crate::db::now_f64() removes the drift risk.
2026-08-08 20:23:58 +08:00
YoursFunny 72130b9023 caption: escape URLs/handles in HTML captions
Post URLs and author URLs were interpolated raw into <a href> attributes
(and the raw user URL from empty_fetched into caption text), so crafted
links could break the HTML parse and fail the send with a 400. All
attribute interpolations now use encode_double_quoted_attribute; text
stays encode_text.
2026-08-08 20:21:56 +08:00
YoursFunny 734cfc2eb3 handlers: skip inline fetches for non-post queries
Inline queries fire per keystroke and each fetch runs the 3-attempt
retry loop; a user typing any text was pumping requests into
X/Pixiv/BSky and risking rate-limit bans. Queries now pass only if
cache_key recognizes them as a supported post URL.
2026-08-08 20:20:05 +08:00
YoursFunny a8156697fa send: keep video thumbnails in the download-and-reupload fallback
media_from_file/media_from_url never attached the thumbnail, so any
video that tripped the fallback lost its cover frame. Both now take
item.thumbnail_url() and apply it, matching build_media_group.
2026-08-08 20:19:19 +08:00
YoursFunny c093dfe5ac handlers: dedup extracted URLs by normalized post id
Exact-string dedup let https://x.com/u/status/1 and
https://x.com/u/status/1/photo/1 (or the same link in text and caption)
through twice, causing duplicate fetches and sends. Dedup now uses
cache_key, falling back to the raw URL for unsupported links.
2026-08-08 20:17:07 +08:00
YoursFunny ee6f3e4a27 state: evict idle chats from the ChatStore cache
prune_expired only shrank edit_message maps, so the cache kept one
ChatData per chat forever (a leak proportional to chat count). Chats
without live edit records are now dropped from the cache and their
per-chat lock (DB row persists; get() reloads). Lock order kept safe:
prune never holds the cache lock while taking the per-chat locks.
2026-08-08 20:16:26 +08:00
YoursFunny 16ed53fead handlers: replace unbounded per-URL spawn with a bounded job channel
The 8-permit semaphore was acquired inside the spawned task, so a burst
queued unlimited tasks (each cloning Bot+Message) and nothing tracked
them at shutdown — in-flight sends fired after the stop notice. URL work
now flows through a 256-slot mpsc drained by 8 workers started from
main; a full channel backpressures the per-chat handler, and shutdown
sets URL_STOP so workers stop pulling.
2026-08-08 20:15:40 +08:00
YoursFunny 1d9e3629c9 state: serialize per-chat get→mutate→set with ChatStore::update
Concurrent handler tasks (the batch-forward design spawns several per
chat) each snapshotted the same ChatData and last-writer-wins silently
dropped mutations — e.g. a second edit_message record, leaving one
prompt's Forward button dead. All write cycles now run under a per-chat
async lock; read-only callers keep get().
2026-08-08 20:12:51 +08:00
YoursFunny b3d87b4f7d send: fail fast when a retried local media file is gone
A retried ugoira/bsky temp MP4 was already deleted with its TempDir, so
the retry failed at multipart-build time with a confusing Io error and
wasted all three attempts. input_file_for now rejects a missing local
path up front as a clean permanent error.
2026-08-08 20:10:35 +08:00
YoursFunny 98c48b99c0 send: don't repeat post-send actions on queue resumes
A resumed SendMediaSequence (batch_index>0 or already-sent ids) ran
post_send_actions again, opening a second edit prompt and inserting a
second edit_message record for the same messages — both Forward buttons
worked, enabling double forwards. Resumes now skip it.
2026-08-08 20:09:58 +08:00
YoursFunny f40639c799 queue: back off 1s when a lease fails
A lease error (e.g. persistent SQLITE_BUSY) while rows are due made the
worker spin with sleep(0), hammering SQLite and flooding the log.
lease_next now returns the error and the loop sleeps 1s before retrying.
2026-08-08 20:09:04 +08:00
YoursFunny 51cc079a85 queue: use notify_one so wakeups are never lost
notify_waiters drops the notification when every worker is between its
DB reads and registering notified(); a task enqueued in that window sat
until a stale timer fired. notify_one stores a permit, so the next
worker to wait wakes immediately and re-leases. stop() still wakes all
workers with notify_waiters.
2026-08-08 20:08:12 +08:00
YoursFunny aa3083792a queue: wire attempt counts into retry backoff
Every network retry hard-coded retry_delay_seconds(0), so backoff was
flat at 1.2-1.8s regardless of attempt; a multi-minute outage dead-
lettered after three rapid tries. The queue now scales the handler's
delay by 2^attempts (cap 300s) before rescheduling.
2026-08-08 20:07:26 +08:00
YoursFunny 6849006ad7 site: honor TELOXIDE_PROXY for site fetches
The shared HTTP client ignored the proxy the Bot API uses, so on
proxy-required networks (e.g. behind the GFW) every site fetch failed
while the bot itself worked. Explicit proxy overrides reqwest's system
detection; unset keeps direct connections.
2026-08-08 20:05:51 +08:00
YoursFunny 042a04ab6e site: anchor pixiv and bsky URL patterns
Both matched URL substrings anywhere in text, so a link like
https://evil.com/?u=pixiv.net/artworks/1 triggered a real fetch and
cache-key pollution. Prefix ^(?:https?://)? like the twitter pattern.
2026-08-08 20:05:09 +08:00
YoursFunny 9f28af4e6b site: classify HTTP status codes, make transient failures retryable
Twitter (syndication + auth GraphQL) mapped every non-2xx to NotFound,
killing retries on 429/5xx; bsky never checked status; pixiv network
errors arrived wrapped in PixivError and were excluded from the retry
loop. New FetchError::Transient covers 429/5xx from all sites, the
retry loop now also retries Pixiv errors, and 404/410 stay permanent.
2026-08-08 20:04:28 +08:00
YoursFunny d61dba5096 send: stream media downloads with hard size caps
download_to_temp now uses download_media_limited: non-photos abort the
moment the 10 MiB upload cap is crossed mid-stream (no more full-body
buffering before the size check), photos cap at the 512 MiB decode
budget, and the ugoira frame zip gets a 512 MiB cap. MediaTooLarge
routes to the existing smaller-URL fallback.
2026-08-08 20:03:14 +08:00
YoursFunny f6df3e28cb pixiv: drop unneeded mut bindings in ugoira extraction 2026-08-08 20:02:06 +08:00
YoursFunny deb1ef2428 site: add total and connect timeouts to the shared HTTP client 2026-08-08 20:01:39 +08:00
YoursFunny b5e5340edc pixiv: harden ugoira zip extraction, degrade instead of panicking
Sniff the frame extension from magic bytes instead of the entry filename,
cap each frame at 64 MiB (declared size + streamed read), and map a
panicked encode worker to the existing degrade path instead of
expect()-panicking the whole fetch handler.
2026-08-08 20:01:14 +08:00
YoursFunny 425d1505cf handlers: fix /set_forward_channel admin checks
Compare the sender's user id (not the chat id, which only matches in
private chats) and require the bot to actually be an admin with post
rights instead of silently passing when it is missing from the list.
Also stops panicking on get_me network failures.
2026-08-08 19:59:27 +08:00
YoursFunny 6e40f55440 queue: recover expired leases at runtime, supervise workers
Rows left in_progress by a panicked/crashed worker were only recovered at
start(); a runtime sweep (30s interval, woken by the same notify) now
re-queues them once the 120s lock TTL expires. Workers run under a
supervisor that respawns a panicked loop instead of silently shrinking
the pool of 4.
2026-08-08 19:58:28 +08:00
YoursFunny 7998114dc3 bsky: remux HLS video playlists to MP4 via ffmpeg
bsky video embeds expose only an m3u8 playlist, which Telegram cannot
fetch. Download the master/variant playlists and TS segments through the
shared client (proxy-aware, size-capped), then concat-remux locally;
keep the temp dir alive via Fetched._keep_alive like the ugoira path.
Also adds site::download_media_limited (streaming size cap), status
checks on media_size, and moves the ffmpeg probe to site/mod.rs for
pixiv/bsky to share.
2026-08-08 19:56:36 +08:00
YoursFunny 9a96f78177 handlers: fix UTF-8 byte-slice panic in message log preview 2026-08-08 19:52:01 +08:00
YoursFunny b50f794d52 feat: register bot commands with Telegram
Call setMyCommands at startup so clients show the command list in the
/ menu. handlers::register_commands wraps Command::bot_commands()
(teloxide derives it from the #[command(description)] attributes);
a registration failure only warns and does not stop the bot.
2026-08-08 00:08:48 +08:00
YoursFunny d32fa969d6 fix: correct singular 'entry' in clear-cache replies
plural() returned "" for one, rendering '1 entr.'; return "y" so
the suffix composes to '1 entry' / '2 entries'.
2026-08-08 00:07:27 +08:00
YoursFunny 020e2d01a3 chore: bump version to 1.0.8 2026-08-07 16:23:40 +08:00
YoursFunny 3d6f8548c3 style: cargo fmt across the workspace
Apply rustfmt to the 11 files that had drifted (86 hunks): x-media
site modules (bsky/pixiv/twitter) and xmedia-bot (config/main/
photo/send). Formatting only - no semantic changes; full test suite
still green.
2026-08-07 16:12:01 +08:00
YoursFunny 063e910473 feat: add admin-only /clear_cache command
/clear_cache with no argument wipes the whole link_cache table;
with a post URL it removes that single entry (normalized via
site::cache_key so fxtwitter/mobile/photo variants collide with
the write-side key). Non-admins get 'Admin only.'. LinkCache gains
clear(Option<&str>) -> usize reporting removed rows.
2026-08-07 16:10:25 +08:00
YoursFunny b0ced34b4c refactor: share sqlite open/with_conn helpers in db.rs
Converge the duplicated open_db (open + busy_timeout) and the
spawn_blocking + expect ceremony that every table access repeated
into one db.rs module. ChatStore no longer creates the tasks table
(schema ownership: queue.rs owns tasks, state.rs chat_state,
link_cache.rs link_cache). No schema or behavior change - all
CREATE TABLE statements are byte-identical, IF NOT EXISTS stays
idempotent, so existing data/task_queue.db files need no migration.
2026-08-07 16:00:12 +08:00
YoursFunny 4060a88031 fix: expand twitter short links like FxEmbed linkFixer
Replace display_text_range slicing with FxEmbed-style content matching:
expand mapped t.co links to their real URLs (dropping internal
x.com/i/web/status pages), then strip every leftover t.co short link
(appended media link, unmapped links).

The old code cut by display_text_range, whose index unit differs per
endpoint (UTF-16 on the syndication endpoint, code points in the
GraphQL fallback), so slicing by either unit left a partial
"https://t." caption tail on the other path. Content matching is
unit-agnostic and also keeps user-posted/quote links at the end of
the text that the trailing cut previously dropped.
2026-08-07 10:25:54 +08:00
YoursFunny fb43441c56 feat: add command descriptions and document commands in README
All bot commands now carry English descriptions, shown in the Telegram
command menu and by /help (which prints Command::descriptions()). The
README command table explains each command's arguments and behavior:
forward channel (@channel or ID), edit-before-forward flow, template
[] placeholder semantics and per-site caption format placeholders.
2026-08-06 21:21:28 +08:00
YoursFunny bf628dc999 chore: drop label value in compose example 2026-08-06 20:45:35 +08:00
YoursFunny de22aa9b4d chore: leave DEFAULT_EMAIL blank in compose example 2026-08-06 20:03:56 +08:00
YoursFunny 65a9554173 bump version to 1.0.7 2026-08-06 18:49:08 +08:00
YoursFunny e755785147 deploy: use named volumes for certs/html/acme, keep data bind mount
certs, html and acme are shared across nginx-proxy, acme-companion and
the acme-ip service, so they become named volumes (dropping the nginx-
prefix). The vhost.d mount is removed: acme-companion only needs it with
ACME_HTTP_CHALLENGE_LOCATION=true or letsencrypt_user_data, and users
who customize nginx config can mount their own. ./data stays a bind
mount so the SQLite state stays directly backup-able. README updated.
2026-08-06 17:22:05 +08:00
YoursFunny 5d5b6d56e7 chore: strip compose comments, document env vars in README
compose files now carry a single comment: the commented ACME_HOST line
(uncomment to enable domain cert issuance). Everything else moved to the
README env table and the deployment sections, which explain ACME_HOST,
DEFAULT_HOST and every other variable.
2026-08-06 17:07:50 +08:00
YoursFunny d0fdf1c5da deploy: default to ACME-issued certs, drop manual cert setup
Domain deployment now uses ACME_HOST (acme-companion auto issue/renew)
instead of the legacy LETSENCRYPT_HOST name. IP-only deployments can use
acme.sh to obtain Let's Encrypt IP certificates (shortlived profile,
~7-day validity, http-01 only) instead of self-signed manual certs:
documented the acme-ip service, first-issue command with install-cert
into nginx-certs and nginx reload via docker socket.

Removed the manual WEBHOOK_CERT flow and the ./cert volume from the
compose example.
2026-08-06 16:48:52 +08:00
YoursFunny 1db4ecfafa chore: use modern nginx-proxy label for acme-companion
acme-companion prefers com.github.nginx-proxy.nginx over the legacy
jrcs.letsencrypt_nginx_proxy_companion namespace (functions.sh checks
the new label first, falls back to the old one).
2026-08-06 11:31:01 +08:00
YoursFunny 47039bbe2d fix: make docker entrypoint restart-safe
docker compose restart reuses the container, so the overlay fs keeps the
user created on first boot; a second useradd fails with exit code 9 and
set -e kills the container on every restart (crash loop, bot never comes
back). Create the user only if missing, align UID otherwise, and never
let chown failure on bind-mounted volumes kill the container.

Also comment out nginx-proxy's empty environment block in the compose
example: an empty mapping fails validation on newer compose versions
(must be a mapping).
2026-08-06 11:10:52 +08:00
YoursFunny bc954e6e0b fix: handle oversized photos Telegram rejects with pure Rust processing 2026-08-06 10:17:29 +08:00
YoursFunny f7cb809e5a bump version to 1.0.6 2026-08-05 02:07:34 +08:00
YoursFunny 51b40cdb42 feat: cache sent media file ids for instant repeat sends 2026-08-05 02:06:30 +08:00
YoursFunny ab2306002a perf: handle batch-forwarded URLs concurrently with queue workers 2026-08-05 01:26:24 +08:00
YoursFunny a92b12f633 bump version to 1.0.5 2026-08-05 00:16:45 +08:00
YoursFunny 74e3b7593c docs: note fresh twitter query ids and TID scope in auth fallback 2026-08-05 00:07:30 +08:00
YoursFunny 2faccaac42 fix: cut tweet text by code points, not UTF-16 units 2026-08-04 23:45:54 +08:00
YoursFunny f4e60d8946 fix: trim CRLF from TWITTER_AUTH_TOKEN 2026-08-04 23:45:54 +08:00
YoursFunny 3006dcd98c feat: fetch NSFW tweets via authenticated twitter API fallback 2026-08-04 23:45:53 +08:00
YoursFunny 8e8acdd859 deploy: add container names and startup order to compose 2026-08-04 23:11:04 +08:00
YoursFunny 8b9dd963e1 bump version to 1.0.4 2026-08-04 22:10:38 +08:00
YoursFunny e83d48f1f7 fix: handle SIGTERM for graceful shutdown on docker stop 2026-08-04 22:09:45 +08:00
YoursFunny 7c55b26731 deploy: add nginx-proxy reverse proxy for webhook TLS 2026-08-04 21:50:19 +08:00
YoursFunny 950db48a13 ci: cache buildkit layers across runs 2026-08-04 18:52:28 +08:00
YoursFunny 32ea8ec6ca fix docker build: fetch ffmpeg from martin-riedl.de 2026-08-04 18:52:27 +08:00
YoursFunny 62dc033452 docs: add AGENTS.md with repository guidelines 2026-08-04 18:52:21 +08:00
YoursFunny 2801aaa39c bump version to 1.0.3 2026-08-04 16:24:16 +08:00
YoursFunny 93d47752cf fallback to smaller media when file too large 2026-08-04 16:22:49 +08:00
YoursFunny 6fd4edb3c5 webhook: drop duplicate set_webhook, ignore empty env vars 2026-08-04 15:58:32 +08:00
YoursFunny 7c5afce0b4 ci: build once on tagged commits, bump docker actions 2026-08-04 01:58:19 +08:00
YoursFunny caf41183a4 bump version to 1.0.2 2026-08-04 01:29:50 +08:00
YoursFunny b6ae88e869 twitter: request original image via name=orig 2026-08-04 01:28:21 +08:00
YoursFunny 3dff9a2473 fix docker build: compile real sources, lf entrypoint 2026-08-04 00:55:35 +08:00
YoursFunny 41f25052fe merge refactor-rs into master 2026-08-03 23:34:45 +08:00
YoursFunny fa95091c7c bump version to 1.0.0 2026-08-03 23:34:01 +08:00
YoursFunny 84ab146069 finish rust rewrite, add docker, drop python 2026-08-03 23:30:42 +08:00
YoursFunny e14363dd1d fix changes of updated async-pixiv module 2026-06-24 00:17:15 +08:00
YoursFunny 0cffd5c297 revert to python 3.12 due to incompatibility of async-pixiv module 2026-06-23 23:46:27 +08:00
YoursFunny b83bed5423 fix pixiv exception import 2026-06-23 23:24:39 +08:00
YoursFunny 0f3131aec7 bump deps version 2026-06-23 23:16:57 +08:00
YoursFunny 5483855de8 fix webhook set as false in compose yaml 2025-12-18 14:59:12 +08:00
YoursFunny 052163084b change to x.com in url template 2025-12-17 17:24:22 +08:00
YoursFunny cdee105c60 bump deps version 2025-12-17 17:23:20 +08:00
YoursFunny fae3dad43a fix start command handler 2025-08-21 10:30:10 +08:00
YoursFunny f4c53719d8 bump dep version and fix start command handler 2025-08-21 10:23:25 +08:00
YoursFunny cf44c3ddd7 minor changes and adding /start command 2025-08-21 09:59:37 +08:00
YoursFunny cbb15e6a33 fix tag check 2024-11-12 17:35:11 +08:00
YoursFunny afc31430e7 new bsky sensitive contents 2024-11-12 17:23:46 +08:00
YoursFunny 282cf46d58 minor fix 2024-11-12 17:12:47 +08:00
YoursFunny 6c53a1429d update to python 3.13 2024-11-10 19:02:18 +08:00
YoursFunny ca9064a1b7 bump version 2024-11-10 18:19:29 +08:00
YoursFunny d58fd7cd46 add clear edit message command 2024-11-10 18:17:51 +08:00
YoursFunny 9aa7aa1c36 fix external bsky media (disable) 2024-11-10 18:17:15 +08:00
YoursFunny 9c2606160a format 2024-10-19 22:41:41 +08:00
YoursFunny 3fe8a1cf3b new bsky support 2024-10-19 22:25:07 +08:00
YoursFunny 62a2820af3 add params support in fetch_json 2024-10-19 22:23:51 +08:00
YoursFunny 80b630d28d minor fixes 2024-10-19 22:22:27 +08:00
YoursFunny 4173972407 fix empty env bot_admin 2024-10-19 22:21:56 +08:00
YoursFunny 0d2648162c bump version 2024-10-19 22:19:44 +08:00
YoursFunny 0847ada3b7 update gitignore 2024-10-18 00:21:19 +08:00
YoursFunny 7cad25125f rust refactor 2024-10-18 00:17:51 +08:00
YoursFunny f0c287a148 fix await 2024-08-15 17:45:14 +08:00
YoursFunny 3658bfd2aa fix handler 2024-08-15 17:42:19 +08:00
YoursFunny ae1b04dee8 add extract url from message 2024-08-15 17:34:40 +08:00
YoursFunny 641f7218a4 minor fixes 2024-08-15 16:45:36 +08:00
YoursFunny 61dfa16011 fix edit message str format 2024-08-15 16:45:06 +08:00
YoursFunny 2986e80076 fix edit message 2024-08-14 01:52:01 +08:00
YoursFunny f112ecdf63 fix text 2024-08-14 01:30:08 +08:00
YoursFunny 79d50ae9a3 fix 2024-08-14 01:19:50 +08:00
YoursFunny 416331303d fix 2024-08-14 01:14:01 +08:00
YoursFunny 55771e01fe fix 2024-08-14 01:13:47 +08:00
YoursFunny afca276f49 minor fixes 2024-08-14 00:57:20 +08:00
YoursFunny 06d25854d2 fix error import 2024-08-14 00:37:01 +08:00
YoursFunny 515aa9711d new set template 2024-08-14 00:29:02 +08:00
YoursFunny 733adfc4a6 refactor using custom callback context 2024-08-13 23:20:24 +08:00
YoursFunny 03066853de refactor 2024-08-13 19:17:58 +08:00
YoursFunny 566c17a855 fix pixiv url 2024-07-19 19:34:18 +08:00
YoursFunny 87f0d16028 fix regex https pattern 2024-07-16 14:28:49 +08:00
YoursFunny 197993e522 bump version 2024-07-16 14:27:15 +08:00
YoursFunny 02abcd899e update pixiv regex 2024-07-16 14:21:43 +08:00
YoursFunny ebb48d2bb5 fix forward channel check admin user 2024-07-09 16:27:13 +08:00
YoursFunny be77a33e9d use large instead of origin for pixiv 2024-06-24 20:02:35 +08:00
YoursFunny 0439adefa0 add refresh token if expire 2024-06-24 15:57:30 +08:00
YoursFunny 81e5d2dbd1 add log 2024-06-24 15:47:12 +08:00
YoursFunny e7ccd70e56 fix deal with invalid url 2024-06-24 02:17:26 +08:00
YoursFunny 23128af96a fix regex 2024-06-24 02:14:50 +08:00
YoursFunny fe1724aca4 fix annotations 2024-06-24 02:06:25 +08:00
YoursFunny 0433650563 fix init pixiv 2024-06-24 02:03:28 +08:00
YoursFunny a617a7e1a0 fix requirement conflict 2024-06-24 01:48:45 +08:00
YoursFunny e35e3895ba fix requirement conflict 2024-06-24 01:45:23 +08:00
YoursFunny a4a9f2eaa0 add pixiv package requirement 2024-06-24 01:29:29 +08:00
YoursFunny 1c779e98a8 add pixiv support 2024-06-24 01:25:03 +08:00
YoursFunny 4a5aa296ce add pixiv refresh token setting 2024-06-23 22:14:58 +08:00
YoursFunny c1c1d42892 refactor, clear structure 2024-06-23 22:06:27 +08:00
YoursFunny cade370538 refactor 2024-06-23 21:52:12 +08:00
YoursFunny a6325d5bc8 fix check instance 2024-06-20 15:46:47 +08:00
YoursFunny 6c0e581135 fix regex match group 2024-06-20 15:40:18 +08:00
YoursFunny 494fc50446 fix missing aexit 2024-06-20 15:29:30 +08:00
YoursFunny a4e568667c fix type import error 2024-06-20 15:25:17 +08:00
YoursFunny 8231eeb20e refactor tweet, better type hint 2024-06-20 15:13:09 +08:00
YoursFunny 74f42a2df3 fix status code check 2024-06-20 02:51:20 +08:00
YoursFunny ae1f5a8c8c rename 2024-06-20 02:48:06 +08:00
YoursFunny 38adbf0cb8 use cached property 2024-06-20 02:40:35 +08:00
66 changed files with 22347 additions and 620 deletions
+10 -1
View File
@@ -24,8 +24,17 @@
**/secrets.dev.yaml
**/values.dev.yaml
*.db
.python-version
LICENSE
README.md
# Documentation and scratch files: the build only ever reads the manifests,
# `crates/` and the entrypoint script.
docs/
*.md
data/
cert/
nginx-certs/
nginx-vhost.d/
nginx-html/
nginx-acme/
**/target/
.idea/
+76
View File
@@ -0,0 +1,76 @@
# 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.
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.
# 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.
LOCAL_USER_ID=1000
# 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=
+2
View File
@@ -0,0 +1,2 @@
# Shell scripts must stay LF: CRLF breaks the shebang inside containers.
*.sh text eol=lf
+32
View File
@@ -0,0 +1,32 @@
version: 2
# Pairs with the `actions-rust-lang/audit` gate in ci.yml: the gate reports
# advisories in Cargo.lock, this is what actually moves the dependencies.
# Patch bumps are batched into one PR; minor/major stay separate so they get
# reviewed and tested individually.
updates:
- package-ecosystem: cargo
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
cargo-patch:
applies-to: version-updates
patterns: ['*']
update-types: ['patch']
# The workflow actions are pinned to commit SHAs; that pin is what makes
# bumping them a manual chore, so let the bot do it.
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
# The Dockerfile's base images (rust:1-bookworm, debian:bookworm-slim).
- package-ecosystem: docker
directory: /
schedule:
interval: monthly
open-pull-requests-limit: 3
+154
View File
@@ -0,0 +1,154 @@
name: CI
# Test/lint gate (offline, no secrets) on every push/PR, plus a live-network
# job that exercises the real source sites and the token-gated pixiv tests.
#
# 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
# build + cargo-audit dependency gate. Runs on every push and PR,
# including forks (it needs no secrets).
# live — the #[ignore]d live-network tests plus the pixiv tests that are
# gated on PIXIV_REFRESH_TOKEN. Runs on schedule / manual dispatch
# / tag pushes only, because pull requests from forks cannot read
# repository secrets. continue-on-error keeps a flaky external site
# from blocking, while the run still records the outcome.
#
# Every action is pinned to a commit SHA (Dependabot keeps the pins current);
# `dtolnay/rust-toolchain` deliberately stays on its channel ref, because the
# ref itself is what selects the toolchain (`@stable` = install stable).
#
# Test gating convention (keep in sync with AGENTS.md "Testing & QA"):
# - pure unit tests: plain #[test] / #[tokio::test], always run.
# - live-network tests: #[ignore = "live network: ..."], only run here.
# - token-gated tests (pixiv): #[tokio::test] with an early return when
# PIXIV_REFRESH_TOKEN is absent or empty (empty = unset CI secret).
on:
push:
branches: [master]
pull_request:
schedule:
# Weekly probe of the live endpoints, so external API changes surface.
- cron: '0 3 * * 1'
workflow_dispatch:
permissions:
contents: read
# A newer push to the same ref supersedes the older run; without this every
# intermediate commit of a PR branch keeps a runner busy to completion.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
# Panicking tests print their backtrace; free when nothing fails.
RUST_BACKTRACE: 1
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:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
# 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
# timeout there would kill the job *before* rust-cache saves its cache —
# leaving every later run cold again.
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
# `--locked` on every cargo invocation: the version bump edits
# Cargo.lock by hand (AGENTS.md), so a stale lock must fail here instead
# of being silently re-resolved — otherwise CI tests a different
# dependency set than the one committed, and than the one the released
# image is built from.
- name: Check formatting
run: cargo fmt --check
- name: Lint (deny warnings)
run: cargo clippy --workspace --all-targets --locked -- -D warnings
- name: Run offline tests
run: cargo test --workspace --locked
# The release profile (lto/strip/codegen-units=1, overflow checks off)
# was otherwise only exercised by the Docker build on master/tag. Same
# package the Dockerfile builds; the cache keeps it cheap after the
# first run.
- name: Build release profile
run: cargo build --release --locked -p xmedia-bot
# Dependency vulnerability gate: fails the build when a crate in
# Cargo.lock has an unfixed security advisory. Unmaintained/unsound
# *warnings* (dotenv, proc-macro-error2, anyhow transitive) do not fail
# the build by default; the advisory DB is cached across runs.
- name: Audit dependencies
uses: actions-rust-lang/audit@72c09e02f132669d52284a3323acdb503cfc1a24 # v1
live:
needs: test
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
timeout-minutes: 30
continue-on-error: true
env:
PIXIV_REFRESH_TOKEN: ${{ secrets.PIXIV_REFRESH_TOKEN }}
TWITTER_AUTH_TOKEN: ${{ secrets.TWITTER_AUTH_TOKEN }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
# Everything network- or secret-gated lives in x-media, and the bot
# crate's suite (MockSender + tempdir stores, no network) already ran in
# the `test` job — rebuilding it here bought nothing.
- name: Run token-gated tests
run: cargo test -p x-media --locked
# The live-network tests, by the "live" name filter (all #[ignore]d).
- name: Run live-network tests
run: cargo test -p x-media --locked -- --ignored live
+132 -9
View File
@@ -1,23 +1,127 @@
name: Build Docker Image
# Release builds (master / v* tags) plus a build-only check on pull requests
# that touch anything the image depends on — the Dockerfile's stub-source
# machinery, the ffmpeg download and the entrypoint are exactly the parts that
# would otherwise break only at release time.
#
# Actions are pinned to commit SHAs (Dependabot keeps the pins current).
on:
push:
tags:
- v*
branches:
- master
pull_request:
paths:
- Dockerfile
- docker-entrypoint.sh
- .dockerignore
- Cargo.toml
- Cargo.lock
- .github/workflows/docker.yml
- 'crates/**/Cargo.toml'
env:
APP_NAME: telegram-twitter-media-bot
DOCKERHUB_REPO: yoursfunny/telegram-twitter-media-bot
permissions:
contents: read
# Serialize runs per ref. Never cancel in progress: a killed run would drop a
# half-finished image push.
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: false
jobs:
docker:
# 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
# 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:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
build: ${{ steps.check.outputs.build }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
# A release tag is the version claim: the manifests are bumped by hand,
# so `v1.5.1` with `Cargo.toml` still at 1.5.0 would publish an image
# whose tag lies about what is inside it (the binary carries no version).
- name: Verify the tag matches both crate versions
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
run: |
tag="${GITHUB_REF_NAME#v}"
status=0
for manifest in crates/x-media/Cargo.toml crates/xmedia-bot/Cargo.toml; do
# tr -d '\r': a CRLF checkout (core.autocrlf on Windows) would
# otherwise yield "1.5.0\r" and false-fail every tag.
version="$(sed -n 's/^version = "\(.*\)"/\1/p' "$manifest" | head -1 | tr -d '\r')"
if [ "$version" != "$tag" ]; then
echo "::error file=$manifest::$manifest is at $version but the tag is v$tag"
status=1
else
echo "$manifest: $version matches v$tag"
fi
done
exit "$status"
- id: check
shell: bash
run: |
zero=0000000000000000000000000000000000000000
if [ "$GITHUB_REF_TYPE" = "branch" ]; then
# A branch run can start before the release tag for its commit
# reaches the remote — pushing master first is the usual way to hit
# 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
echo "build=true" >> "$GITHUB_OUTPUT"
# No `actions/checkout` here on purpose: `docker/build-push-action` defaults
# to the Git context (`https://github.com/<owner>/<repo>.git#<ref>`), so
# BuildKit clones the repo itself and authenticates with the automatic
# github.token. Adding `context: .` below without a checkout step would hand
# BuildKit an empty workspace.
docker:
needs: should-build
if: needs.should-build.outputs.build == 'true'
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
with:
images: ${{ env.DOCKERHUB_REPO }}
tags: |
@@ -26,24 +130,43 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
# Pull requests build the image to prove the Dockerfile still works, but
# must not read registry credentials (fork PRs have none).
-
name: Login to Docker Hub
uses: docker/login-action@v3
if: github.event_name != 'pull_request'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Buildkit cache via the GitHub Actions cache backend (uses the
# automatic GITHUB_TOKEN, no extra secrets). mode=max keeps every
# stage's layers so the cargo-deps and ffmpeg layers are restored
# instead of re-downloaded/recompiled. The scope must be pinned to a
# fixed string: the gha backend defaults to the current git ref, which
# 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.
#
# FFMPEG_URL/FFMPEG_SHA256 come from repository variables when set, so a
# release can pin an exact ffmpeg build (the Dockerfile default follows
# the project's `/redirect/latest/` URL, which has no sha256 sidecar).
#
# Single-arch (amd64) on purpose: adding arm64 means re-adding
# `docker/setup-qemu-action`, `platforms: linux/amd64,linux/arm64`, and
# parameterizing FFMPEG_URL by $TARGETARCH in the Dockerfile.
-
name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with:
push: true
push: ${{ github.event_name != 'pull_request' }}
build-args: |
APP_NAME=${{ env.APP_NAME }}
FFMPEG_URL=${{ vars.FFMPEG_URL || 'https://ffmpeg.martin-riedl.de/redirect/latest/linux/amd64/release/ffmpeg.zip' }}
FFMPEG_SHA256=${{ vars.FFMPEG_SHA256 }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=tgxmb-build
cache-to: ${{ github.event_name != 'pull_request' && 'type=gha,mode=max,scope=tgxmb-build' || '' }}
+10 -2
View File
@@ -2,5 +2,13 @@
__pycache__/
cert/
data/
docker-compose.yml
x.py
nginx-certs/
nginx-vhost.d/
nginx-html/
nginx-acme/
.env
# Added by cargo
/target
+114
View File
@@ -0,0 +1,114 @@
# Repository Guidelines
## Project Overview
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.9.1, edition 2024, resolver 3):
- **`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.
## Architecture & Data Flow
```
Telegram update → Dispatcher (polling or axum webhook) → dptree branches
├─ message → commands (any chat) / URL links (private chat only)
├─ inline_query → InlineQueryResult Photo/Video/Mpeg4Gif
└─ 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 ≤ 10, caption on first item) or `send::send_animation`. On Telegram URL-fetch failure or size error (`send_batch_via_upload`): download via `x_media::site::download_media` to a temp file (≤ 10 MiB), sniff magic bytes (`sniff_ext`), upload via multipart; oversized items fall back to `fallback_url`. On failure: `enqueue_retry` persists resume-state `Task` into the SQLite queue (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). 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.
User-facing failure text is a function of the error class, never one generic sentence: `urls::fetch_error_message` maps `FetchError::NotFound` (post gone), `Sensitive` (withheld, needs `TWITTER_AUTH_TOKEN`), `Blocked` (source risk control), `Disabled { site }` (a registered site switched off — pixiv without a token, the one case `fetch` answers `Err` instead of `Ok(None)`) and `Transient`/`Http` (source down) apart. The same distinction drives the group hint: a supported link posted in a group (not a channel) gets one `GROUP_LINK_HINT` reply, because the link pipeline is private-chat only.
The `/test <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`) hands media URLs straight to Telegram, which fetches them itself and cannot send site-specific headers — so `x_media::site::needs_media_headers(url)` (true exactly where a site's `media_headers` is non-empty, i.e. pixiv's pximg.net) marks the media that must be skipped instead of shipped broken; locally produced media (ugoira MP4, bsky remux) fails `Url::parse` and is skipped the same way. Inline results are therefore URL-only by construction, and 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.
`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
| Path | Purpose |
|---|---|
| `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`; 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``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/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 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`, the group-only `GROUP_LINK_HINT` for a supported link posted outside a private chat), `commands.rs` (teloxide `BotCommands` enum + command executor, incl. `/test <url>` (send-only) / `/debug <url>` (parse-only) and the admin-only `/bot_dict` state dump; `/set_format` rejects unknown `{…}` placeholders and resets with `-`), `urls.rs` (URL extraction + bounded job channel (256) drained by `URL_WORKERS = 8` workers (`start_url_workers`) — backpressure instead of unbounded spawns; teloxide's per-chat workers are sequential — batch-forwards need concurrency; one *shared* in-flight fetch per cache key (`fetch_shared`: 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), `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); 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 + 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, 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, 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/media_sender.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`) 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` holds the scripted `MockSender` and `fake_api` (the stand-in API the real-`Bot` tests drive) |
| `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
```bash
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 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 build --release -p xmedia-bot # release build (Dockerfile does this)
cargo clippy --workspace --all-targets # lint (Clippy is the configured IDE linter)
cargo fmt --check # formatting
```
Docker: `docker build -t tgxmb .` then `docker run --rm -d --name tgxmb --env-file .env -v ./data:/app/data tgxmb`. Runtime requires **ffmpeg** (built into the image). The builder fetches crates.io + ffmpeg; on restricted networks pass proxy build args, e.g. `--build-arg HTTP_PROXY=http://host.docker.internal:10808 --build-arg HTTPS_PROXY=…` (Docker Desktop builds can't reach the host loopback — use `host.docker.internal`).
## Code Conventions & Common Patterns
- **Errors via `thiserror` derive** (no anyhow): the public, stringified errors — `FetchError` (`Http`/`Json`/`Pixiv`/`Site`/`NotFound`/`Blocked`/`Disabled`/`Sensitive`/`TooLarge`/`MediaPrep`/`Transient`/`Io`) and `PixivError` — derive `thiserror::Error` with `#[from]` conversions; `Display`/`source()` come from the derive. The internal control-flow enums — `QueueError` (`Retryable { delay_seconds, payload }` / `Permanent`), `SendError` (Retryable/Permanent), `Classification`, `FallbackError` — carry no `Display` and are handled by direct variant matching. New errors should follow the same split: stringified/public errors derive `thiserror`, internal flow enums stay plain.
- **Global state via `std::sync::LazyLock` statics**, not DI: `CONFIG`, `CHAT_STORE`, `TASK_QUEUE` in `handlers/statics.rs`; shared reqwest `CLIENT` in `x-media/src/site/mod.rs`. `Bot` is passed/cloned into handlers; queue workers share the process-wide `send::BOT` (`LazyLock<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.
- **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.
- **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`).
- **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. 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`). `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 (fallback, retry enqueue, dead-letter is `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, 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
| File | Why it matters |
|---|---|
| `crates/xmedia-bot/src/main.rs` | Startup sequence, webhook vs polling, graceful shutdown (SIGINT via teloxide ctrlc / SIGTERM via `stop_token` for docker, → sweep stop → admin msg → queue stop) |
| `crates/xmedia-bot/src/handlers/` | `statics.rs` = `CHAT_STORE`/`TASK_QUEUE`/`CONFIG` singletons (open `$DATA_DIR/task_queue.db`, default `data/` **relative to CWD**, dir auto-created); `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 `-`); `urls.rs` = URL extraction + the per-URL pipeline (`url_media` takes a `PostSend` mode: chat settings vs `/test`'s suppressed actions); `inline.rs` = debounced inline queries (hotlink-protected and local media skipped); `callback.rs` = edit-before-forward buttons (dptree entry + testable `handle_callback` core, incl. `skip`) |
| `crates/xmedia-bot/src/send/` | `mod.rs`: constants `MAX_MEDIA_GROUP = 10`; `classify_request_error` (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; the senders. `upload.rs`: download-and-reupload fallback triggered only by Telegram API errors (`is_media_fetch_failure` / `is_size_error`), 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. 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), `needs_media_headers` (the same per-site rule, asked by the inline path to skip what Telegram cannot fetch) |
| `crates/x-media/src/site/pixiv/api.rs` | OAuth token exchange (hardcoded app client id/secret), access-token cache, ugoira zip→MP4 via ffmpeg in `spawn_blocking` |
| `Dockerfile` | Multi-stage: cached dep layer via stub sources + `touch *.rs` mtime bump (cargo's freshness is mtime-based and `cargo clean -p` removes 0 files — the touch is what forces the real sources to rebuild while deps stay cached), static ffmpeg from ffmpeg.martin-riedl.de (`FFMPEG_URL` arg, optional `FFMPEG_SHA256` checksum, `unzip -t` integrity check), `debian:bookworm-slim` runtime, entrypoint. Runtime ships **no libssl/libcrypto/CA bundle** — rustls webpki-roots handles all TLS, and the static ffmpeg only processes local files (downloads go through reqwest) |
| `docker-entrypoint.sh` | Privilege drop: `useradd` with `LOCAL_USER_ID` (default 9001) + `setpriv` (no gosu on bookworm-slim) |
| `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 |
| `README.md` | Feature docs + command table (Chinese) |
## Runtime/Tooling Preferences
- **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.
- **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 **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`, 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, `.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).
- 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.
- `.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.
## 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).
- 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` (1), `site/misskey/interface.rs` (1), `site/bilibili/interface.rs` (5), `site/pixiv/api.rs` (1); `photo.rs` adds one `#[ignore = "heavy: …"]` test. `site/mod.rs` also has a **token-gated but not `#[ignore]`d** pixiv download test (`download_media_pixiv_original_with_referer`): it hits `i.pximg.net` whenever `PIXIV_REFRESH_TOKEN` is set, so a local `cargo test --workspace` is not fully offline and can flake on a pixiv CDN body timeout. `disabled_site_is_reported_not_ignored` (same file) is gated the other way round: it asserts `fetch` answers `FetchError::Disabled { site: "pixiv" }` for a pixiv link and early-returns when `PIXIV_REFRESH_TOKEN` **is** set (the site is then enabled). Test gating convention (enforced by `.github/workflows/ci.yml`): pure unit tests always run; live-network tests carry `#[ignore = "live network: ..."]` (run via `cargo test --workspace -- --ignored live`); token-gated pixiv tests early-return when `PIXIV_REFRESH_TOKEN` is absent **or empty** (an unset GitHub secret arrives as `""``is_err()` alone would run them tokenless and fail), and the bilibili live tests early-return when the API answers risk control (`-352`, which bilibili applies per IP by request volume). Run the full offline suite with `cargo test --workspace`.
- Fixtures are inline `serde_json::json!` builder fns (`fixture()`, `thread_json()`, `illust_json()`), not files. The shared `CLIENT` sets `pool_max_idle_per_host(0)` under `#[cfg(test)]` to avoid cross-runtime `DispatchGone`.
- **CI** — `.github/workflows/ci.yml` (actions pinned to commit SHAs, `--locked` on every cargo invocation, `concurrency` cancels superseded runs, `RUST_BACKTRACE=1`) runs (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, `-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**; 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: `config.rs`, `handlers/statics.rs`; `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`. 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`.
- No coverage tracking.
Generated
+3120
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
[workspace]
members = ["crates/x-media", "crates/xmedia-bot"]
resolver = "3"
# Smaller/faster production binary: strip debug symbols, link-time
# optimization across crates, and one codegen unit per crate (bigger LTO
# wins). panic=abort is intentionally NOT set: queue workers and db
# closures rely on JoinHandle catching panics, which abort would defeat.
[profile.release]
strip = true
lto = "thin"
codegen-units = 1
+76 -19
View File
@@ -1,26 +1,83 @@
FROM python:3.12-slim-bullseye
# ---------- build stage ----------
# rust:1-bookworm (full, not slim) ships the C toolchain needed by
# rusqlite's bundled SQLite, plus wget/unzip for the ffmpeg download.
FROM rust:1-bookworm AS builder
ARG APP_NAME=telegram-twitter-media-bot
# Prebuilt static ffmpeg (glibc-linked, includes libx264) for ugoira MP4
# encoding. Served from https://ffmpeg.martin-riedl.de (Cloudflare CDN,
# built on Debian 12 — glibc-compatible with the bookworm-slim runtime).
# johnvansickle.com throttles datacenter IPs and served garbage from GitHub
# runners. `/redirect/latest/` floats to the newest release build; each build
# 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
# Arm64 images need this URL swapped for the `linux/arm64` build (currently
# hardcoded amd64; the workflow builds amd64 only — see docker.yml).
# Optional sha256 of ffmpeg.zip (pinned releases only): set to verify the
# download. The mirror publishes .sha256 sidecars next to pinned builds, e.g.
# https://ffmpeg.martin-riedl.de/download/linux/amd64/<id>_9.0/ffmpeg.zip.sha256
# (the /redirect/latest/ URL itself has no sidecar — pin the effective URL).
ARG FFMPEG_SHA256=
WORKDIR /build
# 1. Rust dependencies first: only the manifests plus stub sources, so the
# expensive dependency fetch + compile lives in a layer invalidated only by
# manifest/lock changes.
COPY Cargo.toml Cargo.lock ./
COPY crates/x-media/Cargo.toml crates/x-media/Cargo.toml
COPY crates/xmedia-bot/Cargo.toml crates/xmedia-bot/Cargo.toml
RUN mkdir -p crates/x-media/src crates/xmedia-bot/src \
&& printf 'fn main() {}\n' > crates/xmedia-bot/src/main.rs \
&& : > crates/x-media/src/lib.rs \
&& 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
# 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
# compile the real sources. `touch` makes every .rs newer than the stub
# artifacts, forcing a rebuild of just the two crates while the compiled
# dependency layer stays cached. (`cargo clean -p` does NOT work here — it
# removes 0 files and the stub binary silently ships.)
COPY crates/ ./crates/
RUN find crates -type f -name '*.rs' -exec touch {} + \
&& cargo build --release --locked -p xmedia-bot
# ---------- runtime stage ----------
FROM debian:bookworm-slim
# ARG scope is per-stage: re-declare for the label below.
ARG APP_NAME=telegram-twitter-media-bot
LABEL maintainer="admin@yoursfunny.top"
LABEL org.opencontainers.image.title="${APP_NAME}"
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
RUN set -eux; \
apt-get update; \
apt-get install -y gosu; \
rm -rf /var/lib/apt/lists/*; \
# verify that the binary works
gosu nobody true
# Everything is copied in — no apt in the runtime stage. Privilege dropping is
# done by docker-entrypoint.sh with setpriv (util-linux, already in
# bookworm-slim), so no gosu needed. TLS is rustls (webpki-roots baked in,
# see Cargo.toml feature `rustls`/`rustls-tls`), so no system CA bundle or
# libssl are needed; the static ffmpeg only processes local files (all
# downloads go through reqwest).
COPY --from=builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
WORKDIR /app
COPY --from=builder /build/target/release/xmedia-bot /usr/local/bin/xmedia-bot
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod a+x /app/docker-entrypoint.sh
COPY requirements.txt /app
RUN python -m pip install --no-cache-dir --upgrade -r requirements.txt
COPY . /app
RUN chmod a+x docker-entrypoint.sh
# State lives in /app/data (SQLite task queue + chat state); mount a volume
# there to keep it across restarts.
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["python", "main.py"]
CMD ["xmedia-bot"]
+140
View File
@@ -0,0 +1,140 @@
# TelegramXMediaBot
A Telegram bot 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.
## Features
- Sending a link in a private chat fetches and sends the images, videos and GIFs automatically; oversized media is split into batches (10 items per group)
- Text-only posts report "no media"; unsupported links are silently ignored. Fetch failures name the reason (post gone / content withheld / source risk control / site not enabled)
- Long posts (text ≥ `CAPTION_QUOTE_TEXT_CHARS`, default 200) show **the text part** of their caption inside a collapsible blockquote, with the link and author line left outside it
- Inline queries (`@bot <link>`) — 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)
- `/start` explains the supported sites and how to use it; `/help` lists the commands plus argument syntax, the caption placeholders and the private-chat rule; the bot's profile description texts are set at startup
- `/settings` shows this chat's configuration (forward channel, edit-before-forward, per-site caption formats, saved templates); templates are added with `/set_template` and removed with `/remove_template`
- Bind a forward channel for automatic forwarding; edit the caption before forwarding and apply custom templates (the prompt carries Confirm / Skip buttons, states its expiry, and is marked expired in place once it lapses)
- Failed sends are retried automatically with persistence; the notice names which link failed, how long the retry waits, or the final cause
- The chat action stays on screen for the whole fetch, so long jobs (ugoira transcode, large uploads) do not look stalled
- Pixiv ugoira animations are transcoded to MP4; Bluesky videos are remuxed (HLS stream → MP4)
- Photos exceeding Telegram's size/dimension limits are compressed automatically (original format kept, JPEG fallback only when needed)
- 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)
## Quick start
```bash
# Required: BotFather token; optional: PIXIV_REFRESH_TOKEN (Pixiv is disabled without it)
export TELOXIDE_TOKEN=<token>
export PIXIV_REFRESH_TOKEN=<token>
cargo run -p xmedia-bot
```
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
cp .env.example .env # fill in TELOXIDE_TOKEN and the rest; every line is commented
docker build -t 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).
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.
### Webhook deployment (needs a reverse proxy)
`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**
1. Point a DNS A record at the server
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
**IP only**
Let's Encrypt can issue certificates for public IPs (available since 2026, validity ~7 days, requires the `shortlived` profile). Use [acme.sh](https://github.com/acmesh-official/acme.sh) to issue and renew automatically, no manual certificates:
1. Add an acme-ip service to compose (issue + daily auto-renewal check):
```yaml
acme-ip:
image: neilpang/acme.sh
container_name: acme-ip
command: daemon
restart: always
volumes:
- certs:/acme.sh
- html:/usr/share/nginx/html
- /var/run/docker.sock:/var/run/docker.sock:ro
networks: [proxy]
```
2. First issuance (replace `<SERVER_IP>` with the server's public IP; IPv6 works too, repeat `-d` for more):
```bash
docker compose exec acme-ip acme.sh --issue --server letsencrypt \
-d <SERVER_IP> --cert-profile shortlived --days 3 \
--webroot /usr/share/nginx/html \
--install-cert --cert-file /acme.sh/<SERVER_IP>.crt \
--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"
```
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.
Telegram only accepts ports 443/80/88/8443.
<details>
<summary>Environment variables</summary>
| Variable | Description |
|---|---|
| `TELOXIDE_TOKEN` | Bot token (required) |
| `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) |
| `BOT_ADMIN` | Admin chat IDs, comma-separated; receives start/stop notifications |
| `EDIT_MESSAGE_TTL_SECONDS` | Edit-before-forward record expiry in seconds, default 86400; once lapsed the prompt is rewritten in place to "expired — nothing was forwarded" (no extra message) |
| `LINK_CACHE_TTL_SECONDS` | Link-result cache expiry in seconds, default 604800 (7 days) |
| `CAPTION_QUOTE_TEXT_CHARS` | **The text part** of the caption (the joined `{title}` + `{content}`) is wrapped in a collapsible blockquote once it reaches this many characters, default 200; `0` disables |
| `DATA_DIR` | Data directory (where the SQLite `task_queue.db` lives), default `data` (relative to the working directory, created automatically) |
| `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). **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 |
| `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 |
| `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_EMAIL` | acme-companion certificate notification email |
| `WEBHOOK` | `true` enables webhook mode (polling by default) |
| `WEBHOOK_LISTEN` / `WEBHOOK_PORT` | Listen address/port inside the bot container |
| `WEBHOOK_URL` | Public HTTPS URL (`https://domain/` or `https://IP/`) |
| `WEBHOOK_CERT` | Optional; self-signed certificate path, only used for Telegram-side validation (TLS is terminated by the reverse proxy) |
| `WEBHOOK_SECRET_TOKEN` | Update validation token (`X-Telegram-Bot-Api-Secret-Token`) |
</details>
## Commands
| Command | Description |
|---|---|
| `/start` | Welcome message |
| `/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 |
| `/remove_forward_channel` | Remove the forward channel |
| `/edit_before_forward` | Toggle "edit before forward": when enabled, the bot posts a prompt after forwarding; replying to it edits the first forwarded message's caption (or tapping a template button applies one), then `↩️ Confirm` forwards and `🛑 Skip` drops this forward; the prompt states its expiry and is marked expired in place when it lapses (nothing is forwarded) |
| `/set_template <name>` | Reply to a message containing `[]` to save it as a named template; `[]` is replaced by the original post link when forwarding (used with "edit before forward") |
| `/remove_template <name>` | Remove a template (names are listed by `/settings`; the prompt's keyboard shows at most 60) |
| `/settings` | Show this chat's configuration: forward channel, edit-before-forward, per-site caption formats, saved templates |
| `/set_format <site> <format>` | Customize the caption format for one site. Sites: `twitter` / `bsky` / `pixiv` / `misskey` / `bilibili`. Placeholders: `{url}` `{author}` `{author_url}` `{title}` `{content}` `{tags}`; unknown placeholders are rejected with the list of valid ones, and `-` restores the site's built-in format (preview with `/debug <link>`) |
| `/clear_cache [link]` | Clear the link cache (admin only); with a link only that entry, otherwise everything |
| `/bot_dict` | Show the current chat state (debugging; admin only) |
| `/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 |
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
- State is persisted in `data/task_queue.db`; compose deployments use the bind mount `./data` (keep it a directory for easy backups)
- The runtime needs ffmpeg (built into the Docker image)
- Tests: `cargo test --workspace`
+140
View File
@@ -0,0 +1,140 @@
# TelegramXMediaBot
Telegram 机器人,将 X / Twitter、Pixiv、Bluesky、Misskey (misskey.io)、Bilibili 动态的帖子链接转换为媒体消息发送,附带帖子标题、作者与标签。
## 功能
- 私聊发送链接后自动抓取并发送图片、视频与 GIF,超量图片自动分批(每批 10 张)
- 纯文字帖提示无媒体;不支持的链接静默忽略。抓取失败会按原因分别提示(帖子已删除 / 内容受限 / 源站风控 / 站点未启用)
- 长帖(正文 ≥ `CAPTION_QUOTE_TEXT_CHARS`,默认 200)的**正文部分**用可折叠引用块展示,链接与作者行留在引用块外
- 支持内联查询(`@机器人 <链接>`;Pixiv 图片与本地转码的动图不支持内联 —— Telegram 取图时无法携带 Referer,会显示破图,因此跳过;这类查询直接返回空结果,不会一直转圈或反复请求);在群聊里发链接会提示改用私聊或内联查询(频道内保持静默)
- `/start` 说明支持的站点与用法,`/help` 列出命令、参数格式、caption 占位符与私聊限制;bot 资料页(description / short description)启动时一并设置
- `/settings` 查看本聊天配置(转发频道、转发前编辑开关、各站点 caption 格式、模板列表);模板可用 `/set_template` 增、`/remove_template`
- 可绑定转发频道自动转发;支持转发前编辑 caption 与自定义模板(提示消息带 Confirm / Skip 按钮并写明过期时间,过期后就地标记为已过期)
- 发送失败自动重试并持久化,重试耗尽后通知用户;提示会写明是哪条链接、重试等待多久、或最终失败的原因
- 抓取期间持续显示"正在输入 / 正在发送"状态,长任务(ugoira 转码、大图上传)不会看起来卡死
- Pixiv ugoira 动图自动转码为 MP4Bluesky 视频自动转码(HLS 流 → MP4)
- 超过 Telegram 尺寸/大小限制的图片自动压缩(保持原格式,必要时转 JPEG)
- 链接结果本地缓存:成功发送后缓存 Telegram file id 与 caption 等,再次收到相同链接直接本地重发,不再请求源站、不保存媒体文件(`LINK_CACHE_TTL_SECONDS` 控制过期,默认 7 天)
## 快速开始
```bash
# 必填:BotFather 的 token;可选:PIXIV_REFRESH_TOKEN(未设置则禁用 Pixiv
export TELOXIDE_TOKEN=<token>
export PIXIV_REFRESH_TOKEN=<token>
cargo run -p xmedia-bot
```
Docker 部署(编排见仓库里的 `docker-compose.yml`,实例相关的值写在同目录的 `.env`compose 会自动替换其中的 `${VAR}`):
```bash
cp .env.example .env # 填 TELOXIDE_TOKEN 等,逐项都有注释
docker build -t 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`(可选)。
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=…`)可恢复访问。当前只发送动态里的图片与动图,动态内嵌视频发送其封面。
### Webhook 部署(需要反向代理)
`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 记录指向服务器
2. `.env` 里设 `VIRTUAL_HOST``WEBHOOK_URL` 为域名;要由 acme-companion 自动签发证书时,再取消 `docker-compose.yml``ACME_HOST` 那行的注释,并在 `.env` 里把 `ACME_HOST` 设为域名
3. acme-companion 自动签发与续期证书,无需手动处理
**只有 IP**
Let's Encrypt 支持为公网 IP 签发证书(2026 年起可用,有效期约 7 天,须 `shortlived` profile)。用 [acme.sh](https://github.com/acmesh-official/acme.sh) 自动签发与续期,无需手动证书:
1. compose 里增加 acme-ip 服务(签发 + 每日检查自动续期):
```yaml
acme-ip:
image: neilpang/acme.sh
container_name: acme-ip
command: daemon
restart: always
volumes:
- certs:/acme.sh
- html:/usr/share/nginx/html
- /var/run/docker.sock:/var/run/docker.sock:ro
networks: [proxy]
```
2. 首次签发(把 `<SERVER_IP>` 换成服务器公网 IP,IPv6 同样支持,多个 `-d` 可并列):
```bash
docker compose exec acme-ip acme.sh --issue --server letsencrypt \
-d <SERVER_IP> --cert-profile shortlived --days 3 \
--webroot /usr/share/nginx/html \
--install-cert --cert-file /acme.sh/<SERVER_IP>.crt \
--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"
```
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-0180 端口必须公网可达);不支持 DNS-01、私有 IP 与 IP 段;同一 IP 集合每 168 小时限签发 5 张。建议先用 `--server letsencrypt_test` 试签,成功后再切正式服务器。
Telegram 只接受 443/80/88/8443 端口。
<details>
<summary>环境变量说明</summary>
| 变量 | 说明 |
|---|---|
| `TELOXIDE_TOKEN` | Bot token(必填) |
| `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 自动获取) |
| `BOT_ADMIN` | 管理员聊天 ID,逗号分隔;接收启动/停止通知 |
| `EDIT_MESSAGE_TTL_SECONDS` | 转发前编辑记录过期秒数,默认 86400;过期后提示消息会被就地改写为「已过期,未转发」(不额外发消息打扰) |
| `LINK_CACHE_TTL_SECONDS` | 链接结果缓存过期秒数,默认 604800(7 天) |
| `CAPTION_QUOTE_TEXT_CHARS` | 正文(`{title}` + `{content}` 合计)达到该长度(字符)时,caption 的**正文部分**用可折叠引用块包裹,默认 200;`0` 关闭 |
| `DATA_DIR` | 数据目录(SQLite 数据库 `task_queue.db` 所在目录),默认 `data`(相对工作目录,会自动创建) |
| `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=`)——teloxide 对无法解析的值会直接 panic;不用代理就别写这一行。容器里要用代理时,`docker-compose.yml` 的 `environment` 里默认没有它(容器内的 `127.0.0.1` 是容器自己),需要时手动加上并把地址换成 `host.docker.internal:<port>` |
| `LOCAL_USER_ID` | 容器内运行用户 UID,默认 9001 |
| `VIRTUAL_HOST` | 对外域名或 IPnginx-proxy 按此路由(写在 `.env`compose 读取) |
| `VIRTUAL_PORT` | bot 容器内监听端口,nginx-proxy 的转发目标 |
| `ACME_HOST` | 域名部署:设为域名时由 acme-companion 自动签发/续期证书 |
| `DEFAULT_HOST` | nginx-proxy 将未知 Host 的请求路由到该 vhost(IP 访问时需要) |
| `DEFAULT_EMAIL` | acme-companion 证书通知邮箱 |
| `WEBHOOK` | `true` 启用 webhook 模式(默认轮询) |
| `WEBHOOK_LISTEN` / `WEBHOOK_PORT` | bot 容器内监听地址/端口 |
| `WEBHOOK_URL` | 对外公网 HTTPS 地址(`https://域名/` 或 `https://IP/` |
| `WEBHOOK_CERT` | 可选;自签名证书路径,仅用于 Telegram 侧验证(TLS 由反向代理终止) |
| `WEBHOOK_SECRET_TOKEN` | 更新校验令牌(`X-Telegram-Bot-Api-Secret-Token` |
</details>
## 命令
| 命令 | 说明 |
|---|---|
| `/start` | 欢迎语 |
| `/help` | 查看全部命令及用法(即本文档的命令表) |
| `/set_forward_channel <频道>` | 设置转发频道,参数为 `@频道名` 或频道 ID;设置后发送的媒体消息会自动转发到该频道 |
| `/remove_forward_channel` | 取消转发频道 |
| `/edit_before_forward` | 开关「转发前编辑」:开启后,转发成功后 bot 会发一条提示消息,回复它可修改第一条转发消息的 caption(或点击模板按钮套用模板),再点 `↩️ Confirm` 才会真正转发,`🛑 Skip` 放弃本次转发;提示消息写明过期时间,过期后原地标记为已过期且不会转发 |
| `/set_template <名称>` | 回复一条含 `[]` 的消息,将其保存为命名模板;转发时 `[]` 会被替换为原帖链接(配合「转发前编辑」使用) |
| `/remove_template <名称>` | 删除某个模板(名称见 `/settings`;提示消息的模板按钮最多显示 60 个) |
| `/settings` | 查看本聊天配置:转发频道、转发前编辑开关、各站点 caption 格式、模板列表 |
| `/set_format <站点> <格式>` | 自定义某站点的 caption 格式。站点:`twitter` / `bsky` / `pixiv` / `misskey` / `bilibili`。占位符:`{url}` `{author}` `{author_url}` `{title}` `{content}` `{tags}`;未识别的占位符会被拒绝并列出可用项,格式填 `-` 恢复站点默认格式(可用 `/debug <链接>` 预览效果) |
| `/clear_cache [链接]` | 清空链接缓存(仅管理员);带链接只清该条,否则清空全部 |
| `/bot_dict` | 查看当前聊天状态(调试用;仅管理员) |
| `/test <链接>` | 解析链接并发送媒体;不转发到频道、不弹转发前编辑提示(仅发送) |
| `/debug <链接>` | 调试:只解析链接并返回解析结果(站点、标题、作者、标签、媒体列表),不发送任何媒体 |
链接处理仅限私聊;命令在任意聊天可用。在群聊里发受支持的链接会回复一条提示(改用私聊或内联查询),频道内保持静默。
## 备注
- 数据持久化于 `data/task_queue.db`compose 部署使用 bind mount `./data`(保持目录形式便于备份)
- 运行环境需安装 ffmpeg(Docker 镜像已内置)
- 测试:`cargo test --workspace`
-36
View File
@@ -1,36 +0,0 @@
import logging
import os
import re
try:
import uvloop, asyncio
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
except ImportError:
uvloop = None
BOT_TOKEN = os.getenv("BOT_TOKEN")
ADMIN = [int(i) for i in os.getenv("BOT_ADMIN").split(",")]
WEBHOOK = os.getenv("WEBHOOK", False)
if WEBHOOK:
WEBHOOK_LISTEN = os.getenv("WEBHOOK_LISTEN", "0.0.0.0")
WEBHOOK_PORT = int(os.getenv("WEBHOOK_PORT", 8443))
WEBHOOK_URL = os.getenv("WEBHOOK_URL")
WEBHOOK_KEY = os.getenv("WEBHOOK_KEY", "cert/private.key")
WEBHOOK_CERT = os.getenv("WEBHOOK_CERT", "cert/cert.pem")
WEBHOOK_SECRET_TOKEN = os.getenv("WEBHOOK_SECRET_TOKEN")
x_url_regex = re.compile(r"^(?:https?://)(?:www\.|mobile\.|)(?:x|twitter|fixvx|vxtwitter)\.com/(.+)/status/(\d+)")
x_media_regex = re.compile(r"^(?:https?://)(pbs|video)\.twimg\.com/(.*)")
x_tco_regex = re.compile(r"(?:https?://)t\.co/.+$", re.M)
message_url_regex = re.compile(r"\[.+]", re.S)
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "WARNING"),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
def get_logger(name: str) -> logging.Logger:
return logging.getLogger(name)
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "x-media"
version = "1.9.1"
edition = "2024"
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip", "http2"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
regex = "1.12"
html-escape = "0.2"
url = "2.5.2"
bytes = "1"
zip = "8"
tempfile = "3"
thiserror = "2"
rand = "0.10"
log = "0.4"
tokio = { version = "1.40", features = ["time", "rt", "fs"] }
[dev-dependencies]
tokio = { version = "1.40", features = ["macros", "rt-multi-thread"] }
dotenv = "0.15"
+10
View File
@@ -0,0 +1,10 @@
use x_media::site;
#[tokio::main]
async fn main() {
let url = std::env::args()
.nth(1)
.expect("usage: cargo run -p x-media --example fetch -- <url>");
let result = site::fetch(&url).await;
println!("{result:#?}");
}
+9
View File
@@ -0,0 +1,9 @@
pub mod media;
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-";
+55
View File
@@ -0,0 +1,55 @@
impl Media {
pub fn url(&self) -> &str {
match self {
Media::Illustration { url, .. } => url,
Media::Video { url, .. } => url,
Media::Animated { url, .. } => url,
}
}
pub fn thumbnail_url(&self) -> Option<&str> {
match self {
Media::Illustration { thumbnail_url, .. } => thumbnail_url.as_deref(),
Media::Video { thumbnail_url, .. } => Some(thumbnail_url),
Media::Animated { thumbnail_url, .. } => Some(thumbnail_url),
}
}
/// A smaller variant of this media's file (used as the fallback when the
/// primary URL or upload exceeds Telegram's size limits). None when no
/// smaller variant exists (videos, animated gifs).
pub fn smaller_url(&self) -> Option<&str> {
match self {
Media::Illustration {
url,
fallback_url,
thumbnail_url,
..
} => fallback_url
.as_deref()
.or(thumbnail_url.as_deref())
.filter(|smaller| *smaller != url),
Media::Video { .. } | Media::Animated { .. } => None,
}
}
}
#[derive(Debug)]
pub enum Media {
Illustration {
title: Option<String>,
url: String,
thumbnail_url: Option<String>,
fallback_url: Option<String>,
},
Video {
title: Option<String>,
url: String,
thumbnail_url: String,
},
Animated {
title: Option<String>,
url: String,
thumbnail_url: String,
},
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
mod interface;
mod model;
pub use interface::{
BilibiliSite, PATTERN, cache_key, enabled, fetch_from_url, is_retryable, media_headers,
};
+158
View File
@@ -0,0 +1,158 @@
//! Serde DTOs for the Bilibili dynamic detail endpoint
//! (`/x/polymer/web-dynamic/v1/detail`), mirroring live responses
//! (field paths verified 2026-09-17). Every field is optional so an API
//! shape change degrades to "no media" instead of a parse failure.
use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub(crate) struct Detail {
/// Business code: `0` = OK, `-352`/`-412` = risk control, `500`/`4101147`
/// = gone.
pub(crate) code: i64,
#[serde(default)]
pub(crate) message: Option<String>,
#[serde(default)]
pub(crate) data: Option<Data>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Data {
#[serde(default)]
pub(crate) item: Option<Box<Item>>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Item {
/// The dynamic id, same numeric id as in the URL.
#[serde(default)]
pub(crate) id_str: String,
#[serde(default)]
pub(crate) modules: Option<Modules>,
/// The quoted dynamic when this item is a forward. A forward shell often
/// carries no media of its own — the original holds it.
#[serde(default)]
pub(crate) orig: Option<Box<Item>>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Modules {
#[serde(default)]
pub(crate) module_author: Option<Author>,
#[serde(default)]
pub(crate) module_dynamic: Option<Dynamic>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Author {
#[serde(default)]
pub(crate) name: String,
#[serde(default)]
pub(crate) mid: Option<i64>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Dynamic {
#[serde(default)]
pub(crate) desc: Option<Desc>,
#[serde(default)]
pub(crate) major: Option<Major>,
/// A single topic (`{"id":…,"name":…}`), the dynamic's only tag source.
#[serde(default)]
pub(crate) topic: Option<Topic>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Desc {
#[serde(default)]
pub(crate) text: String,
}
/// `major` is a tagged union: `type` (`MAJOR_TYPE_DRAW` / `_OPUS` /
/// `_ARCHIVE` / …) plus one payload object per type. Only the three payloads
/// this adapter reads are modeled; an unknown major simply yields no media.
#[derive(Deserialize, Debug)]
pub(crate) struct Major {
#[serde(default)]
pub(crate) draw: Option<Draw>,
#[serde(default)]
pub(crate) opus: Option<Opus>,
#[serde(default)]
pub(crate) archive: Option<Archive>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Draw {
#[serde(default)]
pub(crate) items: Vec<Pic>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Pic {
/// `major.draw` image URL.
#[serde(default)]
pub(crate) src: Option<String>,
/// `major.opus.pics` image URL — the opus shape names the field
/// differently while carrying the same image.
#[serde(default)]
pub(crate) url: Option<String>,
}
impl Pic {
/// The image URL, whichever key this serialization put it under.
pub(crate) fn url(&self) -> Option<&str> {
self.src.as_deref().or(self.url.as_deref())
}
}
/// `major.opus`: the serialization of an image/text post the web client asks
/// for (`features=itemOpusStyle`). It carries the parts the legacy shape drops
/// entirely — the document title and body of an opus post, whose
/// `module_dynamic.desc` comes back `null`.
#[derive(Deserialize, Debug)]
pub(crate) struct Opus {
/// Document headline; often absent.
#[serde(default)]
pub(crate) title: Option<String>,
/// Document body (untruncated: a 307-char sample came back whole).
#[serde(default)]
pub(crate) summary: Option<Desc>,
#[serde(default)]
pub(crate) pics: Vec<Pic>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Archive {
/// The attached video's cover — the only image an AV dynamic has (the
/// video itself is deliberately not resolved, see the module docs).
#[serde(default)]
pub(crate) cover: Option<String>,
/// The video's title. An AV dynamic has no body of its own (`desc` comes
/// back `null`), so this card title is the post's content.
#[serde(default)]
pub(crate) title: Option<String>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Topic {
#[serde(default)]
pub(crate) name: String,
}
/// Response of the anonymous fingerprint endpoint (`/x/frontend/finger/spi`),
/// the source of the adapter's device cookies.
#[derive(Deserialize, Debug)]
pub(crate) struct Fingerprint {
#[serde(default)]
pub(crate) data: Option<FingerprintData>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct FingerprintData {
/// Sent as the `buvid3` cookie.
#[serde(default, rename = "b_3")]
pub(crate) buvid3: String,
/// Sent as the `buvid4` cookie.
#[serde(default, rename = "b_4")]
pub(crate) buvid4: String,
}
+596
View File
@@ -0,0 +1,596 @@
use super::model;
use crate::media::Media;
use crate::site::{FetchError, Fetched, Site, SiteFuture};
use html_escape::{encode_double_quoted_attribute, encode_text};
use regex::Regex;
use std::sync::LazyLock;
/// Registry entry for the bluesky adapter (see [`crate::site::Site`]).
pub struct BskySite;
impl Site for BskySite {
fn id(&self) -> &'static str {
"bsky"
}
fn pattern(&self) -> &'static Regex {
&PATTERN
}
fn cache_key(&self, url: &str) -> Option<String> {
cache_key(url)
}
fn fetch_from_url<'a>(&'a self, url: &'a str) -> SiteFuture<'a, Fetched> {
Box::pin(async move { fetch_from_url(url).await })
}
}
pub static PATTERN: LazyLock<Regex> = LazyLock::new(|| {
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> {
let caps = PATTERN.captures(url).ok_or(FetchError::NotFound)?;
let handle = caps
.get(1)
.map(|m| m.as_str())
.ok_or(FetchError::NotFound)?;
let rkey = caps
.get(2)
.map(|m| m.as_str())
.ok_or(FetchError::NotFound)?;
let post = fetch(handle, rkey).await?;
let mut fetched: Fetched = post.into();
// bsky video embeds expose only an HLS playlist URL, which Telegram
// cannot fetch; remux it to a single MP4 (mirrors the pixiv ugoira
// 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.
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 {
let is_hls = matches!(&item, Media::Video { url, .. }
if url.contains("playlist") || url.ends_with(".m3u8"));
if !is_hls {
media.push(item);
continue;
}
let url = item.url().to_string();
match resolve_bsky_video(&url).await {
Ok(Some((mp4_path, keep_alive))) => {
let thumbnail_url = match &item {
Media::Video { thumbnail_url, .. } => thumbnail_url.clone(),
_ => String::new(),
};
media.push(Media::Video {
title: None,
url: mp4_path.to_string_lossy().into_owned(),
thumbnail_url,
});
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);
}
}
}
if media.is_empty()
&& let Some(reason) = remux_failure
{
return Err(FetchError::MediaPrep(format!(
"bsky video remux failed: {reason}"
)));
}
fetched.media = media;
Ok(fetched)
}
/// Cache key for a bsky URL: `"bsky:<handle>/<rkey>"`. 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!("bsky:{}/{}", &caps[1], &caps[2]))
}
/// Bluesky'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(_))
}
/// bsky media (cdn.bsky.app) needs no extra headers.
pub fn media_headers(_url: &str) -> Option<Vec<(&'static str, String)>> {
None
}
/// Segments fetched (and written) at once while remuxing an HLS video. Small
/// on purpose: a segment can be up to 20 MiB and the whole playlist is capped
/// at 256 MiB, so this is also what bounds the remux's peak memory.
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()
}
/// One HLS fetch (a playlist or a segment) with an in-place retry for a
/// retryable class (transport, 429/5xx). These used to get their retry from the
/// 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).await {
Err(FetchError::Http(_) | FetchError::Transient(_)) => {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
crate::site::download_media_limited(url, cap)
.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
/// single MP4 via ffmpeg. Returns the MP4 path plus the temp dir that must
/// stay alive until the file is uploaded. `Ok(None)` when ffmpeg is missing.
///
/// Verified live (2026-08): bsky master playlists carry `#EXT-X-STREAM-INF`
/// variant lines (e.g. `720p/video.m3u8?session_id=…`), and the media
/// playlists are VOD MPEG-TS segments (`videoN.ts?…`) without EXT-X-MAP, so
/// a plain `-f concat -c copy` remux is valid.
async fn resolve_bsky_video(
playlist_url: &str,
) -> Result<Option<(std::path::PathBuf, tempfile::TempDir)>, String> {
if !crate::site::ffmpeg_available() {
crate::site::log_once_ffmpeg_missing();
return Ok(None);
}
let master = fetch_hls(playlist_url, 1_048_576)
.await
.map_err(|e| format!("bsky video master playlist: {e}"))?;
let master = String::from_utf8_lossy(&master);
// Master playlist: pick the variant with the highest declared bandwidth.
let playlist_url = if master.contains("#EXT-X-STREAM-INF") {
let mut best: Option<(u64, String)> = None;
let mut lines = master.lines();
while let Some(line) = lines.next() {
if !line.starts_with("#EXT-X-STREAM-INF") {
continue;
}
let bandwidth = line
.split_once("BANDWIDTH=")
.and_then(|(_, rest)| rest.split(|c: char| !c.is_ascii_digit()).next())
.and_then(|n| n.parse::<u64>().ok())
.unwrap_or(0);
if let Some(uri) = lines.next().filter(|u| !u.starts_with('#'))
&& bandwidth >= best.as_ref().map(|(b, _)| *b).unwrap_or(0)
{
best = Some((bandwidth, uri.to_string()));
}
}
let Some((_, uri)) = best else {
return Err("bsky video master playlist has no variants".to_string());
};
url::Url::parse(playlist_url)
.and_then(|base| base.join(&uri))
.map_err(|e| format!("bsky video variant URL: {e}"))?
.to_string()
} else {
playlist_url.to_string()
};
let variant = fetch_hls(&playlist_url, 1_048_576)
.await
.map_err(|e| format!("bsky video media playlist: {e}"))?;
let variant = String::from_utf8_lossy(&variant);
// Segment URIs: non-#, non-empty lines, resolved relative to the playlist.
let base = url::Url::parse(&playlist_url).map_err(|e| format!("bsky playlist URL: {e}"))?;
let segments: Vec<String> = variant
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(|l| base.join(l).map(|u| u.to_string()))
.collect::<Result<_, _>>()
.map_err(|e| format!("bsky segment URL: {e}"))?;
if segments.is_empty() {
return Err("bsky video playlist has no segments".to_string());
}
if segments.len() > 500 {
return Err("bsky video has too many segments".to_string());
}
let frames_dir = tempfile::Builder::new()
.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 written: Vec<(usize, std::path::PathBuf)> = Vec::with_capacity(segments.len());
let mut next = 0;
let mut set = tokio::task::JoinSet::new();
loop {
while set.len() < SEGMENT_CONCURRENCY && next < segments.len() {
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 {
return Err("bsky video exceeds total size cap".to_string());
}
written.push((i, path));
}
let list = concat_list(&mut written);
let list_path = frames_dir.path().join("list.txt");
tokio::fs::write(&list_path, &list)
.await
.map_err(|e| e.to_string())?;
let output = out_dir.path().join("video.mp4");
let list_str = list_path.to_string_lossy().into_owned();
let output_str = output.to_string_lossy().into_owned();
let status = tokio::task::spawn_blocking(move || {
std::process::Command::new("ffmpeg")
.args([
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
&list_str,
"-c",
"copy",
"-movflags",
"+faststart",
&output_str,
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
})
.await
.map_err(|e| format!("bsky remux worker panicked: {e}"))?;
match status {
Ok(s) if s.success() => Ok(Some((output, out_dir))),
Ok(s) => Err(format!("ffmpeg exited with {s}")),
Err(e) => Err(format!("ffmpeg spawn failed: {e}")),
}
}
/// Fetches a post thread by handle or DID (`at://` URIs work for both).
pub async fn fetch(handle: &str, rkey: &str) -> Result<Post, FetchError> {
let response = crate::site::CLIENT
.get(API_URL)
.query(&[
("uri", format!("at://{handle}/app.bsky.feed.post/{rkey}")),
("depth", "0".to_string()),
])
.send()
.await?;
// 404/410 = gone (permanent); 429/5xx = transient and retried by fetch.
let status = response.status();
if !status.is_success() {
return match status.as_u16() {
404 | 410 => Err(FetchError::NotFound),
// A refusal or an auth demand is not a bad moment: retrying it
// three times only delays an error the user has to see.
401 | 403 => Err(FetchError::Blocked),
_ => Err(FetchError::Transient(format!("bsky status {status}"))),
};
}
let text = response.text().await?;
Post::from_json(&text, rkey.to_string())
}
#[derive(Debug)]
pub struct Post {
id: String,
author: String,
author_id: String,
text: String,
media: Vec<Media>,
sensitive: bool,
}
impl Post {
fn url(&self) -> String {
format!("{}/post/{}", self.author_url(), self.id)
}
fn author_url(&self) -> String {
format!("https://bsky.app/profile/{}", self.author_id)
}
pub fn caption(&self) -> String {
format!(
"{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> {
let json: serde_json::Value = serde_json::from_str(raw_json).map_err(FetchError::Json)?;
let json: model::Info = serde_json::from_value(json).map_err(FetchError::Json)?;
match json.thread {
model::Thread::Post { post } => {
let text = post.record.text;
let author = post.author.display_name.unwrap_or_default();
let author_id = post.author.handle;
let mut media = vec![];
if let Some(embed) = post.embed {
match embed {
model::Media::Images { images } => {
media.extend(images.into_iter().map(|image| Media::Illustration {
title: None,
url: image.fullsize,
thumbnail_url: Some(image.thumb),
fallback_url: None,
}));
}
model::Media::Video {
playlist,
thumbnail,
} => {
media.push(Media::Video {
title: None,
url: playlist,
thumbnail_url: thumbnail,
});
}
model::Media::External => {}
}
}
let sensitive = post
.labels
.iter()
.any(|label| SENSITIVE_LABEL.contains(&label.val.as_str()));
Ok(Post {
id,
author,
author_id,
text,
media,
sensitive,
})
}
model::Thread::NotFound => Err(FetchError::NotFound),
model::Thread::Blocked => Err(FetchError::Blocked),
}
}
}
impl From<Post> for Fetched {
fn from(post: Post) -> Self {
let url = post.url();
let author_url = post.author_url();
let render_data = Some(crate::site::RenderData {
url: url.clone(),
author: encode_text(&post.author).into_owned(),
author_url: author_url.clone(),
// A post has no title: its text is all content.
title: String::new(),
content: encode_text(&post.text).into_owned(),
tags: String::new(),
});
Fetched {
source_url: url,
caption: post.caption(),
title: String::new(),
content: post.text.clone(),
media: post.media,
sensitive: post.sensitive,
site_id: "bsky",
render_data,
_keep_alive: None,
}
}
}
const API_URL: &str = "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread";
const SENSITIVE_LABEL: [&str; 4] = ["sexual", "nudity", "porn", "graphic-media"];
#[cfg(test)]
mod tests {
use super::*;
fn thread_json(post_json: serde_json::Value) -> serde_json::Value {
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]
fn pattern_matches_handle_and_did() {
let cases = [
(
"https://bsky.app/profile/user.bsky.social/post/3laoveufjv224",
"user.bsky.social",
"3laoveufjv224",
),
(
"https://bsky.app/profile/did:plc:abc123def/post/3xxxx",
"did:plc:abc123def",
"3xxxx",
),
];
for (url, handle, rkey) in cases {
let caps = PATTERN.captures(url).unwrap_or_else(|| panic!("{url}"));
assert_eq!(caps.get(1).unwrap().as_str(), handle);
assert_eq!(caps.get(2).unwrap().as_str(), rkey);
}
}
#[test]
fn pattern_rejects_non_post_urls() {
for url in [
"https://bsky.app/profile/user.bsky.social",
"https://bsky.app/profile/user.bsky.social/posts",
"https://x.com/user/status/123",
] {
assert!(!PATTERN.is_match(url), "{url}");
}
}
/// 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() {
assert!(!is_retryable(&FetchError::MediaPrep(
"bsky video remux failed: segment 400: 503".into()
)));
assert!(is_retryable(&FetchError::Transient("429".into())));
}
#[test]
fn from_json_images_with_missing_defaults() {
let raw = thread_json(serde_json::json!({
"$type": "app.bsky.feed.defs#threadViewPost",
"post": {
"author": { "handle": "user.bsky.social" },
"record": { "$type": "app.bsky.feed.post", "text": "hello <world>" },
"embed": {
"$type": "app.bsky.embed.images#view",
"images": [
{ "thumb": "https://cdn.bsky.app/img/thumb", "fullsize": "https://cdn.bsky.app/img/full", "alt": "" }
]
}
}
}));
let post = Post::from_json(&raw.to_string(), "3xxxx".into()).unwrap();
let fetched: Fetched = post.into();
assert_eq!(
fetched.source_url,
"https://bsky.app/profile/user.bsky.social/post/3xxxx"
);
assert_eq!(fetched.title, "");
assert_eq!(fetched.content, "hello <world>");
assert_eq!(fetched.media.len(), 1);
assert!(!fetched.sensitive);
// display_name absent -> empty fallback
assert!(
fetched.caption.contains("</a>: hello &lt;world&gt;"),
"caption: {}",
fetched.caption
);
}
#[test]
fn from_json_sensitive_labels() {
let raw = thread_json(serde_json::json!({
"$type": "app.bsky.feed.defs#threadViewPost",
"post": {
"author": { "handle": "u.bsky.social", "displayName": "U" },
"record": { "$type": "app.bsky.feed.post", "text": "x" },
"labels": [{ "val": "porn" }]
}
}));
let post = Post::from_json(&raw.to_string(), "3xxxx".into()).unwrap();
assert!(post.sensitive);
}
#[test]
fn from_json_blocked_and_not_found() {
let blocked = thread_json(serde_json::json!({
"$type": "app.bsky.feed.defs#blockedPost",
"blocked": true
}));
assert!(matches!(
Post::from_json(&blocked.to_string(), "3xxxx".into()),
Err(FetchError::Blocked)
));
let not_found = thread_json(serde_json::json!({
"$type": "app.bsky.feed.defs#notFoundPost",
"notFound": true
}));
assert!(matches!(
Post::from_json(&not_found.to_string(), "3xxxx".into()),
Err(FetchError::NotFound)
));
}
/// 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]
#[ignore = "live network: requires outbound HTTPS to public.api.bsky.app"]
async fn live_fetch_with_photos() {
let url = "https://bsky.app/profile/fu-futa.bsky.social/post/3laoveufjv224";
let fetched = fetch_from_url(url).await.unwrap();
assert_eq!(fetched.source_url, url);
assert!(!fetched.caption.is_empty());
assert!(!fetched.media.is_empty(), "expected photos in {url}");
assert!(fetched.sensitive, "expected a label on {url}");
}
}
+6
View File
@@ -0,0 +1,6 @@
mod interface;
mod model;
pub use interface::{
BskySite, PATTERN, Post, cache_key, enabled, fetch_from_url, is_retryable, media_headers,
};
+60
View File
@@ -0,0 +1,60 @@
use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub(crate) struct Info {
pub(crate) thread: Thread,
}
#[derive(Deserialize, Debug)]
#[serde(tag = "$type")]
pub(crate) enum Thread {
#[serde(rename = "app.bsky.feed.defs#threadViewPost")]
Post { post: Post },
#[serde(rename = "app.bsky.feed.defs#notFoundPost")]
NotFound,
#[serde(rename = "app.bsky.feed.defs#blockedPost")]
Blocked,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Post {
pub(crate) author: Author,
pub(crate) record: PostRecord,
pub(crate) embed: Option<Media>,
#[serde(default)]
pub(crate) labels: Vec<Label>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Author {
pub(crate) handle: String,
#[serde(rename = "displayName", default)]
pub(crate) display_name: Option<String>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct PostRecord {
pub(crate) text: String,
}
#[derive(Deserialize, Debug)]
#[serde(tag = "$type")]
pub(crate) enum Media {
#[serde(rename = "app.bsky.embed.images#view")]
Images { images: Vec<Image> },
#[serde(rename = "app.bsky.embed.video#view")]
Video { playlist: String, thumbnail: String },
#[serde(rename = "app.bsky.embed.external#view")]
External,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Image {
pub(crate) thumb: String,
pub(crate) fullsize: String,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Label {
pub(crate) val: String,
}
@@ -0,0 +1,392 @@
//! Site adapter for misskey.io notes: URL pattern, API fetch and
//! normalization into [`Fetched`] (see [`crate::site::Site`]).
use super::model;
use crate::media::Media;
use crate::site::{FetchError, Fetched, RenderData, Site, SiteFuture};
use html_escape::{encode_double_quoted_attribute, encode_text};
use regex::Regex;
use std::sync::LazyLock;
const API_URL: &str = "https://misskey.io/api/notes/show";
/// Registry entry for the misskey.io adapter (see [`crate::site::Site`]).
pub struct MisskeySite;
impl Site for MisskeySite {
fn id(&self) -> &'static str {
"misskey"
}
fn pattern(&self) -> &'static Regex {
&PATTERN
}
fn cache_key(&self, url: &str) -> Option<String> {
cache_key(url)
}
fn fetch_from_url<'a>(&'a self, url: &'a str) -> SiteFuture<'a, Fetched> {
Box::pin(async move { fetch_from_url(url).await })
}
}
pub static PATTERN: LazyLock<Regex> =
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> {
let caps = PATTERN.captures(url).ok_or(FetchError::NotFound)?;
let note_id = caps.get(1).ok_or(FetchError::NotFound)?.as_str();
let note = fetch(note_id).await?;
Ok(note.into())
}
/// Cache key for a misskey URL: `"misskey:<note 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!("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
/// with HTTP 400 + `{"error":{"code":...}}` (NO_SUCH_NOTE → NotFound);
/// everything else non-success is transient and retried by [`crate::site::fetch`].
pub async fn fetch(note_id: &str) -> Result<model::Note, FetchError> {
let response = crate::site::CLIENT
.post(API_URL)
.json(&serde_json::json!({ "noteId": note_id }))
.send()
.await?;
let status = response.status();
if !status.is_success() {
return Err(match status.as_u16() {
400 => not_found_or_invalid(response).await,
// A refusal or an auth demand is not a bad moment.
401 | 403 => FetchError::Blocked,
_ => FetchError::Transient(format!("misskey status {status}")),
});
}
response.json().await.map_err(|e| FetchError::Site {
site: "misskey",
error: Box::new(e),
})
}
/// Maps a 400 response: NO_SUCH_NOTE is permanent NotFound, any other 400 is
/// a site error (permanent — retrying a rejected request cannot succeed).
async fn not_found_or_invalid(response: reqwest::Response) -> FetchError {
match response.json::<serde_json::Value>().await {
Ok(v) if v["error"]["code"] == "NO_SUCH_NOTE" => FetchError::NotFound,
_ => FetchError::Site {
site: "misskey",
error: "note rejected (invalid param or private note)".into(),
},
}
}
/// The note whose content matters: a renote shell has no text/files of its
/// own — the embedded renote carries them.
fn effective(note: &model::Note) -> &model::Note {
match &note.renote {
Some(renote) if note.files.is_empty() => renote,
_ => note,
}
}
impl From<model::Note> for Fetched {
fn from(note: model::Note) -> Self {
let note = &note;
let content = effective(note);
let url = format!("https://misskey.io/notes/{}", note.id);
let author = content
.user
.name
.as_deref()
.filter(|n| !n.is_empty())
.unwrap_or(&content.user.username)
.to_string();
let author_url = format!("https://misskey.io/@{}", content.user.username);
let cw = content.cw.as_deref().unwrap_or_default();
// Notes carry hashtags inline in the text (no structured tags array);
// a CW note gets the marker prefixed so recipients see the spoiler.
let mut text = cw.to_string();
if !cw.is_empty() && !text.ends_with(' ') {
text.push(' ');
}
text.push_str(content.text.as_deref().unwrap_or_default().trim());
let text = text.trim().to_string();
let caption = caption(&url, &author_url, &author, &text);
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();
Fetched {
source_url: url.clone(),
caption,
// A note has no title: its text (CW marker included) is content.
title: String::new(),
content: text.clone(),
media,
sensitive,
site_id: "misskey",
render_data: Some(RenderData {
url,
author: encode_text(&author).into_owned(),
author_url: author_url.clone(),
title: String::new(),
content: encode_text(&text).into_owned(),
tags: String::new(),
}),
_keep_alive: None,
}
}
}
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
/// are skipped (twitter's `_ => {}` precedent). GIF must be matched before
/// the generic image arm.
fn media_from_file(file: &model::DriveFile) -> Option<Media> {
let title = file.name.clone();
match file.mime_type.as_str() {
"image/gif" => Some(Media::Animated {
title,
url: file.url.clone(),
thumbnail_url: file.thumbnail_url.clone().unwrap_or_default(),
}),
mime if mime.starts_with("image/") => Some(Media::Illustration {
title,
url: file.url.clone(),
thumbnail_url: file.thumbnail_url.clone(),
fallback_url: None,
}),
mime if mime.starts_with("video/") => Some(Media::Video {
title,
url: file.url.clone(),
thumbnail_url: file.thumbnail_url.clone().unwrap_or_default(),
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn note_json(json: serde_json::Value) -> model::Note {
serde_json::from_value(json).unwrap()
}
fn base_note() -> serde_json::Value {
serde_json::json!({
"id": "aotihl10lqrs015s",
"text": "hello",
"user": { "name": "ミロン", "username": "donyan47897", "host": null },
"files": []
})
}
#[test]
fn pattern_matches_misskey_note_urls() {
for url in [
"https://misskey.io/notes/aotihl10lqrs015s",
"http://misskey.io/notes/aotihl10lqrs015s",
"misskey.io/notes/aotihl10lqrs015s",
] {
assert!(PATTERN.is_match(url), "{url}");
}
for url in [
"https://misskey.io/",
"https://misskey.io/@user",
"https://misskey.io/notes/",
"https://x.com/user/status/123",
] {
assert!(!PATTERN.is_match(url), "{url}");
}
}
#[test]
fn cache_key_normalizes_variants() {
assert_eq!(
cache_key("https://misskey.io/notes/aotihl10lqrs015s"),
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]
fn from_json_image_file() {
let mut note = base_note();
note["files"] = serde_json::json!([{
"type": "image/webp",
"url": "https://media.misskeyusercontent.jp/io/a.webp",
"thumbnailUrl": "https://media.misskeyusercontent.jp/io/t.webp",
"isSensitive": true,
"name": "pic.webp"
}]);
let fetched: Fetched = note_json(note).into();
assert_eq!(
fetched.source_url,
"https://misskey.io/notes/aotihl10lqrs015s"
);
assert_eq!(fetched.site_id, "misskey");
assert_eq!(fetched.title, "");
assert_eq!(fetched.content, "hello");
assert!(fetched.sensitive);
assert_eq!(fetched.media.len(), 1);
match &fetched.media[0] {
Media::Illustration {
title,
url,
thumbnail_url,
fallback_url,
} => {
assert_eq!(title.as_deref(), Some("pic.webp"));
assert_eq!(url, "https://media.misskeyusercontent.jp/io/a.webp");
assert_eq!(
thumbnail_url.as_deref(),
Some("https://media.misskeyusercontent.jp/io/t.webp")
);
assert!(fallback_url.is_none());
}
other => panic!("expected illustration, got {other:?}"),
}
}
#[test]
fn from_json_gif_video_and_skip_audio() {
let mut note = base_note();
note["files"] = serde_json::json!([
{ "type": "audio/mpeg", "url": "https://m/a.mp3", "isSensitive": false },
{ "type": "image/gif", "url": "https://m/a.gif", "isSensitive": false },
{ "type": "video/webm", "url": "https://m/a.webm", "isSensitive": false }
]);
let fetched: Fetched = note_json(note).into();
assert_eq!(fetched.media.len(), 2);
assert!(
matches!(&fetched.media[0], Media::Animated { url, .. } if url == "https://m/a.gif")
);
assert!(matches!(&fetched.media[1], Media::Video { url, .. } if url == "https://m/a.webm"));
// No thumbnailUrl → empty string, not a broken URL.
match &fetched.media[1] {
Media::Video { thumbnail_url, .. } => assert_eq!(thumbnail_url, ""),
other => panic!("expected video, got {other:?}"),
}
assert!(!fetched.sensitive);
}
#[test]
fn from_json_cw_marks_sensitive_and_prefixes_title() {
let mut note = base_note();
note["cw"] = serde_json::json!("spoiler");
note["text"] = serde_json::json!("body");
let fetched: Fetched = note_json(note).into();
assert!(fetched.sensitive);
assert_eq!(fetched.title, "");
assert_eq!(fetched.content, "spoiler body");
}
#[test]
fn from_json_author_falls_back_to_username() {
let mut note = base_note();
note["user"] = serde_json::json!({ "name": null, "username": "donyan47897", "host": null });
let fetched: Fetched = note_json(note).into();
assert!(
fetched.caption.contains("donyan47897"),
"{}",
fetched.caption
);
assert!(fetched.caption.contains("https://misskey.io/@donyan47897"));
}
#[test]
fn from_json_renote_uses_embedded_content() {
let note = serde_json::json!({
"id": "shell0000000000",
"text": null,
"user": { "name": "shell", "username": "shelluser", "host": null },
"files": [],
"renote": {
"id": "inner000000000",
"text": "inner text",
"user": { "name": "inner", "username": "inneruser", "host": null },
"files": [
{ "type": "image/png", "url": "https://m/i.png", "isSensitive": false }
]
}
});
let fetched: Fetched = note_json(note).into();
assert_eq!(fetched.title, "");
assert_eq!(fetched.content, "inner text");
assert_eq!(fetched.media.len(), 1);
// The source URL still points at the renote shell the user posted.
assert_eq!(
fetched.source_url,
"https://misskey.io/notes/shell0000000000"
);
}
#[test]
fn caption_layout_matches_bsky() {
let fetched: Fetched = note_json(base_note()).into();
assert_eq!(
fetched.caption,
"https://misskey.io/notes/aotihl10lqrs015s\n<a href=\"https://misskey.io/@donyan47897\">ミロン</a>: hello"
);
}
#[test]
fn caption_without_text_has_no_dangling_colon() {
let mut note = base_note();
note["text"] = serde_json::json!(null);
let fetched: Fetched = note_json(note).into();
assert_eq!(
fetched.caption,
"https://misskey.io/notes/aotihl10lqrs015s\n<a href=\"https://misskey.io/@donyan47897\">ミロン</a>"
);
}
#[tokio::test]
#[ignore = "live network: requires outbound HTTPS to misskey.io"]
async fn live_fetch_reference_note() {
let fetched = fetch_from_url("https://misskey.io/notes/aotihl10lqrs015s")
.await
.unwrap();
assert_eq!(fetched.site_id, "misskey");
assert_eq!(fetched.media.len(), 1);
assert!(fetched.sensitive);
assert!(!fetched.caption.is_empty());
}
}
+6
View File
@@ -0,0 +1,6 @@
mod interface;
mod model;
pub use interface::{
MisskeySite, PATTERN, cache_key, enabled, fetch_from_url, is_retryable, media_headers,
};
+35
View File
@@ -0,0 +1,35 @@
use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub(crate) struct Note {
pub(crate) id: String,
pub(crate) text: Option<String>,
#[serde(default)]
pub(crate) cw: Option<String>,
pub(crate) user: User,
#[serde(default)]
pub(crate) files: Vec<DriveFile>,
/// Embedded original note when this note is a renote; the shell's own
/// text/files are usually empty and the content lives here.
#[serde(default)]
pub(crate) renote: Option<Box<Note>>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct User {
pub(crate) name: Option<String>,
pub(crate) username: String,
}
#[derive(Deserialize, Debug)]
pub(crate) struct DriveFile {
#[serde(rename = "type")]
pub(crate) mime_type: String,
pub(crate) url: String,
#[serde(default, rename = "thumbnailUrl")]
pub(crate) thumbnail_url: Option<String>,
#[serde(default, rename = "isSensitive")]
pub(crate) is_sensitive: bool,
#[serde(default)]
pub(crate) name: Option<String>,
}
File diff suppressed because it is too large Load Diff
+417
View File
@@ -0,0 +1,417 @@
//! Native pixiv app-API client (replaces pixiv3-rs).
//!
//! Token exchange against `oauth.secure.pixiv.net` and illust detail against
//! `app-api.pixiv.net`, deserialized with the kept `model.rs` types.
use super::interface::Illustration;
use super::model::{IllustrationModel, TypeModel, UgoiraMetadataModel};
use crate::media::Media;
use crate::site::FetchError;
use std::env;
use std::io::Read;
use std::sync::LazyLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime};
use thiserror::Error;
const AUTH_TOKEN_URL: &str = "https://oauth.secure.pixiv.net/auth/token";
const APP_API_URL: &str = "https://app-api.pixiv.net";
const CLIENT_ID: &str = "MOBrBDS8blbauoSck0ZfDbtuzpyT";
const CLIENT_SECRET: &str = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj";
const AUTH_USER_AGENT: &str = "PixivAndroidApp/5.0.234 (Android 11; Pixel 5)";
const APP_USER_AGENT: &str = "PixivIOSApp/7.13.3 (iOS 14.6; iPhone13,2)";
/// Token refresh safe margin (seconds).
const TOKEN_REFRESH_SAFE_MARGIN: u64 = 300;
#[derive(Debug, Error)]
pub enum PixivError {
/// No refresh token available (PIXIV_REFRESH_TOKEN unset).
#[error("pixiv: no authentication")]
NoAuth,
#[error("pixiv http error: {0}")]
Http(#[from] reqwest::Error),
#[error("pixiv json error: {0}")]
Json(#[from] serde_json::Error),
/// Non-2xx HTTP status from the app API. The code lets [`crate::site::fetch`]
/// retry only transient classes (429 / 5xx) instead of burning attempts on
/// permanent 4xx (bad token, forbidden, not found).
#[error("pixiv status {0}")]
Status(u16),
#[error("pixiv api error: {0}")]
Api(String),
}
/// Native pixiv app-API client.
pub struct PixivAPI {
refresh_token: String,
access_token: tokio::sync::Mutex<Option<(String, SystemTime)>>,
}
impl PixivAPI {
pub fn new(refresh_token: String) -> Self {
Self {
refresh_token,
access_token: tokio::sync::Mutex::new(None),
}
}
/// Returns a valid access token, exchanging the refresh token when none
/// is cached or it has expired.
pub async fn get_access_token(&self) -> Result<String, PixivError> {
let mut guard = self.access_token.lock().await;
if let Some((token, expires_at)) = guard.as_ref()
&& *expires_at > SystemTime::now()
{
return Ok(token.clone());
}
let response = crate::site::CLIENT
.post(AUTH_TOKEN_URL)
.form(&[
("client_id", CLIENT_ID),
("client_secret", CLIENT_SECRET),
("grant_type", "refresh_token"),
("include_policy", "true"),
("refresh_token", &self.refresh_token),
])
.header("User-Agent", AUTH_USER_AGENT)
.send()
.await?;
// Check the status *before* reading the body: a 429/5xx from the
// token endpoint is worth retrying (the class comes from
// `is_retryable`), while parsing a maintenance page as JSON turned it
// into a permanent `Api`/`Json` error with no retry at all.
if !response.status().is_success() {
return Err(PixivError::Status(response.status().as_u16()));
}
let json: serde_json::Value = serde_json::from_str(&response.text().await?)?;
let access_token = json
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| {
let message = json
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("invalid token response");
PixivError::Api(message.to_string())
})?
.to_string();
let expires_in = json
.get("expires_in")
.and_then(|v| v.as_u64())
.filter(|&sec| sec > 0)
.unwrap_or(3600);
let expires_at = SystemTime::now()
+ Duration::from_secs(expires_in.saturating_sub(TOKEN_REFRESH_SAFE_MARGIN));
*guard = Some((access_token.clone(), expires_at));
Ok(access_token)
}
/// Fetches illust detail from the app API.
pub async fn illust_detail(&self, illust_id: u64) -> Result<IllustrationModel, PixivError> {
let access_token = self.get_access_token().await?;
let response = crate::site::CLIENT
.get(format!(
"{APP_API_URL}/v1/illust/detail?illust_id={illust_id}"
))
.header("app-os", "ios")
.header("app-os-version", "14.6")
.header("User-Agent", APP_USER_AGENT)
.bearer_auth(access_token)
.send()
.await?;
if !response.status().is_success() {
return Err(PixivError::Status(response.status().as_u16()));
}
let json: serde_json::Value = serde_json::from_str(&response.text().await?)?;
if json.get("error").is_some() {
let message = json
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("illust detail failed");
return Err(PixivError::Api(message.to_string()));
}
let illust = json
.get("illust")
.ok_or_else(|| PixivError::Api("missing illust in response".to_string()))?;
Ok(serde_json::from_value(illust.clone())?)
}
pub async fn fetch(&self, illust_id: u64) -> Result<Illustration, FetchError> {
let model = self.illust_detail(illust_id).await?;
let mut illustration = Illustration::from_model(&model);
if matches!(&model.r#type, TypeModel::Ugoira) {
// Real ugoira support: download the frame zip and encode an MP4.
// Without ffmpeg the post stays unsupported (empty media, like
// 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 {
Ok(Some((mp4_path, _keep_alive))) => {
illustration.media.push(Media::Video {
title: None,
url: mp4_path,
thumbnail_url: model.image_urls.medium.clone(),
});
illustration._keep_alive = Some(std::sync::Arc::new(_keep_alive));
}
Ok(None) => {}
Err(e) => {
log::error!("ugoira encode failed for {illust_id}: {e}");
return Err(FetchError::Pixiv(e));
}
}
}
Ok(illustration)
}
/// Fetches ugoira metadata (frame zip + frame delays) from the app API.
pub async fn ugoira_metadata(&self, illust_id: u64) -> Result<UgoiraMetadataModel, PixivError> {
let access_token = self.get_access_token().await?;
let response = crate::site::CLIENT
.get(format!(
"{APP_API_URL}/v1/ugoira/metadata?illust_id={illust_id}"
))
.header("app-os", "ios")
.header("app-os-version", "14.6")
.header("User-Agent", APP_USER_AGENT)
.bearer_auth(access_token)
.send()
.await?;
if !response.status().is_success() {
return Err(PixivError::Status(response.status().as_u16()));
}
let json: serde_json::Value = serde_json::from_str(&response.text().await?)?;
if json.get("error").is_some() {
let message = json
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("ugoira metadata failed");
return Err(PixivError::Api(message.to_string()));
}
let metadata = json
.get("ugoira_metadata")
.ok_or_else(|| PixivError::Api("missing ugoira_metadata".to_string()))?;
Ok(serde_json::from_value(metadata.clone())?)
}
/// Downloads the frame zip and encodes one MP4 via ffmpeg. Returns the
/// MP4 path plus the temp directory that must stay alive until the file
/// is uploaded.
async fn ugoira_video(
&self,
illust_id: u64,
) -> Result<Option<(String, tempfile::TempDir)>, PixivError> {
if !crate::site::ffmpeg_available() {
crate::site::log_once_ffmpeg_missing();
return Ok(None);
}
let metadata = self.ugoira_metadata(illust_id).await?;
if metadata.frames.is_empty() {
return Ok(None);
}
let zip_url = metadata
.zip_url
.clone()
.or_else(|| metadata.zip_urls.as_ref().map(|z| z.medium.clone()));
let Some(zip_url) = zip_url else {
return Ok(None);
};
// Stream the frame zip to a temp file instead of buffering it in
// memory: ugoira zips can be hundreds of MB, and the old
// download_media_limited path spiked RAM up to the size cap.
let mut zip_file = tempfile::Builder::new()
.prefix(crate::TEMP_FILE_PREFIX)
.suffix(".zip")
.tempfile()
.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())
.await
.map_err(|e| match e {
FetchError::Http(e) => PixivError::Http(e),
other => PixivError::Api(format!("frame zip download failed: {other}")),
})?;
let frame_delays = metadata.frames.iter().map(|f| f.delay).collect::<Vec<_>>();
let result =
tokio::task::spawn_blocking(move || -> Result<(String, tempfile::TempDir), String> {
let frames_dir = tempfile::Builder::new()
.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())?;
// Extract frames to canonical zero-padded names; pixiv ugoira
// frames are uniformly jpg or png per artwork. The zip is read
// from disk; `zip_file` stays alive for the whole extraction.
let mut archive = zip::ZipArchive::new(
std::fs::File::open(zip_file.path()).map_err(|e| e.to_string())?,
)
.map_err(|e| format!("unzip: {e}"))?;
if archive.is_empty() {
return Err("empty frame zip".to_string());
}
// Uniform jpg or png per artwork; sniff the first entry's
// 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 path = frames_dir
.path()
.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())?;
if entry.size() > 64 * 1024 * 1024 {
return Err(format!("frame {i} exceeds size cap"));
}
let mut bytes = Vec::new();
entry
.take(64 * 1024 * 1024 + 1)
.read_to_end(&mut bytes)
.map_err(|e| e.to_string())?;
if bytes.len() > 64 * 1024 * 1024 {
return Err(format!("frame {i} exceeds size cap"));
}
let path = frames_dir
.path()
.join(format!("img_{count:05}.{extension}"));
std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
count += 1;
}
if count == 0 {
return Err("empty frame zip".to_string());
}
// Constant rate from the median frame delay (ms).
let mut delays = frame_delays;
delays.sort_unstable();
let median = delays[delays.len() / 2].max(1);
let framerate = 1000.0 / median as f64;
let output = out_dir.path().join("ugoira.mp4");
let status = std::process::Command::new("ffmpeg")
.args([
"-y",
"-framerate",
&framerate.to_string(),
"-i",
&frames_dir
.path()
.join(format!("img_%05d.{extension}"))
.to_string_lossy(),
// libx264 needs even dimensions; pixiv ugoira frames can
// be odd-sized (e.g. 277x405).
"-vf",
"scale=trunc(iw/2)*2:trunc(ih/2)*2",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
&output.to_string_lossy(),
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map_err(|e| format!("ffmpeg spawn failed: {e}"))?;
if !status.success() {
return Err(format!("ffmpeg exited with {status}"));
}
Ok((output.to_string_lossy().into_owned(), out_dir))
})
.await
.map_err(|e| {
log::error!("ugoira encode worker panicked for {illust_id}: {e}");
PixivError::Api(format!("ugoira worker failed: {e}"))
})?;
match result {
Ok(pair) => Ok(Some(pair)),
Err(message) => {
log::error!("ugoira encode failed for {illust_id}: {message}");
Ok(None)
}
}
}
}
/// pixiv3-rs replacement: `None` when `PIXIV_REFRESH_TOKEN` is unset.
static PIXIV_CLIENT: LazyLock<Option<PixivAPI>> =
LazyLock::new(|| env::var("PIXIV_REFRESH_TOKEN").ok().map(PixivAPI::new));
/// Set at startup when the login validation fails; pixiv stays disabled until
/// the next process start.
static DISABLED: AtomicBool = AtomicBool::new(false);
pub fn enabled() -> bool {
!DISABLED.load(Ordering::Relaxed) && env::var("PIXIV_REFRESH_TOKEN").is_ok()
}
/// Permanently disables pixiv until the next process start.
pub fn disable() {
DISABLED.store(true, Ordering::Relaxed);
}
/// Forces the refresh-token → access-token exchange now, surfacing invalid
/// tokens and network errors. Called once at bot startup; on failure the bot
/// calls [`disable`].
pub async fn validate() -> Result<(), PixivError> {
match PIXIV_CLIENT.as_ref() {
None => Err(PixivError::NoAuth),
Some(client) => {
client.get_access_token().await?;
Ok(())
}
}
}
pub async fn fetch(illust_id: u64) -> Result<Illustration, FetchError> {
let client = PIXIV_CLIENT
.as_ref()
.filter(|_| enabled())
.ok_or(FetchError::Pixiv(PixivError::NoAuth))?;
client.fetch(illust_id).await
}
#[cfg(test)]
mod tests {
use super::*;
use dotenv::dotenv;
#[tokio::test]
#[ignore = "live network: requires outbound HTTPS to oauth.secure.pixiv.net"]
async fn live_validate_with_bogus_token_fails() {
dotenv().ok();
// A rejected credential must surface as a permanent status, not a panic
// and not a retryable class: the exchange answers 4xx and the status is
// checked before the body is read (api.rs, `get_access_token`). This
// used to assert `Api`, which that check made unreachable — `Api` is
// only reached from a 2xx body without an `access_token`.
let client = PixivAPI::new("bogus_token_for_testing".to_string());
let result = client.get_access_token().await;
assert!(
matches!(result, Err(PixivError::Status(code)) if (400..500).contains(&code)),
"got {result:?}"
);
}
}
+677
View File
@@ -0,0 +1,677 @@
use super::model::{IllustrationModel, TypeModel};
use crate::media::Media;
use crate::site::{FetchError, Fetched, PixivError, Site, SiteFuture};
use html_escape::{encode_double_quoted_attribute, encode_text};
use regex::Regex;
use std::sync::LazyLock;
pub static PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?:https?://)?(?:www\.)?pixiv\.net/(?:en/)?(?:(?:i|artworks)/|member_illust\.php\?(?:mode=[a-z_]*&)?illust_id=)(\d+)").unwrap()
});
pub fn enabled() -> bool {
super::api::enabled()
}
/// Registry entry for the pixiv adapter (see [`crate::site::Site`]).
pub struct PixivSite;
impl Site for PixivSite {
fn id(&self) -> &'static str {
"pixiv"
}
fn pattern(&self) -> &'static Regex {
&PATTERN
}
fn enabled(&self) -> bool {
enabled()
}
fn cache_key(&self, url: &str) -> Option<String> {
cache_key(url)
}
fn fetch_from_url<'a>(&'a self, url: &'a str) -> SiteFuture<'a, Fetched> {
Box::pin(async move { fetch_from_url(url).await })
}
fn is_retryable(&self, err: &FetchError) -> bool {
is_retryable(err)
}
fn media_headers(&self, url: &str) -> Option<Vec<(&'static str, String)>> {
media_headers(url)
}
fn validate(&self) -> SiteFuture<'static, (), String> {
Box::pin(async { startup_validation(super::api::validate().await) })
}
}
/// Turns the startup token exchange's outcome into what the bot reports, and
/// disables pixiv only for a rejected credential. A bad *moment* — a 5xx or a
/// network error while the container comes up — must not disable it: disabling
/// 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}"))
}
}
}
pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
let id = PATTERN
.captures(url)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str())
.ok_or(FetchError::NotFound)?;
let id = id.parse::<u64>().map_err(|_| FetchError::NotFound)?;
Ok(super::api::fetch(id).await?.into())
}
/// Cache key for a pixiv URL: `"pixiv:<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!("pixiv:{}", &caps[1]))
}
/// Pixiv's fetch-retry policy: transient classes only — network errors and
/// HTTP 429/5xx. Permanent 4xx (bad/expired token, forbidden, not found),
/// API/auth errors, unparseable bodies and missing auth are not retried.
pub fn is_retryable(err: &FetchError) -> bool {
match err {
FetchError::Http(_) | FetchError::Transient(_) => true,
FetchError::Pixiv(e) => pixiv_error_is_retryable(e),
_ => 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(_) => 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.
/// The match is on the media host, not the site PATTERN — pixiv's PATTERN
/// only matches `pixiv.net/artworks/...`, never `i.pximg.net`.
pub fn media_headers(url: &str) -> Option<Vec<(&'static str, String)>> {
if url.to_ascii_lowercase().contains("pximg.net") {
Some(vec![("Referer", "https://www.pixiv.net/".to_string())])
} else {
None
}
}
/// Flattens the app API's HTML description into plain text: `<br>` (and `<p>`)
/// become line breaks, other tags are dropped, entities decoded, the ends
/// trimmed. A caption shows text, not markup, so the author's `<a href>` links
/// contribute their link text only.
fn flatten_html(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut chars = raw.chars().peekable();
while let Some(c) = chars.next() {
// Only `<` followed by `/` or a letter opens a tag — a bare `<` in
// prose ("2 < 3") is text.
let opens_tag = c == '<'
&& chars
.peek()
.is_some_and(|next| *next == '/' || next.is_ascii_alphabetic());
if !opens_tag {
out.push(c);
continue;
}
let mut tag = String::new();
let mut closed = false;
for c in chars.by_ref() {
if c == '>' {
closed = true;
break;
}
tag.push(c);
}
if !closed {
// Unclosed `<…`: keep it as text rather than dropping the tail.
out.push('<');
out.push_str(&tag);
break;
}
// `<br>`, `<br/>`, `<br />` with or without attributes, and both
// halves of a paragraph break the line; everything else is dropped.
let tag = tag
.trim()
.trim_start_matches('/')
.trim_end_matches('/')
.trim()
.to_ascii_lowercase();
if tag == "p" || tag.starts_with("br") {
out.push('\n');
}
}
html_escape::decode_html_entities(&out).trim().to_string()
}
#[derive(Debug)]
pub struct Illustration {
id: String,
title: String,
/// The artwork's description, HTML flattened to plain text.
content: String,
author: String,
author_id: String,
tags: Vec<String>,
pub(crate) media: Vec<Media>,
nsfw: bool,
/// Keeps a temp dir (ugoira MP4) alive until the send completes.
pub(crate) _keep_alive: Option<std::sync::Arc<tempfile::TempDir>>,
}
impl Illustration {
fn url(&self) -> String {
format!("https://www.pixiv.net/artworks/{}", self.id)
}
fn author_url(&self) -> String {
format!("https://www.pixiv.net/users/{}", self.author_id)
}
pub fn caption(&self) -> String {
format!(
"<a href=\"{url}\">{title}</a> / <a href=\"{author_url}\">{author}</a>\n{tags}",
url = encode_double_quoted_attribute(&self.url()),
title = encode_text(&self.title),
author_url = encode_double_quoted_attribute(&self.author_url()),
author = encode_text(&self.author),
tags = encode_text(
&self
.tags
.iter()
.map(|tag| format!("#{tag}"))
.collect::<Vec<_>>()
.join(" ")
),
)
}
pub fn from_model(model: &IllustrationModel) -> Self {
let id = model.id.to_string();
let title = model.title.clone();
let content = flatten_html(&model.caption);
let author = model.user.name.clone();
let author_id = model.user.id.to_string();
let mut tags: Vec<String> = model.tags.iter().map(|tag| tag.name.clone()).collect();
// illust_ai_type: 0 = undefined, 1 = not AI, 2 = AI-generated.
// Mark AI works with a leading #AI tag (rendered via the `#{tag}`
// caption format).
if model.illust_ai_type == 2 {
tags.insert(0, "AI".to_string());
}
let mut media = vec![];
if matches!(&model.r#type, TypeModel::Ugoira) {
// No static images for ugoira; the fetch path encodes an MP4 via
// ffmpeg and appends it as a Video item (api.rs). This fallback
// keeps media empty when encoding fails or ffmpeg is missing.
} else if model.page_count > 1 {
media.extend(model.meta_pages.iter().filter_map(|page| {
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
.meta_single_page
.original_image_url
.clone()
.or(model.image_urls.original.clone())
{
media.push(Media::Illustration {
title: None,
url: original,
thumbnail_url: Some(model.image_urls.medium.clone()),
fallback_url: Some(model.image_urls.large.clone()),
});
}
let nsfw = model.sanity_level > 5;
Self {
id,
title,
content,
author,
author_id,
tags,
media,
nsfw,
_keep_alive: None,
}
}
}
impl From<Illustration> for Fetched {
fn from(illustration: Illustration) -> Self {
let url = illustration.url();
let author_url = illustration.author_url();
let tags = illustration
.tags
.iter()
.map(|tag| format!("#{tag}"))
.collect::<Vec<_>>()
.join(" ");
let render_data = Some(crate::site::RenderData {
url: url.clone(),
author: encode_text(&illustration.author).into_owned(),
author_url: author_url.clone(),
title: encode_text(&illustration.title).into_owned(),
content: encode_text(&illustration.content).into_owned(),
tags: encode_text(&tags).into_owned(),
});
Fetched {
source_url: url,
caption: illustration.caption(),
title: illustration.title.clone(),
content: illustration.content.clone(),
media: illustration.media,
sensitive: illustration.nsfw,
site_id: "pixiv",
render_data,
_keep_alive: illustration._keep_alive,
}
}
}
#[cfg(test)]
mod tests {
use super::super::model::IllustrationModel;
use super::*;
fn illust_json(
type_: &str,
page_count: u8,
single_original: Option<&str>,
image_urls_original: Option<&str>,
pages: Vec<(Option<&str>, &str, &str)>,
ai_type: i32,
) -> serde_json::Value {
let meta_pages: Vec<serde_json::Value> = pages
.into_iter()
.map(|(original, medium, large)| {
serde_json::json!({
"image_urls": {
"medium": medium,
"large": large,
"original": original
}
})
})
.collect();
serde_json::json!({
"illust": {
"id": 123,
"title": "Art <title>",
"caption": "一行说明<br />二行 <a href=\"https://x.example/\">链接</a> &amp; 结尾",
"type": type_,
"image_urls": {
"medium": "medium.jpg",
"large": "large.jpg",
"original": image_urls_original
},
"user": { "id": 456, "name": "Artist" },
"tags": [{ "name": "tag1" }, { "name": "tag2" }],
"page_count": page_count,
"sanity_level": 6,
"illust_ai_type": ai_type,
"meta_single_page": { "original_image_url": single_original },
"meta_pages": meta_pages
}
})
}
fn parse(v: serde_json::Value) -> Illustration {
let model: IllustrationModel = serde_json::from_value(v["illust"].clone()).unwrap();
Illustration::from_model(&model)
}
/// The description arrives as HTML and becomes plain-text content: breaks
/// kept, tags dropped (links keep their text), entities decoded.
#[test]
fn from_json_maps_description_to_content() {
let v = illust_json("illust", 1, None, Some("o.jpg"), vec![], 0);
let illustration = parse(v);
assert_eq!(illustration.content, "一行说明\n二行 链接 & 结尾");
let fetched: Fetched = illustration.into();
assert_eq!(fetched.title, "Art <title>");
assert_eq!(fetched.content, "一行说明\n二行 链接 & 结尾");
// The built-in caption keeps its layout: the description stays out of
// it and is available through `{content}`.
assert!(!fetched.caption.contains("一行说明"), "{}", fetched.caption);
assert_eq!(
fetched.render_fields().unwrap().3,
"一行说明\n二行 链接 &amp; 结尾"
);
assert!(
fetched
.caption_with("{title}: {content}")
.ends_with("一行说明\n二行 链接 &amp; 结尾")
);
}
#[test]
fn flatten_html_handles_common_markup() {
assert_eq!(flatten_html(""), "");
assert_eq!(flatten_html("plain"), "plain");
assert_eq!(flatten_html("a<br />b<br/>c<br>d"), "a\nb\nc\nd");
// A paragraph break is a blank line, exactly like `<br /><br />` —
// writing it as one newline would flatten the author's paragraphs.
assert_eq!(flatten_html("<p>one</p><p>two</p>"), "one\n\ntwo");
assert_eq!(flatten_html("a &amp; b &lt;c&gt;"), "a & b <c>");
// Nothing to strip: angle brackets that are not a tag survive.
assert_eq!(flatten_html("2 < 3"), "2 < 3");
}
#[test]
fn pattern_matches_all_forms() {
let cases = [
("https://www.pixiv.net/artworks/123456", "123456"),
("https://pixiv.net/artworks/123456", "123456"),
("https://www.pixiv.net/en/artworks/123456", "123456"),
("https://www.pixiv.net/i/123456", "123456"),
(
"https://www.pixiv.net/member_illust.php?mode=medium&illust_id=123456",
"123456",
),
(
"https://www.pixiv.net/en/member_illust.php?illust_id=123456",
"123456",
),
];
for (url, id) in cases {
let caps = PATTERN.captures(url).unwrap_or_else(|| panic!("{url}"));
assert_eq!(caps.get(1).unwrap().as_str(), id);
}
}
#[test]
fn pattern_rejects_non_artwork_urls() {
for url in [
"https://www.pixiv.net/users/123",
"https://x.com/user/status/123",
"https://bsky.app/profile/u/post/3xxxx",
] {
assert!(!PATTERN.is_match(url), "{url}");
}
}
#[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)] {
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]
fn is_retryable_classifies_transient_and_permanent() {
// Transient: network errors, explicit transient, pixiv 429/5xx.
assert!(is_retryable(&FetchError::Transient("429".into())));
assert!(is_retryable(&FetchError::Pixiv(PixivError::Status(429))));
assert!(is_retryable(&FetchError::Pixiv(PixivError::Status(500))));
assert!(is_retryable(&FetchError::Pixiv(PixivError::Status(503))));
// Permanent: pixiv 4xx (bad/expired token, forbidden, not found),
// api/auth errors, unparseable bodies, not-found/blocked/sensitive.
assert!(!is_retryable(&FetchError::Pixiv(PixivError::Status(400))));
assert!(!is_retryable(&FetchError::Pixiv(PixivError::Status(401))));
assert!(!is_retryable(&FetchError::Pixiv(PixivError::Status(403))));
assert!(!is_retryable(&FetchError::Pixiv(PixivError::Status(404))));
assert!(!is_retryable(&FetchError::Pixiv(PixivError::Api(
"invalid_grant".into()
))));
assert!(!is_retryable(&FetchError::Pixiv(PixivError::NoAuth)));
let json_err = serde_json::from_str::<serde_json::Value>("x").unwrap_err();
assert!(!is_retryable(&FetchError::Pixiv(PixivError::Json(
json_err
))));
assert!(!is_retryable(&FetchError::NotFound));
assert!(!is_retryable(&FetchError::Blocked));
assert!(!is_retryable(&FetchError::Sensitive));
assert!(!is_retryable(&FetchError::TooLarge));
}
#[test]
fn media_headers_adds_referer_only_for_pximg() {
assert_eq!(
media_headers("https://i.pximg.net/img-original/img/1.png"),
Some(vec![("Referer", "https://www.pixiv.net/".to_string())])
);
assert_eq!(media_headers("https://www.pixiv.net/artworks/1"), None);
assert_eq!(media_headers("https://x.com/u/status/1"), None);
}
#[test]
fn ugoira_yields_empty_media() {
let v = illust_json(
"ugoira",
1,
Some("https://i.pximg.net/orig.jpg"),
None,
vec![],
0,
);
let illustration = parse(v);
let fetched: Fetched = illustration.into();
assert!(fetched.media.is_empty());
assert!(fetched.sensitive, "sanity_level 6 > 5");
assert_eq!(fetched.title, "Art <title>");
}
#[test]
fn single_page_with_single_original() {
let v = illust_json(
"illust",
1,
Some("https://i.pximg.net/single.jpg"),
None,
vec![],
0,
);
let fetched: Fetched = parse(v).into();
assert_eq!(fetched.media.len(), 1);
match &fetched.media[0] {
Media::Illustration { url, .. } => {
assert_eq!(url, "https://i.pximg.net/single.jpg")
}
other => panic!("expected Illustration, got {other:?}"),
}
}
#[test]
fn single_page_falls_back_to_image_urls_original() {
let v = illust_json(
"illust",
1,
None,
Some("https://i.pximg.net/fallback.jpg"),
vec![],
0,
);
let fetched: Fetched = parse(v).into();
assert_eq!(fetched.media.len(), 1);
match &fetched.media[0] {
Media::Illustration { url, .. } => {
assert_eq!(url, "https://i.pximg.net/fallback.jpg")
}
other => panic!("expected Illustration, got {other:?}"),
}
}
#[test]
fn single_page_without_any_original_is_empty() {
let v = illust_json("illust", 1, None, None, vec![], 0);
let fetched: Fetched = parse(v).into();
assert!(fetched.media.is_empty());
}
#[test]
fn multi_page_skips_pages_without_original() {
let v = illust_json(
"illust",
2,
None,
None,
vec![
(None, "m1.jpg", "l1.jpg"),
(Some("https://i.pximg.net/p2.jpg"), "m2.jpg", "l2.jpg"),
],
0,
);
let fetched: Fetched = parse(v).into();
assert_eq!(fetched.media.len(), 1);
match &fetched.media[0] {
Media::Illustration {
url,
thumbnail_url,
fallback_url,
..
} => {
assert_eq!(url, "https://i.pximg.net/p2.jpg");
assert_eq!(thumbnail_url.as_deref(), Some("m2.jpg"));
assert_eq!(fallback_url.as_deref(), Some("l2.jpg"));
}
other => panic!("expected Illustration, got {other:?}"),
}
}
#[test]
fn caption_with_escapes_format_and_substitutes() {
let v = illust_json(
"illust",
1,
Some("https://i.pximg.net/o.jpg"),
None,
vec![],
0,
);
let fetched: Fetched = parse(v).into();
// Format string is escaped in full, then placeholders substituted.
let out = fetched.caption_with("{title} by {author} <script> {tags}");
assert!(
out.contains("Art &lt;title&gt; by Artist &lt;script&gt; #tag1 #tag2"),
"got: {out}"
);
assert!(!out.contains("<script>"), "no raw HTML injection: {out}");
// {url} and {author_url} carry the site's own URLs.
let out = fetched.caption_with("{url} {author_url}");
assert_eq!(
out,
"https://www.pixiv.net/artworks/123 https://www.pixiv.net/users/456"
);
// Empty format falls back to the built-in caption.
assert_eq!(fetched.caption_with(""), fetched.caption);
assert_eq!(fetched.site_name(), "pixiv");
}
#[test]
fn ai_work_gets_leading_ai_tag() {
// illust_ai_type == 2 is the only AI marker.
let v = illust_json(
"illust",
1,
Some("https://i.pximg.net/o.jpg"),
None,
vec![],
2,
);
let fetched: Fetched = parse(v).into();
assert!(
fetched.caption.contains("#AI #tag1 #tag2"),
"caption: {}",
fetched.caption
);
// The {tags} placeholder reflects the tag array too.
assert!(
fetched.caption_with("{tags}").starts_with("#AI "),
"got: {}",
fetched.caption_with("{tags}")
);
}
#[test]
fn non_ai_work_has_no_ai_tag() {
// 1 = explicitly not AI, 0 = undefined: neither gets the #AI tag.
for ai_type in [0, 1] {
let v = illust_json(
"illust",
1,
Some("https://i.pximg.net/o.jpg"),
None,
vec![],
ai_type,
);
let fetched: Fetched = parse(v).into();
assert!(
!fetched.caption.contains("#AI"),
"ai_type={ai_type} got: {}",
fetched.caption
);
}
}
#[test]
fn caption_escapes_and_links() {
let v = illust_json(
"illust",
1,
Some("https://i.pximg.net/o.jpg"),
None,
vec![],
0,
);
let fetched: Fetched = parse(v).into();
assert!(
fetched
.caption
.contains("<a href=\"https://www.pixiv.net/artworks/123\">Art &lt;title&gt;</a>"),
"caption: {}",
fetched.caption
);
assert!(fetched.caption.contains("#tag1 #tag2"));
assert_eq!(fetched.source_url, "https://www.pixiv.net/artworks/123");
}
}
+9
View File
@@ -0,0 +1,9 @@
mod api;
mod interface;
mod model;
pub use api::{PixivAPI, PixivError, disable, fetch, validate};
pub use interface::{
Illustration, PATTERN, PixivSite, cache_key, enabled, fetch_from_url, is_retryable,
media_headers,
};
+83
View File
@@ -0,0 +1,83 @@
// Model set for the native pixiv app-API client (app-api.pixiv.net).
use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub struct IllustrationModel {
pub id: u64,
pub title: String,
/// The artwork's description as the app API returns it — HTML in most
/// works (`<br />`, `<a href>`, sometimes `<p>`), empty for many.
#[serde(default)]
pub caption: String,
pub r#type: TypeModel,
pub image_urls: ImageUrlsModel,
pub user: UserInfoModel,
pub tags: Vec<IllustrationTagModel>,
pub page_count: u8,
pub sanity_level: u8,
/// 0 = undefined (unlabeled), 1 = not AI, 2 = AI-generated.
pub illust_ai_type: i32,
pub meta_single_page: MetaSinglePageModel,
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)]
pub struct UserInfoModel {
pub id: u64,
pub name: String,
}
#[derive(Deserialize, Debug)]
pub struct ImageUrlsModel {
pub medium: String,
pub large: String,
#[serde(default)]
pub original: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct IllustrationTagModel {
pub name: String,
}
#[derive(Deserialize, Debug)]
pub struct MetaSinglePageModel {
#[serde(default)]
pub original_image_url: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct MetaPageModel {
pub image_urls: ImageUrlsModel,
}
#[derive(Deserialize, Debug)]
pub struct UgoiraMetadataModel {
/// Older API shape (`zip_url`); newer responses use `zip_urls.medium`.
#[serde(default)]
pub zip_url: Option<String>,
#[serde(default)]
pub zip_urls: Option<UgoiraZipUrlsModel>,
pub frames: Vec<UgoiraFrameModel>,
}
#[derive(Deserialize, Debug)]
pub struct UgoiraZipUrlsModel {
pub medium: String,
}
#[derive(Deserialize, Debug)]
pub struct UgoiraFrameModel {
pub delay: u32,
}
+390
View File
@@ -0,0 +1,390 @@
//! Authenticated fallback for tweets the public syndication endpoint refuses
//! to serve (NSFW / age-restricted tweets come back as an empty `{}`).
//!
//! Mirrors nazurin's web API client ([`web.py`]) and is used *only* when
//! syndication reports [`FetchError::Sensitive`]: the private GraphQL
//! `TweetDetail` endpoint, authenticated with a browser session cookie from
//! `TWITTER_AUTH_TOKEN` (the `auth_token` cookie value of a logged-in x.com
//! session). A fresh random `ct0` is generated per call; X checks that the
//! `x-csrf-token` header matches the cookie, not that it issued the value.
//!
//! [`web.py`]: https://github.com/y-young/nazurin/blob/master/nazurin/sites/twitter/api/web.py
//!
//! # Caveats
//! - X rotates the GraphQL query id when it rolls the web app; if requests
//! start failing, update [`TWEET_DETAIL_QUERY_ID`]. Fresh references from
//! the actively maintained FxEmbed/FxEmbed: TweetDetail
//! `R9IzzyzQBV87-DOWpcvDmw`, TweetResultByRestId `f2sagi1jweVHFkTUIHzmMQ`
//! (the latter is anonymous and surfaces NSFW tweets as
//! `reason: NsfwLoggedOut`).
//! - `x-client-transaction-id` is only required for `SearchTimeline`
//! (verified against FxEmbed's `proxy/allowlist.ts`) — TweetDetail works
//! without it; no need for the nazurin home-page/JS-bundle derivation.
use std::sync::LazyLock;
use serde_json::{Value, json};
use crate::site::FetchError;
use super::interface::Tweet;
/// `auth_token` cookie of a logged-in x.com session; enables the fallback.
/// Trimmed: a CRLF `.env` (Windows) leaves a trailing `\r` on the value,
/// which would make the Cookie header invalid.
static AUTH_TOKEN: LazyLock<Option<String>> = LazyLock::new(|| {
std::env::var("TWITTER_AUTH_TOKEN")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
});
/// Public "logged in" client token used by the x.com web app.
const LOGGED_IN_BEARER: &str = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
/// `TweetDetail` query id (from nazurin; still valid as of 2026-08,
/// corroborated by the current FxEmbed build — see module caveats).
const TWEET_DETAIL_QUERY_ID: &str = "_8aYOgEDz35BrBcBal1-_w";
fn variables(id: &str) -> Value {
json!({
"focalTweetId": id,
"with_rux_injections": false,
"includePromotedContent": false,
"withCommunity": true,
"withQuickPromoteEligibilityTweetFields": false,
"withBirdwatchNotes": false,
"withVoice": true,
})
}
fn features() -> Value {
json!({
"rweb_video_screen_enabled": false,
"profile_label_improvements_pcf_label_in_post_enabled": true,
"rweb_tipjar_consumption_enabled": true,
"verified_phone_label_enabled": false,
"creator_subscriptions_tweet_preview_api_enabled": true,
"responsive_web_graphql_timeline_navigation_enabled": true,
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
"premium_content_api_read_enabled": false,
"communities_web_enable_tweet_community_results_fetch": true,
"c9s_tweet_anatomy_moderator_badge_enabled": true,
"responsive_web_grok_analyze_button_fetch_trends_enabled": false,
"responsive_web_grok_analyze_post_followups_enabled": true,
"responsive_web_jetfuel_frame": false,
"responsive_web_grok_share_attachment_enabled": true,
"articles_preview_enabled": true,
"responsive_web_edit_tweet_api_enabled": true,
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
"view_counts_everywhere_api_enabled": true,
"longform_notetweets_consumption_enabled": true,
"responsive_web_twitter_article_tweet_consumption_enabled": true,
"tweet_awards_web_tipping_enabled": false,
"responsive_web_grok_show_grok_translated_post": false,
"responsive_web_grok_analysis_button_from_backend": true,
"creator_subscriptions_quote_tweet_preview_enabled": false,
"freedom_of_speech_not_reach_fetch_enabled": true,
"standardized_nudges_misinfo": true,
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
"longform_notetweets_rich_text_read_enabled": true,
"longform_notetweets_inline_media_enabled": true,
"responsive_web_grok_image_annotation_enabled": true,
"responsive_web_enhance_cards_enabled": false,
})
}
/// Whether the authenticated fallback is available.
pub fn enabled() -> bool {
AUTH_TOKEN.is_some()
}
/// Fetches a tweet as the logged-in user via the private GraphQL API.
/// Returns the syndication-shaped [`Tweet`] (media included for NSFW posts).
pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
let token = AUTH_TOKEN.as_deref().ok_or(FetchError::Sensitive)?;
// 16 random bytes as 32 hex chars: X rejects ct0 values of any other
// length with 403 code 353 ("matching csrf cookie and header").
let ct0: String = (0..16)
.map(|_| format!("{:02x}", rand::random::<u8>()))
.collect();
let response = crate::site::CLIENT
.get(format!(
"https://x.com/i/api/graphql/{TWEET_DETAIL_QUERY_ID}/TweetDetail"
))
.query(&[
("variables", variables(id).to_string()),
("features", features().to_string()),
])
.header("authorization", LOGGED_IN_BEARER)
.header("x-csrf-token", &ct0)
.header("x-twitter-auth-type", "OAuth2Session")
.header("cookie", format!("auth_token={token}; ct0={ct0}"))
.header("x-twitter-client-language", "en")
.header("x-twitter-active-user", "yes")
.header("referer", "https://x.com/")
.send()
.await?;
// 404/410 = gone (permanent); 429/5xx = transient and retried by fetch.
let status = response.status();
if !status.is_success() {
log::warn!("twitter auth fetch {id}: HTTP {status}");
return match status.as_u16() {
404 | 410 => Err(FetchError::NotFound),
// A stale/refused `auth_token` is not a bad moment: retrying it
// three times only delays the report.
401 | 403 => Err(FetchError::Blocked),
_ => Err(FetchError::Transient(format!(
"twitter auth status {status}"
))),
};
}
let text = response.text().await?;
let json: Value = serde_json::from_str(&text)?;
let result = parse_tweet_result(&json, id)?;
let syndication_shape = to_syndication_shape(&result).ok_or_else(|| {
FetchError::Json(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"missing tweet fields in GraphQL response",
)))
})?;
Tweet::from_syndication_value(syndication_shape).map_err(FetchError::Json)
}
/// Locates the tweet for `id` in a `TweetDetail` response and unwraps
/// visibility wrappers / retweets, mirroring nazurin's `_process_response`.
fn parse_tweet_result(json: &Value, id: &str) -> Result<Value, FetchError> {
if let Some(errors) = json.get("errors").and_then(|e| e.as_array()) {
let messages: Vec<&str> = errors
.iter()
.filter_map(|e| e.get("message").and_then(|m| m.as_str()))
.collect();
log::warn!("twitter auth fetch {id} failed: {}", messages.join("; "));
return Err(FetchError::NotFound);
}
let instructions = json
.pointer("/data/threaded_conversation_with_injections_v2/instructions")
.and_then(|v| v.as_array())
.ok_or(FetchError::NotFound)?;
for instruction in instructions {
if instruction.get("type").and_then(|t| t.as_str()) != Some("TimelineAddEntries") {
continue;
}
let entries = instruction
.get("entries")
.and_then(|e| e.as_array())
.ok_or(FetchError::NotFound)?;
let wanted = format!("tweet-{id}");
for entry in entries {
if entry.get("entryId").and_then(|i| i.as_str()) == Some(wanted.as_str()) {
let result = entry
.pointer("/content/itemContent/tweet_results/result")
.ok_or(FetchError::NotFound)?;
return normalize_tweet_result(result);
}
}
}
Err(FetchError::NotFound)
}
/// Unwraps TweetTombstone/TweetUnavailable errors, the
/// TweetWithVisibilityResults wrapper and retweets, returning the
/// `{core, legacy, ...}` tweet object.
fn normalize_tweet_result(result: &Value) -> Result<Value, FetchError> {
match result.get("__typename").and_then(|t| t.as_str()) {
Some("TweetTombstone") => {
let text = result
.pointer("/tombstone/text/text")
.and_then(|t| t.as_str())
.unwrap_or("tweet is unavailable");
log::warn!("twitter auth fetch: tombstone: {text}");
return Err(FetchError::NotFound);
}
Some("TweetUnavailable") => {
let reason = result
.get("reason")
.and_then(|r| r.as_str())
.unwrap_or("unknown");
log::warn!("twitter auth fetch: tweet unavailable: {reason}");
return Err(FetchError::NotFound);
}
_ => {}
}
// TweetWithVisibilityResults (e.g. limited replies) nests the real tweet.
let tweet = result.get("tweet").unwrap_or(result);
// A retweet's media lives on the original tweet.
if let Some(original) = tweet.pointer("/legacy/retweeted_status_result/result") {
return Ok(original.clone());
}
Ok(tweet.clone())
}
/// Maps a GraphQL `{core, legacy, ...}` tweet onto the syndication JSON
/// shape [`Tweet::from_syndication_value`] parses, so the existing text /
/// media handling (t.co expansion, `name=orig`, mp4 variant) is reused.
fn to_syndication_shape(tweet: &Value) -> Option<Value> {
let legacy = tweet.get("legacy")?;
let user = tweet.pointer("/core/user_results/result/legacy")?;
Some(json!({
"id_str": legacy.get("id_str"),
"text": legacy.get("full_text"),
"user": {
"name": user.get("name"),
"screen_name": user.get("screen_name"),
},
"possibly_sensitive": legacy.get("possibly_sensitive"),
"entities": legacy.get("entities"),
"mediaDetails": legacy.pointer("/extended_entities/media"),
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn tweet_result() -> Value {
json!({
"__typename": "Tweet",
"core": {
"user_results": {
"result": {
"legacy": { "name": "Display Name", "screen_name": "nsfw_author" }
}
}
},
"legacy": {
"id_str": "2083868672721039569",
"full_text": "nsfw content https://t.co/abc123",
"possibly_sensitive": true,
"entities": {
// The appended media link lives in extended_entities.media,
// not entities.urls, so it has no expansion mapping and the
// content-based strip removes it.
"urls": []
},
"extended_entities": {
"media": [
{
"type": "photo",
"media_url_https": "https://pbs.twimg.com/media/nsfw.jpg",
"original_info": { "width": 1200, "height": 800 }
},
{
"type": "video",
"media_url_https": "https://pbs.twimg.com/thumb.jpg",
"video_info": {
"variants": [
{ "content_type": "application/x-mpegURL", "url": "https://x.com/pl.m3u8" },
{ "content_type": "video/mp4", "url": "https://video.twimg.com/nsfw.mp4" }
]
}
}
]
}
}
})
}
fn conversation(tweet: Value) -> Value {
json!({
"data": {
"threaded_conversation_with_injections_v2": {
"instructions": [
{ "type": "TimelineAddEntries", "entries": [
{ "entryId": "tweet-2083868672721039569",
"content": { "itemContent": { "tweet_results": { "result": tweet } } } }
]}
]
}
}
})
}
#[test]
fn parses_graphql_tweet_into_fetched() {
let json = conversation(tweet_result());
let result = parse_tweet_result(&json, "2083868672721039569").unwrap();
let shape = to_syndication_shape(&result).unwrap();
let tweet = Tweet::from_syndication_value(shape).unwrap();
let fetched: crate::site::Fetched = tweet.into();
assert!(fetched.sensitive);
assert_eq!(fetched.media.len(), 2);
match &fetched.media[0] {
crate::media::Media::Illustration { url, .. } => {
assert_eq!(url, "https://pbs.twimg.com/media/nsfw.jpg?name=orig");
}
other => panic!("expected illustration, got {other:?}"),
}
match &fetched.media[1] {
crate::media::Media::Video { url, .. } => {
assert_eq!(url, "https://video.twimg.com/nsfw.mp4");
}
other => panic!("expected video, got {other:?}"),
}
assert_eq!(
fetched.source_url,
"https://x.com/nsfw_author/status/2083868672721039569"
);
// The appended media short link (no URL-entity mapping) is stripped.
assert_eq!(fetched.title, "");
assert_eq!(fetched.content, "nsfw content");
}
#[test]
fn unwraps_retweet_to_original() {
let original = tweet_result();
let mut rt = tweet_result();
rt["legacy"]["retweeted_status_result"] = json!({ "result": original });
let json = conversation(rt);
let result = parse_tweet_result(&json, "2083868672721039569").unwrap();
assert!(result.pointer("/legacy/retweeted_status_result").is_none());
assert_eq!(
result.pointer("/legacy/id_str").unwrap(),
"2083868672721039569"
);
}
#[test]
fn error_response_maps_to_not_found() {
let json = json!({ "errors": [{ "message": "NsfwLoggedOut" }] });
assert!(matches!(
parse_tweet_result(&json, "1"),
Err(FetchError::NotFound)
));
}
#[test]
fn missing_entry_maps_to_not_found() {
let json = conversation(json!({ "__typename": "Tweet" }));
assert!(matches!(
parse_tweet_result(&json, "999"),
Err(FetchError::NotFound)
));
}
#[test]
fn tombstone_maps_to_not_found() {
let tombstone = json!({
"__typename": "TweetTombstone",
"tombstone": { "text": { "text": "Age-restricted adult content" } }
});
let json = conversation(tombstone);
assert!(matches!(
parse_tweet_result(&json, "2083868672721039569"),
Err(FetchError::NotFound)
));
}
#[test]
fn visibility_wrapper_unwraps() {
let inner = tweet_result();
let wrapped = json!({ "__typename": "TweetWithVisibilityResults", "tweet": inner });
let json = conversation(wrapped);
let result = parse_tweet_result(&json, "2083868672721039569").unwrap();
assert_eq!(result.get("__typename").unwrap(), "Tweet");
}
}
@@ -0,0 +1,810 @@
use super::model;
use crate::media::Media;
use crate::site::{FetchError, Fetched, Site, SiteFuture};
use html_escape::{decode_html_entities, encode_double_quoted_attribute, encode_text};
use regex::Regex;
use std::sync::LazyLock;
/// Registry entry for the twitter adapter (see [`crate::site::Site`]).
pub struct TwitterSite;
impl Site for TwitterSite {
fn id(&self) -> &'static str {
"twitter"
}
fn pattern(&self) -> &'static Regex {
&PATTERN
}
fn cache_key(&self, url: &str) -> Option<String> {
cache_key(url)
}
fn fetch_from_url<'a>(&'a self, url: &'a str) -> SiteFuture<'a, Fetched> {
Box::pin(async move { fetch_from_url(url).await })
}
}
pub static PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?:https?://)?(?:www\.|mobile\.)?(?:x|twitter|fixvx|vxtwitter|fixupx|fxtwitter)\.com/[^.]+/status/(\d+)").unwrap()
});
pub fn enabled() -> bool {
true
}
pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
let id = PATTERN
.captures(url)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str())
.ok_or(FetchError::NotFound)?;
match fetch(id).await {
Ok(tweet) => Ok(tweet.into()),
// Syndication withholds NSFW/age-restricted tweets (empty `{}`).
// Retry as the logged-in user when TWITTER_AUTH_TOKEN is set; without
// the token the withholding is reported as `Sensitive`, so the bot can
// answer "age-restricted / needs TWITTER_AUTH_TOKEN" instead of the
// misleading "No media found".
Err(FetchError::Sensitive) => {
if super::auth::enabled() {
match super::auth::fetch(id).await {
Ok(tweet) => Ok(tweet.into()),
// Deleted/suspended (tombstoned) and unexpected fallback
// failures keep their own class: the bot reports what
// actually happened rather than "No media found".
Err(e) => {
log::warn!("twitter auth fallback failed for {id}: {e}");
Err(e)
}
}
} else {
log::debug!("tweet {id} is sensitive; set TWITTER_AUTH_TOKEN to fetch NSFW media");
Err(FetchError::Sensitive)
}
}
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
}
/// Fetches a tweet from the syndication endpoint. Deleted/blocked tweets
/// surface as `FetchError::NotFound`; withheld content (empty tombstone,
/// age-restricted) as `FetchError::Sensitive`.
pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
let id_num = id.parse::<u64>().map_err(|_| FetchError::NotFound)?;
let response = crate::site::CLIENT
.get(format!(
"https://cdn.syndication.twimg.com/tweet-result?id={id}&lang=en&token={}",
syndication_token(id_num)
))
.send()
.await?;
// 404/410 = gone (permanent); 429/5xx = transient and retried by fetch.
let status = response.status();
if !status.is_success() {
return match status.as_u16() {
404 | 410 => Err(FetchError::NotFound),
// A refusal or an auth demand is not a bad moment: retrying it
// three times only delays an error the user has to see.
401 | 403 => Err(FetchError::Blocked),
_ => Err(FetchError::Transient(format!("twitter status {status}"))),
};
}
let text = response.text().await?;
// Classify before building the tweet (see [`parse_syndication_body`]), and
// build it from the value that classification already parsed: this used to
// 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` carries the parsed
/// 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`
/// **with a reason** — "This Post was deleted by the Post author." /
/// "This Post is from a suspended account." (the tweet is gone).
/// - `Sensitive`: content withheld **without a deletion reason** — the empty
/// `{}` shape or an *empty* `TweetTombstone` (`{"__typename":
/// "TweetTombstone","tombstone":{}}`). Live tweets in restricted contexts
/// surface this way; treating them as deleted is a regression (a normal
/// tweet must not report "deleted"). Age-restricted tombstones route here
/// too so the logged-in GraphQL fallback can fetch the real tweet.
/// - `Json`: an unparseable body.
fn parse_syndication_body(text: &str) -> Result<serde_json::Value, FetchError> {
let body: serde_json::Value = serde_json::from_str(text)?;
if body.get("errors").is_some() {
return Err(FetchError::NotFound);
}
if let Some(tombstone) = body.get("tombstone") {
// Only a tombstone with an explicit reason means the tweet is gone;
// a missing reason (empty `tombstone: {}`) or an age-restricted
// reason means the tweet exists but is withheld.
let reason = tombstone
.get("text")
.and_then(|t| t.get("text"))
.and_then(|t| t.as_str())
.unwrap_or("");
if reason.is_empty() || reason.to_ascii_lowercase().contains("age-restricted") {
return Err(FetchError::Sensitive);
}
return Err(FetchError::NotFound);
}
if body.get("id_str").is_none() {
// 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)
}
/// The syndication token: JS `((id / 1e15) * PI).toString(36)` (the
/// `replace('0.','')` is a no-op for realistic tweet ids). The endpoint
/// currently serves public tweets regardless of the token; the formula is
/// kept for parity with the known-good client behavior.
fn syndication_token(id: u64) -> String {
let value = (id as f64 / 1e15) * std::f64::consts::PI;
let integer = value.trunc() as u64;
let mut fraction = value.fract();
let mut digits = String::new();
if integer == 0 {
digits.push('0');
} else {
let mut n = integer;
let mut buf = Vec::new();
while n > 0 {
buf.push(char::from_digit((n % 36) as u32, 36).unwrap());
n /= 36;
}
digits.extend(buf.into_iter().rev());
}
digits.push('.');
for _ in 0..10 {
fraction *= 36.0;
let digit = fraction.trunc() as u32;
digits.push(char::from_digit(digit.min(35), 36).unwrap());
fraction -= digit as f64;
if fraction == 0.0 {
break;
}
}
digits
}
#[derive(Debug)]
pub struct Tweet {
id: String,
text: String,
author: String,
author_id: String,
media: Vec<Media>,
sensitive: bool,
}
impl Tweet {
fn url(&self) -> String {
format!("{}/status/{}", self.author_url(), self.id)
}
fn author_url(&self) -> String {
format!("https://x.com/{}", self.author_id)
}
pub fn caption(&self) -> String {
format!(
"{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),
)
}
/// Builds a tweet from an already-parsed syndication body. Takes the value
/// 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;
// Expand the user's t.co short links to their real destinations and
// strip the appended media short link, mirroring FxEmbed's linkFixer
// (no display_text_range arithmetic — see expand_links).
let text = expand_links(&json.text, &json.entities.urls);
// Twitter APIs (syndication AND GraphQL full_text) return the text
// pre-escaped for HTML (`&gt;` `&lt;` `&amp;` `&#39;` …): decode it so
// the stored text is raw. The caption's own escaping then produces
// the rendered form exactly once — without this, `&gt;^ω^&lt;` would
// be double-escaped to `&amp;gt;^ω^&amp;lt;` and the sent message
// would show literal `&gt;^ω^&lt;`.
let text = decode_html_entities(&text).into_owned();
// `name` is the display name, `screen_name` the handle (Python's
// vxtwitter mapping: author = display name, author_id = handle).
// Display names can carry the same pre-escaped entities.
let author = decode_html_entities(&json.user.name).into_owned();
let author_id = json.user.screen_name;
let mut media = vec![];
for item in json.media_details {
match item.media_type.as_str() {
"photo" => media.push(Media::Illustration {
title: None,
url: original_twimg_url(&item.media_url_https),
thumbnail_url: None,
// The param-less base URL is a reduced-size variant;
// used as the fallback when the original is too large.
fallback_url: Some(item.media_url_https.clone()),
}),
"video" => media.push(Media::Video {
title: None,
url: mp4_variant(&item),
thumbnail_url: item.media_url_https,
}),
"animated_gif" => media.push(Media::Animated {
title: None,
url: mp4_variant(&item),
thumbnail_url: item.media_url_https,
}),
_ => {}
}
}
let sensitive = json.possibly_sensitive.unwrap_or(false);
Ok(Self {
id,
text,
author,
author_id,
media,
sensitive,
})
}
}
/// Mirrors FxEmbed's `linkFixer` (link-fixer.ts): expand every t.co short
/// link that has an entity mapping to its real destination, drop internal
/// `x.com/i/web/status/…` plumbing links, then strip any remaining t.co
/// short link (the appended media link and other unmapped short links).
/// Pure content matching — no `display_text_range` arithmetic, so the
/// endpoint's inconsistent index units (UTF-16 vs code points, see the
/// deleted `strip_trailing_short_links`) never matter.
fn expand_links(text: &str, urls: &[model::SyndicationEntityUrl]) -> String {
let mut out = text.to_string();
for entity in urls {
let Some(expanded) = &entity.expanded_url else {
continue;
};
let replacement = if WEB_STATUS_URL.is_match(expanded) {
""
} else {
expanded
};
out = out.replace(&entity.url, replacement);
}
TCO_LINK.replace_all(&out, "").into_owned()
}
/// Internal x.com page links (reply / quote plumbing) expand to
/// `x.com/i/web/status/<id>`; FxEmbed drops them — the tweet's own content
/// already carries the information.
static WEB_STATUS_URL: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^https://(?:x\.com|twitter\.com)/i/web/status/\w+").unwrap());
/// A t.co short link, optionally preceded by a space. Any leftover
/// occurrence (unmapped — e.g. the appended media link) is removed,
/// mirroring FxEmbed. Real short-link codes are 10 alphanumerics; the
/// length-agnostic class keeps fixtures and hypothetical odd lengths safe.
static TCO_LINK: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r" ?https?://t\.co/[A-Za-z0-9]+").unwrap());
/// pbs.twimg.com serves a reduced default size without size params; `name=orig`
/// returns the original file (fxtwitter used to hand out the original
/// directly, the syndication API does not). Non-twimg URLs pass through
/// unchanged.
fn original_twimg_url(url: &str) -> String {
if url.starts_with("https://pbs.twimg.com/") && (url.ends_with(".jpg") || url.ends_with(".png"))
{
format!("{url}?name=orig")
} else {
url.to_string()
}
}
fn mp4_variant(item: &model::SyndicationMedia) -> String {
item.video_info
.as_ref()
.and_then(|info| {
info.variants
.iter()
.find(|variant| variant.content_type == "video/mp4")
})
.map(|variant| variant.url.clone())
.unwrap_or_else(|| item.media_url_https.clone())
}
impl From<Tweet> for Fetched {
fn from(tweet: Tweet) -> Self {
let url = tweet.url();
let author_url = tweet.author_url();
// A tweet has no title: its text is all content.
let render_data = Some(crate::site::RenderData {
url: url.clone(),
author: encode_text(&tweet.author).into_owned(),
author_url: author_url.clone(),
title: String::new(),
content: encode_text(&tweet.text).into_owned(),
tags: String::new(),
});
Fetched {
source_url: url,
caption: tweet.caption(),
title: String::new(),
content: tweet.text.clone(),
media: tweet.media,
sensitive: tweet.sensitive,
site_id: "twitter",
render_data,
_keep_alive: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(media_details: serde_json::Value) -> serde_json::Value {
serde_json::json!({
"__typename": "Tweet",
"id_str": "861627479294746624",
"text": "a & b <c>",
"user": { "name": "Display Name", "screen_name": "author_handle" },
"possibly_sensitive": true,
"mediaDetails": media_details
})
}
#[test]
fn pattern_matches_all_domains() {
for url in [
"https://x.com/user/status/1234567890",
"https://twitter.com/user/status/1234567890",
"https://mobile.twitter.com/user/status/1234567890",
"https://www.x.com/user/status/1234567890",
"https://fxtwitter.com/user/status/1234567890",
"https://fixupx.com/user/status/1234567890",
"https://fixvx.com/user/status/1234567890",
"https://vxtwitter.com/user/status/1234567890",
] {
let caps = PATTERN.captures(url).unwrap_or_else(|| panic!("{url}"));
assert_eq!(caps.get(1).unwrap().as_str(), "1234567890");
}
}
#[test]
fn pattern_rejects_non_tweet_urls() {
for url in [
"https://x.com/user",
"https://x.com/user/status/abc",
"https://bsky.app/profile/u/post/3xxxx",
"https://pixiv.net/artworks/123",
"https://example.com/x.com/user/status/123",
] {
assert!(!PATTERN.is_match(url), "{url}");
}
}
#[test]
fn syndication_text_is_unescaped_before_storing() {
// Real API shape: the text arrives pre-escaped for HTML — e.g. the
// tweet `>^ω^<` comes back as `&gt;^ω^&lt;` (fxtwitter's raw_text for
// 2060196388252827954) and apostrophes as `&#39;`. Storing it raw and
// escaping once at caption build avoids the double-escape that would
// show literal `&gt;`/`&lt;`/`&amp;` in the sent message.
let raw = serde_json::json!({
"__typename": "Tweet",
"id_str": "1",
"text": "&gt;^ω^&lt; &amp; more &#39;quoted&#39; https://t.co/abc123",
"user": { "name": "O&#39;Brien", "screen_name": "h" },
"entities": { "urls": [] },
"mediaDetails": []
});
let tweet = Tweet::from_syndication_value(raw).unwrap();
// The appended media short link is stripped, then entities decoded.
assert_eq!(tweet.text, ">^ω^< & more 'quoted'");
assert_eq!(tweet.author, "O'Brien");
let fetched: Fetched = tweet.into();
assert_eq!(fetched.title, "");
assert_eq!(fetched.content, ">^ω^< & more 'quoted'");
// The caption escapes the raw text exactly once (encode_text covers
// & < >; apostrophes stay literal — they are harmless in text).
assert!(
fetched.caption.contains("&gt;^ω^&lt; &amp; more 'quoted'"),
"caption: {}",
fetched.caption
);
assert!(
!fetched.caption.contains("&amp;gt;"),
"double-escaped text: {}",
fetched.caption
);
}
#[test]
fn cache_key_prefixes_tweet_id() {
assert_eq!(
cache_key("https://x.com/user/status/1234567890"),
Some("twitter:1234567890".into())
);
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]
fn syndication_json_converts_to_fetched() {
let raw = fixture(serde_json::json!([
{ "type": "photo", "media_url_https": "https://pbs.twimg.com/media/photo.jpg" },
{
"type": "video",
"media_url_https": "https://pbs.twimg.com/thumb.jpg",
"video_info": {
"variants": [
{ "content_type": "application/x-mpegURL", "url": "https://x.com/pl.m3u8" },
{ "content_type": "video/mp4", "url": "https://video.twimg.com/v.mp4" }
]
}
}
]));
let tweet = Tweet::from_syndication_value(raw).unwrap();
let fetched: Fetched = tweet.into();
assert_eq!(
fetched.source_url,
"https://x.com/author_handle/status/861627479294746624"
);
assert_eq!(fetched.title, "");
assert_eq!(fetched.content, "a & b <c>");
assert!(fetched.sensitive);
assert_eq!(fetched.media.len(), 2);
match &fetched.media[0] {
Media::Illustration { url, .. } => {
// Photo URL is rewritten to request the original file.
assert_eq!(url, "https://pbs.twimg.com/media/photo.jpg?name=orig");
}
other => panic!("expected illustration, got {other:?}"),
}
match &fetched.media[1] {
Media::Video {
url, thumbnail_url, ..
} => {
assert_eq!(url, "https://video.twimg.com/v.mp4");
assert_eq!(thumbnail_url, "https://pbs.twimg.com/thumb.jpg");
}
other => panic!("expected video, got {other:?}"),
}
assert!(
fetched.caption.contains(
"<a href=\"https://x.com/author_handle\">Display Name</a>: a &amp; b &lt;c&gt;"
),
"caption: {}",
fetched.caption
);
}
#[test]
fn syndication_gif_maps_to_animated() {
let raw = fixture(serde_json::json!([
{
"type": "animated_gif",
"media_url_https": "https://pbs.twimg.com/g.jpg",
"video_info": {
"variants": [{ "content_type": "video/mp4", "url": "https://video.twimg.com/g.mp4" }]
}
}
]));
let tweet = Tweet::from_syndication_value(raw).unwrap();
assert!(matches!(&tweet.media[0], Media::Animated { .. }));
}
#[test]
fn syndication_text_strips_trailing_media_short_link() {
// Real syndication shape: the appended media short link sits after the
// visible text and there are no URL entities, so the unmapped t.co link
// is stripped by content alone. The second row is real tweet
// 2084567054481571919 (30 code points but 41 UTF-16 units, and the two
// endpoints historically reported `display_text_range` in different
// units): a content-based strip cannot leave a partial link behind for
// either unit system.
for (text, visible) in [
("hello world https://t.co/abc123", "hello world"),
(
"妄想𝑨𝒅𝒅𝒊𝒄𝒕𝒊𝒐𝒏…🩷💚❤️\n#ゼンゼロ #zzzero https://t.co/XnIi83EkEB",
"妄想𝑨𝒅𝒅𝒊𝒄𝒕𝒊𝒐𝒏…🩷💚❤️\n#ゼンゼロ #zzzero",
),
] {
let raw = serde_json::json!({
"__typename": "Tweet",
"id_str": "1",
"text": text,
"user": { "name": "N", "screen_name": "h" },
"mediaDetails": []
});
let tweet = Tweet::from_syndication_value(raw).unwrap();
assert_eq!(tweet.text, visible, "left a partial link in {text:?}");
assert!(!tweet.caption().contains("t.co"), "{text:?}");
}
}
#[test]
fn syndication_text_expands_url_entities() {
// Real FloodSocial shape: the user's own link is a t.co short link in
// the text; the entity mapping expands it, the trailing media short
// link is stripped.
let raw = serde_json::json!({
"__typename": "Tweet",
"id_str": "1",
"text": "Test Tweet with @mentionThis $twtr https://t.co/RzmrQ6wAzD #hashtag https://t.co/9r69akA484",
"user": { "name": "N", "screen_name": "h" },
"entities": {
"urls": [{
"url": "https://t.co/RzmrQ6wAzD",
"expanded_url": "http://bit.ly/2pUk4be",
"display_url": "bit.ly/2pUk4be"
}]
},
"mediaDetails": []
});
let tweet = Tweet::from_syndication_value(raw).unwrap();
assert_eq!(
tweet.text,
"Test Tweet with @mentionThis $twtr http://bit.ly/2pUk4be #hashtag"
);
assert!(!tweet.caption().contains("t.co"));
}
#[test]
fn syndication_text_strips_unmapped_short_links() {
// FxEmbed parity: short links without an entity mapping (appended
// media link, embedded unmapped links) are stripped, not kept.
let raw = serde_json::json!({
"__typename": "Tweet",
"id_str": "1",
"text": "check https://t.co/abc123 #tag https://t.co/def456",
"user": { "name": "N", "screen_name": "h" },
"mediaDetails": []
});
let tweet = Tweet::from_syndication_value(raw).unwrap();
assert_eq!(tweet.text, "check #tag");
}
#[test]
fn syndication_text_drops_internal_web_status_links() {
// FxEmbed parity: a mapped link expanding to an internal
// x.com/i/web/status/... page (reply/quote plumbing) is removed
// instead of being shown.
let raw = serde_json::json!({
"__typename": "Tweet",
"id_str": "1",
"text": "see https://t.co/xyz1234567 for context",
"user": { "name": "N", "screen_name": "h" },
"entities": {
"urls": [{
"url": "https://t.co/xyz1234567",
"expanded_url": "https://x.com/i/web/status/9876543210",
"display_url": "x.com/i/web/status/9876543210"
}]
},
"mediaDetails": []
});
let tweet = Tweet::from_syndication_value(raw).unwrap();
assert_eq!(tweet.text, "see for context");
assert!(!tweet.caption().contains("t.co"));
}
#[test]
fn original_twimg_url_rewrites_photo_urls() {
assert_eq!(
original_twimg_url("https://pbs.twimg.com/media/C_UdnvPUwAE3Dnn.jpg"),
"https://pbs.twimg.com/media/C_UdnvPUwAE3Dnn.jpg?name=orig"
);
assert_eq!(
original_twimg_url("https://pbs.twimg.com/media/abc.png"),
"https://pbs.twimg.com/media/abc.png?name=orig"
);
// Non-twimg URLs (videos, animated gifs) pass through unchanged.
assert_eq!(
original_twimg_url("https://video.twimg.com/v.mp4"),
"https://video.twimg.com/v.mp4"
);
assert_eq!(
original_twimg_url("https://pbs.twimg.com/media/abc.webp"),
"https://pbs.twimg.com/media/abc.webp"
);
}
#[test]
fn syndication_token_matches_js_formula() {
// JS: ((861627479294746624 / 1e15) * PI).toString(36) == "236.vrsocvda".
// This loop truncates ten base-36 fraction digits instead of rendering
// 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]
fn syndication_tombstone_maps_to_not_found() {
// Deleted tweets answer HTTP 200 with a TweetTombstone carrying a
// reason (no `errors`, no `id_str`); they must not fall through to
// Sensitive, which would make the bot reply "No media found" for a
// deleted tweet.
let raw = serde_json::json!({
"__typename": "TweetTombstone",
"tombstone": {
"text": { "rtl": false, "text": "This Post was deleted by the Post author. Learn more" }
}
});
assert!(matches!(
parse_syndication_body(&raw.to_string()),
Err(FetchError::NotFound)
));
}
#[test]
fn syndication_empty_tombstone_maps_to_sensitive() {
// Regression: live tweets in restricted contexts answer with an
// EMPTY tombstone (`{"__typename":"TweetTombstone","tombstone":{}}`)
// — no deletion reason. They must not be reported as deleted.
let raw = serde_json::json!({ "__typename": "TweetTombstone", "tombstone": {} });
assert!(matches!(
parse_syndication_body(&raw.to_string()),
Err(FetchError::Sensitive)
));
}
#[test]
fn syndication_age_restricted_tombstone_maps_to_sensitive() {
// An age-restricted tombstone withholds a live tweet; route it to
// the logged-in fallback instead of reporting it as gone.
let raw = serde_json::json!({
"__typename": "TweetTombstone",
"tombstone": {
"text": { "rtl": false, "text": "Age-restricted adult content" }
}
});
assert!(matches!(
parse_syndication_body(&raw.to_string()),
Err(FetchError::Sensitive)
));
}
#[test]
fn syndication_errors_maps_to_not_found() {
// The classic gone shape: {"errors": [...]}.
let raw = serde_json::json!({ "errors": [{ "message": "Couldn't find Tweet" }] });
assert!(matches!(
parse_syndication_body(&raw.to_string()),
Err(FetchError::NotFound)
));
}
#[test]
fn syndication_empty_object_maps_to_sensitive() {
// NSFW / age-restricted withholding: an empty `{}`.
assert!(matches!(
parse_syndication_body("{}"),
Err(FetchError::Sensitive)
));
}
#[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]
fn syndication_tweet_body_passes() {
let raw = fixture(serde_json::json!([]));
assert!(parse_syndication_body(&raw.to_string()).is_ok());
}
#[tokio::test]
#[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"]
async fn live_fetch_with_photos() {
let fetched = fetch("861627479294746624").await.unwrap();
assert_eq!(fetched.media.len(), 4);
}
#[tokio::test]
#[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"]
async fn live_fetch_text_only() {
let fetched = fetch("1992471125734142256").await.unwrap();
assert!(fetched.media.is_empty());
}
#[tokio::test]
#[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"]
async fn live_fetch_deleted_tweet_is_not_found() {
// Deleted tweet: the syndication endpoint answers with errors.
let result = fetch("0").await;
assert!(
matches!(result, Err(FetchError::NotFound)),
"got {result:?}"
);
}
#[tokio::test]
#[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"]
async fn live_fetch_tombstone_deleted_tweet_is_not_found() {
// Regression: a real deleted tweet answering with a TweetTombstone
// (HTTP 200, no errors/id_str) used to surface as Sensitive and
// degrade to an empty result ("No media found").
let result = fetch("2085948045967986859").await;
assert!(
matches!(result, Err(FetchError::NotFound)),
"got {result:?}"
);
}
#[tokio::test]
#[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"]
async fn live_fetch_empty_tombstone_is_sensitive() {
// Regression: a LIVE tweet (verified via a third-party API) answers
// syndication with an empty TweetTombstone; it must surface as
// Sensitive (withheld), never as NotFound (deleted).
let result = fetch("2087851366253555752").await;
assert!(
matches!(result, Err(FetchError::Sensitive)),
"got {result:?}"
);
}
}
+7
View File
@@ -0,0 +1,7 @@
mod auth;
mod interface;
mod model;
pub use interface::{
PATTERN, Tweet, TwitterSite, cache_key, enabled, fetch_from_url, is_retryable, media_headers,
};
+58
View File
@@ -0,0 +1,58 @@
use serde::Deserialize;
/// Response shape of the syndication endpoint
/// (`cdn.syndication.twimg.com/tweet-result`).
#[derive(Deserialize, Debug)]
pub struct SyndicationTweet {
pub id_str: String,
pub text: String,
pub user: SyndicationUser,
#[serde(default)]
pub possibly_sensitive: Option<bool>,
#[serde(default)]
pub entities: SyndicationEntities,
#[serde(default, rename = "mediaDetails")]
pub media_details: Vec<SyndicationMedia>,
}
#[derive(Deserialize, Debug, Default)]
pub struct SyndicationEntities {
#[serde(default)]
pub urls: Vec<SyndicationEntityUrl>,
}
/// A URL entity: `url` is the t.co short link as it appears in the text,
/// `expanded_url` the real destination.
#[derive(Deserialize, Debug)]
pub struct SyndicationEntityUrl {
pub url: String,
#[serde(default)]
pub expanded_url: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct SyndicationUser {
pub name: String,
pub screen_name: String,
}
#[derive(Deserialize, Debug)]
pub struct SyndicationMedia {
#[serde(rename = "type")]
pub media_type: String,
pub media_url_https: String,
#[serde(default)]
pub video_info: Option<SyndicationVideoInfo>,
}
#[derive(Deserialize, Debug)]
pub struct SyndicationVideoInfo {
#[serde(default)]
pub variants: Vec<SyndicationVariant>,
}
#[derive(Deserialize, Debug)]
pub struct SyndicationVariant {
pub content_type: String,
pub url: String,
}
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "xmedia-bot"
version = "1.9.1"
edition = "2024"
[dependencies]
teloxide = { version = "0.17", default-features = false, features = ["webhooks-axum", "macros", "rustls", "ctrlc_handler"] }
tokio = { version = "1.40", features = ["rt-multi-thread", "macros", "time", "sync"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = "0.4"
pretty_env_logger = "0.5"
dotenv = "0.15"
url = "2.5.2"
html-escape = "0.2"
rusqlite = { version = "0.40", features = ["bundled"] }
rand = "0.10"
tempfile = "3"
parking_lot = "0.12"
bytes = "1"
png = "0.18"
zune-jpeg = "0.5"
fast_image_resize = "6"
jpeg-encoder = "0.7"
x-media = { path = "../x-media" }
[dev-dependencies]
tokio = { version = "1.40", features = ["test-util"] }
+110
View File
@@ -0,0 +1,110 @@
//! Central env handling. The only other places that read env are
//! `Bot::from_env` (TELOXIDE_TOKEN) and x-media (PIXIV_REFRESH_TOKEN,
//! TWITTER_AUTH_TOKEN, BILIBILI_COOKIE).
use std::env;
use std::net::IpAddr;
use std::time::Duration;
pub struct Config {
/// BOT_ADMIN: comma-separated ints; empty when unset.
pub admin_ids: Vec<i64>,
/// EDIT_MESSAGE_TTL_SECONDS, default 86400 (24h).
pub edit_message_ttl: Duration,
/// LINK_CACHE_TTL_SECONDS, default 604800 (7 days).
pub link_cache_ttl: Duration,
/// CAPTION_QUOTE_TEXT_CHARS, default 200: a post whose text (title plus
/// content) is at least this many characters gets that text wrapped in an
/// expandable blockquote inside its caption. `0` disables the wrap.
pub caption_quote_text_chars: usize,
// Webhook settings (moved out of main; names/defaults unchanged).
pub webhook_enabled: bool,
pub webhook_url: Option<url::Url>,
pub webhook_listen: Option<IpAddr>,
pub webhook_port: Option<u16>,
pub webhook_cert: Option<String>,
pub webhook_secret_token: Option<String>,
}
impl Config {
pub fn load() -> Config {
// Fail-fast helpers: a misspelled value must not silently fall back
// to a default and run with different behavior than the operator
// intended — log a loud warning naming the variable instead.
fn parse_u64(name: &str, default: u64) -> u64 {
match env::var(name) {
Ok(v) => v.parse::<u64>().unwrap_or_else(|_| {
log::warn!("invalid {name}={v:?}; using default {default}");
default
}),
Err(_) => default,
}
}
let admin_ids = match env::var("BOT_ADMIN") {
Ok(s) => {
let (ids, bad): (Vec<_>, Vec<_>) = s
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.partition(|part| part.parse::<i64>().is_ok());
if !bad.is_empty() {
log::warn!("BOT_ADMIN: ignoring non-numeric ids: {bad:?}");
}
ids.into_iter()
.filter_map(|p| p.parse::<i64>().ok())
.collect()
}
Err(_) => Vec::new(),
};
let edit_message_ttl =
Duration::from_secs(parse_u64("EDIT_MESSAGE_TTL_SECONDS", 24 * 3600));
let link_cache_ttl =
Duration::from_secs(parse_u64("LINK_CACHE_TTL_SECONDS", 7 * 24 * 3600));
let caption_quote_text_chars = parse_u64("CAPTION_QUOTE_TEXT_CHARS", 200) as usize;
let webhook_enabled = env::var("WEBHOOK")
.is_ok_and(|v| matches!(v.to_lowercase().as_str(), "true" | "yes" | "1"));
// The webhook settings are consumed by `.expect()` in main when
// WEBHOOK=true, so an unparseable value fails fast at startup with a
// clear message; still log here for the WEBHOOK=false case.
let webhook_url = env::var("WEBHOOK_URL").ok().and_then(|s| {
s.parse::<url::Url>().ok().or_else(|| {
log::warn!("invalid WEBHOOK_URL={s:?}");
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
// value that would otherwise come from `.env`).
let webhook_cert = env::var("WEBHOOK_CERT").ok().filter(|s| !s.is_empty());
let webhook_secret_token = env::var("WEBHOOK_SECRET_TOKEN")
.ok()
.filter(|s| !s.is_empty());
Config {
admin_ids,
edit_message_ttl,
link_cache_ttl,
caption_quote_text_chars,
webhook_enabled,
webhook_url,
webhook_listen,
webhook_port,
webhook_cert,
webhook_secret_token,
}
}
}
+188
View File
@@ -0,0 +1,188 @@
//! Runtime context: the collaborators a handler needs, injected as one struct
//! so tests can substitute a scripted sender and tempdir-backed stores.
//!
//! The production context is assembled from the process-wide statics
//! ([`AppContext::from_statics`]); the spawned worker closures hold
//! [`CONTEXT`], which is `'static` for that reason.
use crate::config::Config;
use crate::handlers::{CHAT_STORE, CONFIG, LINK_CACHE, TASK_QUEUE};
use crate::link_cache::LinkCache;
use crate::media_sender::MediaSender;
use crate::queue::PersistentTaskQueue;
use crate::send::BOT;
use crate::state::ChatStore;
use std::sync::LazyLock;
pub struct AppContext<'a> {
pub sender: &'a dyn MediaSender,
pub chat_store: &'a ChatStore,
pub task_queue: &'a PersistentTaskQueue,
pub link_cache: &'a LinkCache,
pub config: &'a Config,
}
impl<'a> AppContext<'a> {
/// The stores are the process-wide statics; `sender` is whatever the caller
/// was handed (the dispatcher's `Bot` clone for update handlers, the shared
/// queue `Bot` for the worker loops). Update handlers build their own
/// context from the `Bot` they received so the same code path works with an
/// injected mock in tests.
pub fn from_statics(sender: &'a dyn MediaSender) -> AppContext<'a> {
AppContext {
sender,
chat_store: &CHAT_STORE,
task_queue: &TASK_QUEUE,
link_cache: &LINK_CACHE,
config: &CONFIG,
}
}
}
/// The URL/queue workers' context: `'static` because `tokio::spawn`ed closures
/// and the queue's handler type require it.
pub static CONTEXT: LazyLock<AppContext<'static>> =
LazyLock::new(|| AppContext::from_statics(&*BOT));
/// Test support: a tempdir-backed set of stores plus the context borrowing
/// them, so a handler test needs one line of setup.
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
use crate::link_cache::{CachedMedia, CachedMediaKind, CachedPost};
use crate::state::EditMessage;
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 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;
}
pub(crate) struct TestStores {
_dir: tempfile::TempDir,
pool: Arc<crate::db::DbPool>,
chat_store: ChatStore,
task_queue: PersistentTaskQueue,
link_cache: LinkCache,
config: Config,
}
impl TestStores {
pub(crate) fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let pool = crate::db::open_store(dir.path().join("ctx.db").to_str().unwrap()).unwrap();
TestStores {
_dir: dir,
chat_store: ChatStore::new(Arc::clone(&pool)),
task_queue: PersistentTaskQueue::new(Arc::clone(&pool)),
link_cache: LinkCache::new(Arc::clone(&pool)),
config: Config::load(),
pool,
}
}
pub(crate) fn ctx<'a>(&'a self, sender: &'a dyn MediaSender) -> AppContext<'a> {
AppContext {
sender,
chat_store: &self.chat_store,
task_queue: &self.task_queue,
link_cache: &self.link_cache,
config: &self.config,
}
}
pub(crate) fn chat_store(&self) -> &ChatStore {
&self.chat_store
}
/// The parsed config, mutable so a test can pin a knob (e.g. the
/// caption-quote threshold) instead of depending on the environment.
pub(crate) fn config_mut(&mut self) -> &mut Config {
&mut self.config
}
pub(crate) fn link_cache(&self) -> &LinkCache {
&self.link_cache
}
pub(crate) fn task_queue(&self) -> &PersistentTaskQueue {
&self.task_queue
}
/// Rows persisted in the task queue: what "queued for retry" looks like
/// from the outside.
pub(crate) async fn queued_tasks(&self) -> i64 {
let pool = Arc::clone(&self.pool);
pool.with_conn(|conn| {
conn.query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get(0))
})
.await
.unwrap()
}
/// The single queued task payload, for asserting what was rescheduled.
pub(crate) async fn queued_payload(&self) -> serde_json::Value {
let pool = Arc::clone(&self.pool);
let payload: String = pool
.with_conn(|conn| {
conn.query_row("SELECT payload FROM tasks LIMIT 1", [], |row| row.get(0))
})
.await
.unwrap();
serde_json::from_str(&payload).unwrap()
}
}
}
+343
View File
@@ -0,0 +1,343 @@
//! Shared SQLite plumbing for the three tables in `data/task_queue.db`
//! (`tasks` in queue.rs, `chat_state` in state.rs, `link_cache` in
//! link_cache.rs).
//!
//! All I/O runs inside `spawn_blocking` via [`DbPool::with_conn`] — rusqlite
//! connections are not Send-friendly to hold across an await point, and
//! blocking the async executor stalls every handler. Connections are reused
//! through a small per-store pool instead of opening a fresh connection per
//! operation: WAL lets readers run alongside writer leases, and the pool's
//! semaphore bounds how many DB operations run concurrently, giving natural
//! backpressure on hot paths (every message / URL / callback touches
//! chat_state or the link cache).
use parking_lot::Mutex;
use rusqlite::Connection;
use std::sync::Arc;
use std::time::Duration;
/// Upper bound on pooled (reused) connections and on concurrent DB
/// operations per store. Small on purpose: the queue's `BEGIN IMMEDIATE`
/// leases serialize writes anyway, and WAL readers rarely need more.
const POOL_SIZE: usize = 4;
/// A tiny connection pool for one SQLite file. Connections are checked out
/// on a blocking thread and returned afterwards; `acquire` opens a new
/// connection only when the idle list is empty, so the steady-state cost of
/// an operation is a list pop instead of a fresh open (+ busy timeout + WAL
/// pragma). The semaphore caps the number of concurrent operations, so a
/// burst of handlers queues up instead of opening unbounded connections.
pub struct DbPool {
// Arc so [`DbPool::with_conn`] can hand an owned handle to
// `spawn_blocking` without borrowing across the await point.
inner: Arc<PoolInner>,
}
struct PoolInner {
path: String,
permits: tokio::sync::Semaphore,
idle: Mutex<Vec<Connection>>,
}
impl DbPool {
pub fn new(path: &str) -> Self {
DbPool {
inner: Arc::new(PoolInner {
path: path.to_string(),
permits: tokio::sync::Semaphore::new(POOL_SIZE),
idle: Mutex::new(Vec::new()),
}),
}
}
/// Runs `f` against a pooled connection on a blocking thread, returning
/// the closure's result. Owns the semaphore + `spawn_blocking` +
/// `expect` ceremony shared by every table access; the caller maps
/// errors to its own log line.
pub async fn with_conn<T, F>(&self, f: F) -> rusqlite::Result<T>
where
T: Send + 'static,
F: FnOnce(&mut Connection) -> rusqlite::Result<T> + Send + 'static,
{
let _permit = self
.inner
.permits
.acquire()
.await
.expect("db pool semaphore closed");
let inner = Arc::clone(&self.inner);
tokio::task::spawn_blocking(move || {
let mut conn = inner.acquire()?;
let result = f(&mut conn);
inner.release(conn);
result
})
.await
.expect("db worker panicked")
}
/// The database file this pool serves (used by tests that need a raw
/// connection, e.g. to seed rows directly).
#[cfg(test)]
pub fn path(&self) -> &str {
&self.inner.path
}
}
impl PoolInner {
/// Reuses an idle connection or opens a fresh one.
fn acquire(&self) -> rusqlite::Result<Connection> {
if let Some(conn) = self.idle.lock().pop() {
return Ok(conn);
}
open_db(&self.path)
}
/// Returns a connection to the pool (dropped when the pool is full).
fn release(&self, conn: Connection) {
let mut idle = self.idle.lock();
if idle.len() < POOL_SIZE {
idle.push(conn);
}
}
}
/// Opens the shared DB with a busy timeout.
pub fn open_db(path: &str) -> rusqlite::Result<Connection> {
let conn = Connection::open(path)?;
conn.busy_timeout(Duration::from_secs(5))?;
// WAL lets readers run alongside writer leases instead of blocking on
// the rollback journal; the mode persists in the DB header, so the
// idempotent pragma here and in ensure_schema only needs to win once.
conn.pragma_update(None, "journal_mode", "WAL")?;
Ok(conn)
}
/// 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
/// share the returned pool); tests call it per tempdir.
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)?;
schema_init(&conn)?;
migrate(&conn)?;
Ok(Arc::new(DbPool::new(path)))
}
/// Schema migrations, applied in order and tracked by `PRAGMA user_version`
/// (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;
}
conn.execute_batch(statement)?;
// `PRAGMA` does not take bind parameters; the value is our own index.
conn.execute_batch(&format!("PRAGMA user_version = {target}"))?;
}
Ok(())
}
fn rusqlite_error(e: std::io::Error) -> rusqlite::Error {
rusqlite::Error::ToSqlConversionFailure(Box::new(e))
}
/// Creates the `tasks`, `chat_state` and `link_cache` tables (idempotent).
/// The three stores used to own their own schema; keeping it in one place
/// means one initialization for the whole database file.
///
/// This is the **baseline** schema (version 0): a fresh database is created
/// exactly like this, and anything that must *change* an existing one is
/// appended to [`MIGRATIONS`] instead of being edited in here — otherwise a
/// database created before the change would never gain the new column and a
/// freshly created one would try to apply the migration a second time.
pub fn schema_init(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_tasks_pending ON tasks(status, run_after); \
CREATE TABLE IF NOT EXISTS chat_state (chat_id TEXT PRIMARY KEY, payload TEXT NOT NULL); \
CREATE TABLE IF NOT EXISTS link_cache (url TEXT PRIMARY KEY, payload TEXT NOT NULL, \
created_at REAL NOT NULL);",
)
}
/// Unix timestamp in fractional seconds. Shared by the queue, chat store and
/// link cache (previously four private copies).
pub fn now_f64() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
/// Unix timestamp in whole seconds. Same clock as [`now_f64`], for fields
/// that store integer seconds (chat-state expiry, edit prompts).
pub fn unix_now() -> 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();
}
}
+441
View File
@@ -0,0 +1,441 @@
//! Callback query handling: the edit-before-forward prompt's `"forward"` and
//! `"template|<name>"` buttons.
//!
//! [`callback_query_handler`] is the dptree entry; it only pulls the plain
//! values out of the teloxide update and hands them to [`handle_callback`],
//! which holds the button logic and is driven directly by tests.
use crate::ctx::AppContext;
use crate::db::unix_now;
use crate::send::{self, Task};
use teloxide::RequestError;
use teloxide::prelude::*;
use teloxide::types::{CallbackQuery, CallbackQueryId, MessageId};
/// The `"forward"` button's data.
const FORWARD: &str = "forward";
/// The `"skip"` button's data: drop the prompt without forwarding.
const SKIP: &str = "skip";
/// Prefix of a template button's data: `"template|<name>"`.
const TEMPLATE_PREFIX: &str = "template|";
pub async fn callback_query_handler(bot: Bot, query: CallbackQuery) -> Result<(), RequestError> {
let Some(message) = &query.message else {
return respond(());
};
let Some(data) = query.data.clone() else {
return respond(());
};
let ctx = AppContext::from_statics(&bot);
handle_callback(
&ctx,
query.id.clone(),
message.chat().id.0,
message.id().0 as i64,
&data,
)
.await;
respond(())
}
/// Handles one button press on the edit-before-forward prompt.
async fn handle_callback(
ctx: &AppContext<'_>,
callback_query_id: CallbackQueryId,
chat_id: i64,
prompt_message_id: i64,
data: &str,
) {
let ttl_secs = ctx.config.edit_message_ttl.as_secs() as i64;
let chat_data = ctx.chat_store.get(chat_id).await;
let edit = chat_data.edit_message.get(&prompt_message_id).cloned();
let Some(edit) = edit else {
log::debug!("callback from {chat_id}: no edit record for prompt {prompt_message_id}");
let _ = ctx
.sender
.answer_callback_query(callback_query_id, Some("Expired".to_string()))
.await;
return;
};
// Lazy expiry: a stale record (past the TTL, not yet swept) is dropped.
if edit.created_at + ttl_secs <= unix_now() {
ctx.chat_store
.update(chat_id, |data| {
data.edit_message.remove(&prompt_message_id);
})
.await;
let _ = ctx
.sender
.answer_callback_query(callback_query_id, Some("Expired".to_string()))
.await;
return;
}
log::info!("callback from {chat_id} on prompt {prompt_message_id}: {data}");
if data == SKIP {
// Skip works with or without a forward channel: it is the explicit
// "do not forward this" answer, and it drops the record so the forward
// can never happen later.
log::info!("edit-before-forward prompt {prompt_message_id} skipped");
ctx.chat_store
.update(chat_id, |data| {
data.edit_message.remove(&prompt_message_id);
})
.await;
let _ = ctx
.sender
.delete_message(ChatId(chat_id), MessageId(prompt_message_id as i32))
.await;
let _ = ctx
.sender
.answer_callback_query(
callback_query_id,
Some("Skipped — nothing was forwarded.".to_string()),
)
.await;
return;
}
if data == FORWARD {
match chat_data.forward_channel_id {
Some(channel_id) => {
let forward_task = Task::ForwardMessages {
from_chat_id: edit.chat_id,
to_chat_id: channel_id,
message_ids: edit.forward_message_ids.clone(),
notify_chat_id: Some(chat_id),
notify_message_id: Some(prompt_message_id),
};
let (answer, settled) = match send::forward_messages(ctx, &forward_task).await {
Ok(()) => {
log::info!(
"forwarded {} message(s) to channel {channel_id}",
edit.forward_message_ids.len()
);
("✅ Forwarded".to_string(), true)
}
Err(send::SendError::Retryable {
delay_seconds,
task,
}) => {
// The queued row owns the forward from here (it carries
// the message ids itself), so the prompt is settled
// 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, .. }) => {
log::error!("forward failed permanently: {message}");
(format!("Forward failed: {message}"), false)
}
};
if settled {
// The prompt is done: drop it and its record.
let _ = ctx
.sender
.delete_message(ChatId(chat_id), MessageId(prompt_message_id as i32))
.await;
ctx.chat_store
.update(chat_id, |data| {
data.edit_message.remove(&prompt_message_id);
})
.await;
}
let _ = ctx
.sender
.answer_callback_query(callback_query_id, Some(answer))
.await;
}
None => {
log::debug!("forward callback without a forward channel set");
let _ = ctx
.sender
.answer_callback_query(
callback_query_id,
Some("No forward channel set.".to_string()),
)
.await;
}
}
return;
}
if let Some(name) = data.strip_prefix(TEMPLATE_PREFIX) {
let mut answer = None;
if let Some(template_html) = chat_data.template.get(name).cloned()
&& let Some(first_forward_id) = edit.forward_message_ids.first().copied()
{
// Raw template including the [] placeholder (Python parity).
match super::apply_caption_edit(
ctx.sender,
ChatId(chat_id),
MessageId(first_forward_id as i32),
template_html,
)
.await
{
super::EditOutcome::Applied => {
ctx.chat_store
.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}");
}
// 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 '{name}' could not be applied: {reason}");
answer = Some(format!("Could not apply the template: {reason}"));
}
}
}
let _ = ctx
.sender
.answer_callback_query(callback_query_id, answer)
.await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ctx::test_support::{FORWARDED_ID, PROMPT_ID, TestStores, api_error, seed_prompt};
use crate::media_sender::test_support::{MockSender, Outcome};
/// The Telegram wording the mocks answer with: a chat the bot cannot reach.
const API_ERROR: &str = "Bad Request: chat not found";
fn callback_id() -> CallbackQueryId {
CallbackQueryId("cb-1".to_string())
}
#[tokio::test]
async fn template_button_swaps_the_caption_and_records_the_choice() {
let sender = MockSender::scripted(vec![Outcome::EditOk], || 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, "template|tpl").await;
assert_eq!(
sender.calls(),
vec!["edit_message_caption", "answer_callback_query"]
);
// The raw template, including the [] the user edits into.
assert_eq!(sender.captions(), vec!["<b>[]</b>"]);
assert_eq!(sender.answers(), vec![None]);
let data = ctx.chat_store.get(1).await;
assert_eq!(data.edit_message[&PROMPT_ID].template, "tpl");
}
#[tokio::test]
async fn a_failed_template_swap_is_reported_in_the_toast() {
let sender = MockSender::scripted(vec![Outcome::EditErr], || 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, "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;
assert_eq!(
sender.calls(),
vec!["copy_messages", "delete_message", "answer_callback_query"]
);
assert_eq!(sender.answers(), vec![Some("✅ Forwarded".to_string())]);
assert!(
ctx.chat_store.get(1).await.edit_message.is_empty(),
"a settled prompt must drop its record"
);
}
#[tokio::test]
async fn skip_drops_the_prompt_without_forwarding() {
// "skip" needs no forward channel and no scripted outcomes: it deletes
// the prompt and drops the record, so no forward can ever happen.
let sender = MockSender::scripted(vec![], || api_error(API_ERROR));
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
seed_prompt(&ctx, "", crate::db::unix_now()).await;
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "skip").await;
assert_eq!(
sender.calls(),
vec!["delete_message", "answer_callback_query"]
);
assert_eq!(
sender.answers(),
vec![Some("Skipped — nothing was forwarded.".to_string())]
);
assert!(
ctx.chat_store.get(1).await.edit_message.is_empty(),
"a skipped prompt must drop its record"
);
}
/// 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
.update(1, |data| data.forward_channel_id = None)
.await;
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await;
assert_eq!(sender.calls(), vec!["answer_callback_query"]);
assert_eq!(
sender.answers(),
vec![Some("No forward channel set.".to_string())]
);
}
#[tokio::test]
async fn retryable_forward_is_queued_and_settles_the_prompt() {
use teloxide::types::Seconds;
let sender = MockSender::scripted(vec![Outcome::CopyErr], || {
RequestError::RetryAfter(Seconds::from_seconds(7))
});
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;
// 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!(
sender.calls(),
vec!["copy_messages", "delete_message", "answer_callback_query"]
);
assert_eq!(
sender.answers(),
vec![Some("Forward queued for retry.".to_string())]
);
assert_eq!(stores.queued_tasks().await, 1);
assert!(
!ctx.chat_store
.get(1)
.await
.edit_message
.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]
async fn unknown_and_expired_prompts_answer_expired() {
let sender = MockSender::scripted(vec![], || api_error(API_ERROR));
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
// No record at all.
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await;
assert_eq!(sender.answers(), vec![Some("Expired".to_string())]);
// 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;
seed_prompt(&ctx, "", stale).await;
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await;
assert_eq!(
sender.answers(),
vec![Some("Expired".to_string()), Some("Expired".to_string())]
);
assert!(
ctx.chat_store.get(1).await.edit_message.is_empty(),
"the expired record must be dropped"
);
assert_eq!(sender.calls(), vec!["answer_callback_query"; 2]);
}
}
File diff suppressed because it is too large Load Diff
+324
View File
@@ -0,0 +1,324 @@
//! Inline query handling with a keystroke debounce: only a query stable for
//! [`INLINE_DEBOUNCE`] triggers a fetch, and repeats are served by Telegram's
//! inline cache instead of re-fetching.
use super::log_key;
use std::collections::HashMap;
use std::sync::LazyLock;
use teloxide::RequestError;
use teloxide::prelude::*;
use teloxide::types::{
InlineQuery, InlineQueryResult, InlineQueryResultMpeg4Gif, InlineQueryResultPhoto,
InlineQueryResultVideo, ParseMode,
};
use x_media::media::Media;
/// Debounce window for inline queries: Telegram fires an inline query on
/// every keystroke, and each prefix of a pasted URL (e.g. `.../status/12`,
/// `.../status/123`, ...) already matches the site patterns. Without a
/// debounce every keystroke triggers a fetch (3 attempts!) of a half-typed
/// 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);
/// 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.
/// 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
/// user id — a single shared slot would let one user's typing burst (or a
/// different user's query) cancel another user's pending answer.
struct InlineDebounceState {
query: String,
answered: bool,
/// When a query last touched this entry, so the periodic sweep can drop
/// one per user who ever used inline mode (the map had no eviction at all,
/// unlike the rate limiter's buckets and the chat store).
last_seen: std::time::Instant,
}
#[derive(Default)]
struct DebounceStates(HashMap<u64, InlineDebounceState>);
impl DebounceStates {
/// 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
/// it; re-fetching would only hit the source site again).
fn note(&mut self, user_id: u64, query: &str) -> bool {
if let Some(prev) = self.0.get(&user_id)
&& prev.query == query
&& prev.answered
{
return false;
}
self.0.insert(
user_id,
InlineDebounceState {
query: query.to_string(),
answered: false,
last_seen: std::time::Instant::now(),
},
);
true
}
/// Drops entries no query has touched for `idle_for`. Split from the clock
/// so the boundary is testable without ageing a monotonic instant.
fn prune_idle_at(&mut self, now: std::time::Instant, idle_for: std::time::Duration) -> usize {
let before = self.0.len();
self.0
.retain(|_, state| now.saturating_duration_since(state.last_seen) < idle_for);
before - self.0.len()
}
/// Claims the answer for the user's newest query; false when a newer query
/// superseded it or the answer was already claimed.
fn claim(&mut self, user_id: u64, query: &str) -> bool {
let Some(state) = self.0.get_mut(&user_id) else {
return false;
};
if state.query != query || state.answered {
return false;
}
state.answered = true;
state.last_seen = std::time::Instant::now();
true
}
/// Releases a claimed-but-unsent answer so a repeat can retry the fetch.
fn release(&mut self, user_id: u64, query: &str) {
if let Some(state) = self.0.get_mut(&user_id)
&& state.query == query
{
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>> =
LazyLock::new(|| parking_lot::Mutex::new(DebounceStates::default()));
pub async fn inline_query_handler(bot: Bot, query: InlineQuery) -> Result<(), RequestError> {
if query.query.is_empty() {
return respond(());
}
// 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;
if !INLINE_DEBOUNCE_STATE.lock().note(user_id, &query.query) {
return respond(());
}
let query_text = query.query.clone();
tokio::spawn(async move {
tokio::time::sleep(INLINE_DEBOUNCE).await;
// Only the user's last query of a typing burst survives: earlier
// timers see the query changed and give up without answering.
if !INLINE_DEBOUNCE_STATE.lock().claim(user_id, &query_text) {
return;
}
match answer_inline_query(bot, query).await {
Ok(true) => {}
// The fetch or the answer call failed: release so a repeat of the
// same query may retry it. An *empty* answer is a real answer
// (`Ok(true)`), so a link whose media Telegram cannot fetch is not
// re-fetched on every keystroke.
Ok(false) | Err(_) => INLINE_DEBOUNCE_STATE.lock().release(user_id, &query_text),
}
});
respond(())
}
/// Fetches the post behind an inline query and answers it. The caller has
/// already applied the debounce. Returns `true` when an answer was sent.
async fn answer_inline_query(bot: Bot, query: InlineQuery) -> Result<bool, RequestError> {
// 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: {}", query.query);
// No retries: the debounce plus a 1s/2s backoff would outlast the inline
// query the answer belongs to.
match x_media::site::fetch_once(&query.query).await {
Ok(Some(fetched)) => {
let mut results: Vec<InlineQueryResult> = Vec::new();
// Inline results have the same 1024-char caption limit as regular
// messages; truncate once here for all items, then apply the same
// long-post quoting as the send paths. `answer_inline_query` has no
// `AppContext` (the debounce spawns it), so the parsed config comes
// from the process-wide static, and the text is the *escaped*
// 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 text = fetched
.render_fields()
.map(|(_, _, title, content, _)| x_media::site::compose_text(title, content))
.unwrap_or_default();
let caption = crate::send::quote_long_caption(
&caption,
&text,
super::CONFIG.caption_quote_text_chars,
);
for (i, media) in fetched.media.iter().enumerate() {
let id = format!("{i}");
// Telegram fetches an inline result's URL itself and cannot
// send site-specific headers, so hotlink-protected media
// (pixiv's pximg.net) would render as a broken file there.
// Locally produced media (ugoira MP4, bsky remux) is a local
// path and does not parse as a URL at all — same skip.
if x_media::site::needs_media_headers(media.url()) {
log::debug!("inline: skipping hotlink-protected media {id}");
continue;
}
let Some(url) = url::Url::parse(media.url()).ok() else {
continue;
};
let thumbnail = media
.thumbnail_url()
.and_then(|t| url::Url::parse(t).ok())
.unwrap_or_else(|| url.clone());
let caption = caption.clone().into_owned();
let result = match media {
Media::Illustration { .. } => {
// Inline photo results have their own (smaller) size
// cap; use the reduced variant when one exists.
let photo_url = media
.smaller_url()
.and_then(|u| url::Url::parse(u).ok())
.unwrap_or_else(|| url.clone());
InlineQueryResult::Photo(
InlineQueryResultPhoto::new(id, photo_url, thumbnail)
.caption(caption)
.parse_mode(ParseMode::Html),
)
}
Media::Video { .. } => InlineQueryResult::Video(
InlineQueryResultVideo::new(
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);
}
// Every item was skipped: Telegram fetches an inline result's URL
// itself, so pixiv's hotlink-protected media (and a local ugoira /
// bsky MP4) can never be one. Answer *empty* — the client stops
// spinning, and the same query is not re-fetched on every
// keystroke: an unanswered query releases the debounce below
// (`Ok(false)`), which is what made this re-run the fetch each
// time, and the window lets Telegram serve the repeats itself.
log::debug!("inline: nothing Telegram can fetch for the query; answering empty");
bot.answer_inline_query(query.id, Vec::new())
.cache_time(300)
.await?;
return Ok(true);
}
Ok(None) => {}
Err(e) => log::error!("inline fetch [key={}]: {e}", log_key(&query.query)),
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::{DebounceStates, INLINE_STATE_TTL};
const URL_A: &str = "https://x.com/a/status/1";
const URL_B: &str = "https://x.com/b/status/2";
#[test]
fn debounce_state_is_per_user() {
let mut states = DebounceStates::default();
// Two users query different links: both proceed, and neither timer
// cancels the other (a single shared slot dropped one of them).
assert!(states.note(1, URL_A));
assert!(states.note(2, URL_B));
assert!(states.claim(1, URL_A), "user 1's answer was cancelled");
assert!(states.claim(2, URL_B), "user 2's answer was cancelled");
}
#[test]
fn answered_query_is_suppressed_per_user_only() {
let mut states = DebounceStates::default();
assert!(states.note(1, URL_A));
assert!(states.claim(1, URL_A));
// A repeat of the answered query by the same user is left to
// Telegram's inline cache.
assert!(!states.note(1, URL_A));
// Another user pasting the same link still gets an answer.
assert!(states.note(2, URL_A));
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));
let first = states.0[&1].last_seen;
// Entry 2 is strictly newer, so one timestamp can sit exactly on the
// window's edge for one and comfortably inside it for the other.
std::thread::sleep(std::time::Duration::from_millis(2));
assert!(states.note(2, URL_B));
assert_eq!(
states.prune_idle_at(first + INLINE_STATE_TTL, INLINE_STATE_TTL),
1
);
assert!(
!states.0.contains_key(&1),
"the entry past the window must go"
);
assert!(states.0.contains_key(&2), "the live entry must stay");
// A pruned user's repeat is answered fresh instead of suppressed.
assert!(states.note(1, URL_A));
}
#[test]
fn newer_query_supersedes_and_failed_answer_is_released() {
let mut states = DebounceStates::default();
assert!(states.note(1, URL_A));
assert!(states.note(1, URL_B));
// The stale timer for the half-typed query gives up…
assert!(!states.claim(1, URL_A));
// …and the newest one answers.
assert!(states.claim(1, URL_B));
// No results → release so a repeat may retry the fetch.
states.release(1, URL_B);
assert!(states.claim(1, URL_B));
}
}
+501
View File
@@ -0,0 +1,501 @@
//! Update handlers and the per-URL media pipeline.
//!
//! Split into per-concern modules: [`commands`] (the `/`-command executor),
//! [`urls`] (URL extraction + the bounded worker pool + send dispatch),
//! [`inline`] (debounced inline queries), [`callback`] (edit-before-forward
//! buttons) and [`statics`] (the shared process-wide stores). This module
//! holds the message entry point and the helpers the others share.
mod callback;
mod commands;
mod inline;
mod statics;
mod urls;
pub use callback::callback_query_handler;
pub use commands::register_commands;
pub use inline::inline_query_handler;
pub(crate) use inline::prune_idle_states;
/// 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(crate) use urls::repair_lost_local_media;
pub use urls::{start_url_workers, stop_url_workers};
use crate::ctx::AppContext;
use crate::media_sender::MediaSender;
use commands::{Command, execute_command};
use teloxide::RequestError;
use teloxide::prelude::*;
use teloxide::types::{
ChatId, ChatKind, Message, MessageId, ParseMode, PublicChatKind, ReplyParameters,
};
use teloxide::utils::command::BotCommands;
use urls::{URL_JOBS, extract_urls};
/// Reply to a message by id, keeping the reply decoration even if the
/// original was already deleted. Returns the reply's message id.
pub(crate) async fn reply(
sender: &dyn MediaSender,
chat_id: i64,
reply_to: MessageId,
text: impl Into<String>,
) -> Result<i64, RequestError> {
sender
.send_message(ChatId(chat_id), text.into(), Some(reply_to), None)
.await
}
/// 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 →
/// forward) together: the normalized cache key (`twitter:123…`, `pixiv:123`,
/// `bsky:handle/rkey`, `bilibili:123…`) instead of the raw URL, so logs stay
/// short and do not echo full user-submitted URLs at info level.
pub fn log_key(url: &str) -> String {
x_media::site::cache_key(url).unwrap_or_else(|| "<unsupported>".to_string())
}
/// 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
/// forwarded message. Returns true when the message was consumed as an edit.
/// Body of [`message_handler`]'s edit branch, without teloxide update types so
/// it can be driven by tests.
async fn edit_message_handler(
ctx: &AppContext<'_>,
chat_id: i64,
reply_to_message_id: i64,
text: &str,
) -> bool {
let chat_data = ctx.chat_store.get(chat_id).await;
let Some(edit) = chat_data.edit_message.get(&reply_to_message_id) else {
return false;
};
let Some(first_forward_id) = edit.forward_message_ids.first() else {
return false;
};
let link = format!(
"<a href=\"{0}\">{1}</a>",
html_escape::encode_double_quoted_attribute(&edit.url),
html_escape::encode_text(text)
);
let new_text = if edit.template.is_empty() {
link
} else {
chat_data
.template
.get(&edit.template)
.map(|template| template.replace("[]", &link))
.unwrap_or(link)
};
match apply_caption_edit(
ctx.sender,
ChatId(chat_id),
MessageId(*first_forward_id as i32),
new_text,
)
.await
{
EditOutcome::Applied => log::info!(
"edit-before-forward: caption swapped on message {first_forward_id} for prompt {reply_to_message_id}"
),
// 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
}
/// 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> {
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 = matches!(message.chat.kind, ChatKind::Private(_));
let sender = message
.from
.as_ref()
.map(|from| from.full_name())
.unwrap_or_else(|| "unknown".to_string());
let text_preview = message
.text()
.map(|t| {
let end = t.floor_char_boundary(120.min(t.len()));
&t[..end]
})
.unwrap_or("<no text>");
// 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!(
"message from {sender} in {} (private={is_private})",
message.chat.id
);
log::trace!("message text: {text_preview}");
// URL/edit flows only run in private chats; commands run in any chat.
if is_private
&& let Some(reply) = message.reply_to_message()
&& let Some(text) = message.text()
&& edit_message_handler(ctx, message.chat.id.0, reply.id.0 as i64, text).await
{
return respond(());
}
if let Some(text) = message.text()
&& let Ok(command) = Command::parse(text, "")
{
// The command name is what the operator needs at `debug`; its argument
// may be a user-supplied URL, which stays at `trace`.
log::debug!(
"command from {}: {}",
message.chat.id,
text.split_whitespace().next().unwrap_or("<empty>")
);
log::trace!("command text: {text_preview}");
execute_command(bot, &message, command).await?;
return respond(());
}
if is_private {
// 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() {
// Debug only, and echo the normalized keys instead of the raw URLs.
let keys: Vec<String> = urls.iter().map(|u| log_key(u)).collect();
log::debug!("queuing {} supported URL(s): {keys:?}", urls.len());
}
for url in urls {
// Clone out of the lock: the parking_lot guard is !Send and must
// not be held across the await below.
let Some(tx) = URL_JOBS.lock().clone() else {
log::warn!("url workers not started; dropping link");
break;
};
// A closed channel means the workers are stopping (shutdown):
// report the dropped link instead of losing it silently.
if tx.send((message.clone(), url)).await.is_err() {
log::warn!("url workers stopped; dropping link");
break;
}
}
} else if is_group(&message.chat.kind)
&& extract_urls(&message)
.iter()
.any(|url| x_media::site::cache_key(url).is_some())
{
// A supported link in a group used to be dropped in silence, which
// reads as a broken bot (the command menu is registered globally, so
// the expectation is there). Unsupported links stay ignored; the hint
// names the two paths that do work. Channels are excluded — the reply
// would be posted into the channel itself.
let _ = reply(ctx.sender, message.chat.id.0, message.id, GROUP_LINK_HINT).await;
}
respond(())
}
/// Answer for a link posted where the pipeline does not run (a group): links
/// are private-chat only, inline mode is the group path.
const GROUP_LINK_HINT: &str =
"Links are handled in private chat only — send me this link there, or use inline mode here.";
/// Groups and supergroups, as opposed to private chats and channels.
fn is_group(kind: &ChatKind) -> bool {
matches!(
kind,
ChatKind::Public(chat)
if matches!(
chat.kind,
PublicChatKind::Group | PublicChatKind::Supergroup(_)
)
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ctx::test_support::{FORWARDED_ID, PROMPT_ID, TestStores, api_error, seed_prompt};
use crate::media_sender::test_support::{MockSender, Outcome};
use teloxide::RequestError;
/// The Telegram wording the mocks answer with: a message the bot cannot
/// edit (the prompt was deleted).
const API_ERROR: &str = "Bad Request: message not found";
#[tokio::test]
async fn reply_to_a_prompt_swaps_the_caption_through_its_template() {
let sender = MockSender::scripted(vec![Outcome::EditOk], || api_error(API_ERROR));
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
seed_prompt(&ctx, "tpl", crate::db::unix_now()).await;
let consumed = edit_message_handler(&ctx, 1, PROMPT_ID, "new caption").await;
assert!(consumed, "a reply to the prompt must be consumed");
assert_eq!(
sender.captions(),
vec!["<b><a href=\"https://x.com/u/status/1\">new caption</a></b>"]
);
}
#[tokio::test]
async fn reply_text_and_url_are_escaped_into_the_caption() {
let sender = MockSender::scripted(vec![Outcome::EditOk], || api_error(API_ERROR));
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
seed_prompt(&ctx, "", crate::db::unix_now()).await;
edit_message_handler(&ctx, 1, PROMPT_ID, "<script>alert(1)</script>").await;
// No raw markup from user text may reach the HTML caption.
assert_eq!(
sender.captions(),
vec!["<a href=\"https://x.com/u/status/1\">&lt;script&gt;alert(1)&lt;/script&gt;</a>"]
);
}
#[tokio::test]
async fn a_failed_caption_swap_is_reported_and_consumed() {
// 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 ctx = stores.ctx(&sender);
seed_prompt(&ctx, "tpl", crate::db::unix_now()).await;
// The edit failed (message deleted etc.); the reply must still be
// 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_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]
async fn reply_to_an_unrelated_message_is_not_consumed() {
let sender = MockSender::scripted(vec![], || api_error(API_ERROR));
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
// No prompt record for that message id → the reply runs the normal
// (URL/command) path instead.
assert!(!edit_message_handler(&ctx, 1, PROMPT_ID, "hello").await);
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);
}
#[test]
fn the_link_hint_is_for_groups_only() {
use teloxide::types::{ChatPrivate, ChatPublic, PublicChatChannel, PublicChatSupergroup};
let group = ChatKind::Public(ChatPublic {
title: None,
kind: PublicChatKind::Group,
});
let supergroup = ChatKind::Public(ChatPublic {
title: None,
kind: PublicChatKind::Supergroup(PublicChatSupergroup {
username: None,
is_forum: false,
}),
});
// A channel must stay silent: the hint reply would be posted into the
// channel itself.
let channel = ChatKind::Public(ChatPublic {
title: None,
kind: PublicChatKind::Channel(PublicChatChannel { username: None }),
});
let private = ChatKind::Private(ChatPrivate {
username: None,
first_name: None,
last_name: None,
});
assert!(is_group(&group));
assert!(is_group(&supergroup));
assert!(!is_group(&channel));
assert!(!is_group(&private));
}
}
+39
View File
@@ -0,0 +1,39 @@
//! Process-wide singletons shared by the handler modules: the one SQLite
//! pool (and the three stores built on it) plus the configuration.
use crate::config::Config;
use crate::db::{self};
use crate::link_cache::LinkCache;
use crate::queue::PersistentTaskQueue;
use crate::state::ChatStore;
use std::sync::{Arc, LazyLock};
/// One shared SQLite pool for the three stores (chat state, task queue, link
/// cache): a single pool bounds concurrent DB work on `data/task_queue.db`
/// instead of three independent pools competing for the same file. The schema
/// for all three tables is initialized once, here.
static DB: LazyLock<Arc<db::DbPool>> = LazyLock::new(|| {
let path = db_path();
db::open_store(&path.to_string_lossy()).expect("failed to open database")
});
/// DB file location: `$DATA_DIR/task_queue.db` (default `data`, relative to
/// the working directory — keeps the docker-compose `./data` mount and local
/// runs unchanged). The directory is created if missing: SQLite does not
/// 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
/// CWD-relative path is a footgun for systemd / cron deployments — `DATA_DIR`
/// lets them pin the state anywhere. Also read by the startup config line, so
/// 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_path = std::path::Path::new(&dir);
std::fs::create_dir_all(dir_path).expect("failed to create data directory");
dir_path.join("task_queue.db")
}
pub static CHAT_STORE: LazyLock<ChatStore> = LazyLock::new(|| ChatStore::new(Arc::clone(&DB)));
pub static TASK_QUEUE: LazyLock<PersistentTaskQueue> =
LazyLock::new(|| PersistentTaskQueue::new(Arc::clone(&DB)));
pub static LINK_CACHE: LazyLock<LinkCache> = LazyLock::new(|| LinkCache::new(Arc::clone(&DB)));
pub static CONFIG: LazyLock<Config> = LazyLock::new(Config::load);
File diff suppressed because it is too large Load Diff
+370
View File
@@ -0,0 +1,370 @@
//! Persistent cache of successfully sent posts.
//!
//! After a media send succeeds, the raw render data plus the Telegram
//! `file_id`s of the sent items are stored keyed by [`crate::site` cache
//! key]. A repeated link is then answered entirely from local state — no
//! re-fetch of the source site, no re-upload — and no media file is stored
//! on disk (the file ids point at Telegram's servers). Entries expire after
//! `Config::link_cache_ttl`; a stale entry is dropped lazily on read and
//! by the periodic prune in `main`.
use crate::db::now_f64;
use rusqlite::params;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum CachedMediaKind {
Photo,
Video,
Animation,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct CachedMedia {
pub kind: CachedMediaKind,
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:
/// the canonical URL, pre-escaped caption fields, and the file ids produced
/// by the original successful send.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct CachedPost {
pub url: String,
/// The site's built-in caption (used when the chat has no format
/// override).
pub caption: String,
pub title: String,
/// The post's body text. Defaulted on read: entries written before the
/// title/content split carry it inside `title`.
#[serde(default)]
pub content: String,
pub author: String,
pub author_url: String,
pub tags: String,
pub sensitive: bool,
pub media: Vec<CachedMedia>,
}
/// SQLite-backed cache sharing `data/task_queue.db` with the queue and chat
/// state (same shared pool, see [`crate::db::open_store`]).
pub struct LinkCache {
pool: Arc<crate::db::DbPool>,
}
impl LinkCache {
/// Wraps the shared DB pool (the `link_cache` table lives in the merged
/// schema alongside `tasks` and `chat_state`).
pub fn new(pool: Arc<crate::db::DbPool>) -> Self {
LinkCache { pool }
}
/// Returns the cached post if present and not expired; a stale entry is
/// removed on the spot.
pub async fn get(&self, key: &str, ttl: Duration) -> Option<CachedPost> {
let key = key.to_string();
let ttl = ttl.as_secs_f64();
let result = self
.pool
.with_conn(move |conn| {
let mut stmt =
conn.prepare("SELECT payload, created_at FROM link_cache WHERE url = ?1")?;
let mut rows = stmt.query(params![key])?;
let Some(row) = rows.next()? else {
return Ok(None);
};
let payload: String = row.get(0)?;
let created_at: f64 = row.get(1)?;
if now_f64() - created_at > ttl {
conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key])?;
return Ok(None);
}
match serde_json::from_str::<CachedPost>(&payload) {
Ok(post) => Ok(Some(post)),
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])?;
Err(rusqlite::Error::ToSqlConversionFailure(Box::new(e)))
}
}
})
.await;
match result {
Ok(v) => v,
Err(e) => {
log::warn!("link cache read failed: {e}");
None
}
}
}
pub async fn put(&self, key: &str, post: &CachedPost) {
let key = key.to_string();
let payload = serde_json::to_string(post).expect("cached post serializes");
let result = self
.pool
.with_conn(move |conn| {
conn.execute(
"INSERT OR REPLACE INTO link_cache (url, payload, created_at) VALUES (?1, ?2, ?3)",
params![key, payload, now_f64()],
)?;
Ok(())
})
.await;
if let Err(e) = result {
log::warn!("link cache write failed: {e}");
}
}
/// Drops an entry (e.g. a cached file id that turned out invalid).
pub async fn remove(&self, key: &str) {
let key = key.to_string();
let result = self
.pool
.with_conn(move |conn| {
conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key])?;
Ok(())
})
.await;
if let Err(e) = result {
log::warn!("link cache delete failed: {e}");
}
}
/// Removes expired entries; returns how many were deleted.
pub async fn prune(&self, ttl: Duration) -> usize {
let cutoff = now_f64() - ttl.as_secs_f64();
let result = self
.pool
.with_conn(move |conn| {
conn.execute(
"DELETE FROM link_cache WHERE created_at < ?1",
params![cutoff],
)
})
.await;
match result {
Ok(n) => n,
Err(e) => {
log::warn!("link cache prune failed: {e}");
0
}
}
}
/// Deletes one entry (by normalized cache key) or the whole cache when
/// `key` is `None`. Returns how many rows were removed.
pub async fn clear(&self, key: Option<&str>) -> usize {
let key = key.map(str::to_string);
let result = self
.pool
.with_conn(move |conn| match &key {
Some(key) => conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key]),
None => conn.execute("DELETE FROM link_cache", []),
})
.await;
match result {
Ok(n) => n,
Err(e) => {
log::warn!("link cache clear failed: {e}");
0
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ctx::test_support::cached_photo;
/// A payload written before the title/content split has no `content`
/// field. It must still read back — the cache deletes what it cannot
/// parse — with its text left where it was stored (`title`) and the
/// caption it replays untouched. No migration: a self-hosted cache entry
/// lives one TTL, and moving the text would only reshuffle `/set_format`
/// placeholders until it expires.
#[tokio::test]
async fn pre_split_entry_still_parses() {
let dir = tempfile::tempdir().unwrap();
let cache = LinkCache::new(
crate::db::open_store(dir.path().join("c.db").to_str().unwrap()).unwrap(),
);
let legacy = serde_json::json!({
"url": "https://x.com/u/status/1",
"caption": "https://x.com/u/status/1\n<a href=\"au\">a</a>: old text",
"title": "old text",
"author": "a",
"author_url": "au",
"tags": "",
"sensitive": false,
"media": [{"kind": "photo", "file_id": "AgAC..."}]
});
{
let conn = rusqlite::Connection::open(dir.path().join("c.db")).unwrap();
conn.execute(
"INSERT INTO link_cache (url, payload, created_at) VALUES (?1, ?2, ?3)",
params!["twitter:1", legacy.to_string(), now_f64()],
)
.unwrap();
}
let got = cache
.get("twitter:1", Duration::from_secs(3600))
.await
.expect("a pre-split payload must not be dropped");
assert_eq!(got.title, "old text");
assert_eq!(got.content, "");
assert_eq!(
got.caption,
"https://x.com/u/status/1\n<a href=\"au\">a</a>: old text"
);
}
#[tokio::test]
async fn put_get_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let cache = LinkCache::new(
crate::db::open_store(dir.path().join("c.db").to_str().unwrap()).unwrap(),
);
cache.put("twitter:1", &cached_photo()).await;
let got = cache.get("twitter:1", Duration::from_secs(3600)).await;
assert!(got.is_some());
let got = got.unwrap();
assert_eq!(got.url, "https://x.com/u/status/1");
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]
async fn expired_entry_removed_on_read() {
let dir = tempfile::tempdir().unwrap();
let cache = LinkCache::new(
crate::db::open_store(dir.path().join("c.db").to_str().unwrap()).unwrap(),
);
cache.put("twitter:1", &cached_photo()).await;
// Force the row into the past so a 1s TTL expires it.
{
let conn = rusqlite::Connection::open(dir.path().join("c.db")).unwrap();
conn.execute("UPDATE link_cache SET created_at = created_at - 100", [])
.unwrap();
}
assert!(
cache
.get("twitter:1", Duration::from_secs(1))
.await
.is_none()
);
assert!(
cache
.get("twitter:1", Duration::from_secs(3600))
.await
.is_none()
);
}
#[tokio::test]
async fn unreadable_entry_is_dropped_on_read() {
// A payload from an older schema must not be re-parsed on every hit:
// the row is removed and the read reports a miss.
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("c.db");
let cache = LinkCache::new(crate::db::open_store(db_path.to_str().unwrap()).unwrap());
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"INSERT INTO link_cache (url, payload, created_at) VALUES (?1, ?2, ?3)",
params!["twitter:1", "{not json", now_f64()],
)
.unwrap();
}
assert!(
cache
.get("twitter:1", Duration::from_secs(3600))
.await
.is_none()
);
// Dropped, not left behind for the next hit.
assert_eq!(cache.clear(None).await, 0, "corrupted row still present");
}
#[tokio::test]
async fn remove_and_prune() {
let dir = tempfile::tempdir().unwrap();
let cache = LinkCache::new(
crate::db::open_store(dir.path().join("c.db").to_str().unwrap()).unwrap(),
);
cache.put("twitter:1", &cached_photo()).await;
cache.put("pixiv:2", &cached_photo()).await;
cache.remove("twitter:1").await;
assert!(
cache
.get("twitter:1", Duration::from_secs(3600))
.await
.is_none()
);
assert!(
cache
.get("pixiv:2", Duration::from_secs(3600))
.await
.is_some()
);
{
let conn = rusqlite::Connection::open(dir.path().join("c.db")).unwrap();
conn.execute("UPDATE link_cache SET created_at = created_at - 100", [])
.unwrap();
}
assert_eq!(cache.prune(Duration::from_secs(1)).await, 1);
assert!(
cache
.get("pixiv:2", Duration::from_secs(3600))
.await
.is_none()
);
}
#[tokio::test]
async fn clear_one_entry_or_all() {
let dir = tempfile::tempdir().unwrap();
let cache = LinkCache::new(
crate::db::open_store(dir.path().join("c.db").to_str().unwrap()).unwrap(),
);
cache.put("twitter:1", &cached_photo()).await;
cache.put("pixiv:2", &cached_photo()).await;
// By key: only the matching row is removed.
assert_eq!(cache.clear(Some("twitter:1")).await, 1);
assert!(
cache
.get("twitter:1", Duration::from_secs(3600))
.await
.is_none()
);
assert!(
cache
.get("pixiv:2", Duration::from_secs(3600))
.await
.is_some()
);
// Whole cache: nothing left; removing an absent key deletes 0 rows.
assert_eq!(cache.clear(None).await, 1);
assert!(
cache
.get("pixiv:2", Duration::from_secs(3600))
.await
.is_none()
);
assert_eq!(cache.clear(None).await, 0);
}
}
+514
View File
@@ -0,0 +1,514 @@
use dotenv::dotenv;
use std::time::Duration;
use teloxide::dptree::endpoint;
use teloxide::prelude::*;
use teloxide::stop::StopToken;
use teloxide::types::{ChatId, InputFile, MessageId};
use teloxide::update_listeners::{self, UpdateListener, webhooks};
use tokio::sync::watch;
use x_media::site;
mod config;
mod ctx;
mod db;
mod handlers;
mod link_cache;
mod media_sender;
mod photo;
mod queue;
mod rate_limit;
mod send;
mod state;
use ctx::CONTEXT;
use handlers::{CHAT_STORE, CONFIG, LINK_CACHE, TASK_QUEUE};
/// Docker `stop` / `compose down` delivers SIGTERM, which teloxide's ctrlc
/// handler (SIGINT only) never sees — without this the process would die
/// before the graceful shutdown below (admin notice, queue drain). Stopping
/// the token unwinds the dispatcher exactly like Ctrl+C does.
#[cfg(unix)]
fn spawn_sigterm_handler(stop_token: StopToken) {
tokio::spawn(async move {
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler");
sigterm.recv().await;
log::info!("SIGTERM received, stopping the dispatcher");
stop_token.stop();
});
}
#[cfg(not(unix))]
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]
async fn main() {
dotenv().ok();
// 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");
// 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();
// Force the queue workers' shared Bot to initialize now so a missing
// token fails at startup, not on the first queued task.
let _ = &*send::BOT;
// Register the command list with Telegram (client `/` menu).
if let Err(e) = handlers::register_commands(&bot).await {
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!(
"config: {} admin(s), state {}, edit-message TTL {}s, link cache TTL {}s, caption quote {quote_chars}, proxy={}",
CONFIG.admin_ids.len(),
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 = handlers::repair_lost_local_media(&CONTEXT).await;
if repaired > 0 {
log::info!("startup repair: re-fetched {repaired} queued task(s)");
}
// Queue worker: handles typed tasks, dead-letters failed sends to the
// task's chat. Both closures use the shared context (the queue requires
// 'static handlers, and the statics are process-wide anyway).
TASK_QUEUE
.start(
|payload| send::handle_task(&CONTEXT, payload),
|payload, message| send::dead_letter_notify(&CONTEXT, payload, message),
)
.await;
log::info!("task queue worker started");
// URL job workers: bounded channel + fixed pool for per-URL work.
handlers::start_url_workers().await;
log::info!("url workers started");
// Site login validation (user request): a failed login notifies the
// admin and the site disables itself for this process (pixiv).
let failures = site::validate_all().await;
if failures.is_empty() {
log::info!("site logins validated");
} else {
for (site_id, message) in &failures {
log::error!("{site_id} login failed: {message}");
if let Some(admin) = CONFIG.admin_ids.first() {
let _ = bot
.send_message(ChatId(*admin), format!("{site_id} login failed: {message}"))
.await;
}
}
}
// Background sweep: expires the edit prompts and prunes what has aged out.
log::info!(
"edit-expiry sweep: every {}s, ttl {}",
SWEEP_INTERVAL.as_secs(),
CONFIG.edit_message_ttl.as_secs()
);
let (stop_tx, stop_rx) = watch::channel(false);
{
let bot = bot.clone();
tokio::spawn(async move {
periodic_sweep(
&bot,
&CHAT_STORE,
&LINK_CACHE,
&TASK_QUEUE,
&CONFIG,
stop_rx,
)
.await;
});
}
let handler = dptree::entry()
.branch(Update::filter_message().branch(endpoint(handlers::message_handler)))
.branch(Update::filter_inline_query().branch(endpoint(handlers::inline_query_handler)))
.branch(Update::filter_callback_query().branch(endpoint(handlers::callback_query_handler)));
let mut dispatcher = Dispatcher::builder(bot.clone(), handler)
.dependencies(dptree::deps![""])
.enable_ctrlc_handler()
.build();
if CONFIG.webhook_enabled {
log::info!("running in webhook mode");
let url = CONFIG.webhook_url.clone().expect("WEBHOOK_URL is not set");
// `webhooks::axum` calls set_webhook itself (with the full options,
// secret token included) — no explicit registration here.
let listen = CONFIG.webhook_listen.expect("WEBHOOK_LISTEN is not set");
let port = CONFIG.webhook_port.expect("WEBHOOK_PORT is not set");
let mut options = webhooks::Options::new((listen, port).into(), url);
if let Some(cert) = &CONFIG.webhook_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)
.await
.expect("Failed to create webhook listener");
let stop_token = listener.stop_token();
spawn_sigterm_handler(stop_token);
dispatcher
.dispatch_with_listener(
listener,
LoggingErrorHandler::with_custom_text("Error from update listener"),
)
.await;
} else {
log::info!("running in polling mode");
// Same listener `dispatch()` builds internally — using
// `dispatch_with_listener` just exposes its stop token so SIGTERM can
// unwind the dispatcher before the graceful shutdown below.
let mut listener = update_listeners::polling_default(bot.clone()).await;
let stop_token = listener.stop_token();
spawn_sigterm_handler(stop_token);
dispatcher
.dispatch_with_listener(
listener,
LoggingErrorHandler::with_custom_text("Error from update listener"),
)
.await;
}
// Graceful stop (Ctrl+C / SIGTERM): stop the sweep, notify the admin,
// drain the queue. Bounded: a worker mid-download (30 s timeout) or a
// long ugoira encode must not hold the shutdown hostage forever.
log::info!("Stopping bot");
const SHUTDOWN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
let shutdown = async {
let _ = stop_tx.send(true);
handlers::stop_url_workers().await;
if let Some(admin) = CONFIG.admin_ids.first() {
let _ = bot.send_message(ChatId(*admin), "Shutting down...").await;
}
TASK_QUEUE.stop().await;
};
if tokio::time::timeout(SHUTDOWN_TIMEOUT, shutdown)
.await
.is_err()
{
log::warn!("graceful shutdown timed out after {SHUTDOWN_TIMEOUT:?}; exiting");
} else {
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 its collaborators instead of reaching for the statics 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(
sender: &dyn crate::media_sender::MediaSender,
chat_store: &crate::state::ChatStore,
link_cache: &crate::link_cache::LinkCache,
task_queue: &crate::queue::PersistentTaskQueue,
config: &crate::config::Config,
mut stop: watch::Receiver<bool>,
) {
loop {
tokio::select! {
_ = stop.changed() => break,
_ = tokio::time::sleep(SWEEP_INTERVAL) => {}
}
let removed = chat_store.prune_expired(config.edit_message_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)");
}
// 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)) = 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) = 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;
let (stop_tx, stop_rx) = watch::channel(false);
let sweep = periodic_sweep(
&sender,
stores.chat_store(),
stores.link_cache(),
stores.task_queue(),
&config,
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;
}
}
+702
View File
@@ -0,0 +1,702 @@
//! 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 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 {
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_text(
&self,
chat_id: ChatId,
message_id: MessageId,
text: String,
) -> BoxFuture<'_, Result<(), RequestError>> {
Box::pin(async move {
<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 {
<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,
}
/// 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)>>,
/// 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()),
edited_texts: 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()
}
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_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(())
})
}
}
}
+729
View File
@@ -0,0 +1,729 @@
//! Pure-Rust photo processing: brings a downloaded photo within Telegram's
//! limits (width + height ≤ 10000 px, bytes ≤ 10 MiB) without ffmpeg.
//!
//! Stack: `png` (image-png) for PNG decode/encode, `zune-jpeg` for JPEG
//! decode, `fast_image_resize` (Lanczos3) for downsampling, `jpeg-encoder`
//! for JPEG output.
//!
//! Bit-depth rule: a PNG above 24 bits (32-bit RGBA or 16-bit per channel)
//! is reduced to 24-bit RGB; 24-bit and lower depths are left untouched —
//! gray stays gray, never upconverted. The only upconversion is palette
//! expansion, which resampling requires. Alpha is flattened onto white (JPEG
//! and 24-bit RGB have no alpha channel).
use std::io::Write;
use std::sync::LazyLock;
use fast_image_resize as fir;
use tempfile::NamedTempFile;
/// Telegram rejects photos whose width + height exceed this limit
/// (PHOTO_INVALID_DIMENSIONS). Verified empirically: 6300x3730 (sum 10030)
/// fails, 6100x3900 (sum 10000) passes.
pub const PHOTO_MAX_DIMENSION_SUM: u32 = 10000;
/// Resize target with a safety margin so rounding cannot cross the cap.
pub const PHOTO_TARGET_DIMENSION_SUM: u32 = 9900;
/// Upload cap (bytes): files above this are not uploaded; the bot falls back
/// to a smaller media URL instead.
pub const MAX_UPLOAD_BYTES: u64 = 10 * 1024 * 1024;
/// Decode budget (bytes): a larger intermediate buffer is not worth the peak
/// memory; the photo degrades to the smaller URL instead.
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 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
}
/// Units to charge for `bytes`, clamped to the whole budget: an item must never
/// ask for more than exists, or it would wait for itself forever.
fn memory_units(bytes: u64) -> u32 {
bytes
.div_ceil(MEMORY_UNIT_BYTES)
.clamp(1, MEMORY_UNITS as u64) as u32
}
/// 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 decode buffer a downloaded photo will allocate, from its header alone —
/// zero when it is already within Telegram's limits and is uploaded as-is, zero
/// for a format [`prepare_photo`] does not decode. Mirrors the early return and
/// the guard of the two branches below.
pub(crate) fn decode_budget_bytes(bytes: &[u8]) -> u64 {
if let Some((w, h, _depth, color)) = parse_png_header(bytes) {
if within_limits(w, h, bytes) {
return 0;
}
return decode_bytes(w, h, output_channels(color));
}
if let Some((w, h)) = jpeg_dims(bytes) {
if within_limits(w, h, bytes) {
return 0;
}
return decode_bytes(w, h, 3);
}
0
}
/// Whether a photo is uploaded untouched (Telegram's dimension sum, and the
/// upload cap its bytes are compared against).
fn within_limits(w: u32, h: u32, bytes: &[u8]) -> bool {
w + h <= PHOTO_MAX_DIMENSION_SUM && bytes.len() as u64 <= MAX_UPLOAD_BYTES
}
/// 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).
const JPEG_QUALITY: u8 = 90;
/// What to upload for a downloaded photo.
pub enum PhotoPrep {
/// Upload this file (the original when within limits, else the processed
/// copy).
Upload(NamedTempFile),
/// The photo cannot be brought within Telegram's limits — the caller
/// falls back to the item's smaller URL.
UseFallback,
}
/// A decoded image buffer tagged with its channel layout.
#[derive(Debug)]
enum PixBuf {
Gray(Vec<u8>),
GrayAlpha(Vec<u8>),
Rgb(Vec<u8>),
}
impl PixBuf {
fn pixel_type(&self) -> fir::PixelType {
match self {
PixBuf::Gray(_) => fir::PixelType::U8,
PixBuf::GrayAlpha(_) => fir::PixelType::U8x2,
PixBuf::Rgb(_) => fir::PixelType::U8x3,
}
}
fn into_vec(self) -> Vec<u8> {
match self {
PixBuf::Gray(v) | PixBuf::GrayAlpha(v) | PixBuf::Rgb(v) => v,
}
}
}
/// Entry point: detects the format and processes the photo if needed.
/// The caller hands in the already-downloaded bytes (they are in memory from
/// the download anyway; re-reading the temp file would double the I/O).
pub fn prepare_photo(file: NamedTempFile, bytes: &[u8]) -> Result<PhotoPrep, String> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
prepare_png(file, bytes)
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
prepare_jpeg(file, bytes)
} else {
log::warn!("photo in unsupported format; falling back to smaller media");
Ok(PhotoPrep::UseFallback)
}
}
/// Parses the PNG IHDR (bytes 8..26: signature + length + "IHDR" + width +
/// height + bit depth + color type).
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 {
return None;
}
let w = u32::from_be_bytes(bytes.get(16..20)?.try_into().ok()?);
let h = u32::from_be_bytes(bytes.get(20..24)?.try_into().ok()?);
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
/// STRIP_16; palette expands to RGB).
fn output_channels(color: png::ColorType) -> usize {
match color {
png::ColorType::Grayscale => 1,
png::ColorType::GrayscaleAlpha => 2,
png::ColorType::Rgb | png::ColorType::Indexed => 3,
png::ColorType::Rgba => 4,
}
}
/// The 32→24 rule: RGBA (32-bit) becomes RGB with alpha composited onto
/// white; 16-bit per channel was already stripped to 8-bit at decode.
fn flatten_rgba_to_rgb(rgba: &[u8]) -> Vec<u8> {
let mut rgb = Vec::with_capacity(rgba.len() / 4 * 3);
for px in rgba.as_chunks::<4>().0 {
let a = px[3] as u32;
for v in &px[..3] {
// Over white: C = C*a/255 + 255*(1 - a/255).
let v = (*v as u32 * a + 255 * (255 - a)) / 255;
rgb.push(v.min(255) as u8);
}
}
rgb
}
/// Lanczos3 downsampling via fast_image_resize.
fn resize_pix(pix: PixBuf, w: u32, h: u32, nw: u32, nh: u32) -> Result<PixBuf, String> {
let pixel_type = pix.pixel_type();
let src = fir::images::Image::from_vec_u8(w, h, pix.into_vec(), pixel_type)
.map_err(|e| format!("resize input: {e}"))?;
let mut dst = fir::images::Image::new(nw, nh, pixel_type);
let mut resizer = fir::Resizer::new();
let options = fir::ResizeOptions::default()
.resize_alg(fir::ResizeAlg::Convolution(fir::FilterType::Lanczos3));
resizer
.resize(&src, &mut dst, &options)
.map_err(|e| format!("resize: {e}"))?;
let buf = dst.into_vec();
Ok(match pixel_type {
fir::PixelType::U8 => PixBuf::Gray(buf),
fir::PixelType::U8x2 => PixBuf::GrayAlpha(buf),
_ => PixBuf::Rgb(buf),
})
}
fn encode_png(out: &mut Vec<u8>, pix: &PixBuf, w: u32, h: u32) -> Result<(), png::EncodingError> {
let (color, buf) = match pix {
PixBuf::Gray(v) => (png::ColorType::Grayscale, v.as_slice()),
PixBuf::GrayAlpha(v) => (png::ColorType::GrayscaleAlpha, v.as_slice()),
PixBuf::Rgb(v) => (png::ColorType::Rgb, v.as_slice()),
};
let mut encoder = png::Encoder::new(out, w, h);
encoder.set_color(color);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header()?;
writer.write_image_data(buf)?;
Ok(())
}
fn encode_jpeg(pix: &PixBuf, w: u32, h: u32) -> Result<Vec<u8>, String> {
use jpeg_encoder::{ColorType, Encoder};
let mut out = Vec::new();
let encoder = Encoder::new(&mut out, JPEG_QUALITY);
match pix {
PixBuf::Gray(v) => encoder
.encode(v, w as u16, h as u16, ColorType::Luma)
.map_err(|e| format!("jpeg encode: {e}"))?,
PixBuf::GrayAlpha(v) => {
// JPEG has no alpha: composite onto white, output as gray.
let gray: Vec<u8> = v
.as_chunks::<2>()
.0
.iter()
.map(|px| {
let (g, a) = (px[0] as u32, px[1] as u32);
((g * a + 255 * (255 - a)) / 255).min(255) as u8
})
.collect();
encoder
.encode(&gray, w as u16, h as u16, ColorType::Luma)
.map_err(|e| format!("jpeg encode: {e}"))?;
}
PixBuf::Rgb(v) => encoder
.encode(v, w as u16, h as u16, ColorType::Rgb)
.map_err(|e| format!("jpeg encode: {e}"))?,
}
Ok(out)
}
fn write_temp(bytes: &[u8], ext: &str) -> Result<NamedTempFile, String> {
let mut file = tempfile::Builder::new()
.prefix(x_media::TEMP_FILE_PREFIX)
.suffix(&format!(".{ext}"))
.tempfile()
.map_err(|e| format!("temp file failed: {e}"))?;
file.as_file_mut()
.write_all(bytes)
.map_err(|e| format!("temp file write failed: {e}"))?;
Ok(file)
}
fn target_dims(w: u32, h: u32) -> (u32, u32) {
let scale = PHOTO_TARGET_DIMENSION_SUM as f64 / (w + h) as f64;
(
((w as f64 * scale).round() as u32).max(1),
((h as f64 * scale).round() as u32).max(1),
)
}
/// PNG branch: decode (16→8, palette→RGB; gray/GA stay), flatten RGBA to
/// RGB, Lanczos-downscale beyond the dimension cap, encode PNG — a PNG still
/// over the upload cap afterwards becomes JPEG.
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 size_over = bytes.len() as u64 > MAX_UPLOAD_BYTES;
if w + h <= PHOTO_MAX_DIMENSION_SUM && !size_over {
return Ok(PhotoPrep::Upload(file));
}
log::debug!(
"photo {w}x{h} ({_bit_depth:?} {color_type:?}, {} bytes) needs processing",
bytes.len()
);
let channels = output_channels(color_type);
if decode_bytes(w, h, channels) > MAX_DECODE_BYTES {
log::warn!("photo decode buffer exceeds the memory budget; falling back to smaller media");
return Ok(PhotoPrep::UseFallback);
}
// STRIP_16 drops 16-bit to 8-bit (the depth-reduction step); palette
// expands to RGB (resampling requires it). Gray and gray-alpha are kept.
let transforms = match color_type {
png::ColorType::Indexed => png::Transformations::EXPAND,
_ => png::Transformations::STRIP_16,
};
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
decoder.set_transformations(transforms);
let mut reader = decoder
.read_info()
.map_err(|e| format!("png decode: {e}"))?;
let out_w = reader.info().width;
let out_h = reader.info().height;
let mut buf = vec![
0u8;
reader
.output_buffer_size()
.ok_or("png output buffer size")?
];
reader
.next_frame(&mut buf)
.map_err(|e| format!("png frame: {e}"))?;
let mut pix = match color_type {
png::ColorType::Rgba => PixBuf::Rgb(flatten_rgba_to_rgb(&buf)),
png::ColorType::Grayscale => PixBuf::Gray(buf),
png::ColorType::GrayscaleAlpha => PixBuf::GrayAlpha(buf),
png::ColorType::Rgb | png::ColorType::Indexed => PixBuf::Rgb(buf),
};
let (mut w, mut h) = (out_w, out_h);
if w + h > PHOTO_MAX_DIMENSION_SUM {
let (nw, nh) = target_dims(w, h);
pix = resize_pix(pix, w, h, nw, nh)?;
(w, h) = (nw, nh);
log::debug!("downscaled photo to {w}x{h} (Lanczos3)");
}
let mut png_bytes = Vec::new();
encode_png(&mut png_bytes, &pix, w, h).map_err(|e| format!("png encode: {e}"))?;
if png_bytes.len() as u64 <= MAX_UPLOAD_BYTES {
return Ok(PhotoPrep::Upload(write_temp(&png_bytes, "png")?));
}
log::debug!("PNG still over the upload cap after processing; transcoding to JPEG");
let jpeg_bytes = encode_jpeg(&pix, w, h)?;
if jpeg_bytes.len() as u64 <= MAX_UPLOAD_BYTES {
return Ok(PhotoPrep::Upload(write_temp(&jpeg_bytes, "jpg")?));
}
log::warn!("processed photo still exceeds the upload cap; falling back to smaller media");
Ok(PhotoPrep::UseFallback)
}
/// JPEG branch: zune-jpeg decode → Lanczos downscale → jpeg-encoder output.
fn prepare_jpeg(file: NamedTempFile, bytes: &[u8]) -> Result<PhotoPrep, String> {
let mut decoder = zune_jpeg::JpegDecoder::new(std::io::Cursor::new(bytes));
// Decodes to RGB by default. Headers first so dimensions are known before
// the (potentially huge) pixel decode.
decoder
.decode_headers()
.map_err(|e| format!("jpeg headers: {e}"))?;
let info = decoder.info().ok_or("jpeg info unavailable")?;
let (w, h) = (info.width as u32, info.height as u32);
let size_over = bytes.len() as u64 > MAX_UPLOAD_BYTES;
if w + h <= PHOTO_MAX_DIMENSION_SUM && !size_over {
return Ok(PhotoPrep::Upload(file));
}
if decode_bytes(w, h, 3) > MAX_DECODE_BYTES {
log::warn!("photo decode buffer exceeds the memory budget; falling back to smaller media");
return Ok(PhotoPrep::UseFallback);
}
let pixels = decoder.decode().map_err(|e| format!("jpeg decode: {e}"))?;
let mut pix = PixBuf::Rgb(pixels);
let (mut w, mut h) = (w, h);
if w + h > PHOTO_MAX_DIMENSION_SUM {
let (nw, nh) = target_dims(w, h);
pix = resize_pix(pix, w, h, nw, nh)?;
(w, h) = (nw, nh);
log::debug!("downscaled jpeg to {w}x{h} (Lanczos3)");
}
let jpeg_bytes = encode_jpeg(&pix, w, h)?;
if jpeg_bytes.len() as u64 <= MAX_UPLOAD_BYTES {
return Ok(PhotoPrep::Upload(write_temp(&jpeg_bytes, "jpg")?));
}
log::warn!("processed photo still exceeds the upload cap; falling back to smaller media");
Ok(PhotoPrep::UseFallback)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
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();
bytes.extend(w.to_be_bytes());
bytes.extend(h.to_be_bytes());
bytes.extend([depth, color, 0, 0, 0]);
bytes
}
/// 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 = MAX_DECODE_BYTES + MAX_PHOTO_DOWNLOAD_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: they charge
/// their real (small) buffers, not a fixed heavyweight slot.
#[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).
let ordinary = 4 * 1024 * 1024 + 36 * 1024 * 1024;
let mut held = Vec::new();
for i in 0..MEMORY_UNITS {
held.push(
tokio::time::timeout(Duration::from_millis(50), reserve(budget.clone(), ordinary))
.await
.unwrap_or_else(|_| panic!("ordinary photo {i} waited for budget")),
);
}
}
#[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
);
}
/// 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 nothing,
/// one that gets processed costs its decoded buffer.
#[test]
fn decode_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!(decode_budget_bytes(&oversized), 9999 * 2 * 3);
// Inside the limits (dimensions *and* bytes) → uploaded as-is.
let small = png_header(100, 100, 8, 2);
assert_eq!(decode_budget_bytes(&small), 0);
// A format the pipeline does not decode costs nothing either.
assert_eq!(decode_budget_bytes(b"GIF89a not a photo"), 0);
// 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!(decode_budget_bytes(&bytes), 9999 * 2 * 3);
}
#[test]
fn parses_png_header() {
let bytes = png_header(8979, 5316, 16, 6); // 16-bit RGBA
let (w, h, depth, color) = parse_png_header(&bytes).unwrap();
assert_eq!((w, h), (8979, 5316));
assert_eq!(depth, png::BitDepth::Sixteen);
assert_eq!(color, png::ColorType::Rgba);
let (_, _, depth, color) = parse_png_header(&png_header(10, 10, 8, 0)).unwrap();
assert_eq!(depth, png::BitDepth::Eight);
assert_eq!(color, png::ColorType::Grayscale);
assert!(parse_png_header(b"not a png").is_none());
}
#[test]
fn flatten_rgba_to_rgb_composites_over_white() {
// opaque red stays red
assert_eq!(flatten_rgba_to_rgb(&[255, 0, 0, 255]), vec![255, 0, 0]);
// fully transparent → white
assert_eq!(flatten_rgba_to_rgb(&[0, 0, 0, 0]), vec![255, 255, 255]);
// half alpha red → (255+255)/2 = 255, (0*128 + 255*127)/255 = 127
let out = flatten_rgba_to_rgb(&[255, 0, 0, 128]);
assert_eq!(out[0], 255);
assert_eq!(out[1], 127);
assert_eq!(out[2], 127);
}
#[test]
fn target_dims_stay_under_the_cap() {
for (w, h) in [(12000u32, 7000u32), (10000, 10000), (8979, 5316)] {
let (nw, nh) = target_dims(w, h);
assert!(nw + nh <= PHOTO_MAX_DIMENSION_SUM, "{w}x{h} -> {nw}x{nh}");
assert!(nw >= 1 && nh >= 1);
}
// already within limits: no change expected from the caller, but the
// helper must not produce zero dimensions.
let (nw, nh) = target_dims(500, 400);
assert!(nw >= 1 && nh >= 1);
}
#[test]
fn resize_pix_changes_dimensions() {
// 300x200 RGB → 100x66
let buf: Vec<u8> = (0..300 * 200 * 3).map(|i| (i % 251) as u8).collect();
let resized = resize_pix(PixBuf::Rgb(buf), 300, 200, 100, 66).unwrap();
match resized {
PixBuf::Rgb(v) => assert_eq!(v.len(), 100 * 66 * 3),
other => panic!("expected rgb, got {other:?}"),
}
}
#[test]
fn png_encode_roundtrip_keeps_gray() {
let gray = vec![128u8; 4 * 4];
let mut out = Vec::new();
encode_png(&mut out, &PixBuf::Gray(gray), 4, 4).unwrap();
assert!(!out.is_empty());
let (_, _, depth, color) = parse_png_header(&out).unwrap();
assert_eq!(depth, png::BitDepth::Eight);
assert_eq!(color, png::ColorType::Grayscale);
}
#[test]
fn jpeg_encode_produces_bytes() {
let rgb = vec![128u8; 8 * 8 * 3];
let out = encode_jpeg(&PixBuf::Rgb(rgb), 8, 8).unwrap();
assert!(out.len() > 100);
assert!(out.starts_with(&[0xFF, 0xD8]));
}
/// Writes a small dimension-oversized PNG (9999x2 → sum 10001) to a temp
/// file and runs the full pipeline.
fn run_pipeline(w: u32, h: u32, color: png::ColorType, fill: u8) -> Result<PhotoPrep, String> {
let (channels, data): (usize, Vec<u8>) = match color {
png::ColorType::Grayscale => (1, vec![fill; (w * h) as usize]),
png::ColorType::Rgb => (3, vec![fill; (w * h * 3) as usize]),
_ => unreachable!(),
};
let mut bytes = Vec::new();
{
let mut encoder = png::Encoder::new(&mut bytes, w, h);
encoder.set_color(color);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&data).unwrap();
}
assert_eq!(data.len(), channels * (w * h) as usize);
let mut file = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
std::io::Write::write_all(file.as_file_mut(), &bytes).unwrap();
prepare_photo(file, &bytes)
}
#[test]
fn pipeline_downscales_oversized_png_keeping_format() {
let prep = run_pipeline(9999, 2, png::ColorType::Rgb, 128).unwrap();
match prep {
PhotoPrep::Upload(file) => {
let out = std::fs::read(file.path()).unwrap();
let (w, h, depth, color) = parse_png_header(&out).unwrap();
assert!(w + h <= PHOTO_MAX_DIMENSION_SUM, "{w}x{h}");
assert_eq!(depth, png::BitDepth::Eight);
assert_eq!(color, png::ColorType::Rgb);
}
PhotoPrep::UseFallback => panic!("over-dimension PNG should have been resized"),
}
}
#[test]
fn pipeline_keeps_gray_png_gray() {
let prep = run_pipeline(9999, 2, png::ColorType::Grayscale, 200).unwrap();
match prep {
PhotoPrep::Upload(file) => {
let out = std::fs::read(file.path()).unwrap();
let (_, _, _, color) = parse_png_header(&out).unwrap();
assert_eq!(color, png::ColorType::Grayscale, "gray must not upconvert");
}
PhotoPrep::UseFallback => panic!("over-dimension gray PNG should have been resized"),
}
}
#[test]
fn pipeline_resizes_oversized_jpeg() {
// 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 rgb = vec![90u8; (w as usize) * (h as usize) * 3];
let mut bytes = Vec::new();
{
let encoder = jpeg_encoder::Encoder::new(&mut bytes, 90);
encoder
.encode(&rgb, w, h, jpeg_encoder::ColorType::Rgb)
.unwrap();
}
let mut file = tempfile::Builder::new().suffix(".jpg").tempfile().unwrap();
std::io::Write::write_all(file.as_file_mut(), &bytes).unwrap();
match prepare_photo(file, &bytes).unwrap() {
PhotoPrep::Upload(file) => {
let out = std::fs::read(file.path()).unwrap();
assert!(out.starts_with(&[0xFF, 0xD8]), "output must stay jpeg");
let mut decoder = zune_jpeg::JpegDecoder::new(std::io::Cursor::new(out.as_slice()));
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"),
}
}
#[test]
#[ignore = "heavy: generates a >10 MiB PNG (run explicitly)"]
fn pipeline_transcodes_oversized_png_to_jpeg() {
// 6000x4000 (sum 10000 — under the dimension cap) smooth gradient with
// small per-pixel noise: PNG-incompressible (delta filters defeated)
// but JPEG-friendly (DCT smooths the small noise). Verified with
// ffmpeg: 8000x6000 amp-5 variant is a 59 MB PNG / 3.3 MB JPEG.
let (w, h) = (6000u32, 4000u32);
let mut rng = 0x1234_5678_9abc_def0u64;
let mut data = Vec::with_capacity((w * h * 3) as usize);
for y in 0..h {
for x in 0..w {
let base = (x + y) * 255 / (w + h);
rng = rng
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let n = ((rng >> 33) % 11) as i32 - 5; // noise in [-5, 5]
let v = (base as i32 + n).clamp(0, 255) as u8;
data.extend_from_slice(&[v, v, v]);
}
}
let mut bytes = Vec::new();
{
let mut encoder = png::Encoder::new(&mut bytes, w, h);
encoder.set_color(png::ColorType::Rgb);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&data).unwrap();
}
assert!(
bytes.len() as u64 > MAX_UPLOAD_BYTES,
"test needs a >10MiB PNG, got {}",
bytes.len()
);
let mut file = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
std::io::Write::write_all(file.as_file_mut(), &bytes).unwrap();
match prepare_photo(file, &bytes).unwrap() {
PhotoPrep::Upload(file) => {
let out = std::fs::read(file.path()).unwrap();
assert!(out.starts_with(&[0xFF, 0xD8]), "must transcode to JPEG");
assert!(out.len() as u64 <= MAX_UPLOAD_BYTES);
}
PhotoPrep::UseFallback => panic!("PNG over the byte cap must transcode to JPEG"),
}
}
}
File diff suppressed because it is too large Load Diff
+226
View File
@@ -0,0 +1,226 @@
//! Per-chat token-bucket rate limiting.
//!
//! Telegram throttles bots on two budgets: one per chat (roughly 20
//! messages/min for channels/groups) and a bot-wide one (~30 messages per
//! second). Both are smoothed here *before* the burst reaches the API — the
//! per-chat bucket charges one token per message, and [`acquire_global`]
//! charges the same spend against the bot-wide budget, which no per-chat
//! bucket can see (a forward fanned out over many chats spends one token in
//! each and nothing anywhere). The queue retry stays as the safety net for
//! whatever neither bucket models.
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::LazyLock;
use std::time::Duration;
/// Burst capacity: how many messages may be sent at once without waiting.
const CAPACITY: f64 = 20.0;
/// Sustained refill: ~20 messages per minute.
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 {
/// Current token balance; may go negative (debt from an acquire larger
/// than the capacity, repaid by subsequent refills).
tokens: f64,
last_refill: tokio::time::Instant,
}
/// A token bucket: at most `CAPACITY` tokens accumulate, refilled at
/// `REFILL_PER_SEC`. [`TokenBucket::acquire`] consumes `n` tokens, waiting
/// for the deficit (a single acquire may exceed the capacity and goes into
/// debt, which the refill repays).
pub struct TokenBucket {
capacity: f64,
refill_per_sec: f64,
state: Mutex<State>,
}
impl TokenBucket {
fn new(capacity: f64, refill_per_sec: f64) -> Self {
TokenBucket {
capacity,
refill_per_sec,
state: Mutex::new(State {
tokens: capacity,
last_refill: tokio::time::Instant::now(),
}),
}
}
/// Applies the elapsed refill to `state`. Shared by [`Self::acquire`] and
/// the idle check so the two cannot drift apart.
fn refill(&self, state: &mut State) {
let now = tokio::time::Instant::now();
let elapsed = now
.saturating_duration_since(state.last_refill)
.as_secs_f64();
// Refill up to the capacity; a debt (negative balance) is repaid
// before any surplus accumulates.
state.tokens = (state.tokens + elapsed * self.refill_per_sec).min(self.capacity);
state.last_refill = now;
}
/// Waits until `n` tokens are available, consuming them. The wait is
/// bounded: the deficit is committed as debt and repaid over time, so a
/// large acquire returns once its share of the refill budget has passed.
pub async fn acquire(&self, n: f64) {
// The parking_lot guard is confined to this block: only the plain
// `wait` duration crosses the await (a guard across an await point
// would make the future !Send).
let wait = {
let mut state = self.state.lock();
self.refill(&mut state);
if state.tokens >= n {
state.tokens -= n;
return;
}
// Commit the whole consumption now; the caller proceeds once the
// deficit's worth of refill time has passed.
let debt = n - state.tokens;
state.tokens = -debt;
debt / self.refill_per_sec
};
tokio::time::sleep(Duration::from_secs_f64(wait)).await;
}
/// 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 {
let mut state = self.state.lock();
self.refill(&mut state);
state.tokens
}
/// True when the bucket has refilled to capacity: no debt outstanding, so
/// the chat has not sent anything recently.
fn is_idle(&self) -> bool {
let mut state = self.state.lock();
self.refill(&mut state);
state.tokens >= self.capacity
}
}
/// One limiter per chat, created on first use. Per-chat so one chat's burst
/// never throttles another.
static LIMITERS: LazyLock<Mutex<HashMap<i64, Arc<TokenBucket>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Returns the shared limiter for a chat, creating it on first use.
pub fn limiter_for(chat_id: i64) -> Arc<TokenBucket> {
LIMITERS
.lock()
.entry(chat_id)
.or_insert_with(|| Arc::new(TokenBucket::new(CAPACITY, REFILL_PER_SEC)))
.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
/// 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.
/// Called from the periodic sweep; returns how many were dropped.
pub fn prune_idle() -> usize {
let mut limiters = LIMITERS.lock();
let before = limiters.len();
// Lock order map → bucket, the only order taken anywhere.
limiters.retain(|_, bucket| Arc::strong_count(bucket) > 1 || !bucket.is_idle());
before - limiters.len()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn limiter_for_reuses_the_per_chat_bucket() {
let a = limiter_for(1);
let b = limiter_for(1);
let c = limiter_for(2);
assert!(Arc::ptr_eq(&a, &b), "same chat → same bucket");
assert!(!Arc::ptr_eq(&a, &c), "different chat → different bucket");
}
#[tokio::test(start_paused = true)]
async fn burst_is_consumed_instantly_then_refill_waits() {
let bucket = TokenBucket::new(3.0, 1.0);
// A burst within capacity passes without waiting.
bucket.acquire(3.0).await;
// The bucket is empty now; one token needs 1s of refill.
let start = tokio::time::Instant::now();
bucket.acquire(1.0).await;
assert!(
start.elapsed() >= Duration::from_secs(1),
"elapsed {:?}",
start.elapsed()
);
}
#[tokio::test(start_paused = true)]
async fn acquire_larger_than_capacity_waits_for_the_deficit() {
let bucket = TokenBucket::new(2.0, 1.0);
// 5 tokens with a capacity of 2: the 3-token deficit takes 3s.
let start = tokio::time::Instant::now();
bucket.acquire(5.0).await;
assert!(
start.elapsed() >= Duration::from_secs(3),
"elapsed {:?}",
start.elapsed()
);
}
#[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)]
async fn prune_idle_drops_full_unheld_buckets_only() {
// Held by this task: kept even at full capacity, a sender has it.
let held = limiter_for(9_001);
assert!(held.is_idle(), "a fresh bucket is full");
// Only the map holds this one and it is full → dropped.
limiter_for(9_002);
// Mid-debt (an acquire larger than the capacity): kept.
{
let bucket = Arc::new(TokenBucket::new(CAPACITY, REFILL_PER_SEC));
bucket.state.lock().tokens = -1.0;
LIMITERS.lock().insert(9_003, bucket);
}
assert!(prune_idle() >= 1);
let limiters = LIMITERS.lock();
assert!(limiters.contains_key(&9_001), "held bucket pruned");
assert!(!limiters.contains_key(&9_002), "idle unheld bucket kept");
assert!(limiters.contains_key(&9_003), "indebted bucket pruned");
}
}
+128
View File
@@ -0,0 +1,128 @@
//! Payload → Telegram input types: `InputFile` selection (cached file id /
//! URL / local path), the per-kind `InputMedia` builders and the media-group
//! assembly with its caption rule.
use super::MediaItemPayload;
use teloxide::types::{
InputFile, InputMedia, InputMediaAnimation, InputMediaPhoto, InputMediaVideo, ParseMode,
};
fn parse_media_url(s: &str) -> Result<url::Url, String> {
url::Url::parse(s).map_err(|e| format!("invalid media URL: {e}"))
}
pub(super) fn item_url(item: &MediaItemPayload) -> &str {
match item {
MediaItemPayload::Photo { media, .. }
| MediaItemPayload::Video { media, .. }
| MediaItemPayload::Animation { media, .. } => media,
}
}
/// Remote http(s) URLs are handed to Telegram to fetch; everything else
/// (e.g. a locally encoded ugoira MP4) is uploaded directly.
pub(super) fn input_file_for(media: &str) -> Result<InputFile, String> {
if media.starts_with("http://") || media.starts_with("https://") {
Ok(InputFile::url(parse_media_url(media)?))
} else if !std::path::Path::new(media).exists() {
// A retried task may reference a temp file the original send's
// TempDir already cleaned up; fail fast and permanent instead of
// burning retries on a file that can never come back.
Err(format!("local media file missing: {media}"))
} else {
Ok(InputFile::file(media))
}
}
impl MediaItemPayload {
/// 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
/// path (transient upload fallback).
fn input_file(&self) -> Result<InputFile, String> {
match self {
MediaItemPayload::Photo {
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 {
let mut photo = InputMediaPhoto::new(file).parse_mode(ParseMode::Html);
if let Some(caption) = caption {
photo = photo.caption(caption);
}
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
/// the caption (Telegram rejects captions on later items).
pub(super) fn build_media_group(
batch: &[MediaItemPayload],
caption: Option<&str>,
) -> Result<Vec<InputMedia>, String> {
batch
.iter()
.enumerate()
.map(|(i, item)| {
let item_caption = if i == 0 { caption } else { None };
Ok(match item {
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()
}
File diff suppressed because it is too large Load Diff
+545
View File
@@ -0,0 +1,545 @@
//! Everything around a send: the link-cache write that follows one, the
//! keep-alive registry for locally produced media, task settlement, the
//! post-send actions (edit prompt / channel forward) and the queue entry
//! points.
use super::{SendError, Task, forward_messages, send_animation, send_media_sequence};
use crate::ctx::AppContext;
use crate::db::{now_f64, unix_now};
use crate::handlers::log_key;
use crate::link_cache::{CachedMedia, CachedMediaKind};
use crate::media_sender::MediaSender;
use crate::queue::{PersistentTaskQueue, QueueError};
use crate::state::EditMessage;
use std::collections::HashMap;
use std::sync::LazyLock;
use teloxide::types::{ChatId, InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageId};
/// Persists a successful send under the post's cache key. Skips a send that was
/// 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>) {
let Some(cache_data) = task.cache_data() else {
return;
};
if cache_data.media.iter().any(|m| !m.file_id.is_empty()) || media.is_empty() {
return;
}
let mut post = cache_data.clone();
post.media = media;
if let Some(key) = x_media::site::cache_key(&post.url) {
ctx.link_cache.put(&key, &post).await;
log::debug!("cached send for [key={}]", log_key(&post.url));
}
}
/// Persists a lone animation send under the post's cache key.
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()) {
cache_sent_task(
ctx,
task,
vec![CachedMedia {
kind: CachedMediaKind::Animation,
file_id,
url: source_url.to_string(),
}],
)
.await;
}
}
/// How a task ended. The two states differ only in whether a link-cache entry
/// may still be holding the (now unusable) media.
pub(crate) enum Settled {
Sent,
Failed,
}
/// 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
/// 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 deal with the
/// 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) {
if matches!(outcome, Settled::Failed) {
invalidate_cache(ctx, task).await;
}
release_keep_alive(task);
}
/// A cached Telegram file id failed permanently (stale/expired). The media
/// itself is usually fine, so the entry is *degraded* rather than dropped: its
/// file ids go away and the source URLs stay, and the next request re-sends the
/// post from those — no source request, no ugoira encode, no HLS remux — with
/// the media fetched by Telegram (or by the upload fallback). An entry that is
/// 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
/// and report what the source says.
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
/// must stay alive while their task may be retried by the queue. The fetch
/// pipeline hands a reference here via [`x_media::site::Fetched::keep_alive`]
/// before that [`x_media::site::Fetched`] is dropped; a queued retry runs after
/// that drop, so without this the local file would be gone by the time the
/// retry sends it. `Arc` because one fetch can serve several tasks (a
/// concurrent duplicate of the same link shares it): each holder keeps the
/// directory alive until its own task settles. Entries are removed when the
/// 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()));
/// Drops the keep-alive temp dirs holding media referenced by `task` (matched
/// by path prefix). Called once a task settles — sent or permanently failed —
/// so retry-only temp files do not leak; retryable tasks keep them alive.
pub(crate) fn release_keep_alive(task: &Task) {
let paths = task.local_media_paths();
if paths.is_empty() {
return;
}
let mut alive = KEEP_ALIVE.lock();
alive.retain(|dir| {
let dir_path = dir.path();
!paths.iter().any(|p| p.starts_with(dir_path))
});
}
/// The edit-before-forward prompt's text. It names both controls and the TTL,
/// because the buttons alone left users waiting for a forward that never came
/// (nothing is forwarded until Confirm).
pub(super) fn edit_prompt_text(ttl: std::time::Duration) -> String {
format!(
"Reply to edit the caption, or tap a template, then ↩️ Confirm to forward. \
Expires in {}. Nothing is forwarded until you confirm.",
coarsest_unit(ttl)
)
}
/// Text the prompt is rewritten to once its record expires. The sweep edits
/// the prompt in place (see `main`): announcing the expiry with a new message
/// would wake the chat up to a full TTL later about a prompt nobody is
/// waiting on.
pub(crate) const EDIT_PROMPT_EXPIRED_TEXT: &str = "⌛ Expired — nothing was forwarded.";
/// `24h` / `90m` / `45s`: the coarsest whole unit, so the prompt stays short.
fn coarsest_unit(ttl: std::time::Duration) -> String {
let secs = ttl.as_secs();
if secs >= 3600 {
format!("{}h", secs / 3600)
} else if secs >= 60 {
format!("{}m", secs / 60)
} else {
format!("{secs}s")
}
}
/// Templates per keyboard row. Telegram rejects a keyboard with more than 100
/// buttons *outright*, which would silently drop the whole prompt, so the
/// names are folded and capped rather than listed one per row.
pub(super) const TEMPLATE_BUTTONS_PER_ROW: usize = 3;
/// Hard cap on template buttons; the prompt text names the ones not shown.
pub(super) const MAX_TEMPLATE_BUTTONS: usize = 60;
/// Template buttons ([`TEMPLATE_BUTTONS_PER_ROW`] per row, at most
/// [`MAX_TEMPLATE_BUTTONS`]), then the confirm/skip pair. Sorted by name: the
/// templates live in a `HashMap`, so an unsorted walk would reshuffle the
/// buttons between prompts.
pub(super) fn build_edit_markup(templates: &HashMap<String, String>) -> InlineKeyboardMarkup {
let mut names: Vec<&String> = templates.keys().collect();
names.sort();
let shown = names.len().min(MAX_TEMPLATE_BUTTONS);
let mut rows = Vec::with_capacity(shown / TEMPLATE_BUTTONS_PER_ROW + 2);
for chunk in names[..shown].chunks(TEMPLATE_BUTTONS_PER_ROW) {
rows.push(
chunk
.iter()
.map(|name| {
InlineKeyboardButton::callback(name.as_str(), format!("template|{name}"))
})
.collect(),
);
}
// Skip exists because the prompt holds the forward hostage until Confirm:
// without it the only escape was deleting the message and waiting out the
// TTL for a forward that then never happens.
rows.push(vec![
InlineKeyboardButton::callback("↩️ Confirm", "forward"),
InlineKeyboardButton::callback("🛑 Skip", "skip"),
]);
InlineKeyboardMarkup::new(rows)
}
/// How many templates the markup could not fit, for the prompt text.
pub(super) fn hidden_template_count(templates: &HashMap<String, String>) -> usize {
templates.len().saturating_sub(MAX_TEMPLATE_BUTTONS)
}
/// Notifies a chat about a dead-lettered task (skips when `notify_chat_id` is
/// absent).
pub(crate) async fn notify_failure(
sender: &dyn MediaSender,
chat_id: Option<i64>,
message_id: Option<i64>,
message: &str,
) {
let Some(chat_id) = chat_id else { return };
let reply_to = message_id.map(|id| MessageId(id as i32));
if let Err(e) = sender
.send_message(ChatId(chat_id), message.to_string(), reply_to, None)
.await
{
log::error!("failed to notify about failed task: {e}");
}
}
/// After a successful send: either open the edit-before-forward prompt or
/// forward to the configured channel (with retry/queue handling).
pub(crate) async fn post_send_actions(ctx: &AppContext<'_>, task: &Task, message_ids: Vec<i64>) {
let (
chat_id,
reply_to,
source_url,
edit_before_forward,
forward_channel_id,
notify_chat_id,
notify_message_id,
) = match task {
Task::SendMediaSequence {
chat_id,
reply_to_message_id,
source_url,
edit_before_forward,
forward_channel_id,
notify_chat_id,
notify_message_id,
..
}
| Task::SendAnimation {
chat_id,
reply_to_message_id,
source_url,
edit_before_forward,
forward_channel_id,
notify_chat_id,
notify_message_id,
..
} => (
*chat_id,
*reply_to_message_id,
source_url.clone(),
*edit_before_forward,
*forward_channel_id,
*notify_chat_id,
*notify_message_id,
),
Task::ForwardMessages { .. } => return,
};
if edit_before_forward {
let templates = ctx.chat_store.get(chat_id).await.template;
let keyboard = build_edit_markup(&templates);
let mut text = edit_prompt_text(ctx.config.edit_message_ttl);
let hidden = hidden_template_count(&templates);
if hidden > 0 {
// The keyboard is capped; say so instead of silently hiding them.
text.push_str(&format!(
"\n({hidden} more templates not shown — /remove_template to prune.)"
));
}
let prompt = ctx
.sender
.send_message(
ChatId(chat_id),
text,
Some(MessageId(reply_to as i32)),
Some(keyboard),
)
.await;
match prompt {
Ok(prompt_id) => {
log::info!(
"edit-before-forward prompt {prompt_id} opened for {} message(s) [key={}] chat={chat_id}",
message_ids.len(),
log_key(&source_url)
);
let source_url = source_url.clone();
ctx.chat_store
.update(chat_id, move |data| {
data.edit_message.insert(
prompt_id,
EditMessage {
url: source_url,
chat_id,
forward_message_ids: message_ids,
template: String::new(),
created_at: unix_now(),
},
);
})
.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;
}
}
return;
}
if let Some(channel_id) = forward_channel_id {
log::info!(
"forwarding {} message(s) to channel {channel_id} from chat {chat_id} [key={}]",
message_ids.len(),
log_key(&source_url)
);
let forward_task = Task::ForwardMessages {
from_chat_id: chat_id,
to_chat_id: channel_id,
message_ids,
notify_chat_id,
notify_message_id,
};
match forward_messages(ctx, &forward_task).await {
Ok(()) => {}
Err(SendError::Retryable {
delay_seconds,
task,
}) => {
// 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, .. }) => {
notify_failure(
ctx.sender,
notify_chat_id,
notify_message_id,
&failure_text(None, &message),
)
.await;
}
}
}
}
/// Enqueues a task for a later attempt (retry / forward resume). Returns
/// whether the retry is actually persisted: when the enqueue itself fails the
/// task can never run again, so its keep-alive temp media is released instead
/// of leaking until process exit — and the caller must not tell the user a
/// 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;
if let Err(e) = queue.enqueue(payload, run_after).await {
log::error!("failed to enqueue retry: {e}");
release_keep_alive(task);
return false;
}
true
}
/// Queue entry point: parses the stored task and dispatches.
pub(crate) async fn handle_task(
ctx: &AppContext<'_>,
payload: serde_json::Value,
) -> Result<(), QueueError> {
let task: Task = match serde_json::from_value(payload.clone()) {
Ok(task) => task,
Err(e) => {
return Err(QueueError::Permanent {
message: format!("invalid task payload: {e}"),
payload,
});
}
};
match task {
Task::SendMediaSequence { .. } | Task::SendAnimation { .. } => {
let message_ids = match send_media_or_animation(ctx, &task).await {
Ok(ids) => ids,
Err(SendError::Retryable {
delay_seconds,
task,
}) => {
return Err(QueueError::Retryable {
delay_seconds,
payload: serde_json::to_value(task).expect("task serializes"),
});
}
Err(SendError::Permanent { message, task }) => {
settle_task(ctx, &task, Settled::Failed).await;
return Err(QueueError::Permanent {
message,
payload: serde_json::to_value(task).expect("task serializes"),
});
}
};
// A task only reaches the queue after a failed send, so this
// successful run is the first time post_send_actions can fire —
// the fresh attempt failed before it ever got here. Run it
// unconditionally: `post_send_actions` executes once, after the
// whole sequence (every batch) completed, so the channel forward
// and the edit-before-forward prompt must not be lost just
// because the send needed a retry.
post_send_actions(ctx, &task, message_ids).await;
settle_task(ctx, &task, Settled::Sent).await;
Ok(())
}
Task::ForwardMessages { .. } => match forward_messages(ctx, &task).await {
Ok(()) => Ok(()),
Err(SendError::Retryable {
delay_seconds,
task,
}) => Err(QueueError::Retryable {
delay_seconds,
payload: serde_json::to_value(task).expect("task serializes"),
}),
Err(SendError::Permanent { message, task }) => {
settle_task(ctx, &task, Settled::Failed).await;
Err(QueueError::Permanent {
message,
payload: serde_json::to_value(task).expect("task serializes"),
})
}
},
}
}
async fn send_media_or_animation(ctx: &AppContext<'_>, task: &Task) -> Result<Vec<i64>, SendError> {
match task {
Task::SendMediaSequence { .. } => send_media_sequence(ctx, task).await,
Task::SendAnimation { .. } => send_animation(ctx, task).await,
Task::ForwardMessages { .. } => unreachable!(),
}
}
/// 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
/// notifies its chat.
pub(crate) async fn dead_letter_notify(
ctx: &AppContext<'_>,
payload: serde_json::Value,
message: String,
) {
// A dead-lettered task never runs again, and the queue dead-letters retry
// exhaustion itself (the handler is not called again), so this is the only
// place that sees the final payload.
let task = serde_json::from_value::<Task>(payload.clone()).ok();
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_message_id = payload.get("notify_message_id").and_then(|v| v.as_i64());
notify_failure(
ctx.sender,
notify_chat_id,
notify_message_id,
&failure_text(
task.as_ref()
.and_then(|task| task.source_url())
.or_else(|| payload_source_url(&payload)),
&message,
),
)
.await;
}
+401
View File
@@ -0,0 +1,401 @@
//! Download-and-reupload fallback: when Telegram cannot fetch a media URL
//! itself (hotlink protection), the bot downloads the file, shrinks photos
//! 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::{MediaItemPayload, SendError, Task, classify_to_send_error, retry_delay_seconds};
use crate::media_sender::MediaSender;
use crate::photo::{self, MAX_UPLOAD_BYTES, PhotoPrep};
use std::sync::LazyLock;
use teloxide::prelude::*;
use teloxide::types::{ChatId, InputFile, InputMedia, MessageId};
use tempfile::NamedTempFile;
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));
/// Infers a file extension from magic bytes so Telegram detects the mime type
/// on multipart uploads.
pub(super) fn sniff_ext(bytes: &[u8]) -> &'static str {
if bytes.starts_with(&[0xFF, 0xD8]) {
"jpg"
} else if bytes.starts_with(b"\x89PNG") {
"png"
} else if bytes.starts_with(b"RIFF") && bytes.len() >= 12 && &bytes[8..12] == b"WEBP" {
"webp"
} else if bytes.starts_with(b"GIF8") {
"gif"
} else if bytes.len() >= 12 && &bytes[4..8] == b"ftyp" {
"mp4"
} else {
"bin"
}
}
pub(super) enum FallbackError {
Retryable {
delay_seconds: f64,
},
Permanent {
message: String,
},
/// The downloaded file exceeds the upload cap; the caller falls back to
/// the item's smaller URL.
MediaTooLarge,
}
/// Brings a downloaded photo within Telegram's limits via the pure-Rust
/// chain in [`crate::photo`] (no ffmpeg): dimension cap / upload cap
/// exceeded photos are decoded, downscaled with Lanczos3, PNG bit depth
/// reduced (>24-bit → 24-bit RGB, ≤24-bit untouched) and transcoded to JPEG
/// only if still too big. Anything that cannot be fixed falls back to the
/// item's smaller URL.
///
/// Downloads one media item to a temp file (deleted on drop), returning the
/// file plus the downloaded bytes (photos keep the bytes for
/// [`photo::prepare_photo`] — re-reading the file would double the I/O).
/// Network errors are retryable; size over the upload cap and other download
/// errors are not.
async fn download_to_temp(
item: &MediaItemPayload,
) -> Result<(NamedTempFile, bytes::Bytes), FallbackError> {
let media_url = match item {
MediaItemPayload::Photo { media, .. }
| MediaItemPayload::Video { media, .. }
| MediaItemPayload::Animation { media, .. } => media,
};
// Photos are downloaded even over the upload cap so `prepare_photo` can
// downscale / transcode them, up to their own download cap; videos and
// animations are refused as soon as the declared size crosses the upload
// cap. The limit is that cap, not `cap + 1`: a file of 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 limit = if matches!(item, MediaItemPayload::Photo { .. }) {
photo::MAX_PHOTO_DOWNLOAD_BYTES
} else {
MAX_UPLOAD_BYTES
};
let bytes = match x_media::site::download_media_limited(media_url, limit).await {
Ok(bytes) => bytes,
Err(e) => return Err(classify_download_error(e)),
};
let ext = sniff_ext(&bytes);
let mut file = tempfile::Builder::new()
.prefix(x_media::TEMP_FILE_PREFIX)
.suffix(&format!(".{ext}"))
.tempfile()
.map_err(|e| FallbackError::Permanent {
message: format!("temp file failed: {e}"),
})?;
use std::io::Write;
// A write failure is resource exhaustion far more often than a broken temp
// 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.
file.as_file_mut().write_all(&bytes).map_err(|e| {
log::error!("temp file write failed: {e}");
FallbackError::Retryable {
delay_seconds: retry_delay_seconds(0),
}
})?;
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::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.
fn media_from_file(
item: &MediaItemPayload,
path: std::path::PathBuf,
caption: Option<&str>,
thumbnail: Option<&str>,
) -> Result<InputMedia, String> {
let mut media = match item {
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.
fn media_from_url(
item: &MediaItemPayload,
url: &str,
caption: Option<&str>,
thumbnail: Option<&str>,
) -> Result<InputMedia, String> {
let mut media = match item {
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
/// the temp file that must stay on disk until the group request completes.
pub(super) struct PreparedItem {
/// Original position in the batch (concurrent prep completes out of order).
pub(super) index: usize,
pub(super) media: InputMedia,
pub(super) keep_alive: Option<NamedTempFile>,
}
/// Downloads / processes one media item for the upload fallback (see
/// [`send_batch_via_upload`]). Local files are uploaded directly; oversized
/// items fall back to their smaller URL; photos are downscaled/transcoded.
pub(super) async fn prepare_upload_item(
item: MediaItemPayload,
index: usize,
caption: Option<&str>,
) -> Result<PreparedItem, FallbackError> {
// Locally produced files (ugoira / bsky remux MP4): nothing to download
// or shrink — upload the file directly. The send is a multipart upload,
// so the only remaining failure is an upload-cap error, which is
// 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://") {
let media = media_from_file(
&item,
std::path::PathBuf::from(media_url),
caption,
item.thumbnail_url(),
)
.map_err(|message| FallbackError::Permanent { message })?;
return Ok(PreparedItem {
index,
media,
keep_alive: None,
});
}
// 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).await {
Ok((file, bytes)) => {
if matches!(item, MediaItemPayload::Photo { .. }) {
// Telegram rejects photos wider+taller than 10000 px combined
// (PHOTO_INVALID_DIMENSIONS): downscale the downloaded file
// before uploading; photos that cannot be brought within the
// limits degrade to the smaller URL. CPU-heavy work runs off
// the async executor thread.
//
// The header decides what that will cost in memory, so the
// probe travels with the downloaded bytes (both stay alive
// through the decode) and the reservation covers their sum:
// `PREP_SLOTS` bounds how many photos are prepared at once,
// this bounds what they hold between them — 512 MiB, whatever
// the batch looks like.
let (bytes, decode) = tokio::task::spawn_blocking(move || {
let decode = photo::decode_budget_bytes(&bytes);
(bytes, decode)
})
.await
.map_err(|e| FallbackError::Permanent {
message: format!("photo worker panicked: {e}"),
})?;
let _budget = photo::reserve_memory(bytes.len() as u64 + decode).await;
let prep = tokio::task::spawn_blocking(move || 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 {
PhotoPrep::Upload(upload) => {
let path = upload.path().to_path_buf();
let media = media_from_file(&item, path, caption, item.thumbnail_url())
.map_err(|message| FallbackError::Permanent { message })?;
Ok(PreparedItem {
index,
media,
keep_alive: Some(upload),
})
}
PhotoPrep::UseFallback => {
let url = item.fallback_url().ok_or_else(|| FallbackError::Permanent {
message: "photo dimensions exceed Telegram limits and no smaller variant is available"
.into(),
})?;
let media = media_from_url(&item, url, caption, item.thumbnail_url())
.map_err(|message| FallbackError::Permanent { message })?;
Ok(PreparedItem {
index,
media,
keep_alive: None,
})
}
}
} else {
let path = file.path().to_path_buf();
let media = media_from_file(&item, path, caption, item.thumbnail_url())
.map_err(|message| FallbackError::Permanent { message })?;
Ok(PreparedItem {
index,
media,
keep_alive: Some(file),
})
}
}
Err(FallbackError::MediaTooLarge) => {
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 })?;
Ok(PreparedItem {
index,
media,
keep_alive: None,
})
}
Err(e) => Err(e),
}
}
/// 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
/// (which Telegram fetches itself). Items are prepared concurrently because the
/// downloads are network-bound, under one process-wide bound ([`PREP_SLOTS`] —
/// the URL and queue workers can each be inside a batch, so a per-batch bound
/// 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(
sender: &dyn MediaSender,
chat_id: i64,
reply_to: i64,
batch: &[MediaItemPayload],
caption: Option<&str>,
task: Task,
) -> Result<Vec<Message>, SendError> {
let mut set = tokio::task::JoinSet::new();
for (i, item) in batch.iter().enumerate() {
let item_caption = if i == 0 {
caption.map(str::to_string)
} else {
None
};
let item = item.clone();
set.spawn(async move {
let _permit = PREP_SLOTS.acquire().await.expect("upload semaphore closed");
prepare_upload_item(item, i, item_caption.as_deref()).await
});
}
let mut prepared: Vec<Option<InputMedia>> = (0..batch.len()).map(|_| None).collect();
let mut keep_alive: Vec<NamedTempFile> = Vec::new();
while let Some(joined) = set.join_next().await {
let item = match joined {
Ok(Ok(item)) => item,
// Dropping the JoinSet aborts the remaining prep tasks; their
// temp files are cleaned up on drop (short-circuit like before).
Ok(Err(e)) => return Err(SendError::from_fallback(e, task.clone())),
Err(e) => {
return Err(SendError::Permanent {
message: format!("upload worker panicked: {e}"),
task: Box::new(task),
});
}
};
let PreparedItem {
index,
media,
keep_alive: file_opt,
} = item;
if let Some(file) = file_opt {
keep_alive.push(file);
}
prepared[index] = Some(media);
}
let items: Vec<InputMedia> = prepared
.into_iter()
.map(|m| m.expect("every upload item was prepared"))
.collect();
// `keep_alive` holds the temp files until the group request completes.
let result = sender
.send_media_group(ChatId(chat_id), MessageId(reply_to as i32), items)
.await;
drop(keep_alive);
match result {
Ok(messages) => Ok(messages),
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
));
}
}
+351
View File
@@ -0,0 +1,351 @@
//! Per-chat state with SQLite persistence (table `chat_state` in
//! `data/task_queue.db`, shared with the task queue).
use crate::db::unix_now;
use parking_lot::Mutex;
use rusqlite::params;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
#[derive(Serialize, Deserialize, Default, Clone, Debug)]
pub struct ChatData {
pub forward_channel_id: Option<i64>,
pub edit_before_forward: bool,
/// Key: prompt message id.
pub edit_message: HashMap<i64, EditMessage>,
/// name -> HTML template containing "[]"
pub template: HashMap<String, String>,
/// site name (twitter/bsky/misskey/pixiv/bilibili) -> user-supplied caption format
/// with {url} {author} {author_url} {title} {content} {tags} placeholders.
pub message_format: HashMap<String, String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct EditMessage {
pub url: String,
pub chat_id: i64,
pub forward_message_ids: Vec<i64>,
pub template: String,
/// Unix seconds at registration; expiry = created_at + ttl.
pub created_at: i64,
}
pub struct ChatStore {
/// In-memory cache; the DB is the source of truth on first access.
cache: Mutex<HashMap<i64, ChatData>>,
/// Per-chat async locks serializing get→mutate→set so concurrent handler
/// tasks (batch-forwards, callbacks) cannot clobber each other's writes.
locks: Mutex<HashMap<i64, Arc<tokio::sync::Mutex<()>>>>,
pool: Arc<crate::db::DbPool>,
}
impl ChatStore {
/// Wraps the shared DB pool (schema initialized once by
/// [`crate::db::open_store`]; the `chat_state` table lives in the merged
/// schema alongside `tasks` and `link_cache`).
pub fn new(pool: Arc<crate::db::DbPool>) -> Self {
ChatStore {
cache: Mutex::new(HashMap::new()),
locks: Mutex::new(HashMap::new()),
pool,
}
}
pub async fn get(&self, chat_id: i64) -> ChatData {
if let Some(data) = self.cache.lock().get(&chat_id) {
return data.clone();
}
let chat_key = chat_id.to_string();
let payload = self
.pool
.with_conn(move |conn| {
// Concurrent handler tasks (batch-forwards) may write chat_state
// while this read runs; the shared busy timeout handles the
// write-lock collision instead of failing the query.
let mut stmt = conn.prepare("SELECT payload FROM chat_state WHERE chat_id = ?1")?;
let mut rows = stmt.query(params![chat_key])?;
match rows.next()? {
Some(row) => Ok(Some(row.get::<_, String>(0)?)),
None => Ok(None),
}
})
.await
.unwrap_or_else(|e| {
log::warn!("chat_state read failed: {e}");
None
})
.unwrap_or_default();
let data: ChatData = serde_json::from_str(&payload).unwrap_or_default();
self.cache.lock().insert(chat_id, data.clone());
data
}
/// Write-through: update the cache and the DB.
pub async fn set(&self, chat_id: i64, data: &ChatData) {
self.cache.lock().insert(chat_id, data.clone());
let payload = serde_json::to_string(data).expect("chat state serializes");
let chat_id = chat_id.to_string();
let result = self
.pool
.with_conn(move |conn| {
conn.execute(
"INSERT OR REPLACE INTO chat_state (chat_id, payload) VALUES (?1, ?2)",
params![chat_id, payload],
)?;
Ok(())
})
.await;
if let Err(e) = result {
log::warn!("chat_state write failed: {e}");
}
}
/// The per-chat async lock serializing get→mutate→set cycles.
fn lock_for(&self, chat_id: i64) -> Arc<tokio::sync::Mutex<()>> {
self.locks
.lock()
.entry(chat_id)
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
}
/// Serializes a get→mutate→set cycle per chat: concurrent handler tasks
/// (the batch-forward design spawns several per chat) each snapshot the
/// same `ChatData` and last-writer-wins would silently drop mutations,
/// e.g. a second `edit_message` record. The per-chat lock makes the
/// cycle atomic. Returns the closure's result.
pub async fn update<R>(&self, chat_id: i64, f: impl FnOnce(&mut ChatData) -> R) -> R {
let lock = self.lock_for(chat_id);
let _guard = lock.lock().await;
let mut data = self.get(chat_id).await;
let r = f(&mut data);
self.set(chat_id, &data).await;
r
}
/// Removes edit-before-forward records whose `created_at + ttl` is in the
/// past. Returns the removed `(chat_id, prompt_message_id)` pairs so the
/// caller can clear the prompt's buttons.
pub async fn prune_expired(&self, ttl: Duration) -> Vec<(i64, i64)> {
let now = unix_now();
let ttl_secs = ttl.as_secs() as i64;
// Chats worth looking at, from a cache snapshot: the ones with an
// expired record, plus the ones holding no record at all. The latter
// used to be left alone for the process lifetime — every chat that ever
// sent a message or ran a command stayed in the cache and in the
// 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 candidates: Vec<i64> = {
let cache = self.cache.lock();
cache
.iter()
.filter(|(_, data)| {
data.edit_message.is_empty()
|| data
.edit_message
.values()
.any(|entry| entry.created_at + ttl_secs <= now)
})
.map(|(chat_id, _)| *chat_id)
.collect()
};
let mut removed = Vec::new();
let mut evicted_chats = Vec::new();
for chat_id in candidates {
let lock = self.lock_for(chat_id);
let _guard = lock.lock().await;
let mut data = self.get(chat_id).await;
let before = data.edit_message.len();
data.edit_message.retain(|key, entry| {
if entry.created_at + ttl_secs > now {
return true;
}
removed.push((chat_id, *key));
false
});
if data.edit_message.len() != before {
self.set(chat_id, &data).await;
}
// Chats with no live edit records: evicted from the cache (and
// their per-chat lock) so the cache stays bounded to active
// prompts. The DB keeps the row; the next get() reloads it.
if data.edit_message.is_empty() {
evicted_chats.push(chat_id);
}
}
if !evicted_chats.is_empty() {
let mut cache = self.cache.lock();
let mut locks = self.locks.lock();
for chat_id in &evicted_chats {
cache.remove(chat_id);
locks.remove(chat_id);
}
}
if !removed.is_empty() {
log::info!(
"pruned {} expired edit-before-forward record(s)",
removed.len()
);
}
removed
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn concurrent_updates_do_not_lose_edit_records() {
let dir = tempfile::tempdir().unwrap();
let pool = crate::db::open_store(dir.path().join("s.db").to_str().unwrap()).unwrap();
let store = std::sync::Arc::new(ChatStore::new(pool));
let mut handles = Vec::new();
for i in 0..4 {
let store = Arc::clone(&store);
handles.push(tokio::spawn(async move {
store
.update(1001, |data| {
data.edit_message.insert(
i,
EditMessage {
url: format!("https://x.com/u/status/{i}"),
chat_id: 1001,
forward_message_ids: vec![i],
template: String::new(),
created_at: 0,
},
);
})
.await;
}));
}
for h in handles {
h.await.unwrap();
}
let data = store.get(1001).await;
assert_eq!(
data.edit_message.len(),
4,
"concurrent get→mutate→set must not drop records"
);
}
fn edit_entry(chat_id: i64, created_at: i64) -> EditMessage {
EditMessage {
url: "https://x.com/u/status/1".into(),
chat_id,
forward_message_ids: vec![9],
template: String::new(),
created_at,
}
}
#[tokio::test]
async fn prune_removes_only_expired_records() {
let dir = tempfile::tempdir().unwrap();
let pool = crate::db::open_store(dir.path().join("p.db").to_str().unwrap()).unwrap();
let store = ChatStore::new(pool);
let now = unix_now();
store
.update(7, |data| {
data.template.insert("t".into(), "[]".into());
data.edit_message.insert(1, edit_entry(7, now - 3600));
data.edit_message.insert(2, edit_entry(7, now));
})
.await;
let removed = store.prune_expired(Duration::from_secs(60)).await;
assert_eq!(removed, vec![(7, 1)]);
let data = store.get(7).await;
assert!(data.edit_message.contains_key(&2), "live record pruned");
assert_eq!(
data.template.get("t").map(String::as_str),
Some("[]"),
"unrelated state lost by the prune"
);
}
#[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;
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;
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]
async fn prune_eviction_keeps_the_persisted_state() {
// Every record expires → the chat is evicted from the cache; the
// pruned state must already be in the DB when that happens.
let dir = tempfile::tempdir().unwrap();
let pool = crate::db::open_store(dir.path().join("p.db").to_str().unwrap()).unwrap();
let store = ChatStore::new(pool);
store
.update(8, |data| {
data.template.insert("keep".into(), "[]".into());
data.edit_message.insert(1, edit_entry(8, 0));
})
.await;
let removed = store.prune_expired(Duration::from_secs(60)).await;
assert_eq!(removed, vec![(8, 1)]);
let data = store.get(8).await;
assert!(data.edit_message.is_empty());
assert_eq!(
data.template.get("keep").map(String::as_str),
Some("[]"),
"eviction dropped state the DB never received"
);
}
}
+108
View File
@@ -0,0 +1,108 @@
# 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
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:-1000}'
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
# Webhook mode only (in polling mode there is no listener, so drop this
# block or set WEBHOOK=true): 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/${WEBHOOK_PORT:-8443}'"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
certs:
html:
acme:
networks:
proxy:
name: proxy
-21
View File
@@ -1,21 +0,0 @@
services:
tgxmb: image: yoursfunny/telegram-twitter-media-bot: latest
restart: always
ports:
- "8443:8443"
environment:
LOCAL_USER_ID: '1000'
BOT_TOKEN: ''
BOT_ADMIN: ''
WEBHOOK: false
WEBHOOK_LISTEN: '127.0.0.1'
WEBHOOK_PORT: 8443
WEBHOOK_URL: 'https://example.com'
WEBHOOK_KEY: './cert/private.key'
WEBHOOK_CERT: './cert/cert.pem'
WEBHOOK_SECRET_TOKEN: 'secret-token'
# LOG_LEVEL: 'WARNING'
volumes:
- ./data: /app/data
# - ./cert:/app/cert
container_name: tgxmb
+17 -4
View File
@@ -5,12 +5,25 @@ if [ "$(id -u)" -eq '0' ]
then
USER_ID=${LOCAL_USER_ID:-9001}
useradd --shell /bin/bash -u ${USER_ID} -o -c "" -m user > /dev/null 2>&1
usermod -a -G root user > /dev/null 2>&1
chown -R `id -u user`:`id -u user` /app > /dev/null 2>&1
# `docker compose restart` / `docker restart` reuse the same container, so
# 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
# container on every restart. Create only if missing; align the UID
# otherwise so LOCAL_USER_ID changes still apply.
if ! id user > /dev/null 2>&1
then
useradd --shell /bin/bash -u ${USER_ID} -o -c "" -m user > /dev/null 2>&1 || true
else
usermod -u ${USER_ID} -o user > /dev/null 2>&1 || true
fi
# Bind-mounted volumes may not support chown; a failure here must not kill
# the container either.
chown -R `id -u user`:`id -u user` /app > /dev/null 2>&1 || true
export HOME=/home/user
exec gosu user "$0" "$@"
# setpriv (util-linux, present in bookworm-slim) replaces gosu: drop to the
# target user and exec, keeping the process as PID 1.
exec setpriv --reuid=`id -u user` --regid=`id -g user` --init-groups "$@"
fi
exec "$@"
+168
View File
@@ -0,0 +1,168 @@
# Bilibili 动态支持:研究与实现记录
状态:已实现(`crates/x-media/src/site/bilibili/`)。本文记录上游调研、实测数据与最终设计;
长期契约以 `AGENTS.md` 为准。
范围:**只发动态里的图片与动图**。动态内嵌视频不发流,降级为封面图;`b23.tv` 短链不匹配;
视频页 / 番剧 / 直播间 / 专栏 / 音频均不支持。
---
## 1. 上游实现研究
### 1.1 nazurin`nazurin/sites/bilibili/`4 个文件 ~6 KB
- 入口正则:`t\.bilibili\.com/(\d+)``t\.bilibili\.com/h5/dynamic/detail/(\d+)``bilibili\.com/opus/(\d+)`
- 请求:`GET https://api.bilibili.com/x/polymer/web-dynamic/v1/detail?id={id}`,仅加 `Referer: https://t.bilibili.com/{id}`
**无 cookie、无 WBI 签名、无 `build` 参数**
- 错误:`code == 4101147` → not found`code != 0` 或缺 `data` → 报错。
- 媒体:只取 `item.modules.module_dynamic.major.draw.items[].src`;缩略图 `src + "@518w.jpg"`
`size` 字段单位是 **KB**`major` 为空或 `draw.items` 为空 → "No image found"。
**忽略视频、转发(forward)与纯文字动态**
- caption`"#" + module_author.name` + `module_dynamic.desc.text`,链接写死 `https://www.bilibili.com/opus/{id}`
### 1.2 telegram-bili-feed-helper`biliparser/provider/bilibili/`9 个文件 ~57 KB
- 9 个策略类(Video/Opus/Live/Audio/Read + Feed 基类 + Credential + api 工具):门禁正则
`bilibili\.com|b23\.tv|BV\w{10}|av\d+`,再分流,兜底 `client.head(url)` 跟随重定向后按子串分流。
- 动态:`GET /x/polymer/web-dynamic/desktop/v1/detail?id={id}&build=11605`**单条,无分页**);
客户端带桌面 UA、随机 `buvid3={uuid}infoc`;登录态用 `bilibili-api-python``Credential`
Redis 持久化 `SESSDATA/bili_jct/buvid3/buvid4/ac_time_value/DedeUserID`,扫码登录)。
- **同样没有 WBI 签名 / appkey 签名**playurl 用的是非 WBI 的 `/x/player/playurl`
- 媒体:`major.type` 分派 —— DRAW 取全部 `items[].src`ARCHIVE/PGC/ARTICLE/MUSIC/COMMON/LIVE
只取一张 `cover`;FORWARD 取原动态作者/正文并递归进 `orig` 找媒体。
- 视频:仅独立 video 策略解析(`qn` 720P→480P→360P 试 durl,再退 DASH + ffmpeg 合并);
**动态内嵌视频只发封面**
- 错误:要求 `status==200 && code==0`;风控 `-352`/`-412` 无特殊处理。
### 1.3 取舍
| 维度 | nazurin | bff | 本仓库 |
|---|---|---|---|
| 接口 | `v1/detail?id=` | `desktop/v1/detail?id=&build=` | `v1/detail?id=`(实测可用) |
| 认证 | 无 | buvid3 + SESSDATA | 默认匿名;可选 `BILIBILI_COOKIE` |
| WBI | 无 | 无 | 不实现(无需求) |
| 图片 | `major.draw.items` | 同 + forward 递归 | 同,加 `orig` 递归、`http→https``.gif → Animated` |
| 视频 | 完全忽略 | 动态内嵌视频发封面 | 发封面(不发流) |
| 短链 | 不匹配 | 跟随重定向 | 不匹配(多数短链是视频,会让"静默忽略"变成失败提示) |
---
## 2. 实测验证(2026-09-17,真实请求)
| 验证项 | 结果 |
|---|---|
| `v1/detail?id=`(无 cookie、UA `Mozilla/5.0`、带 Referer | `200 {"code":0}` ✅ |
| 同上,不带 cookie 也不带 Referer | `200 {"code":0}` ✅(无强制鉴权) |
| bff 的 `bilibili_pc/…Electron/22.3.27` UA | `code:-352` ❌ → **不要抄它的 UA** |
| `desktop/v1/detail?build=11605` | `code:-352` ❌ |
| `feed/space?host_mid=`(用户时间线) | 首次成功、随后 `-352`,也见过 HTTP 412 → **不碰** |
| 不存在 / 已删除的动态 | `code:500` "Cannot read property 'only_fans' of undefined"nazurin 的 4101147 已失效) |
| 非数字 id | `code:-400` param parsing failed |
| 图片 `i0.hdslb.com/bfs/new_dyn/*.jpg` | `HEAD 200 image/jpeg`,带/不带 Referer 均可;`+@518w.jpg` → 2542 KB ✅ |
| `t.bilibili.com/h5/dynamic/detail/<id>` | `200` ✅ |
| `m.bilibili.com/dynamic/<id>` | `302 → t.bilibili.com/<id>` ✅ |
| `www.bilibili.com/opus/<id>` | `200`,转发动态 `302 → t.bilibili.com/<id>` ✅ |
| `b23.tv/BV1JTtt6JEZu` | `302 → www.bilibili.com/video/BV…`(视频) |
| `b23.tv/<无效码>` | **HTTP 200** + `{"code":-404}` ⚠️ 短链判定不能只看状态码 |
| `playurl`(仅调研用,未采用) | `fnval=1` 匿名给 durl720P=9.18 MiB / 360P=2.97 MiB`fnval=4048` 匿名 DASH 上限仅 480P |
| `dyn_archive` 字段 | 有 `aid/bvid/cover/title/duration_text`**没有 `cid`**(所以发流要再来一次 `view` 请求) |
| **风控阶梯(同一 IP 连续请求后实测)** | ① 无 cookie → `-352`;② 仅 `buvid3` → 仍 `-352`;③ `buvid3`+`buvid4`(取自匿名 `/x/frontend/finger/spi`)→ **`code:0` 恢复**;④ 继续高频请求后 → 连同 buvid 一起 `-352`(此时只有登录 cookie 或换 IP) |
| **正文位置(24 条真实动态逐条审计)** | 有正文的动态都在 `module_dynamic.desc.text`(图文/转发/纯文字,含 34–193 字样本);**AV(视频投稿)动态 `desc` 恒为 `null`**,内容在 `major.archive.title` / `.desc` 卡片里 → 已做 title 回退 |
| **`features=itemOpusStyle` 的效果** | 同一端点带此参数后,图文帖改为 `major.opus` 形态:`pics[]`(图,key 是 `url`)、`summary.text`(正文,未截断,实测 307 字整段)、`title`(可选标题);不带参数则是 legacy `major.draw` + `desc`,而 **opus 图文帖的 `desc` 为 `null`、正文与标题完全丢失**`opus/1248857553488576532`legacy `desc:null`,带参数 `summary.text="[doge_金箍]黑白搭配"`)。AV / 转发帖不受该参数影响 → 适配器改为请求时带参数,并保留 legacy 形态兜底 |
| feed 与 detail 的差异 | `feed/space` 的 item 会把 `desc.text` 挖空,**只有 detail 有正文** → 排查时不要用 feed 数据判断正文缺失 |
| 不存在的 19 位 id | `4101105 请求数据发生错误`(提示可重试,但只出现在不可能存在的 id 上)→ 仍归入永久错误,见 `code_error` 注释 |
测试样本(live 测试用):
| 样本 | id | 期望 |
|---|---|---|
| 图片动态(2 图 + 话题) | `1245284537985925159` | 2 个 `Illustration``{tags}` = `ALin出道20周年快乐` |
| 转发动态 | `1248982077447077907` | 媒体来自 `orig`1 图),正文可含 `//@` |
| 视频动态 | `1248717597691609105` | 封面 1 张 `Illustration` |
| 纯文字动态 | `1246767523595026450` | `media` 为空 |
关键字段路径:
```
data.item.id_str
data.item.modules.module_author.{name,mid}
data.item.modules.module_dynamic.desc.text
data.item.modules.module_dynamic.topic.{id,name} # 单话题,{tags} 来源
data.item.modules.module_dynamic.major.{draw.items[].src, archive.cover}
data.item.orig # 转发时存在,结构与 item 相同
```
---
## 3. 实现
```
crates/x-media/src/site/bilibili/mod.rs # re-export
crates/x-media/src/site/bilibili/interface.rs # PATTERN / cache_key / enabled / is_retryable /
# media_headers / BilibiliSite / fetch / code_error /
# From<Item> for Fetched / caption / 12 单测 + 2 live
crates/x-media/src/site/bilibili/model.rs # 纯 Deserialize DTO(全 Option
```
- **正则**(同时用于分发、抽 id、缓存键,一个正则三用):
`^(?:https?://)?(?:www|t|m)\.bilibili\.com/(?:opus/|dynamic/|h5/dynamic/detail/)?(\d+)`
- **缓存键**`bilibili:<动态 id>``source_url` 统一 `https://www.bilibili.com/opus/{id}`
- **请求**`GET /x/polymer/web-dynamic/v1/detail?id=` + `Referer: https://www.bilibili.com/`
`Cookie` 头按优先级取:`BILIBILI_COOKIE` → 缓存的设备 cookie`GET /x/frontend/finger/spi``buvid3`/`buvid4`
进程内缓存一次;取不到就不带 cookie,仅 debug 日志)→ 无。指纹接口本身失败**不**让抓取失败。
走共享 `CLIENT`UA `Mozilla/5.0`30s 超时,`TELOXIDE_PROXY` 透传)。
- **错误映射**`0` → 成功;`-352/-412` 与 HTTP 412 → `Transient`(可重试,队列退避;首次记一条 warn 提示
`BILIBILI_COOKIE`);`500`/`4101147``NotFound`(永久);其他 code → `Site`(永久)。
- **媒体**
- `major.opus.pics[]`(带 `features=itemOpusStyle` 时的图文帖形态,字段名是 `url`)→ 每张一张图;
其次 `major.draw.items[]`legacy,字段名 `src`)→ 同样逐张;`http://` / `//``https://`,非 https 开头直接丢弃。
`.gif``Media::Animated``thumbnail_url` 留空,Telegram 自己取首帧——`@518w.jpg` 只对 jpg/webp 实测过),
其余 → `Media::Illustration``thumbnail_url = url + "@518w.jpg"`,兼作超大时的降级 URL)。
- `major.archive.cover` → 1 张 `Illustration`(视频不发流)。
- 转发且自身无媒体 → 递归取 `orig` 的媒体;正文拼 `//@{原作者}:\n{原文}`
- 其他 majorPGC/ARTICLE/MUSIC/LIVE/COMMON)不建模 → 无媒体,走既有 "No media found"。
- **正文 / title**(按信息量从多到少回退):`major.opus.title` + `major.opus.summary.text`
`module_dynamic.desc.text``major.archive.title`。三者分别对应:图文文档(标题+正文)、
legacy/转发帖正文、视频投稿卡片标题。开头结尾空白做 trim;整体再由既有 `truncate_caption` 截断。
- **caption**(与 misskey 同形):`{opus 链接}\n<a href="space.bilibili.com/{mid}">{name}</a>: {正文}`
`RenderData``{tags}` 来自话题名;正文由既有 `truncate_caption` 截断。
- **注册表**`SITES` 末尾追加 → `/set_format` 白名单、链接缓存、启动校验、日志前缀全部自动生效。
- **bot 侧仅文案**`handlers/commands.rs` 三处站点清单字符串 + `state.rs`/`handlers/mod.rs` 注释。
### 与原计划的偏差(及原因)
| 原计划 | 实际 | 原因 |
|---|---|---|
| `x/web-interface/view` + `playurl` 发视频 | 不做 | 需求收窄为图片/动图;视频只发封面 |
| `site/mod.rs``MAX_MEDIA_UPLOAD_BYTES` 常量 | 不加 | 没有视频尺寸决策就不需要该常量,避免跨 crate 耦合 |
| `b23.tv` 短链(跟随重定向) | 不匹配 | 多数短链指向视频,匹配后会把"静默忽略"变成用户的 "Failed to fetch media" |
| `validate()` 校验 cookie | 不做 | 匿名可用,cookie 失效不致命;校验要额外请求一个端点,收益低 |
| `media_headers` 给 hdslb 加 Referer | 返回 `None` | 实测图片与 durl 均无需 Referer(注释里记了这条验证) |
| 计划阶段认为设备 cookie 是 YAGNI,不实现 | **实现**`buvid3`+`buvid4`) | 计划之后做了对照实验:同一 IP 上"无 cookie → -352、只有 buvid3 → -352、buvid3+buvid4 → code:0",说明这是对本适配器主要失败模式的直接修复,而不是冗余保险 |
| 只用不带参数的 `v1/detail` | 加 `features=itemOpusStyle` | 用户实测反馈"有内容的动态没有 title":不带参数时 opus 图文帖返回 legacy 形态,`desc``null`,正文与标题整个丢失。带参数后同一 ID 返回 `major.opus.summary.text` / `title` / `pics`。AV / 转发帖不受影响,legacy 形态仍保留为兜底 |
---
## 4. 测试与验证
- 单元(13):正则匹配/拒绝/忽略短链、缓存键归一、图片映射(https 归一 + 缩略图 + `.gif → Animated`)、
封面、转发取 `orig` 媒体与正文拼接、纯文字无媒体、caption 转义、业务 code 分类(可重试性)、URL 归一、
设备 cookie 拼装。
- live3`#[ignore = "live network: …"]`):设备 cookie 可取、图片动态 2 图、纯文字动态无媒体。
CI 的 `live` job 已覆盖。动态接口被风控时这两条 live 测试打印 `skipping:` 并提前返回(与 pixiv 的
token 门控同款约定),设备 cookie 那条仍会真实执行。
- 实测命令:
`cargo run -p x-media --example fetch -- https://www.bilibili.com/opus/1245284537985925159`
(输出 2 张 `https://i0.hdslb.com/…jpg` + `@518w.jpg` 缩略图 + 话题 tags)。
- 全套:`cargo fmt --check``cargo clippy --workspace --all-targets -- -D warnings``cargo test --workspace` 全绿。
## 5. 已知限制
- 风控按 IP/请求量漂移,阶梯见 §2 最后一行:轻度靠设备 cookie 自愈,重度需 `BILIBILI_COOKIE` 或换 IP。
被拦时按**可重试**失败处理(队列退避)+ 一条 warn,不会静默丢帖。
- 接口 schema 会漂移(`module_dynamic.major` 实测可为 `null` 而正文留在 `desc`);DTO 全 `Option`
未知形态降级为"无媒体",不 panic。
- 动态内嵌视频只发封面图(与 bff 同策略),不下载流。
- 纯文字动态复用既有 "No media found" 回复。
- `b23.tv` 短链不被匹配(见上表)。
+143
View File
@@ -0,0 +1,143 @@
# 架构优化设计:可测试性接缝 + handlers 拆分
> 状态:**阶段 A、B、C 已实施**(A: `c9e72fd`B: `50206a9` + `ae69d72`C:
> rate_limit 提交);**D 已延迟**——待下次数据库 schema 变化时实施(见 §5)。
> 目标:把仓库最大的测试空白(`handlers.rs`/`send.rs` 的发送与分派逻辑)补上
> 可测试接缝,并把 ~1100 行的 handlers 单体拆成模块。
---
## 1. 现状与动机
- `handlers.rs`(~1100 行)混装:命令解析/执行、URL 提取 + 任务通道、inline
debounce、callback、edit-before-forward、全部全局静态。
- 关键路径零测试:`url_media` 的分派、`dispatch_send` 的失败分类、缓存命中路径、
edit-before-forward、转发重试——AGENTS.md 自认 "untested: handlers.rs"。
- 根因:`handlers.rs`/`send.rs` 直接依赖 teloxide `Bot`(具体类型)与全局静态
`CHAT_STORE`/`TASK_QUEUE`/`LINK_CACHE`/`CONFIG`),没有注入点。
## 2. 阶段 A:handlers 拆分(纯组织,零风险,先行)
`handlers.rs` 拆为模块(仅移动代码,不改签名):
```
handlers/
mod.rs — 入口:message/inline/callback 分发 + 公共类型(UrlJob、log_key
statics.rs — CHAT_STORE / TASK_QUEUE / LINK_CACHE / DB / CONFIG / URL_JOBS
commands.rs — Command enum + execute_command + set_forward_channel_handler
urls.rs — extract_urls + start/stop_url_workers + url_media + build_send_task + media_to_payload
inline.rs — inline_query_handler + debounce 状态机 + answer_inline_query
callback.rs — callback_query_handler + edit_message_handler
```
- `mod.rs``pub use` 重导出,bot 侧引用 `handlers::xxx` 不变。
- 收益:每个模块独立审阅;后续阶段 B 的接缝改动落在明确的模块内。
## 3. 阶段 BMediaSender 接缝(核心)
**动机**`send.rs` 的所有发送入口(`send_media_group`/`send_animation`/
`copy_messages`)都挂在具体 `Bot` 上;测试无法注入失败/成功。
**设计**:新增 `crates/xmedia-bot/src/media_sender.rs`
```rust
/// 发送抽象:生产用 teloxide Bot,测试用记录型 mock。
/// 方法签名与 teloxide 调用点一一对应,返回 Result 以便注入任意失败。
pub trait MediaSender: Send + Sync {
fn send_media_group(&self, chat_id: ChatId, items: Vec<InputMedia>)
-> BoxFuture<'_, Result<Vec<Message>, RequestError>>;
fn send_animation(&self, chat_id: ChatId, file: InputFile, caption: Option<&str>, spoiler: bool, reply_to: i64)
-> BoxFuture<'_, Result<Message, RequestError>>;
fn copy_messages(&self, to: ChatId, from: ChatId, ids: Vec<MessageId>)
-> BoxFuture<'_, Result<Vec<MessageId>, RequestError>>;
// 按需扩展:edit_message_caption / delete_message / answer_callback_query …
}
impl MediaSender for Bot { /* 委托现有 teloxide 调用 */ }
```
配套:`ChatStore`/`LinkCache`/`PersistentTaskQueue` 已是具体类型——给 `send.rs`/
`url_media` 需要的最小面加 trait`ChatStoreReader`/`LinkCacheReader` 等),或直接
注入具体类型(它们已有内存态,测试用真实 tempdir 即可,见阶段 B-注)。
**接入点**
- `dispatch_send` / `send_media_sequence` / `send_animation` / `forward_messages` /
`post_send_actions` / `notify_failure``bot: &Bot` 参数改为 `sender: &dyn MediaSender`
- `url_media``url_media(bot, message, url)` 改为 `url_media(sender, store, queue, cache, message, url)`(或聚合为一个 `AppContext` 结构传引用)。
**测试策略**(仓库无 mock 框架,手写 mock):
- `MockSender` 记录调用序列、按脚本返回 Ok/Err(覆盖:URL 发送成功、media-fetch
失败触发兜底、RetryAfter 触发入队、Permanent 触发缓存失效)。
- `ChatStore`/`LinkCache` 用真实 tempdir 实例(现有测试已这么做)。
- 新增测试:`send_media_sequence` 分批续传、`send_animation` 兜底、`url_media`
缓存命中 vs 未命中、`dispatch_send` 三分支。
**风险**:中。动 `send.rs`/`handlers.rs` 签名(约 15 处调用点),行为不变。
**不做**`main.rs` 的 teloxide 装配不抽象(那是真正的胶水,无测试价值)。
## 4. 阶段 C:主动限流(已实施)
批量转发时的突发会触发 Telegram 频道限速,现在靠 `RetryAfter → 队列重试` 被动
应对。新增轻量令牌桶(`rate_limit.rs`):
```rust
pub struct TokenBucket { capacity, refill_per_sec, state: Mutex<State> }
impl TokenBucket {
pub async fn acquire(&self, n: f64); // 按 n 个 token 等待并消费
}
pub fn limiter_for(chat_id: i64) -> Arc<TokenBucket>; // 每频道一个桶
```
- 默认 `CAPACITY = 20``REFILL_PER_SEC = 20/60`(约 20 msg/min);
单次 acquire 可超出容量(记为债务,由后续 refill 偿还)。
- 挂点:`MediaSender for Bot``send_media_group`(按 items 数)、
`copy_messages`(按 ids 数)、`send_animation`1 token)前置 `acquire`
MockSender 不受影响(测试不经过限流)。
- 收益:减少 429 → 重试 → 死信;队列重试仍是全局限速的安全网。
- 风险:低,独立模块;`tokio::time`paused-clock 可测)。
## 5. 阶段 D:DB 版本化迁移(**已延迟**)
> ⚠️ **待办提醒**:本阶段**推迟到下次数据库 schema 变化时实施**(给
> `link_cache`/`chat_state`/`tasks` 加列、改结构等)。当前 `schema_init`
> `CREATE TABLE IF NOT EXISTS`,无版本概念;一旦需要迁移已有线上库,必须先落地
> 本方案(`PRAGMA user_version` 迁移链)再改 schema。`db.rs``schema_init`
> 处已留注释指向这里。
```rust
// db.rs
const MIGRATIONS: &[&str] = &[
// v1: 初始 schematasks / chat_state / link_cache
"CREATE TABLE IF NOT EXISTS tasks (...); ...",
];
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
let v: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
for (i, sql) in MIGRATIONS.iter().enumerate().skip(v as usize) {
conn.execute_batch(sql)?;
conn.pragma_update(None, "user_version", (i + 1) as i64)?;
}
Ok(())
}
```
- 低优先级:schema 未变时无收益;将来加列/改结构时必须有。
- `open_store` 改用 `migrate` 替换 `schema_init` 调用。
## 6. 明确不做
- **不拆 xmedia-core**`Task`/队列/发送抽成独立 lib crate 是大工程,除非出现
第二个客户端,否则收益不抵成本。
- **不引入 DI 框架**:仓库惯例是 LazyLock 静态 + 显式传参,保持。
- **不抽象 main.rs 的 teloxide 装配**
## 7. 实施记录
| 阶段 | 提交 | 说明 |
|---|---|---|
| A | `c9e72fd` | handlers 拆为 `{mod, statics, commands, urls, inline, callback}` |
| B | `50206a9` | `media_sender.rs``trait MediaSender` + `impl for Bot``<Bot as Requester>::` 消歧);send.rs 8 处签名改 `&dyn MediaSender``MockSender` 测试覆盖兜底触发与错误分类(+5 测试) |
| B | `ae69d72` | `AppContext` 注入 `url_media`sender/store/queue/cache),url_media 全链路测试(缓存命中/失效/成功/不支持 URL,+3 测试) |
| C | rate_limit 提交 | `rate_limit.rs` 令牌桶 + 每频道注册表;`MediaSender for Bot` 的 group/copy/animation 前置 `acquire`+3 测试) |
| D | — | **已延迟**:待下次数据库 schema 变化时实施(见 §5) |
A、B、C 为核心并已实施;D 在 schema 变更时落地。
+241
View File
@@ -0,0 +1,241 @@
# 站点适配器重构方案:让新增站点变成"新模块 + 注册一行"
> 状态:**已实施**(阶段 1-5,提交 `7ca8fd1` / `5e23916` / `bf4e615` / `5679a8c` +
> 本文档收尾)。目标:把"加一个新站点"从改 8-9 处收敛到 3 处,并让站点身份、
> 重试策略、下载 header 等站点能力归位到站点模块自身。实施过程中的关键偏差
> async 形态)见 §3 的 "async 形态" 段——原生 AFIT 实测不可用于 dyn 分派,
> 最终采用手写 `BoxFuture``SiteFuture` 别名)。
---
## 1. 现状摩擦清单
> ⚠️ 本节记录的是**重构前**的现状:其中的行号、以及 `site/mod.rs` 里的
> `fetch_once` 分派函数(当时的实现)都已不存在,仅作历史记录。当前形态见
> `site/mod.rs``SITES` 注册表——新增站点 = 新模块 + 注册一行。
以现有三站(twitter / bsky / pixiv)为基线,新增第 4 个站点(代号 `example`
今天需要触碰的位置:
| # | 位置(当前行号) | 改动 | 必改? |
|---|---|---|---|
| 1 | 新目录 `crates/x-media/src/site/example/{mod,interface,model}.rs` | 新模块 | 必改 |
| 2 | `site/mod.rs:354-365` `fetch_once` | 加一个 `if` 分派分支 | 必改 |
| 3 | `site/mod.rs:167-178` `cache_key` | 加一个 `if` 分支 + 约定 key 前缀 `"example:..."` | 必改 |
| 4 | `site/mod.rs:53-63` `site_name()` | 加一个 URL `contains` 嗅探分支 | 必改 |
| 5 | `handlers.rs:405` `SetFormat` 白名单 | `["twitter","bsky","pixiv"]` 加字符串 | 必改 |
| 6 | `config.rs` / `main.rs:74-84` | 仿 pixiv 加启动校验(token、`disable()` | 视站点 |
| 7 | `site/mod.rs:312-326` `fetch_error_is_retryable` | 若重试策略特殊,改中央分类函数 | 视站点 |
| 8 | `site/mod.rs:377/391/430` 三个下载函数 | 若媒体有防盗链,加 header(现在是硬编码 pximg 判断) | 视站点 |
| 9 | `site/mod.rs:16,180-197` `FetchError` | 若错误类型特殊,加嵌套 variant(仿 `Pixiv(PixivError)` | 视站点 |
**根因**:仓库里没有"站点"这个实体。站点的四类能力——URL 识别(PATTERN +
cache_key)、抓取、重试策略、下载 header——分别散落在中央 if 链、URL 字符串嗅探、
魔法字符串 key 和 bot crate 的白名单里。`AGENTS.md` 现行约定 "no trait, no enum
dispatch" 是刻意的简单性选择;本方案的目标是在**不推翻它精神的前提下**收敛摩擦,
并在阶段 3 提供完整的 trait 注册表选项。
## 2. 目标架构
```
crates/x-media/src/site/mod.rs
├─ SITES: LazyLock<Vec<Box<dyn Site>>> ← 注册表(唯一的"站点列表")
├─ find_site(url) / fetch(url) / cache_key(url) / site_ids()
└─ 通用类型:Fetched { site_id, ... } / FetchError(通用类 + Site 变体)
├─ site/twitter/{mod,interface,model}.rs impl Site
├─ site/bsky/… impl Site
└─ site/pixiv/… impl Site (download_headers: pximg Referer)
(validate: token 校验)
crates/xmedia-bot
├─ handlers.rs SetFormat 白名单 ← x_media::site::ids()(不再写死)
├─ handlers.rs site 格式查找 ← fetched.site_id(缓存/新鲜两条路径同口径)
└─ main.rs 启动校验 ← site::validate_all()(不再特判 pixiv
```
## 3. 分阶段迁移
每个阶段是一个独立提交,保持 `cargo fmt` / `cargo clippy -- -D warnings` /
`cargo test --workspace` 全绿;行为完全不变,只挪代码、不换语义。
### 阶段 1:站点身份单一来源(低风险,推荐先做)
**动机**:同一概念目前有两个来源——缓存命中路径用 `key.split(':').next()`
`handlers.rs:639`),新鲜抓取路径用 `fetched.site_name()``handlers.rs:724`);
`site_name()` 又是对 `source_url``contains` 字符串嗅探,还有 `"unknown"`
兜底分支。
**改动**
1. `site/mod.rs``Fetched` 增加字段 `site_id: &'static str`(由各站点的
`impl From<SiteStruct> for Fetched` 填充;`empty_fetched` 同步填)。
`Fetched::site_name()` 改为 `return self.site_id`(保留方法名,删除
`source_url.contains` 嗅探与 `"unknown"` 分支)。
2. `site/mod.rs`:新增 `pub fn site_id_from_key(key: &str) -> &'static str`
(解析 `"example:..."` 前缀,未知前缀返回 `"unknown"`),bot 缓存命中路径改用它,
`fetched.site_id` 口径统一。
3. `handlers.rs:405``SetFormat` 白名单改为 `x_media::site::ids()`——阶段 1 先实现
`ids()``["twitter","bsky","pixiv"]` 的常量函数(数据源仍集中,行为不变),
阶段 3 再改为遍历注册表。
4. `twitter/interface.rs:48-60` / `bsky` / `pixiv``From<SiteStruct> for Fetched`
各补 `site_id` 字段。
**风险**:低。纯增量字段;`site_name()` 语义不变(测试 `pixiv/interface.rs:355`
已断言 `"pixiv"`)。
**验证**:现有全部单测;`cache_key_normalizes_domain_variants` 等不变。
**回滚**revert 该提交。
### 阶段 2:站点能力下沉(不引入 trait,静态分派)
**动机**:把"每个站点自己才知道"的逻辑搬回站点模块,中央只做迭代。这是
`AGENTS.md` 现有约定(无 trait)与完整注册表之间的折中,可独立交付。
**改动**:每个站点模块新增并 `mod.rs` 重新导出:
```rust
// site/twitter/interface.rsbsky/pixiv 同构)
pub fn cache_key(url: &str) -> Option<String>; // 用自身 PATTERN,返回 "twitter:<id>"
pub fn is_retryable(err: &FetchError) -> bool; // 默认 Http|Transientpixiv 覆盖 PixivError 分支
pub fn media_headers(url: &str) -> Option<Vec<(&'static str, String)>>;
// pixiv: url 含 "pximg.net" → Referer
```
`site/mod.rs` 相应改为迭代三站:
- `cache_key`:逐个调 `site::cache_key`,不再自己写 key 格式;
- `fetch_error_is_retryable`:删除,`fetch()` 重试循环改调 `current_site::is_retryable`
`fetch_once` 已能确定站点,把站点传下去);
- `media_size` / `download_media_limited` / `download_media_to_file` 里的
`pximg.net → Referer` 硬编码删除,改为遍历 `SITES`(阶段 2 是遍历
`[twitter, bsky, pixiv]` 静态列表)取 `media_headers(url)` 合并。
**注意**Referer 判定依据是媒体 URL 的 host(`pximg.net`),**不是**站点
PATTERNpixiv 的 PATTERN 只匹配 `pixiv.net/artworks/...`),所以 `media_headers`
不能挂在 PATTERN 匹配上,必须按 URL 独立匹配——这正是把它做成独立函数的原因。
**风险**:中。下载函数签名不变,行为必须逐字节不变;新增单元测试覆盖
`media_headers("https://i.pximg.net/...") == Some(Referer)`
`cache_key` 等价性(对全部既有用例断言新旧结果一致)。
**回滚**revert。
### 阶段 3Site trait + SITES 注册表(完整方案,可选)
**动机**:加站点时 bot crate 与中央分派零改动;站点列表成为唯一注册点。
**新增**`site/mod.rs`,按实施后的实际形态):
```rust
/// Boxed, Send future produced by a Site async method. Boxed so the trait
/// stays dyn-compatible; Send because URL/queue workers tokio::spawn these.
type SiteFuture<'a, T, E = FetchError> =
Pin<Box<dyn Future<Output = Result<T, E>> + Send + 'a>>;
pub trait Site: Send + Sync {
fn id(&self) -> &'static str;
fn pattern(&self) -> &'static Regex;
fn enabled(&self) -> bool { true } // 默认: true
fn cache_key(&self, url: &str) -> Option<String>;
fn fetch_from_url<'a>(&'a self, url: &'a str) -> SiteFuture<'a, Fetched>;
fn is_retryable(&self, err: &FetchError) -> bool; // 默认: Http|Transient
fn media_headers(&self, url: &str) -> Option<Vec<(&'static str, String)>>; // 默认: None
fn validate(&self) -> SiteFuture<'static, (), String>; // 默认: Ok(())
}
static SITES: LazyLock<Vec<Box<dyn Site>>> = LazyLock::new(|| vec![
Box::new(twitter::TwitterSite), Box::new(bsky::BskySite), Box::new(pixiv::PixivSite),
]);
```
- `fetch``find_site(url)`(注册表中首个 PATTERN 命中且 `enabled()` 的站点,
返回 `&'static dyn Site`)→ `site.fetch_from_url(url).await`
- `cache_key` / `site_ids()` / `site_id_from_key()` / `apply_media_headers()` /
`validate_all()` 全部遍历 `SITES``validate_all` 返回失败列表,pixiv 的
`Site::validate` 失败时自行 `disable()`
- `match_site`/`SiteKind`(阶段 2 的静态分派)与中央 `fetch_error_is_retryable`
删除,重试判定走 `site.is_retryable`
- `main.rs` 的 pixiv 特判 → `site::validate_all()` + 通用失败通知;
- 保留各站点的 `PATTERN`/`enabled()`/`fetch_from_url()` 顶层导出(兼容既有
测试),trait impl 只是薄壳。
**async 形态**(实施结论):**原生 AFIT 不可行**。
- 实测(rustc 1.95.0edition 2024**1.97.1 复测一致**):trait 里写
`async fn` 报 "method is `async`"(非 dyn 兼容);写反糖
`-> impl Future<...> + Send + '_` 报 "references an `impl Trait` type in its
return type"(同样非 dyn 兼容);纯 RPITIT(无 `+ Send`)也一样。即:
**RPITIT/AFIT 目前无法用于 `Vec<Box<dyn Site>>` 注册表**,与早期设计的
判断相反。
- **为什么**:dyn 分派要求调用方在编译期知道返回值大小以分配空间,而
`async fn`/RPITIT 返回不透明的 Future——这是"非定长返回值走 dyn"的普遍问题,
与 async 无关。Rust 1.75 稳定的 AFIT 只覆盖**静态分派**,dyn 路径被排除;
原生 dyn 支持(AFIDT)是 2026-2027 的已接受项目目标,尚未进入 stable。
参见 <https://rust-lang.github.io/rust-project-goals/2026/afidt-box.html>。
- **采用 (a) 手写 `Pin<Box<dyn Future + Send + '_>>`**`SiteFuture` 别名):
零新依赖、dyn 兼容、future 保证 Send。签名噪音靠别名缓解;生命周期坑因
站点是无状态单元结构体 + `'a` 同时约束 `&self``url` 而完全可控
(future 只借用调用域内的 url)。
- **(b) `async-trait`** 仍是可行备选(语法更干净、同样 box),但新增依赖;
本仓库采用 (a) 后无需引入。
- 若未来 Rust 稳定版落地 AFIDT(调用点 `dyn_box!`),可平滑迁移回原生
`async fn`,实现体几乎不动。
**风险**:中。动中央分派,但每站点行为不变;注册表迭代 + `find_site` 补单测
`fetch`/`cache_key` 对既有 URL 集合的结果与阶段 2 完全一致)。
**回滚**revert。
### 阶段 4FetchError 泛化(已实施)
**改动**`FetchError` 新增 `Site { site: &'static str, error: Box<dyn std::error::Error + Send + Sync> }`
变体(`Display`/`source()` 同步)。**`Pixiv(PixivError)` 变体保留**(未迁移)——
它已有完整的 `Display`/`source()`/`is_retryable` 处理,替换纯属 churn。`Site`
变体默认永久性(各站点 `is_retryable` 都不匹配它);需要可重试站点错误的站点
应自行转换为 `Http`/`Transient` 再返回。
**风险**:低(纯增量变体)。测试:`site_error_variant_displays_and_sources`
### 阶段 5:收尾
- 更新 `AGENTS.md` 的 "Site adapter convention" 段:写新约定(注册表 + `impl Site` +
每站点 `cache_key`/`is_retryable`/`media_headers`),删除 "no trait" 表述;
- `examples/fetch.rs` 不变(走 `site::fetch`);
- 新增站点 checklist 见 §4。
## 4. 重构后新增站点 checklist
```
1. crates/x-media/src/site/example/{mod,interface,model}.rs // 新模块
2. impl Site for ExampleSite 并注册进 SITES // 注册一行
3. (可选)token 读取 + validate() 实现 // 启动校验自动生效
── bot crate 零改动 ──
```
对比现状的 8-9 处,bot crate 完全不碰:`SetFormat` 白名单、格式查找口径、
缓存 key、启动校验全部自动跟随注册表。
## 5. 权衡与明确不做的事
- **不做**Media 类型扩展(`media.rs` + `MediaItemPayload` + `CachedMediaKind` +
send.rs 约 10+ 处 match 的 blast radius)——这是"新增媒体类型"的摩擦,与"新增
站点"正交,优先级低,保持现状。
- **不做**DI/全局注入改造(`CHAT_STORE`/`TASK_QUEUE`/`CONFIG``LazyLock` 静态
模式是仓库惯例,与站点扩展无关)。
- **不做**:schema 迁移——新站点只产生新的 cache key 前缀与 `message_format` JSON
key`link_cache`/`chat_state` 表结构均无需变化。
- **代价**:阶段 3 引入 `dyn Site` 与 boxed future 签名(`SiteFuture`,见 §3);
`Send` 约束前移到 trait 边界,站点 impl 的 future 必须 Send(现仅在各
`tokio::spawn` 点检查,重构后在 impl 处即报错,提前暴露问题)。
若站点数量长期 ≤5 且无新增迹象,阶段 2 的折中方案已够用;本次已按完整方案
实施到阶段 4。
## 6. 提交序列(已按此实施)
| 阶段 | 提交 | hash |
|---|---|---|
| 1 | `refactor(site): carry site_id on Fetched; unify cache-key site lookup` | `7ca8fd1` |
| 2 | `refactor(site): move cache_key/is_retryable/media_headers into site modules` | `5e23916` |
| 3 | `refactor(site): introduce Site trait and SITES registry` | `bf4e615` |
| 4 | `refactor(site): genericize FetchError::Site` | `5679a8c` |
| 5 | `docs: update site adapter convention in AGENTS.md` | 本文档收尾提交 |
每阶段独立合入、独立回滚;阶段 2 完成后"加站点"摩擦已收敛,3/4 为深化。
-263
View File
@@ -1,263 +0,0 @@
from __future__ import annotations
import html
from functools import wraps
from typing import TYPE_CHECKING
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from telegram.constants import ChatAction, ChatType, ParseMode
from telegram.ext import (ApplicationBuilder, CallbackQueryHandler, CommandHandler, Defaults,
InlineQueryHandler, MessageHandler, PicklePersistence, filters)
import common
from tweet import TelegramTweet
if TYPE_CHECKING:
from telegram import Chat, Message, Update
from telegram.ext import Application, ContextTypes
logger = common.get_logger(__name__)
def send_action(action):
def decorator(func):
@wraps(func)
async def command_func(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs):
await update.effective_chat.send_action(action)
return await func(update, context, *args, **kwargs)
return command_func
return decorator
async def inline_query(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.inline_query.query
if query == "":
return
logger.info(f"Query: {query}")
async with TGTweet(query) as tweet:
result = list(tweet.inline_query_generator)
await update.inline_query.answer(result)
@send_action(ChatAction.UPLOAD_PHOTO)
async def url_media(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
url = update.message.text
logger.info(f"Receiving url: {url}")
async with TGTweet(url) as tweet:
media = list(tweet.pm_media_generator)
message_to_send = await update.effective_message.reply_media_group(
media,
caption=tweet.message_text,
reply_to_message_id=update.message.message_id,
) if not tweet.is_single_gif else await update.effective_message.reply_animation(
media[0][0],
caption=tweet.message_text,
reply_to_message_id=update.message.message_id,
has_spoiler=media[0][1]
)
if not isinstance(message_to_send, tuple):
message_to_send = (message_to_send,)
url = tweet.url
if context.user_data.get('edit_before_forward', False):
message_reply = await update.effective_message.reply_text(
"Reply to edit message. [URL]",
reply_markup=InlineKeyboardMarkup.from_button(
InlineKeyboardButton("↩️ Confirm", callback_data="forward")
),
reply_to_message_id=update.message.message_id,
)
context.user_data['message_reply'] = message_reply
context.user_data['message_to_send'] = message_to_send
context.user_data['message_url'] = url
return
if 'forward_channel_id' in context.user_data:
await forward_message(update, context, message_to_send)
async def forward_message(
update: Update,
context: ContextTypes.DEFAULT_TYPE,
message_to_send: tuple[Message, ...],
) -> None:
try:
await update.effective_chat.copy_messages(
context.user_data['forward_channel_id'],
[m.id for m in message_to_send]
)
except Exception as e:
await update.effective_message.reply_text(str(e))
async def edit_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if 'message_reply' not in context.user_data:
return
if update.message.reply_to_message != context.user_data['message_reply']:
return
template = context.user_data.get('template', None)
message_url = '<a href="{0}">{1}</a>'
url = context.user_data['message_url']
if template:
update_text = template.replace("[]", message_url.format(
url,
html.escape(update.message.text)
))
else:
update_text = html.escape(update.message.text)
match = common.message_url_regex.search(update_text)
if match:
match = match.span()
update_text = update_text[:match[0]] + message_url.format(
url,
update_text[match[0] + 1:match[1] - 1]
) + update_text[match[1]:]
message_to_send = context.user_data['message_to_send']
await message_to_send[0].edit_caption(update_text)
async def query_forward_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
message_to_send = context.user_data['message_to_send']
await forward_message(update, context, message_to_send)
await update.callback_query.answer('✅ Forwarded')
await update.callback_query.delete_message()
del context.user_data['message_reply']
del context.user_data['message_to_send']
del context.user_data['message_url']
@send_action(ChatAction.TYPING)
async def cmd_set_forward_channel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not context.args:
await update.effective_message.reply_text("Please provide a channel username or id.")
return
channel = context.args[0]
try:
channel: Chat = await context.bot.get_chat(channel)
except Exception as e:
await update.effective_message.reply_text(str(e))
return
if channel.type != ChatType.CHANNEL:
await update.effective_message.reply_text("That is not a channel.")
return
try:
channel_admin = await channel.get_administrators()
except Exception as e:
await update.effective_message.reply_text(str(e) + "\nPlease add the bot to the channel and set as admin")
return
user_bot = filter(lambda x: x.user.id == context.bot.id, channel_admin)
user_bot = next(user_bot, None)
if user_bot.can_post_messages:
context.user_data['forward_channel_id'] = channel.id
await update.effective_message.reply_text("Add successfully.")
@send_action(ChatAction.TYPING)
async def cmd_remove_forward_channel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if 'forward_channel_id' in context.user_data:
del context.user_data['forward_channel_id']
await update.effective_message.reply_text("Remove successfully.")
return
await update.effective_message.reply_text("No channel to remove.")
@send_action(ChatAction.TYPING)
async def cmd_edit_before_forward(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if context.user_data.get('forward_channel_id', None) is None:
await update.effective_message.reply_text("Please enable forward channel first.")
return
ebf_status = context.user_data.get('edit_before_forward', False)
if ebf_status:
context.user_data['edit_before_forward'] = False
context.user_data.pop('message_reply', None)
context.user_data.pop('message_to_send', None)
context.user_data.pop('message_url', None)
await update.effective_message.reply_text("Disable edit before forward.")
return
context.user_data['edit_before_forward'] = True
await update.effective_message.reply_text("Enable edit before forward.")
@send_action(ChatAction.TYPING)
async def cmd_set_template(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
reply = update.effective_message.reply_to_message
if not reply:
await update.effective_message.reply_text("Please reply to a message to set as template.")
return
if '[]' not in reply.text_html:
await update.effective_message.reply_text("Please reply to a message with [] to set as template.")
return
context.user_data['template'] = reply.text_html
await update.effective_message.reply_text("Template set.")
@send_action(ChatAction.TYPING)
async def cmd_user_dict(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.effective_message.reply_text(str(context.user_data))
async def post_init(application: Application) -> None:
# commands = [
# BotCommand('start', CMD_START),
# ]
# await application.bot.set_my_commands(commands)
DESCRIPTION = "A bot to fetch tweets from Twitter."
await application.bot.set_my_description(DESCRIPTION)
await application.bot.set_my_short_description(DESCRIPTION)
TGTweet.init_client()
async def post_stop(application: Application) -> None:
await application.bot.send_message(common.ADMIN[0], "Shutting down...")
async def post_shutdown(application: Application) -> None:
await TGTweet.close_client()
def main():
defaults = Defaults(parse_mode=ParseMode.HTML, allow_sending_without_reply=True)
persistence = PicklePersistence(filepath='data/pers.pkl')
application = (ApplicationBuilder()
.token(common.BOT_TOKEN)
.defaults(defaults)
.persistence(persistence)
.post_init(post_init)
.post_stop(post_stop)
.post_shutdown(post_shutdown)
.concurrent_updates(True)
.build()
)
user_filter = filters.User()
user_filter.add_user_ids(common.ADMIN)
handlers = [
InlineQueryHandler(inline_query, common.x_url_regex),
MessageHandler(filters.Regex(common.x_url_regex) & filters.ChatType.PRIVATE, url_media),
CommandHandler("set_forward_channel", cmd_set_forward_channel),
CommandHandler("remove_forward_channel", cmd_remove_forward_channel),
CommandHandler("edit_before_forward", cmd_edit_before_forward),
CommandHandler("set_template", cmd_set_template),
MessageHandler(~filters.COMMAND & filters.ChatType.PRIVATE, edit_message),
CallbackQueryHandler(query_forward_message, pattern="forward"),
CommandHandler("bot_dict", cmd_user_dict, filters=user_filter),
]
application.add_handlers(handlers)
if common.WEBHOOK:
application.run_webhook(
listen=common.WEBHOOK_LISTEN,
port=common.WEBHOOK_PORT,
secret_token=common.WEBHOOK_SECRET_TOKEN,
key=common.WEBHOOK_KEY,
cert=common.WEBHOOK_CERT,
webhook_url=common.WEBHOOK_URL
)
else:
application.run_polling()
if __name__ == '__main__':
main()
-3
View File
@@ -1,3 +0,0 @@
python-telegram-bot[webhooks]~=21.3
httpx[http2]~=0.27.0
uvloop~=0.19.0; sys_platform != 'win32'
-261
View File
@@ -1,261 +0,0 @@
from __future__ import annotations
import html
from functools import cached_property
from typing import TYPE_CHECKING
from uuid import uuid4
from httpx import AsyncClient
from telegram import (InlineQueryResultMpeg4Gif, InlineQueryResultPhoto, InlineQueryResultVideo, InputMediaPhoto,
InputMediaVideo)
from common import get_logger, x_media_regex, x_tco_regex, x_url_regex
if TYPE_CHECKING:
from typing import Generator, TypedDict
logger = get_logger(__name__)
twimg_url = 'https://pbs.twimg.com/'
vx_api_url = 'https://api.vxtwitter.com/{0}/status/{1}'
message_raw_text = """{url}
<a href="{author_url}">{author}</a>: {text}
"""
def create_client() -> AsyncClient:
return AsyncClient(http2=True)
async def close_client(_client: AsyncClient) -> None:
await _client.aclose()
async def fetch_json(_client: AsyncClient, url: str) -> dict:
logger.info(f"Fetching {url}")
response = await _client.get(url)
assert response.status_code == response.is_success, f"Failed to fetch {url}, status code {response.status_code}"
return response.json()
class TweetMedia:
def __init__(self, url: str, thumb: str, media_type: str):
self._url: str = url
self._thumb: str = thumb
self._type: str = media_type
def __str__(self):
return f"Media[url: {self.url} thumb: {self.thumb} type: {self.type}]"
@property
def _uri(self) -> str | None:
match = x_media_regex.match(self._url)
if match:
return match.group(2).removesuffix('.jpg').removesuffix('.png')
return None
@property
def url(self) -> str:
match self._type:
case "image":
return f"{twimg_url}{self._uri}?format=jpg&name=4096x4096"
case "video":
return self._url
case "gif":
return self._url
case _:
return self._url
@property
def thumb(self) -> str:
match self._type:
case "image":
return f"{twimg_url}{self._uri}?format=jpg&name=thumb"
case "video":
return self._thumb
case "gif":
return self._thumb
case _:
return self._thumb
@property
def type(self) -> str:
return self._type
class Tweet:
def __init__(
self,
tweet_id: str,
author: str,
author_id: str,
text: str,
media: list[TweetMedia],
sensitive: bool = False
):
self._id: str = tweet_id
self._author: str = author
self._author_id: str = author_id
self._text: str = text
self._media: list[TweetMedia] = media
self._sensitive: bool = sensitive
@property
def id(self) -> str:
return self._id
@property
def url(self) -> str:
return f"https://twitter.com/{self._author_id}/status/{self._id}"
@property
def author(self) -> str:
return self._author
@property
def author_url(self) -> str:
return f"https://twitter.com/{self._author_id}"
@property
def text(self) -> str:
return self._text
@property
def media(self) -> list[TweetMedia]:
return self._media
@property
def sensitive(self) -> bool:
return self._sensitive
class TGTweet(Tweet):
_httpx_client: AsyncClient
def __init__(self, url: str):
self._url: str = url
self._api_param: tuple[str] = self._tweet_id
self._is_single_gif: bool = False
assert self._api_param
async def __aenter__(self):
self._tweet: dict = await self._fetch_tweet(self._api_param)
super().__init__(*self._init_properties)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
@classmethod
def init_client(cls) -> None:
cls._httpx_client = create_client()
@classmethod
async def close_client(cls) -> None:
await close_client(cls._httpx_client)
async def _fetch_tweet(self, api_param: tuple[str]) -> dict:
return await fetch_json(self._httpx_client, vx_api_url.format(*api_param))
@property
def _tweet_id(self) -> tuple[str] | None:
match = x_url_regex.match(self._url)
if match:
return match.groups()
return None
@property
def _tweet_text(self) -> str:
match = x_tco_regex.search(self._tweet['text'])
return self._tweet['text'][:match.start()].strip(" ") if match else self._tweet['text']
@property
def _tweet_media(self) -> list[TweetMedia]:
return [
TweetMedia(
url=x['url'],
thumb=x['thumbnail_url'],
media_type=x['type']
)
for x in self._tweet['media_extended']
]
@property
def _init_properties(self) -> tuple:
id = self._tweet['tweetID']
author = self._tweet['user_name']
author_id = self._tweet['user_screen_name']
text = self._tweet_text
media = self._tweet_media
sensitive = self._tweet['possibly_sensitive']
return id, author, author_id, text, media, sensitive
@property
def is_single_gif(self) -> bool:
return self._is_single_gif
@property
def message_text(self) -> str:
return message_raw_text.format(
url=self.url,
author_url=self.author_url,
author=html.escape(self.author),
text=html.escape(self.text)
)
@property
def inline_query_generator(self) -> Generator[
InlineQueryResultPhoto | InlineQueryResultVideo | InlineQueryResultMpeg4Gif, None, None
]:
for tweet_media in self.media:
logger.info(str(tweet_media))
if tweet_media.type == "image":
yield InlineQueryResultPhoto(
id=str(uuid4()),
photo_url=tweet_media.url,
thumbnail_url=tweet_media.thumb,
caption=self.message_text
)
elif tweet_media.type == "video":
yield InlineQueryResultVideo(
id=str(uuid4()),
video_url=tweet_media.url,
mime_type="video/mp4",
thumbnail_url=tweet_media.thumb,
title=self.text,
caption=self.message_text
)
elif tweet_media.type == "gif":
yield InlineQueryResultMpeg4Gif(
id=str(uuid4()),
mpeg4_url=tweet_media.url,
thumbnail_url=tweet_media.thumb,
caption=self.message_text
)
@property
def pm_media_generator(self) -> Generator[InputMediaPhoto | InputMediaVideo | tuple[str, bool], None, None]:
for tweet_media in self.media:
logger.info(str(tweet_media))
if tweet_media.type == "image":
yield InputMediaPhoto(
media=tweet_media.url,
has_spoiler=self.sensitive
)
elif tweet_media.type == "video":
yield InputMediaVideo(
media=tweet_media.url,
has_spoiler=self.sensitive,
thumbnail=tweet_media.thumb
)
elif tweet_media.type == "gif":
if len(self.media) == 1:
self._is_single_gif = True
yield tweet_media.url, self.sensitive
yield InputMediaVideo(
media=tweet_media.url,
has_spoiler=self.sensitive,
thumbnail=tweet_media.thumb
)