Files
TelegramTwitterMediaBot/AGENTS.md
T

36 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.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 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, 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_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), 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_ids; 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

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::TooLargeMediaTooLarge) — 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-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", "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 (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, 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, .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

  • ~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 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: 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.