Commit Graph
32 Commits
Author SHA1 Message Date
YoursFunny 7b4e273533 fix: bound site JSON response bodies 2026-09-24 21:25:03 +08:00
YoursFunny d05450d88a fix(fetch): jitter the retry backoff and honor a429's Retry-After
Two gaps in fetch_with_attempts: the sleep was the bare 1 << attempt, so every worker that failed together (a source coming back, a shared proxy blip) also recovered on the same tick and re-stamped the source; and a429 arrived as an anonymous Transient, its Retry-After header — the one place a source tells you exactly how long it wants silence — dropped on the floor.

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

Everywhere a Transient meant 'retryable' the new variant joins: the Site trait default, pixiv's override (plus its ugoira-zip mapping), bsky's HLS second attempt, the download classifier, and the user-facing message arm. Tests pin the429 rows (with and without the header, cap included) and retry_wait's math; AGENTS' retries bullet and variant list follow.
2026-09-24 15:35:55 +08:00
YoursFunny 9fc5533676 refactor(twitter): delegate the tweet caption to the shared builder
Tweet::caption duplicated site::caption byte for byte for non-empty text (and dropped the shared empty-text rule, leaving a dangling ': ' behind the author link when a tweet's text comes out empty after link expansion). It delegates now, like bsky's Post::caption a commit earlier and bilibili/misskey before them; encode_double_quoted_attribute loses its last twitter user.
2026-09-24 04:37:53 +08:00
YoursFunny 4e42855c59 refactor(x-media): drop Media's write-only title field
Every adapter set title (mostly None) on all three Media variants and
nothing ever read it: the bot's CachedMedia carries kind/file_id/url, and the
one read was misskey's own test. Removing it also drops misskey's
DriveFile.name, which existed only to feed it. Fetched::title (the post's
own headline, which captions do use) is untouched.
2026-09-21 17:15:27 +08:00
YoursFunny 9a7cda05c9 refactor(x-media): drop the per-site fns the Site defaults already cover
twitter, bsky, misskey and bilibili each carried enabled() -> true,
media_headers(url) -> None and is_retryable(err) -> the trait's own default,
with no caller outside their tests (the adapters never override those
methods, so the default was already the production policy). The tests that
only restated the default are gone; the two that pin site-specific classes
(bsky's MediaPrep, bilibili's risk-control codes) now ask the Site impl, and
site/mod.rs keeps one assertion of the shared retry policy. The live
verification notes (no Referer needed for hdslb/twimg) survive as comments.
2026-09-21 17:12:42 +08:00
YoursFunny 5630a86d88 refactor(x-media): share the HTTP-status error mapping
twitter, twitter auth and bsky carried the same 404/410 -> NotFound,
401/403 -> Blocked, else Transient block (comments included).
site::status_error holds it once. bilibili and misskey keep their own
matches: neither maps 404/410 and each has a status the others do not
(412 risk control, 400 + NO_SUCH_NOTE), so routing them through the shared
block would have reclassified those statuses for the user.
2026-09-21 17:09:07 +08:00
YoursFunny 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 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 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 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 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 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 fae8dc6f2d fix(twitter): treat empty tombstone as withheld content, not deleted 2026-08-14 21:21:15 +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 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 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 755330e585 chore: clippy and rustfmt cleanup on new code 2026-08-08 20:35:43 +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 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 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 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 ab2306002a perf: handle batch-forwarded URLs concurrently with queue workers 2026-08-05 01:26:24 +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 93d47752cf fallback to smaller media when file too large 2026-08-04 16:22:49 +08:00
YoursFunny b6ae88e869 twitter: request original image via name=orig 2026-08-04 01:28:21 +08:00
YoursFunny 84ab146069 finish rust rewrite, add docker, drop python 2026-08-03 23:30:42 +08:00