Files
TelegramTwitterMediaBot/AGENTS.md
T
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

31 KiB

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.8.0, 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 Tasksend::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.

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/media_size; 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_json HTML-decodes the API text — syndication and GraphQL full_text both arrive pre-escaped (&gt; &lt; &amp; &#39;) — so the stored text is raw and the caption escap…
crates/xmedia-bot/src/main.rs Entry point: env/log init, command registration (register_commandssetMyCommands 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), queue worker start, site login validation (site::validate_all), 300 s edit-expiry sweep (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), 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), 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), inline.rs/callback.rs (inline queries / edit-before-forward buttons, incl. skip; a forward that fails retryably is both queued and settles the prompt — the queued row carries the message ids itself, and a prompt left live let a second Confirm copy the same messages twice and let Skip answer "nothing was forwarded" while the row still delivered), statics.rs (global statics)
crates/xmedia-bot/src/state.rs ChatStore: parking_lot Mutex<HashMap> cache + SQLite write-through (chat_state table)
crates/xmedia-bot/src/link_cache.rs LinkCache: SQLite-backed cache (link_cache table) of successfully sent posts — raw caption fields + Telegram file_ids; repeat links re-send locally (no fetch/upload), TTL + prune, invalidated on permanent send failure
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), notify_one worker wakeup plus a separate Notify for the 30 s lease-expiry sweep (a shared one let the sweep steal the workers' wakeup permit), busy_timeout on all connections
crates/xmedia-bot/src/ctx.rs AppContext: the injected collaborators (sender + ChatStore/PersistentTaskQueue/LinkCache/Config), from_statics for production and the CONTEXT static the worker closures hold. test_support::TestStores backs handler tests with a tempdir store set
crates/xmedia-bot/src/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_caption/delete_message/send_chat_action) implemented by teloxide Bot (per-chat rate-limited) and by a recording MockSender in tests. Admin/setup APIs (get_chat, set_my_commands, …) stay on the concrete Bot
crates/xmedia-bot/src/rate_limit.rs Per-chat token bucket (CAPACITY = 20, ~20 msg/min refill) paced before sends reach the API so batch forwards don't trip flood control

Development Commands

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/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); 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). post_send.rs: settlement (settle_task), cache write, post-send actions (dead-letter text via failure_text: post key + cause, since the raw error alone does not say which link died), queue handlers. input_media.rs: payload → InputMedia
crates/xmedia-bot/src/photo.rs Pure-Rust photo processing (no ffmpeg): png (image-png) decode/encode + zune-jpeg decode + fast_image_resize Lanczos3 downscale + jpeg-encoder. Photos over Telegram's limits (width + height > 10000 px → PHOTO_INVALID_DIMENSIONS; bytes > 10 MiB) are decoded, downscaled keeping the format, PNG bit depth > 24 (RGBA 32-bit / 16-bit per channel) reduced to 24-bit RGB with alpha flattened white (≤24-bit untouched, never upconverted), and transcoded to JPEG only if still over the cap; memory budget guarded, otherwise the item's smaller fallback URL
crates/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-mediaxmedia-bot). No [workspace.package]/shared deps — each crate lists deps independently.
  • TLS is rustls end-to-end (no native-tls/openssl in the tree, no libssl in the Docker runtime image): teloxide is declared default-features = false with ["webhooks-axum", "macros", "rustls", "ctrlc_handler"] (the removed default also carried native-tls and ctrlc_handler — the latter must stay); x-media's reqwest is default-features = false with ["json", "rustls-tls"] (webpki-roots baked in, so the image ships no CA bundle). One reqwest 0.12.28 in the lock.
  • Versioning: bump the version in all three places (crates/x-media/Cargo.toml, crates/xmedia-bot/Cargo.toml, Cargo.lock) and keep README.md, README.en.md and AGENTS.md in sync with the code on every bump, then commit (chore: bump version to X.Y.Z), create an annotated tag vX.Y.Z, and push branch + tag (the tag push triggers the Docker Hub build). The tag must equal both crate versions: .github/workflows/docker.yml verifies that before building, and --locked verifies the lock file.
  • Config is environment-variable driven (dotenv loads .env, gitignored; no .env.example exists). Key vars: TELOXIDE_TOKEN (required), PIXIV_REFRESH_TOKEN, TWITTER_AUTH_TOKEN (optional; x.com auth_token cookie — enables the logged-in GraphQL fallback that fetches NSFW tweets syndication withholds), BILIBILI_COOKIE (optional; whole bilibili cookie string — bilibili dynamics fetch anonymously and add their own device cookies, this only rescues an egress IP that bilibili has hard-flagged with -352/412), BOT_ADMIN (comma-separated ids), EDIT_MESSAGE_TTL_SECONDS (default 86400), LINK_CACHE_TTL_SECONDS (default 604800), CAPTION_QUOTE_TEXT_CHARS (default 200; a post whose text — the title plus content joined, see site::compose_text — reaches this length gets that text wrapped in an expandable blockquote inside its caption, the URL and author line staying outside; 0 disables it. Applied at the send boundary in send::quote_long_caption, which locates the text as what follows the author link, so a /set_format that moves {title}/{content} elsewhere and pixiv's title-inside-a-link layout opt out; copy_messages forwards and queued retries inherit the wrap, while the edit-before-forward rewrite stays unquoted by design), DATA_DIR (default data, CWD-relative; the SQLite dir, auto-created), WEBHOOK/WEBHOOK_URL/WEBHOOK_LISTEN/WEBHOOK_PORT/WEBHOOK_CERT/WEBHOOK_SECRET_TOKEN (webhook mode requires URL/listen/port, .expected; 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

  • ~200 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.
  • Live-network tests exist in site/twitter/interface.rs (5), site/bsky/interface.rs (2), site/misskey/interface.rs (1), site/bilibili/interface.rs (4), site/pixiv/api.rs (1); photo.rs adds one #[ignore = "heavy: …"] test. site/mod.rs also has a token-gated but not #[ignore]d pixiv download test (download_media_pixiv_original_with_referer): it hits i.pximg.net whenever PIXIV_REFRESH_TOKEN is set, so a local cargo test --workspace is not fully offline and can flake on a pixiv CDN body timeout. 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 cargo fmt --check + cargo clippy --workspace --all-targets --locked -- -D warnings + cargo test --workspace --locked + a release-profile cargo build --release --locked + an actions-rust-lang/audit dependency-vulnerability gate (offline, no secrets, on every push/PR) and a live job (schedule/manual/tag only, -p x-media since every network/secret-gated test lives there, continue-on-error) for the #[ignore]d live + token tests. .github/workflows/docker.yml builds and pushes the image on master/tag and runs a build-only check on pull requests touching the build inputs (Dockerfile, entrypoint, manifests, .dockerignore); a release tag must match both crate versions or the build stops, and FFMPEG_URL/FFMPEG_SHA256 are taken from repository variables when set (a release can pin an exact ffmpeg build). .github/dependabot.yml keeps crates, the pinned actions and the Docker base images current.
  • Untested and hard to test without a mock seam: main.rs, config.rs, db.rs, handlers/statics.rs, media_sender.rs (holds the MockSender itself); in x-media: media.rs, lib.rs, all model.rs. The commands.rs executor needs a real Bot (only its pure report builder is tested). Everything else — handlers/{mod,callback,inline,urls}.rs, send/*, ctx.rs, state.rs, queue.rs, link_cache.rs, rate_limit.rs — is driven through TestStores/ctx::test_support and the scripted MockSender.
  • No coverage tracking.