Commit Graph
451 Commits
Author SHA1 Message Date
YoursFunny b2b01721b2 fix: debounce inline generations and cap results 2026-09-24 20:40:27 +08:00
YoursFunny 85c3be5ef5 fix: always answer inline query 2026-09-24 20:33:23 +08:00
YoursFunny 5e7a87c0dd fix: send text posts as HTML 2026-09-24 20:29:04 +08:00
YoursFunny 6a2ba6a50a fix: handle empty media and prompt persistence 2026-09-24 19:51:50 +08:00
YoursFunny fb354c9434 test: handle chat state Result in callback 2026-09-24 19:46:23 +08:00
YoursFunny bc7f249c20 fix: filter local animation cache sources 2026-09-24 19:44:48 +08:00
YoursFunny 403b84996f test: cover ugoira expansion budgets 2026-09-24 19:42:50 +08:00
YoursFunny f08b187964 fix: drop unsafe upstream media entries 2026-09-24 19:39:46 +08:00
YoursFunny 8428468712 fix: surface chat state read failures 2026-09-24 19:37:29 +08:00
YoursFunny 412359e84c fix: do not cache local media paths 2026-09-24 18:52:18 +08:00
YoursFunny 438ced278f fix: align CI tags and webhook healthcheck 2026-09-24 18:49:20 +08:00
YoursFunny 704b14f05a fix: bound ffmpeg encoding processes 2026-09-24 18:46:09 +08:00
YoursFunny 1f9da14772 fix: bound ugoira archive expansion 2026-09-24 18:44:21 +08:00
YoursFunny 4694e60ee3 fix: reject upstream local media paths 2026-09-24 18:41:23 +08:00
YoursFunny 88dc0ccb9e fix: stop persistent queue before URL workers 2026-09-24 18:37:17 +08:00
YoursFunny b563b949bb fix: avoid overwriting chat state after read failure 2026-09-24 18:35:52 +08:00
YoursFunny e7855b02fe fix: fence queue dead-letter side effects 2026-09-24 18:33:21 +08:00
YoursFunny 5e265f2413 chore(log): align levels with the documented convention
Three places disagreed with AGENTS' own level rules: get_chat/get_chat_administrators failures during /set_channel printed at error even though the usual cause is a mistyped channel name the user fixes on the spot (error is reserved for dead letters and the like — now warn, same as other user-served degradations); the edit-before-forward callback arrival line logged at info while the sibling arrival lines for commands and inline queries log at debug (now debug, still carries the escaped payload); and the two upload-fallback lines logged at info while photo.rs's parallel degradations log at warn (now warn both — Telegram failing to fetch our media is a degradation that leaves the user served). AGENTS' convention sentence is now explicit about all three instead of the ambiguous '(fallback, retry enqueue, dead-letter is error)'.
2026-09-24 15:38:25 +08:00
YoursFunny 5060b760ba fix(fetch): cap one fetch at a 900-second total budget
The per-request CLIENT timeout and the per-chunk DOWNLOAD_IDLE_TIMEOUT both reset on progress, so neither bounded the fetch as a whole: a URL that keeps dripping (a trickle the idle timeout reads as life) could hold one of the eight FETCH_SLOTS for effectively ever, and the queue behind the slots is what then stalls. The attempts loop now runs inside tokio::time::timeout(900 s) — generous for a genuinely large ugoira zip on a honest slow link (minutes), fatal for a drip — and answers Transient with the cache key, so the retry and its jittered backoff take over. The permit is taken outside the timeout: the slot is released on either path. Audit low finding 'url_workers long tail' (process-wide total-deadline option).
2026-09-24 15:38:25 +08:00
YoursFunny 7019f34801 fix(send): refuse a file id at the upload fallback's door
download_to_temp guarded against a file id, but prepare_upload_item answered a non-http media_url first — and item_url maps FileId to the id itself, so the id was presented as a local *path* and died later with a confusing file-open error instead of a classification. The guard now sits at the entry, download_to_temp takes the already-narrowed URL (the inner guard deleted as dead), and the test pins the refusal as Permanent before any I/O. The file also carries item10's download-classification arm (RateLimited is retryable there too) — same file, landed here to keep each tree compiling.
2026-09-24 15:35:55 +08:00
YoursFunny d05450d88a fix(fetch): jitter the retry backoff and honor a429's Retry-After
Two gaps in fetch_with_attempts: the sleep was the bare 1 << attempt, so every worker that failed together (a source coming back, a shared proxy blip) also recovered on the same tick and re-stamped the source; and a429 arrived as an anonymous Transient, its Retry-After header — the one place a source tells you exactly how long it wants silence — dropped on the floor.

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

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

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

