11 KiB
Repository Guidelines
Project Overview
Telegram bot (teloxide) that turns post links from X/Twitter, Pixiv, and Bluesky 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 and user-facing strings are in Chinese. 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.0.3, edition 2024, resolver 3):
crates/x-media— library that fetches and normalizes media from the three 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 ≤ 9, 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 → single worker leases (120 s lock TTL) → retry with exponential backoff (≤ 30 s, 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.
The x-media library: site::fetch(url) dispatches (in order) twitter → bsky → pixiv via per-site regex PATTERN and returns Ok(None) for unmatched URLs. Fetched { source_url, caption, title, media: Vec<Media>, sensitive, … }; caption_with(format) substitutes {url} {author} {author_url} {title} {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>/ |
One directory per site: mod.rs (re-exports), interface.rs (PATTERN, enabled(), fetch_from_url(), site struct, From<SiteStruct> for Fetched), model.rs (serde DTOs). Pixiv adds api.rs (auth + transport) |
crates/xmedia-bot/src/main.rs |
Entry point: env/log init, queue worker start, pixiv validation, 300 s edit-expiry sweep, dptree handler tree, webhook vs polling dispatch |
crates/xmedia-bot/src/config.rs |
Manual env parsing into Config |
crates/xmedia-bot/src/handlers.rs |
Command enum (teloxide BotCommands), message/inline/callback handlers, URL extraction, global statics |
crates/xmedia-bot/src/state.rs |
ChatStore: parking_lot Mutex<HashMap> cache + SQLite write-through (chat_state table) |
crates/xmedia-bot/src/queue.rs |
PersistentTaskQueue: SQLite-backed single-worker queue (tasks table) |
crates/xmedia-bot/src/send.rs |
Media senders, upload fallback, error classification, queue task handlers |
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).
Code Conventions & Common Patterns
- No anyhow/thiserror. Errors are hand-rolled enums with manual
Display/source()/Fromimpls:QueueError(Retryable { delay_seconds, payload }/Permanent),SendError(Retryable/Permanent),FetchError(Http/Json/Pixiv/NotFound/Blocked),PixivError,Classification. New errors should follow this pattern. - Global state via
std::sync::LazyLockstatics, not DI:CONFIG,CHAT_STORE,TASK_QUEUEinhandlers.rs; shared reqwestCLIENTinx-media/src/site/mod.rs.Botis passed/cloned into handlers; queue workers rebuildBot::from_env(). - 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 (no trait, no enum dispatch — follow the existing convention): each site module exports
PATTERN: LazyLock<Regex>,enabled() -> bool,fetch_from_url(url) -> Result<Fetched, FetchError>;site/mod.rsre-exports the site struct andfetch_onceadds one guarded if-branch. Adding a site = newsite/<name>/{mod.rs,interface.rs,model.rs}+ one branch infetch_once. - 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). Queue retries are explicitQueueError::Retryablewith computed delay (retry_delay_seconds). - Logging via
logmacros (pretty_env_logger, level fromRUST_LOG).
Important Files
| File | Why it matters |
|---|---|
crates/xmedia-bot/src/main.rs |
Startup sequence, webhook vs polling, graceful shutdown (ctrlc → sweep stop → admin msg → queue stop) |
crates/xmedia-bot/src/handlers.rs |
CHAT_STORE/TASK_QUEUE/CONFIG singletons (open data/task_queue.db relative to CWD); command dispatch; URL extraction; retry enqueue |
crates/xmedia-bot/src/send.rs |
Constants MAX_MEDIA_GROUP = 9, MAX_UPLOAD_BYTES = 10 MiB; fallback chain; classify_request_error |
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) |
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 hack, static ffmpeg 7.0.2 (johnvansickle), debian:bookworm-slim runtime, entrypoint |
docker-entrypoint.sh |
Privilege drop: useradd with LOCAL_USER_ID (default 9001) + setpriv (no gosu on bookworm-slim) |
docker-compose.yml.example |
Deployment env reference (real docker-compose.yml is gitignored) |
.github/workflows/docker.yml |
CI: build+push to Docker Hub on tag v*/master; no test step |
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. - Two reqwest versions coexist in the lock (0.12.28 via teloxide, 0.13.3 in x-media) — don't unify casually.
- Config is environment-variable driven (dotenv loads
.env, gitignored; no.env.exampleexists). Key vars:TELOXIDE_TOKEN(required),PIXIV_REFRESH_TOKEN,BOT_ADMIN(comma-separated ids),EDIT_MESSAGE_TTL_SECONDS(default 86400),WEBHOOK/WEBHOOK_URL/WEBHOOK_LISTEN/WEBHOOK_PORT/WEBHOOK_CERT/WEBHOOK_SECRET_TOKEN(webhook mode requires cert + port,.expected),RUST_LOG,TELOXIDE_PROXY,LOCAL_USER_ID(entrypoint only). - SQLite via
rusqlitewithbundledfeature (no system libsqlite needed). DB filedata/task_queue.dbis CWD-relative — run from the workspace root, or/appin Docker. Mount./dataand./certvolumes. .gitattributesenforces LF for*.sh(CRLF breaks shebangs in containers)..gitignore:.env,data/,cert/,docker-compose.yml,/target,.idea/.- Docs are in Chinese; user-facing bot strings too. Keep that convention when editing captions/templates/docs.
Testing & QA
- ~51 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(3),site/bsky/interface.rs(2),site/pixiv/interface.rs/api.rs(env-gated onPIXIV_REFRESH_TOKEN/dotenv, skip by early return). Run the full 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 runs no tests —
.github/workflows/docker.ymlonly builds/pushes the image; verification is a local responsibility. - Untested and hard to test without a mock seam:
handlers.rs(depends directly on teloxideBot);main.rs,config.rs,state.rs;media.rs,lib.rs, allmodel.rs. - No coverage tracking, no lint gate in CI.