Files
TelegramTwitterMediaBot/AGENTS.md
T
YoursFunny d3560dca52 docs: add .env.example as the deployment template
`cp .env.example .env` is now the documented starting point: the tracked
template carries every variable (grouped required / sites / bot behaviour /
network / webhook / reverse proxy) with the defaults the code would use
anyway, and the compose comment plus both READMEs point at it. The proxy note
is spelled out where it matters — teloxide panics on a blank `TELOXIDE_PROXY`,
and inside a container the proxy host must be `host.docker.internal`.

Two follow-ups the template exposed:

- `RUST_LOG=` (present but blank, which `.env` makes easy) silenced the log
  again: "unset" was handled, "empty" was not. A blank value now falls back to
  the same default. Verified: blank and unset both produce the full startup
  sequence.
- `TWITTER_AUTH_TOKEN` was in the README prose but missing from the env table
  (both languages).

Verified: `docker compose --env-file .env.example config -q` resolves, and a
script comparing the compose's `${VAR}` references against the template's keys
finds none missing.
2026-09-20 22:28:01 +08:00

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, 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

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