37 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 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, 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 (> < & ') — 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), 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
thiserrorderive (no anyhow): the public, stringified errors —FetchError(Http/Json/Pixiv/Site/NotFound/Blocked/Disabled/Sensitive/TooLarge/MediaPrep/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); 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::TooLarge → MediaTooLarge) — 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, 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", "gzip", "http2"](webpki-roots baked in, so the image ships no CA bundle;gzipbecause the site APIs answer their JSON compressed — twitter's syndication body is 4469 bytes identity vs 1066 gzipped — andhttp2because 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 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 in one push (git push origin master vX.Y.Z; the tag push triggers the Docker Hub build). Pushing them separately with the branch first makes the master run ofdocker.ymlbuild the same commit as the tag run — its duplicate check can only see the tags that already exist on the remote. 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, which is gitignored;.env.exampleis the tracked template —cp .env.example .env— and is also the filedocker composesubstitutes${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.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
- ~180 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. Tests that must go through a realBot(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, atokioTCP listener that records every call and answers the smallest result each method needs — teloxide keys methods by payload type, so the recorded name isSendMediaGroup, notsendMediaGroup): a media group, the edit-before-forward prompt through the real callback path, andhandlers::handle_message(the context-taking body ofmessage_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.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) runs (behind achangesgate job, so a push/PR whose entire diff is markdown skips it instead of burning four minutes on nothing)cargo 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; itsshould-buildgate skips a branch push that is already tagged (git tag --points-at— the tag run builds it, so push both refs together) or that touched no build input at all, while a tag push always builds (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:
config.rs,handlers/statics.rs;db.rsis covered for the migration chain but not for pool behaviour under contention;main.rsis covered where it was split out (periodic_sweep,sweep_temp_dir) but not for startup/shutdown or itsdptreebranch tree (the handlers themselves are, through the stand-in API); 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.