- `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).
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 Task → send::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 (> < & ') — so the stored text is raw and the caption escap… |
crates/xmedia-bot/src/main.rs |
Entry point: env/log init, command registration (register_commands — setMyCommands 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
thiserrorderive (no anyhow): the public, stringified errors —FetchError(Http/Json/Pixiv/Site/NotFound/Blocked/Disabled/Sensitive/TooLarge/Transient/Io) andPixivError— derivethiserror::Errorwith#[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 noDisplayand are handled by direct variant matching. New errors should follow the same split: stringified/public errors derivethiserror, internal flow enums stay plain. - Global state via
std::sync::LazyLockstatics, not DI:CONFIG,CHAT_STORE,TASK_QUEUEinhandlers/statics.rs; shared reqwestCLIENTinx-media/src/site/mod.rs.Botis passed/cloned into handlers; queue workers share the process-widesend::BOT(LazyLock<Bot>, force-initialized inmainso a missing token fails at startup). - Async: tokio multi-thread runtime (
#[tokio::main]default). All rusqlite I/O insidetokio::task::spawn_blocking. Long loops usetokio::select!withtokio::sync::{watch, Notify}stop/wake channels. No streams. - Blocking sync primitives:
parking_lot::Mutexfor hot caches,tokio::sync::Mutexfor async-shared state (pixiv token cache),AtomicBoolfor feature gates. - Site adapter convention: each site module exports
PATTERN: LazyLock<Regex>,enabled() -> bool,fetch_from_url(url) -> Result<Fetched, FetchError>, pluscache_key/is_retryable/media_headers, and a unit struct<Name>Siteimplementingsite::Site; the central dispatcher (site/mod.rs) only iterates theSITESregistry. Adding a site = newsite/<name>/{mod.rs,interface.rs,model.rs}+ oneBox::new(...)entry inSITES— the bot crate never lists sites (SetFormat whitelist, cache-key site lookup and startup validation all derive from the registry). Async trait methods returnSiteFuture(a boxedPin<Box<dyn Future + Send>>) becauseasync fnin traits is not dyn-compatible. - Serde: per-site
model.rsare pureDeserializeDTOs mirroring API JSON; site structs ininterface.rshave private fields, acaption()builder, andimpl From<SiteStruct> for Fetched. Persisted payloads use internally-tagged enums (#[serde(tag = "kind")]/type). - Naming: module-per-concern, snake_case files,
CamelCasetypes,snake_casefns.//!module docs and///docs on non-obvious logic (syndication token, ugoira encoding,display_text_range). - Retries: only
x-media::site::fetchretries (3 attempts,1 << attemptbackoff, HTTP errors only);site::fetch_onceis 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 areNotFoundand 401/403 areBlocked(permanent, reported at once), 429/5xx areTransientand retried. Queue retries are explicitQueueError::Retryablewith computed delay (retry_delay_seconds), scaled per attempt byscaled_retry_delay— which only ever scales up, so a delay the server asked for (Telegramretry_after) is never shortened.send::classify_request_erroris the send-side counterpart:RetryAfterandNetworkare 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 anInvalidJsonwhose raw body is not JSON, i.e. a proxy/error page). - Logging via
logmacros (pretty_env_logger, level fromRUST_LOG).main.rsinitializes the timed builder with a default filter ofinfo,hyper_util=warn,reqwest=warnwhenRUST_LOGis unset: the plaininithad no timestamps and fell back toerror, so a deployment that forgot the variable logged nothing at all, and atdebugthe HTTP client's own lines outnumbered the bot's two to one. An explicitRUST_LOGoverrides the default wholesale. Level convention:info= lifecycle + per-post business results (sent/forwarded/copied, withchat=and the totalms), admin/operator actions and anomalies (fallback, retry enqueue, dead-letter iserror);debug= per-request detail (URL extraction,fetching/fetchedwith the fetch duration, batch sends, queue processing with the row'schat=/key=and per-attemptms, photo processing, inline queries);trace= user data (the full URL, the message text, the inline query). Atdebugand above links are printed via the normalized cache key (handlers::log_key, e.g.[key=twitter:123...]), so adebuglog can be shared without echoing what users pasted, and degradations that leave the user served (a failed cache read/write, a failed chat action) arewarn, noterror. 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, norust-toolchain.toml— recent stable is assumed. No nightly features. - Package manager: Cargo (workspace with path dep
x-media←xmedia-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):
teloxideis declareddefault-features = falsewith["webhooks-axum", "macros", "rustls", "ctrlc_handler"](the removeddefaultalso carriednative-tlsandctrlc_handler— the latter must stay); x-media's reqwest isdefault-features = falsewith["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 keepREADME.md,README.en.mdandAGENTS.mdin sync with the code on every bump, then commit (chore: bump version to X.Y.Z), create an annotated tagvX.Y.Z, and push branch + tag (the tag push triggers the Docker Hub build). The tag must equal both crate versions:.github/workflows/docker.ymlverifies that before building, and--lockedverifies the lock file. - Config is environment-variable driven (dotenv loads
.env, gitignored; no.env.exampleexists). Key vars:TELOXIDE_TOKEN(required),PIXIV_REFRESH_TOKEN,TWITTER_AUTH_TOKEN(optional; x.comauth_tokencookie — 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 — thetitlepluscontentjoined, seesite::compose_text— reaches this length gets that text wrapped in an expandable blockquote inside its caption, the URL and author line staying outside;0disables it. Applied at the send boundary insend::quote_long_caption, which locates the text as what follows the author link, so a/set_formatthat moves{title}/{content}elsewhere and pixiv's title-inside-a-link layout opt out;copy_messagesforwards and queued retries inherit the wrap, while the edit-before-forward rewrite stays unquoted by design),DATA_DIR(defaultdata, 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_CERTis 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
rusqlitewithbundledfeature (no system libsqlite needed). DB file$DATA_DIR/task_queue.db(defaultdata/task_queue.db, CWD-relative — run from the workspace root, or/appin Docker; setDATA_DIRto pin state anywhere). Mount./dataand./certvolumes. .gitattributesenforces LF for*.sh(CRLF breaks shebangs in containers)..gitignore:.env,data/,cert/,nginx-*(proxy state),/target,.idea/(the compose file is tracked; only.envcarries 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— notests/integration directories. Framework: built-in Rust test +#[tokio::test](dev-deps only inx-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.rsadds one#[ignore = "heavy: …"]test.site/mod.rsalso has a token-gated but not#[ignore]d pixiv download test (download_media_pixiv_original_with_referer): it hitsi.pximg.netwheneverPIXIV_REFRESH_TOKENis set, so a localcargo test --workspaceis 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 assertsfetchanswersFetchError::Disabled { site: "pixiv" }for a pixiv link and early-returns whenPIXIV_REFRESH_TOKENis 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 viacargo test --workspace -- --ignored live); token-gated pixiv tests early-return whenPIXIV_REFRESH_TOKENis 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 withcargo test --workspace. - Fixtures are inline
serde_json::json!builder fns (fixture(),thread_json(),illust_json()), not files. The sharedCLIENTsetspool_max_idle_per_host(0)under#[cfg(test)]to avoid cross-runtimeDispatchGone. - CI —
.github/workflows/ci.yml(actions pinned to commit SHAs,--lockedon every cargo invocation,concurrencycancels superseded runs,RUST_BACKTRACE=1) runscargo fmt --check+cargo clippy --workspace --all-targets --locked -- -D warnings+cargo test --workspace --locked+ a release-profilecargo build --release --locked+ anactions-rust-lang/auditdependency-vulnerability gate (offline, no secrets, on every push/PR) and alivejob (schedule/manual/tag only,-p x-mediasince every network/secret-gated test lives there,continue-on-error) for the#[ignore]d live + token tests..github/workflows/docker.ymlbuilds 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, andFFMPEG_URL/FFMPEG_SHA256are taken from repository variables when set (a release can pin an exact ffmpeg build)..github/dependabot.ymlkeeps 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 theMockSenderitself); inx-media:media.rs,lib.rs, allmodel.rs. Thecommands.rsexecutor needs a realBot(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 throughTestStores/ctx::test_supportand the scriptedMockSender. - No coverage tracking.