- the test-suite comment claimed no CI test step exists; ci.yml has run
  cargo test --workspace --locked all along (the audit's H5).
- the untested list still named config.rs as fully untested although three
  parsing tests pin its webhook truth table, TTL fallback and blank secrets.
- the commands.rs executor was said to need a real Bot; its three tests run
  through the scripted MockSender, and it joins the driven-through-mocks list.
- model.rs was called untested although every adapter fixture deserializes
  into those DTOs.
- the volume line claimed ./cert is mounted; the shipped compose mounts only
  ./data, and WEBHOOK_CERT (a Telegram-facing self-signed upload) needs the
  operator to add mount and env line themselves, which .env.example already
  says. The live-test census also gained site/download.rs (2) and the bot
  crate's repair.rs/urls.rs pair.
2026-09-24 14:54:07 +08:00
YoursFunny 2b6cb506c7 refactor(download): fold apply_media_headers into media_request
apply_media_headers had exactly one caller — media_request, the documented 'one choke point' every download already goes through — so the site-header walk sits inside that choke point now instead of one indirection past it, and the two doc comments merge into the one that describes both rules (guard + headers).
2026-09-24 04:37:54 +08:00
YoursFunny c49e2175a1 refactor(site): drop RenderData's url field, source_url already carries it
Every adapter built RenderData { url } from the exact value it then moved into Fetched.source_url — a copy of the same canonical URL held in two places, feeding one placeholder. caption_with reads {url} from self.source_url now and the field is gone from the struct (bsky's and twitter's halves of this change landed with their caption commits; their render builders no longer set it either).
2026-09-24 04:37:54 +08:00
YoursFunny 2ec13a0624 refactor(send): inline hidden_template_count into its two call sites
A one-line saturating_sub wrapper exported for one production line (the prompt text) and two assertions — the expression is shorter than its name at every use.
2026-09-24 04:37:54 +08:00
YoursFunny 0765b7deec refactor(send): media_from reads the thumbnail off the item itself
All three callers passed item.thumbnail_url() as the fourth argument — a parameter that could never vary without defeating its own purpose, and only the video arm ever reads it. The function takes the item already; it asks the item. Three call sites lose an argument.
2026-09-24 04:37:53 +08:00
YoursFunny e72cb1b98f refactor(send): inline the per-kind media builders into media_from
photo_media, video_media and animation_media each wrapped the same five lines (build the kind, attach the caption, attach the spoiler) for exactly one caller — media_from's own match arms. The arms carry those lines now; three pub(super) functions and their call indirection are gone, and the dispatch the doc already described as 'the one place' actually is the one place.
2026-09-24 04:37:53 +08:00
YoursFunny 9fc5533676 refactor(twitter): delegate the tweet caption to the shared builder
Tweet::caption duplicated site::caption byte for byte for non-empty text (and dropped the shared empty-text rule, leaving a dangling ': ' behind the author link when a tweet's text comes out empty after link expansion). It delegates now, like bsky's Post::caption a commit earlier and bilibili/misskey before them; encode_double_quoted_attribute loses its last twitter user.
2026-09-24 04:37:53 +08:00
YoursFunny 46bbe8639d refactor(bsky): delegate the post caption to the shared builder
Post::caption hand-formatted exactly the string site::caption produces — same template, same three escapes (the shared fn even handles the empty-text case these two lines did not: no dangling ': ' after the author link). One line now, matching what bilibili and misskey already do; the encode_double_quoted_attribute import loses its last bsky user.
2026-09-24 04:37:53 +08:00
YoursFunny 2ff213e22f refactor(inline): delete InlineKind, convert straight to CachedMediaKind
InlineKind mirrored CachedMediaKind variant for variant (Photo/Video/Gif) and existed only to feed url_result, with two conversions kept in step: Media → InlineKind at the fetch call and CachedMediaKind → InlineKind at the cache call (plus the enum and the mapper). url_result takes CachedMediaKind now — the type the cache path already carries and the fetch path maps to with the same three-arm match — and the degraded-video skip collapses to a guard before the call. One enum fewer between a media kind and its Telegram result.
2026-09-24 04:37:53 +08:00
YoursFunny 2c45b3491d refactor(main): the sweep takes one AppContext instead of five collaborators
periodic_sweep listed sender, chat_store, link_cache, task_queue and config as separate parameters — exactly the five fields AppContext already carries, and exactly what handle_message and the workers pass around as one value. It takes &AppContext now (production via from_statics(&bot), the test via stores.ctx(&sender), which that test already had); CHAT_STORE and LINK_CACHE lose their last direct use in main and leave the import. The collaborator bundle is unchanged, so the paused-clock test drives the same loop.
2026-09-24 04:37:52 +08:00
YoursFunny a3d03cf30f refactor(site): one ffmpeg gate instead of a probe plus a log call
ffmpeg_available() and log_once_ffmpeg_missing() only ever appeared together (both encoders, three lines each: check, log, return), and calling one without the other was a bug in waiting — a probe that never logged, or a log that never gated. ffmpeg_missing() is the single gate: false when the binary is there, otherwise log once per process and say yes. Both call sites shrink to one condition.
2026-09-24 04:37:52 +08:00
YoursFunny e64b48a453 refactor(site): match the site pattern once, then check enabled
find_site walked the registry with "enabled && matches" and disabled_site walked it again with "!enabled && matches" — two passes re-running the same regexes to answer one question. matching_site finds the first match and the fetch decides what a disabled site means; the patterns are disjoint (one domain each), so "first enabled match" and "first match, then check" never disagree.
2026-09-24 04:37:52 +08:00
YoursFunny 022f63916c refactor(site): drop site_id_from_key, split the key at its caller
site_id_from_key split the prefix out and then walked the registry to confirm the prefix was a registered site id — a round trip over a value cache_key itself produced from that registry: the unknown and no-colon branches were unreachable for any key the bot makes. The single caller (the link-cache hit path) splits the prefix directly; the registry-echo test and the misskey assertion of it go with it.
2026-09-24 04:06:13 +08:00
YoursFunny f984428169 refactor(photo): fold photo_plan into its only caller
photo_plan wrapped the header dispatch for exactly one consumer (decode_budget_bytes); plan_photo itself has three callers and stays. The dispatch is inlined into the budget function with its doc merged, saving the wrapper's signature, doc duplication and call indirection.
2026-09-24 04:03:59 +08:00
YoursFunny 85fb12edc7 refactor(photo): read the PNG header with the png crate
parse_png_header hand-decoded the IHDR — byte offsets, the depth byte's five legal values, the color byte's five — roughly thirty lines the png crate already implements (and validates properly: CRC included). It is now Decoder::new + read_info, which is all the header a plan needs; no pixels are decoded. The synthetic test fixture gained the IHDR CRC and an IDAT header (read_info stops at the first IDAT; the hand parser stopped four bytes earlier and checked no CRC), with a nine-line reflected CRC-32 alongside it; the assertions on width/height/depth/color are unchanged, as are the real-file cases.
2026-09-24 04:03:08 +08:00
YoursFunny 7bdf760c7b ops(compose): align the deploy template defaults and gate the healthcheck
Two template landmines for anyone running docker compose from the tracked files. (1) LOCAL_USER_ID defaulted to 1000 in compose and .env.example while the entrypoint and both READMEs say 9001 — the ./data owner on the host silently depended on which doc you read; all four now say 9001. (2) The healthcheck probed WEBHOOK_PORT unconditionally, so the template's own WEBHOOK=false (polling, no listener) shipped a permanently unhealthy container — the probe is now conditional on the interpolated WEBHOOK value (test '<v>' != true || exec 3<>/dev/tcp/…): polling deployments answer healthy without a port, webhook deployments still surface a dead listener to the orchestrator. Both expressions verified locally; compose YAML parses.
2026-09-24 03:47:34 +08:00
YoursFunny 3dbcb45899 test(config): pin the webhook truth table, TTL fallback and blank secrets
Config::load had exactly one test (BOT_ADMIN parsing) while carrying the branches an operator is most likely to mistype: the WEBHOOK truth table (case-insensitive true|yes|1 — 'on' must not enable it), an unparseable EDIT_MESSAGE_TTL_SECONDS (warn + default, not a silent 0 that expires prompts instantly), and empty WEBHOOK_CERT/WEBHOOK_SECRET_TOKEN (compose injects ${VAR:-} as an empty string, which must read as unset, not as a one-character secret). Three table-style tests reuse the existing env save/restore pattern; none of them asserts a field another test reads, so they are safe under parallel execution.
2026-09-24 03:46:59 +08:00
YoursFunny aee1cf35ae ci(docker): run the image build check on PRs that touch crate sources
The pull_request paths filter listed only Dockerfile/entrypoint/.dockerignore/Cargo.{toml,lock}/workflow/crate manifests, so a PR changing only Rust sources never triggered the build-only check — and the Dockerfile's stub-plus-touch layering is exactly the thing that breaks against source structure it has never seen (a new crate directory, a build script). Such PRs now get the check before master/tag reveals the problem; crates/**/Cargo.toml is subsumed by crates/**.
2026-09-24 03:45:35 +08:00
YoursFunny 7b50ad82b0 ci: make the live job honest about what it ran and skipped
Three holes in the live job. (1) It ran -p x-media only, on the comment that everything network-gated lives there — false: xmedia-bot carries two #[ignore]d live tests (repair refetch, text-only link) that CI never executed; both crates run now, still behind the 'live' name filter, and the bot crate's tests need no token (their sends go through MockSender). (2) BILIBILI_COOKIE was never passed, so the bilibili live tests always hit the risk-control early return. (3) That early return — like the pixiv download test's token gate — printed an indistinguishable 'skipping:' line that --show-output never showed, so with continue-on-error a fully-skipped weekly run read exactly like full coverage; skips now print a 'SKIP ' prefix, the steps tee with --show-output, and a summary step lists every skip in the run summary plus a workflow warning. The redundant 'Run token-gated tests' step (the whole non-ignored suite, a rerun of the test job) goes away with the comment that justified it.
2026-09-24 03:45:17 +08:00
YoursFunny a49bb1d1af test(x-media): gate the pixiv download test as live-network too
The pixiv download test was token-gated but not #[ignore]d, so a shell with PIXIV_REFRESH_TOKEN exported made every local cargo test --workspace hit i.pximg.net — where it demonstrably flaked (twice in one session: TLS EOF mid-body) — and CI's non-ignored step ran it as a side effect of having a secret. It carries the convention's #[ignore = "live network: …"] now and is renamed with the live_ prefix so the live job's --ignored live name filter actually picks it up (the old name did not contain 'live' and would have been filtered out). Its skip prints the SKIP prefix the next commit's live job greps, and AGENTS.md stops claiming a local workspace run is not fully offline.
2026-09-24 03:44:06 +08:00
YoursFunny e99f8b0bc0 build: pin ffmpeg to a versioned URL and verify its sha256 unconditionally
The default build fetched /redirect/latest/ — a floating URL that changes under every build — and skipped the sha256 check whenever FFMPEG_SHA256 was empty, which it always was: neither the Dockerfile nor docker.yml carried a hash, so any binary the CDN served reached the image unverified and then processed untrusted media bytes. Both now pin the 9.0.2 release build with the hash the mirror publishes beside it (verified here by direct digest of the downloaded zip: fa8ecf4a…909d7f matches the sidecar), docker.yml's build-args fall back to the same pair, and the check is unconditional — an FFMPEG_URL override without its matching hash fails the build at the download step. The ffmpeg layer also moves above the dependency layer so a manifest/lock edit no longer re-downloads it (the old comment claimed caching 'unless FFMPEG_URL changes', which was never true), and the stale step reference in the sources comment goes with it. Local note: Git-Bash's sha256sum -c reads files in text mode here and cannot roundtrip anything binary — the -c line itself is standard and is exercised by the PR build in CI.
2026-09-24 03:43:04 +08:00