Compare commits

...
211 Commits
Author SHA1 Message Date
YoursFunny 5830a3f013 chore: bump version to 1.2.1 2026-08-14 17:55:26 +08:00
YoursFunny 183bb7e435 docs: add site registry refactor design 2026-08-14 17:55:10 +08:00
YoursFunny 2a8433a8d2 fix(twitter): map syndication TweetTombstone to NotFound
Deleted tweets answer the syndication endpoint with HTTP 200 and a
TweetTombstone (no `errors`, no `id_str`). The body classifier only
knew the `errors` shape, so tombstones fell through to the
`no id_str -> Sensitive` branch and degraded to an empty result,
making the bot reply "No media found" for a deleted tweet.

- extract parse_syndication_body(); tombstone shape -> NotFound
- propagate NotFound from the TWITTER_AUTH_TOKEN GraphQL fallback
  instead of swallowing it into empty_fetched
- unit tests for all body classes + live regression test on a real
  tombstoned tweet
2026-08-14 12:10:36 +08:00
YoursFunny 6b3e61881d logging: re-level, redact user data at info, and key links by post id
P0 — level rework + redaction:
- info now carries only lifecycle, per-post business results (sent /
  forwarded / copied / template applied), admin actions and anomalies
  (upload fallback, retry enqueue; dead-letter stays error).
- Per-request detail moved to debug: message/command logging, URL
  extraction, fetching/fetched, link-cache hits, media-group batch sends,
  queue processing (enqueue/processing/completed/rescheduled), photo
  processing (downscale/transcode), inline queries, sensitive-tweet note.
- Full user-submitted URLs and message text now appear only at debug; at
  info and above links are printed via the normalized cache key.

P1 — request correlation:
- handlers::log_key() maps a URL to its normalized post key
  (twitter:<id> / pixiv:<id> / bsky:<handle>/<rkey>). The whole lifecycle
  of one link (fetch -> send -> cache -> fallback) now logs [key=...], so
  multi-worker logs can be correlated by grepping the key.

Convention documented in AGENTS.md.
2026-08-13 23:34:06 +08:00
YoursFunny 47935dd7c6 fix(pixiv): stop retrying permanent 4xx API errors
site::fetch retried every PixivError, so a bad/expired token (403) or a
deleted artwork (404) burned all 3 attempts with backoff against pixiv's
API for nothing. Add PixivError::Status(u16) — the app-API calls now
surface the HTTP status — and retry only the transient classes: network
errors, 429 and 5xx. 4xx / Api (token errors) / Json / NoAuth are
returned immediately. The classification is a pure helper
(fetch_error_is_retryable) with unit tests.
2026-08-13 23:17:52 +08:00
YoursFunny 6911e9146e fix(handlers): stop URL workers by closing the job channel
The old stop only set an atomic flag checked between jobs: a worker
blocked in recv() never woke (the channel was never closed), and queued
jobs were neither drained nor abandoned in a defined way despite the
"drains up to 256 jobs" comment. Now stop_url_workers sets the flag,
drops the sender so blocked recv() calls wake with None, and awaits the
worker JoinHandles (each finishes its in-flight job first). main awaits
it inside the existing 30s shutdown timeout.
2026-08-13 23:15:14 +08:00
YoursFunny aa705aef90 chore: bump version to 1.2.0
New in 1.2.0:
- feat: debounced inline queries, bounded graceful shutdown, config
  fail-fast on invalid env values
- fix: local-media tasks (ugoira/bsky MP4) survive queue retries,
  photo-first ordering in mixed media groups, caption truncation to
  Telegram's 1024-char limit
- perf: SQLite connection pool, concurrent upload-fallback downloads,
  ugoira zip streamed to disk, photo processing without re-reading the
  temp file, release profile LTO
- ci: test/clippy gate + layered live/token job
- docs: English README (README.en.md)
2026-08-13 22:52:38 +08:00
YoursFunny 39260a8817 style: rustfmt config.rs and main.rs from the last two features 2026-08-13 22:43:35 +08:00
YoursFunny 6b9640aa48 build: enable thin LTO and single codegen units for release
Smaller/faster production binary (verified: cargo build --release -p
xmedia-bot builds clean with the new profile). panic=abort is
intentionally not set — queue workers and db closures rely on JoinHandle
catching panics, which abort would defeat.
2026-08-13 22:42:42 +08:00
YoursFunny ad59f518ff docs: add English README (README.en.md)
Full English translation of README.md — features, quick start, webhook
deployment (domain + IP-only with acme.sh), env table, command table.
The Chinese README stays the primary one.
2026-08-13 22:37:12 +08:00
YoursFunny e21643063e feat(config): fail fast on misspelled env values
A typo in EDIT_MESSAGE_TTL_SECONDS / WEBHOOK_PORT etc. used to silently
fall back to a default, so the bot ran with different behavior than the
operator intended (or failed much later on a bare .expect). Unparseable
values now log a loud warning naming the variable; invalid webhook
settings still surface as a hard .expect in webhook mode.
2026-08-13 22:26:23 +08:00
YoursFunny 246fc989f0 feat(main): bound graceful shutdown with a 30s timeout
The stop sequence awaited the queue workers, which can be mid-download
(30s client timeout) or mid-ugoira encode (minutes). A stuck worker would
hold shutdown forever; now the process logs and exits after 30s.
2026-08-13 22:26:16 +08:00
YoursFunny 2e2d1b3506 style: rustfmt the DbPool call sites from the connection-pool change
Formatting-only; the pool commit landed before cargo fmt was run.
2026-08-13 22:25:13 +08:00
YoursFunny 1747d321d8 perf(photo): stop re-reading the downloaded temp file
download_to_temp buffered the full bytes, wrote them to a temp file, and
prepare_photo then read the whole file back from disk. The bytes are
already in memory — pass them through (download_to_temp now returns
(file, bytes)) so photo processing never touches the disk for input.
Adds the bytes dependency to xmedia-bot (already in the lock via x-media).
2026-08-13 22:24:58 +08:00
YoursFunny 505990e49e perf(pixiv): stream the ugoira frame zip to disk instead of RAM
download_media_limited buffered the whole frame zip (cap 512 MB) in
memory before extraction, spiking RAM for large ugoira. New
site::download_media_to_file streams chunks straight to a temp file with
the same Content-Length / stream cap checks, and ugoira_video now opens
the zip from disk inside spawn_blocking. Adds FetchError::Io for local
write failures (hand-rolled error pattern preserved).
2026-08-13 22:22:13 +08:00
YoursFunny 95b475ff08 perf(send): prepare upload-fallback items concurrently
The download-and-reupload fallback downloaded each batch item serially,
so a 9-item batch took 9× the slowest download. Items are now prepared
concurrently (bounded to 3 in-flight downloads + photo processing) via a
JoinSet, then the group is uploaded in its original order; the per-item
logic moved into prepare_upload_item. Temp files stay alive until the
group request completes. A failing item still aborts the batch (the
JoinSet drop cancels the remaining prep tasks, as before).
2026-08-13 22:19:39 +08:00
YoursFunny 2297fdc91c fix(site): truncate captions to Telegram's 1024-char limit
A long tweet text or a pixiv artwork with many tags can exceed Telegram's
1024-char caption cap for HTML parse mode, turning an otherwise fine send
into a permanent 400. truncate_caption() cuts at a char boundary (never
splitting a multi-byte rune or an HTML entity like &amp;) and appends an
ellipsis. Applied in caption_with / caption_from_fields, the link-cache
re-send path and the inline-query captions.
2026-08-13 22:15:33 +08:00
YoursFunny 4580b79d4f fix(send): order photos first in mixed media groups
Telegram's sendMediaGroup requires the first item to be a photo when a
group mixes photos and videos; the previous code kept the source-site
order, so a mixed post with a video first (twitter media order is not
guaranteed) would 400 permanently. photos_first() stable-sorts photos
ahead of videos/animations before chunking; within-kind order is kept.
2026-08-13 22:14:07 +08:00
YoursFunny edb32c23b4 perf(db): reuse SQLite connections via a small per-store pool
Every DB operation (queue lease/enqueue, chat_state get/set, link_cache
read/write) used to open a fresh connection — including the busy timeout
and WAL pragma — then close it, on every message, URL job and callback.

Replace with DbPool: a tiny pool (4 connections max, semaphore-bounded
concurrency for backpressure) whose with_conn() method runs the closure on
a pooled connection inside spawn_blocking. Steady-state cost of an
operation is a list pop + semaphore acquire instead of a connection open.
2026-08-13 22:12:34 +08:00
YoursFunny 4a467641aa ci: add test/clippy workflow and gate live/token tests
The docker workflow only builds/pushes; tests were a local responsibility.
Add .github/workflows/ci.yml with two layers:

- test: cargo fmt --check + cargo clippy --workspace --all-targets -D
  warnings + cargo test --workspace (fully offline, no secrets) on every
  push/PR, including forks.
- live: the #[ignore]d live-network tests plus the pixiv token-gated
  tests, run on schedule / manual dispatch / tag pushes only (fork PRs
  cannot read repository secrets), with PIXIV_REFRESH_TOKEN /
  TWITTER_AUTH_TOKEN injected and continue-on-error for flaky sites.

Test gating (documented in AGENTS.md):
- live-network tests now carry #[ignore = "live network: ..."] (twitter 3,
  bsky 2, pixiv bogus-token 1) and run via -- --ignored live.
- pixiv token tests early-return when PIXIV_REFRESH_TOKEN is absent or
  empty (an unset GitHub secret arrives as ""); test_fetch previously
  failed without a token.

Also fixes the three clippy assertions_on_constants warnings in send.rs
(required for -D warnings).
2026-08-13 22:07:32 +08:00
YoursFunny bd43a12dee fix(send): keep local media (ugoira/bsky remux MP4) alive across queue retries
A task whose media is a locally produced file (pixiv ugoira MP4, bsky HLS
remux MP4) references a path inside a tempfile TempDir owned by Fetched.
The retry ran after that TempDir was dropped, so the file was already gone
and the retry always failed permanently ("local media file missing") — and
the upload fallback even tried to GET the local path as a URL.

Keep the temp dirs in a process-wide registry (Fetched::take_keep_alive ->
send::KEEP_ALIVE) that is only released when the task settles (sent or
permanently failed); the upload fallback now uploads local files directly
instead of attempting to download them.

Restart-mid-queue still loses the files (documented behavior in
input_file_for) — only the in-process retry path is fixed here.
2026-08-13 22:05:07 +08:00
YoursFunny c496e41c55 feat(handlers): debounce inline queries to stop fetch storms while typing
Telegram fires an inline query on every keystroke and every prefix of a
pasted URL (status/12, status/123, ...) matches the site patterns, so
typing used to trigger a full 3-attempt fetch per keystroke. Answer only
after the query has been stable for 800ms, dedupe repeats through
Telegram's inline cache (explicit cache_time 300), and let a repeat of a
query that produced no answer retry the fetch.
2026-08-13 21:57:07 +08:00
YoursFunny 68f026c990 docs: note version-bump convention in AGENTS.md 2026-08-10 19:29:21 +08:00
YoursFunny 62d80c8905 docs: sync README and AGENTS.md with 1.1.1 state 2026-08-10 19:16:56 +08:00
YoursFunny 78c9c841c6 chore: bump version to 1.1.1 2026-08-10 18:19:16 +08:00
YoursFunny e49d500d23 deps: switch TLS to rustls, drop libssl from the runtime image
OpenSSL came from two removable defaults: teloxide's `default` feature
(native-tls) and x-media's reqwest default features (default-tls). Switch
both to rustls (webpki-roots) so the binary links no system TLS libs:

- teloxide: default-features=false + rustls + ctrlc_handler (was part of
  the removed default)
- reqwest (x-media): default-features=false + rustls-tls

Verified: openssl-sys/native-tls gone from the tree, cargo check clean,
all 5 live twitter/bsky fetches pass over rustls, full container startup
works. The runtime image now needs no libssl.so.3/libcrypto.so.3 or CA
bundle (ffmpeg only processes local files; all downloads go through
reqwest), saving ~8MB.
2026-08-10 17:07:23 +08:00
YoursFunny 6feabd723b docker: fix 1.1.0 container startup (stub binary + missing libssl)
c40b074 broke the image two ways:
- `cargo clean -p` removes 0 files, so the real sources (host mtimes
  older than the step-1 stub build) were never recompiled and the image
  shipped the 337KB fn-main stub, exiting 0 on start. Restore the
  touch-based rebuild, which forces cargo to see every .rs as newer.
- bookworm-slim does not ship libssl3 despite the old comment; the bot
  links OpenSSL via teloxide/reqwest native-tls, so restore the
  libssl/libcrypto copies from the builder (same Debian release).
2026-08-10 16:37:12 +08:00
YoursFunny ea72516d5c chore: bump version to 1.1.0 2026-08-10 10:32:54 +08:00
YoursFunny 755330e585 chore: clippy and rustfmt cleanup on new code 2026-08-08 20:35:43 +08:00
YoursFunny c40b074b3c docker: fail-closed rebuild, ffmpeg checksum, smaller runtime
- Replace the mtime-touch stub-rebuild hack with cargo clean -p (a
  future-dated host file could silently ship the stub binary)
- Optional FFMPEG_SHA256 build arg verified before extraction
- Drop the redundant libssl3/libcrypto copies and the root
  supplementary group; strip the release binary; add a webhook-mode
  healthcheck to the compose example
2026-08-08 20:34:21 +08:00
YoursFunny 9910da2914 deps: drop unused regex, unify reqwest on 0.12
x-media's reqwest 0.13 dragged in quinn/rustls/aws-lc-rs (cmake C
build) alongside teloxide's 0.12; unifying on 0.12 removes the whole
second TLS/QUIC stack from the build and image. The unused regex dep in
xmedia-bot is gone; x-media keeps url (the bsky HLS remux uses it).
2026-08-08 20:31:03 +08:00
YoursFunny ebc0122264 db: enable WAL and index the pending-task lease query
The lease/earliest_run_after queries full-scanned tasks, and the
rollback journal blocked readers behind worker writes. journal_mode=WAL
(persistent, idempotent) plus idx_tasks_pending(status, run_after)
covers both without a schema migration.
2026-08-08 20:26:07 +08:00
YoursFunny 44cba8abe0 send: reuse one process-wide Bot for queue workers
handle_task and dead_letter_notify built a fresh Bot (env parse + HTTP
client) per queue item. A single LazyLock<Bot> is forced at startup so
a missing TELOXIDE_TOKEN fails fast instead of on the first task.
2026-08-08 20:25:22 +08:00
YoursFunny 99009aae9a db: share one now_f64() instead of four private copies
handlers, queue, send and link_cache each carried the same SystemTime
helper; a single crate::db::now_f64() removes the drift risk.
2026-08-08 20:23:58 +08:00
YoursFunny 72130b9023 caption: escape URLs/handles in HTML captions
Post URLs and author URLs were interpolated raw into <a href> attributes
(and the raw user URL from empty_fetched into caption text), so crafted
links could break the HTML parse and fail the send with a 400. All
attribute interpolations now use encode_double_quoted_attribute; text
stays encode_text.
2026-08-08 20:21:56 +08:00
YoursFunny 734cfc2eb3 handlers: skip inline fetches for non-post queries
Inline queries fire per keystroke and each fetch runs the 3-attempt
retry loop; a user typing any text was pumping requests into
X/Pixiv/BSky and risking rate-limit bans. Queries now pass only if
cache_key recognizes them as a supported post URL.
2026-08-08 20:20:05 +08:00
YoursFunny a8156697fa send: keep video thumbnails in the download-and-reupload fallback
media_from_file/media_from_url never attached the thumbnail, so any
video that tripped the fallback lost its cover frame. Both now take
item.thumbnail_url() and apply it, matching build_media_group.
2026-08-08 20:19:19 +08:00
YoursFunny c093dfe5ac handlers: dedup extracted URLs by normalized post id
Exact-string dedup let https://x.com/u/status/1 and
https://x.com/u/status/1/photo/1 (or the same link in text and caption)
through twice, causing duplicate fetches and sends. Dedup now uses
cache_key, falling back to the raw URL for unsupported links.
2026-08-08 20:17:07 +08:00
YoursFunny ee6f3e4a27 state: evict idle chats from the ChatStore cache
prune_expired only shrank edit_message maps, so the cache kept one
ChatData per chat forever (a leak proportional to chat count). Chats
without live edit records are now dropped from the cache and their
per-chat lock (DB row persists; get() reloads). Lock order kept safe:
prune never holds the cache lock while taking the per-chat locks.
2026-08-08 20:16:26 +08:00
YoursFunny 16ed53fead handlers: replace unbounded per-URL spawn with a bounded job channel
The 8-permit semaphore was acquired inside the spawned task, so a burst
queued unlimited tasks (each cloning Bot+Message) and nothing tracked
them at shutdown — in-flight sends fired after the stop notice. URL work
now flows through a 256-slot mpsc drained by 8 workers started from
main; a full channel backpressures the per-chat handler, and shutdown
sets URL_STOP so workers stop pulling.
2026-08-08 20:15:40 +08:00
YoursFunny 1d9e3629c9 state: serialize per-chat get→mutate→set with ChatStore::update
Concurrent handler tasks (the batch-forward design spawns several per
chat) each snapshotted the same ChatData and last-writer-wins silently
dropped mutations — e.g. a second edit_message record, leaving one
prompt's Forward button dead. All write cycles now run under a per-chat
async lock; read-only callers keep get().
2026-08-08 20:12:51 +08:00
YoursFunny b3d87b4f7d send: fail fast when a retried local media file is gone
A retried ugoira/bsky temp MP4 was already deleted with its TempDir, so
the retry failed at multipart-build time with a confusing Io error and
wasted all three attempts. input_file_for now rejects a missing local
path up front as a clean permanent error.
2026-08-08 20:10:35 +08:00
YoursFunny 98c48b99c0 send: don't repeat post-send actions on queue resumes
A resumed SendMediaSequence (batch_index>0 or already-sent ids) ran
post_send_actions again, opening a second edit prompt and inserting a
second edit_message record for the same messages — both Forward buttons
worked, enabling double forwards. Resumes now skip it.
2026-08-08 20:09:58 +08:00
YoursFunny f40639c799 queue: back off 1s when a lease fails
A lease error (e.g. persistent SQLITE_BUSY) while rows are due made the
worker spin with sleep(0), hammering SQLite and flooding the log.
lease_next now returns the error and the loop sleeps 1s before retrying.
2026-08-08 20:09:04 +08:00
YoursFunny 51cc079a85 queue: use notify_one so wakeups are never lost
notify_waiters drops the notification when every worker is between its
DB reads and registering notified(); a task enqueued in that window sat
until a stale timer fired. notify_one stores a permit, so the next
worker to wait wakes immediately and re-leases. stop() still wakes all
workers with notify_waiters.
2026-08-08 20:08:12 +08:00
YoursFunny aa3083792a queue: wire attempt counts into retry backoff
Every network retry hard-coded retry_delay_seconds(0), so backoff was
flat at 1.2-1.8s regardless of attempt; a multi-minute outage dead-
lettered after three rapid tries. The queue now scales the handler's
delay by 2^attempts (cap 300s) before rescheduling.
2026-08-08 20:07:26 +08:00
YoursFunny 6849006ad7 site: honor TELOXIDE_PROXY for site fetches
The shared HTTP client ignored the proxy the Bot API uses, so on
proxy-required networks (e.g. behind the GFW) every site fetch failed
while the bot itself worked. Explicit proxy overrides reqwest's system
detection; unset keeps direct connections.
2026-08-08 20:05:51 +08:00
YoursFunny 042a04ab6e site: anchor pixiv and bsky URL patterns
Both matched URL substrings anywhere in text, so a link like
https://evil.com/?u=pixiv.net/artworks/1 triggered a real fetch and
cache-key pollution. Prefix ^(?:https?://)? like the twitter pattern.
2026-08-08 20:05:09 +08:00
YoursFunny 9f28af4e6b site: classify HTTP status codes, make transient failures retryable
Twitter (syndication + auth GraphQL) mapped every non-2xx to NotFound,
killing retries on 429/5xx; bsky never checked status; pixiv network
errors arrived wrapped in PixivError and were excluded from the retry
loop. New FetchError::Transient covers 429/5xx from all sites, the
retry loop now also retries Pixiv errors, and 404/410 stay permanent.
2026-08-08 20:04:28 +08:00
YoursFunny d61dba5096 send: stream media downloads with hard size caps
download_to_temp now uses download_media_limited: non-photos abort the
moment the 10 MiB upload cap is crossed mid-stream (no more full-body
buffering before the size check), photos cap at the 512 MiB decode
budget, and the ugoira frame zip gets a 512 MiB cap. MediaTooLarge
routes to the existing smaller-URL fallback.
2026-08-08 20:03:14 +08:00
YoursFunny f6df3e28cb pixiv: drop unneeded mut bindings in ugoira extraction 2026-08-08 20:02:06 +08:00
YoursFunny deb1ef2428 site: add total and connect timeouts to the shared HTTP client 2026-08-08 20:01:39 +08:00
YoursFunny b5e5340edc pixiv: harden ugoira zip extraction, degrade instead of panicking
Sniff the frame extension from magic bytes instead of the entry filename,
cap each frame at 64 MiB (declared size + streamed read), and map a
panicked encode worker to the existing degrade path instead of
expect()-panicking the whole fetch handler.
2026-08-08 20:01:14 +08:00
YoursFunny 425d1505cf handlers: fix /set_forward_channel admin checks
Compare the sender's user id (not the chat id, which only matches in
private chats) and require the bot to actually be an admin with post
rights instead of silently passing when it is missing from the list.
Also stops panicking on get_me network failures.
2026-08-08 19:59:27 +08:00
YoursFunny 6e40f55440 queue: recover expired leases at runtime, supervise workers
Rows left in_progress by a panicked/crashed worker were only recovered at
start(); a runtime sweep (30s interval, woken by the same notify) now
re-queues them once the 120s lock TTL expires. Workers run under a
supervisor that respawns a panicked loop instead of silently shrinking
the pool of 4.
2026-08-08 19:58:28 +08:00
YoursFunny 7998114dc3 bsky: remux HLS video playlists to MP4 via ffmpeg
bsky video embeds expose only an m3u8 playlist, which Telegram cannot
fetch. Download the master/variant playlists and TS segments through the
shared client (proxy-aware, size-capped), then concat-remux locally;
keep the temp dir alive via Fetched._keep_alive like the ugoira path.
Also adds site::download_media_limited (streaming size cap), status
checks on media_size, and moves the ffmpeg probe to site/mod.rs for
pixiv/bsky to share.
2026-08-08 19:56:36 +08:00
YoursFunny 9a96f78177 handlers: fix UTF-8 byte-slice panic in message log preview 2026-08-08 19:52:01 +08:00
YoursFunny b50f794d52 feat: register bot commands with Telegram
Call setMyCommands at startup so clients show the command list in the
/ menu. handlers::register_commands wraps Command::bot_commands()
(teloxide derives it from the #[command(description)] attributes);
a registration failure only warns and does not stop the bot.
2026-08-08 00:08:48 +08:00
YoursFunny d32fa969d6 fix: correct singular 'entry' in clear-cache replies
plural() returned "" for one, rendering '1 entr.'; return "y" so
the suffix composes to '1 entry' / '2 entries'.
2026-08-08 00:07:27 +08:00
YoursFunny 020e2d01a3 chore: bump version to 1.0.8 2026-08-07 16:23:40 +08:00
YoursFunny 3d6f8548c3 style: cargo fmt across the workspace
Apply rustfmt to the 11 files that had drifted (86 hunks): x-media
site modules (bsky/pixiv/twitter) and xmedia-bot (config/main/
photo/send). Formatting only - no semantic changes; full test suite
still green.
2026-08-07 16:12:01 +08:00
YoursFunny 063e910473 feat: add admin-only /clear_cache command
/clear_cache with no argument wipes the whole link_cache table;
with a post URL it removes that single entry (normalized via
site::cache_key so fxtwitter/mobile/photo variants collide with
the write-side key). Non-admins get 'Admin only.'. LinkCache gains
clear(Option<&str>) -> usize reporting removed rows.
2026-08-07 16:10:25 +08:00
YoursFunny b0ced34b4c refactor: share sqlite open/with_conn helpers in db.rs
Converge the duplicated open_db (open + busy_timeout) and the
spawn_blocking + expect ceremony that every table access repeated
into one db.rs module. ChatStore no longer creates the tasks table
(schema ownership: queue.rs owns tasks, state.rs chat_state,
link_cache.rs link_cache). No schema or behavior change - all
CREATE TABLE statements are byte-identical, IF NOT EXISTS stays
idempotent, so existing data/task_queue.db files need no migration.
2026-08-07 16:00:12 +08:00
YoursFunny 4060a88031 fix: expand twitter short links like FxEmbed linkFixer
Replace display_text_range slicing with FxEmbed-style content matching:
expand mapped t.co links to their real URLs (dropping internal
x.com/i/web/status pages), then strip every leftover t.co short link
(appended media link, unmapped links).

The old code cut by display_text_range, whose index unit differs per
endpoint (UTF-16 on the syndication endpoint, code points in the
GraphQL fallback), so slicing by either unit left a partial
"https://t." caption tail on the other path. Content matching is
unit-agnostic and also keeps user-posted/quote links at the end of
the text that the trailing cut previously dropped.
2026-08-07 10:25:54 +08:00
YoursFunny fb43441c56 feat: add command descriptions and document commands in README
All bot commands now carry English descriptions, shown in the Telegram
command menu and by /help (which prints Command::descriptions()). The
README command table explains each command's arguments and behavior:
forward channel (@channel or ID), edit-before-forward flow, template
[] placeholder semantics and per-site caption format placeholders.
2026-08-06 21:21:28 +08:00
YoursFunny bf628dc999 chore: drop label value in compose example 2026-08-06 20:45:35 +08:00
YoursFunny de22aa9b4d chore: leave DEFAULT_EMAIL blank in compose example 2026-08-06 20:03:56 +08:00
YoursFunny 65a9554173 bump version to 1.0.7 2026-08-06 18:49:08 +08:00
YoursFunny e755785147 deploy: use named volumes for certs/html/acme, keep data bind mount
certs, html and acme are shared across nginx-proxy, acme-companion and
the acme-ip service, so they become named volumes (dropping the nginx-
prefix). The vhost.d mount is removed: acme-companion only needs it with
ACME_HTTP_CHALLENGE_LOCATION=true or letsencrypt_user_data, and users
who customize nginx config can mount their own. ./data stays a bind
mount so the SQLite state stays directly backup-able. README updated.
2026-08-06 17:22:05 +08:00
YoursFunny 5d5b6d56e7 chore: strip compose comments, document env vars in README
compose files now carry a single comment: the commented ACME_HOST line
(uncomment to enable domain cert issuance). Everything else moved to the
README env table and the deployment sections, which explain ACME_HOST,
DEFAULT_HOST and every other variable.
2026-08-06 17:07:50 +08:00
YoursFunny d0fdf1c5da deploy: default to ACME-issued certs, drop manual cert setup
Domain deployment now uses ACME_HOST (acme-companion auto issue/renew)
instead of the legacy LETSENCRYPT_HOST name. IP-only deployments can use
acme.sh to obtain Let's Encrypt IP certificates (shortlived profile,
~7-day validity, http-01 only) instead of self-signed manual certs:
documented the acme-ip service, first-issue command with install-cert
into nginx-certs and nginx reload via docker socket.

Removed the manual WEBHOOK_CERT flow and the ./cert volume from the
compose example.
2026-08-06 16:48:52 +08:00
YoursFunny 1db4ecfafa chore: use modern nginx-proxy label for acme-companion
acme-companion prefers com.github.nginx-proxy.nginx over the legacy
jrcs.letsencrypt_nginx_proxy_companion namespace (functions.sh checks
the new label first, falls back to the old one).
2026-08-06 11:31:01 +08:00
YoursFunny 47039bbe2d fix: make docker entrypoint restart-safe
docker compose restart reuses the container, so the overlay fs keeps the
user created on first boot; a second useradd fails with exit code 9 and
set -e kills the container on every restart (crash loop, bot never comes
back). Create the user only if missing, align UID otherwise, and never
let chown failure on bind-mounted volumes kill the container.

Also comment out nginx-proxy's empty environment block in the compose
example: an empty mapping fails validation on newer compose versions
(must be a mapping).
2026-08-06 11:10:52 +08:00
YoursFunny bc954e6e0b fix: handle oversized photos Telegram rejects with pure Rust processing 2026-08-06 10:17:29 +08:00
YoursFunny f7cb809e5a bump version to 1.0.6 2026-08-05 02:07:34 +08:00
YoursFunny 51b40cdb42 feat: cache sent media file ids for instant repeat sends 2026-08-05 02:06:30 +08:00
YoursFunny ab2306002a perf: handle batch-forwarded URLs concurrently with queue workers 2026-08-05 01:26:24 +08:00
YoursFunny a92b12f633 bump version to 1.0.5 2026-08-05 00:16:45 +08:00
YoursFunny 74e3b7593c docs: note fresh twitter query ids and TID scope in auth fallback 2026-08-05 00:07:30 +08:00
YoursFunny 2faccaac42 fix: cut tweet text by code points, not UTF-16 units 2026-08-04 23:45:54 +08:00
YoursFunny f4e60d8946 fix: trim CRLF from TWITTER_AUTH_TOKEN 2026-08-04 23:45:54 +08:00
YoursFunny 3006dcd98c feat: fetch NSFW tweets via authenticated twitter API fallback 2026-08-04 23:45:53 +08:00
YoursFunny 8e8acdd859 deploy: add container names and startup order to compose 2026-08-04 23:11:04 +08:00
YoursFunny 8b9dd963e1 bump version to 1.0.4 2026-08-04 22:10:38 +08:00
YoursFunny e83d48f1f7 fix: handle SIGTERM for graceful shutdown on docker stop 2026-08-04 22:09:45 +08:00
YoursFunny 7c55b26731 deploy: add nginx-proxy reverse proxy for webhook TLS 2026-08-04 21:50:19 +08:00
YoursFunny 950db48a13 ci: cache buildkit layers across runs 2026-08-04 18:52:28 +08:00
YoursFunny 32ea8ec6ca fix docker build: fetch ffmpeg from martin-riedl.de 2026-08-04 18:52:27 +08:00
YoursFunny 62dc033452 docs: add AGENTS.md with repository guidelines 2026-08-04 18:52:21 +08:00
YoursFunny 2801aaa39c bump version to 1.0.3 2026-08-04 16:24:16 +08:00
YoursFunny 93d47752cf fallback to smaller media when file too large 2026-08-04 16:22:49 +08:00
YoursFunny 6fd4edb3c5 webhook: drop duplicate set_webhook, ignore empty env vars 2026-08-04 15:58:32 +08:00
YoursFunny 7c5afce0b4 ci: build once on tagged commits, bump docker actions 2026-08-04 01:58:19 +08:00
YoursFunny caf41183a4 bump version to 1.0.2 2026-08-04 01:29:50 +08:00
YoursFunny b6ae88e869 twitter: request original image via name=orig 2026-08-04 01:28:21 +08:00
YoursFunny 3dff9a2473 fix docker build: compile real sources, lf entrypoint 2026-08-04 00:55:35 +08:00
YoursFunny 41f25052fe merge refactor-rs into master 2026-08-03 23:34:45 +08:00
YoursFunny fa95091c7c bump version to 1.0.0 2026-08-03 23:34:01 +08:00
YoursFunny 84ab146069 finish rust rewrite, add docker, drop python 2026-08-03 23:30:42 +08:00
YoursFunny e14363dd1d fix changes of updated async-pixiv module 2026-06-24 00:17:15 +08:00
YoursFunny 0cffd5c297 revert to python 3.12 due to incompatibility of async-pixiv module 2026-06-23 23:46:27 +08:00
YoursFunny b83bed5423 fix pixiv exception import 2026-06-23 23:24:39 +08:00
YoursFunny 0f3131aec7 bump deps version 2026-06-23 23:16:57 +08:00
YoursFunny 5483855de8 fix webhook set as false in compose yaml 2025-12-18 14:59:12 +08:00
YoursFunny 052163084b change to x.com in url template 2025-12-17 17:24:22 +08:00
YoursFunny cdee105c60 bump deps version 2025-12-17 17:23:20 +08:00
YoursFunny fae3dad43a fix start command handler 2025-08-21 10:30:10 +08:00
YoursFunny f4c53719d8 bump dep version and fix start command handler 2025-08-21 10:23:25 +08:00
YoursFunny cf44c3ddd7 minor changes and adding /start command 2025-08-21 09:59:37 +08:00
YoursFunny cbb15e6a33 fix tag check 2024-11-12 17:35:11 +08:00
YoursFunny afc31430e7 new bsky sensitive contents 2024-11-12 17:23:46 +08:00
YoursFunny 282cf46d58 minor fix 2024-11-12 17:12:47 +08:00
YoursFunny 6c53a1429d update to python 3.13 2024-11-10 19:02:18 +08:00
YoursFunny ca9064a1b7 bump version 2024-11-10 18:19:29 +08:00
YoursFunny d58fd7cd46 add clear edit message command 2024-11-10 18:17:51 +08:00
YoursFunny 9aa7aa1c36 fix external bsky media (disable) 2024-11-10 18:17:15 +08:00
YoursFunny 9c2606160a format 2024-10-19 22:41:41 +08:00
YoursFunny 3fe8a1cf3b new bsky support 2024-10-19 22:25:07 +08:00
YoursFunny 62a2820af3 add params support in fetch_json 2024-10-19 22:23:51 +08:00
YoursFunny 80b630d28d minor fixes 2024-10-19 22:22:27 +08:00
YoursFunny 4173972407 fix empty env bot_admin 2024-10-19 22:21:56 +08:00
YoursFunny 0d2648162c bump version 2024-10-19 22:19:44 +08:00
YoursFunny 0847ada3b7 update gitignore 2024-10-18 00:21:19 +08:00
YoursFunny 7cad25125f rust refactor 2024-10-18 00:17:51 +08:00
YoursFunny f0c287a148 fix await 2024-08-15 17:45:14 +08:00
YoursFunny 3658bfd2aa fix handler 2024-08-15 17:42:19 +08:00
YoursFunny ae1b04dee8 add extract url from message 2024-08-15 17:34:40 +08:00
YoursFunny 641f7218a4 minor fixes 2024-08-15 16:45:36 +08:00
YoursFunny 61dfa16011 fix edit message str format 2024-08-15 16:45:06 +08:00
YoursFunny 2986e80076 fix edit message 2024-08-14 01:52:01 +08:00
YoursFunny f112ecdf63 fix text 2024-08-14 01:30:08 +08:00
YoursFunny 79d50ae9a3 fix 2024-08-14 01:19:50 +08:00
YoursFunny 416331303d fix 2024-08-14 01:14:01 +08:00
YoursFunny 55771e01fe fix 2024-08-14 01:13:47 +08:00
YoursFunny afca276f49 minor fixes 2024-08-14 00:57:20 +08:00
YoursFunny 06d25854d2 fix error import 2024-08-14 00:37:01 +08:00
YoursFunny 515aa9711d new set template 2024-08-14 00:29:02 +08:00
YoursFunny 733adfc4a6 refactor using custom callback context 2024-08-13 23:20:24 +08:00
YoursFunny 03066853de refactor 2024-08-13 19:17:58 +08:00
YoursFunny 566c17a855 fix pixiv url 2024-07-19 19:34:18 +08:00
YoursFunny 87f0d16028 fix regex https pattern 2024-07-16 14:28:49 +08:00
YoursFunny 197993e522 bump version 2024-07-16 14:27:15 +08:00
YoursFunny 02abcd899e update pixiv regex 2024-07-16 14:21:43 +08:00
YoursFunny ebb48d2bb5 fix forward channel check admin user 2024-07-09 16:27:13 +08:00
YoursFunny be77a33e9d use large instead of origin for pixiv 2024-06-24 20:02:35 +08:00
YoursFunny 0439adefa0 add refresh token if expire 2024-06-24 15:57:30 +08:00
YoursFunny 81e5d2dbd1 add log 2024-06-24 15:47:12 +08:00
YoursFunny e7ccd70e56 fix deal with invalid url 2024-06-24 02:17:26 +08:00
YoursFunny 23128af96a fix regex 2024-06-24 02:14:50 +08:00
YoursFunny fe1724aca4 fix annotations 2024-06-24 02:06:25 +08:00
YoursFunny 0433650563 fix init pixiv 2024-06-24 02:03:28 +08:00
YoursFunny a617a7e1a0 fix requirement conflict 2024-06-24 01:48:45 +08:00
YoursFunny e35e3895ba fix requirement conflict 2024-06-24 01:45:23 +08:00
YoursFunny a4a9f2eaa0 add pixiv package requirement 2024-06-24 01:29:29 +08:00
YoursFunny 1c779e98a8 add pixiv support 2024-06-24 01:25:03 +08:00
YoursFunny 4a5aa296ce add pixiv refresh token setting 2024-06-23 22:14:58 +08:00
YoursFunny c1c1d42892 refactor, clear structure 2024-06-23 22:06:27 +08:00
YoursFunny cade370538 refactor 2024-06-23 21:52:12 +08:00
YoursFunny a6325d5bc8 fix check instance 2024-06-20 15:46:47 +08:00
YoursFunny 6c0e581135 fix regex match group 2024-06-20 15:40:18 +08:00
YoursFunny 494fc50446 fix missing aexit 2024-06-20 15:29:30 +08:00
YoursFunny a4e568667c fix type import error 2024-06-20 15:25:17 +08:00
YoursFunny 8231eeb20e refactor tweet, better type hint 2024-06-20 15:13:09 +08:00
YoursFunny 74f42a2df3 fix status code check 2024-06-20 02:51:20 +08:00
YoursFunny ae1f5a8c8c rename 2024-06-20 02:48:06 +08:00
YoursFunny 38adbf0cb8 use cached property 2024-06-20 02:40:35 +08:00
YoursFunny 55ae73ee6b use uuid for query 2024-06-20 02:21:14 +08:00
YoursFunny d6589fec5b fix annotations 2024-06-20 02:20:10 +08:00
YoursFunny 91cad86325 use http2 in httpx 2024-06-20 02:11:39 +08:00
YoursFunny cfdb05e476 add use uvloop 2024-06-20 01:41:40 +08:00
YoursFunny 6df6a950c8 update to httpx 2024-06-19 23:19:42 +08:00
YoursFunny 78d185723b add type checking import 2024-06-19 22:47:10 +08:00
YoursFunny 1252ec8804 fix import logger 2024-06-19 01:42:50 +08:00
YoursFunny cde1091c85 update call concurrently 2024-06-19 01:39:59 +08:00
YoursFunny 8b2b6b49ab update regex pattern 2024-06-19 01:03:16 +08:00
YoursFunny 86b4a972c9 bump version 2024-06-19 00:58:23 +08:00
YoursFunny 483a3de649 fix edit message caption 2024-05-13 00:55:49 +08:00
YoursFunny 48bc81898d fix edit message tuple 2024-05-13 00:47:37 +08:00
YoursFunny f660bc2c5e fix edit message url 2024-05-13 00:38:36 +08:00
YoursFunny 7747dab4a3 fix edit message caption 2024-05-13 00:32:17 +08:00
YoursFunny f2fac6a9e0 fix edit message 2024-05-13 00:24:03 +08:00
YoursFunny e3dcb6e59b fix send gif 2024-05-13 00:19:24 +08:00
YoursFunny a1d1dc5ea3 fix template replace 2024-05-13 00:00:08 +08:00
YoursFunny e629899a72 fix missing return 2024-05-12 23:51:19 +08:00
YoursFunny d079271ef9 fix action 2024-05-12 23:42:18 +08:00
YoursFunny 1526c7900c add set template 2024-05-12 23:34:11 +08:00
YoursFunny 79d68b2e82 fix int admin id 2024-04-28 01:57:10 +08:00
YoursFunny e700ae65ae add message user dict debug 2024-04-28 01:36:36 +08:00
YoursFunny 9ac6065366 remove dict key if disable edit forward 2024-04-28 01:33:50 +08:00
YoursFunny 40877b52eb fix confirm 2024-04-28 01:26:43 +08:00
YoursFunny 0ca9b55ea8 try edit reply markup 2024-04-28 01:18:35 +08:00
YoursFunny ef17b05c14 fix html escape only message 2024-04-28 01:12:02 +08:00
YoursFunny 85d0257c51 feat: change forward confirm reply markup 2024-04-28 01:08:22 +08:00
YoursFunny 7f90670f1c fix edit message html escape 2024-04-28 01:01:59 +08:00
YoursFunny 755c405196 update ptb version 2024-04-26 15:49:52 +08:00
YoursFunny 0ffa9903c4 add hint 2024-04-26 15:19:04 +08:00
YoursFunny 248e7d48e0 fix caption of multiple inline query results 2024-04-26 15:15:13 +08:00
YoursFunny e915cd13de fix html escape 2024-04-26 15:13:38 +08:00
YoursFunny a7d144f2ee fix multiple media forward 2024-03-25 16:54:08 +08:00
YoursFunny 081ee595b1 remove edit reply 2024-03-24 22:54:23 +08:00
YoursFunny 4272454f9a fix url match 2024-03-24 22:33:36 +08:00
YoursFunny 7d289c9db7 fix multiple line substitution 2024-03-24 22:32:22 +08:00
YoursFunny 500ba14565 fix caption text substitute 2024-03-24 22:15:06 +08:00
YoursFunny 1cb64b64e2 fix edit enable 2024-03-24 21:59:55 +08:00
YoursFunny 8ad9078c5e fix edit enable 2024-03-24 21:54:50 +08:00
YoursFunny ebfaaad75a fix message handler filter 2024-03-24 21:46:32 +08:00
YoursFunny fdd21e22ed add command handler 2024-03-24 21:20:34 +08:00
YoursFunny ee92b8d8aa add edit before forward 2024-03-24 21:17:05 +08:00
YoursFunny 50161243ea simplify send action 2024-03-24 16:45:42 +08:00
YoursFunny 7846873b9c fix video title 2024-03-24 01:03:39 +08:00
YoursFunny 6d06e28598 fix gif media 2024-03-23 15:01:17 +08:00
44 changed files with 12201 additions and 444 deletions
+6 -1
View File
@@ -24,8 +24,13 @@
**/secrets.dev.yaml
**/values.dev.yaml
*.db
.python-version
LICENSE
README.md
data/
cert/
nginx-certs/
nginx-vhost.d/
nginx-html/
nginx-acme/
**/target/
.idea/
+2
View File
@@ -0,0 +1,2 @@
# Shell scripts must stay LF: CRLF breaks the shebang inside containers.
*.sh text eol=lf
+64
View File
@@ -0,0 +1,64 @@
name: CI
# Test/lint gate (offline, no secrets) on every push/PR, plus a live-network
# job that exercises the real source sites and the token-gated pixiv tests.
#
# Layering:
# test — fmt + clippy + the full offline unit suite. Runs on every push
# and PR, including forks (it needs no secrets).
# live — the #[ignore]d live-network tests plus the pixiv tests that are
# gated on PIXIV_REFRESH_TOKEN. Runs on schedule / manual dispatch
# / tag pushes only, because pull requests from forks cannot read
# repository secrets. continue-on-error keeps a flaky external site
# from blocking, while the run still records the outcome.
#
# Test gating convention (keep in sync with AGENTS.md "Testing & QA"):
# - pure unit tests: plain #[test] / #[tokio::test], always run.
# - live-network tests: #[ignore = "live network: ..."], only run here.
# - token-gated tests (pixiv): #[tokio::test] with an early return when
# PIXIV_REFRESH_TOKEN is absent or empty (empty = unset CI secret).
on:
push:
branches: [master]
pull_request:
schedule:
# Weekly probe of the live endpoints, so external API changes surface.
- cron: '0 3 * * 1'
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
- name: Check formatting
run: cargo fmt --check
- name: Lint (deny warnings)
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Run offline tests
run: cargo test --workspace
live:
needs: test
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
continue-on-error: true
env:
PIXIV_REFRESH_TOKEN: ${{ secrets.PIXIV_REFRESH_TOKEN }}
TWITTER_AUTH_TOKEN: ${{ secrets.TWITTER_AUTH_TOKEN }}
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# Full suite: with the secret present, the pixiv token-gated tests run;
# without it they skip themselves. Live tests stay #[ignore]d here.
- name: Run token-gated tests
run: cargo test --workspace
# The live-network tests, by the "live" name filter (all #[ignore]d).
- name: Run live-network tests
run: cargo test --workspace -- --ignored live
+36 -5
View File
@@ -12,12 +12,35 @@ env:
DOCKERHUB_REPO: yoursfunny/telegram-twitter-media-bot
jobs:
# A tag push and a branch push to the same commit fire two workflow runs;
# build only once. Tag runs always build; master runs build only when the
# pushed commit is not already tagged (the tag run covers it).
should-build:
runs-on: ubuntu-latest
outputs:
build: ${{ steps.check.outputs.build }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: check
shell: bash
run: |
if [ "$GITHUB_REF_TYPE" = "branch" ] && git tag --points-at "$GITHUB_SHA" | grep -q .; then
echo "commit already tagged; the tag run builds the image"
echo "build=false" >> "$GITHUB_OUTPUT"
else
echo "build=true" >> "$GITHUB_OUTPUT"
fi
docker:
needs: should-build
if: needs.should-build.outputs.build == 'true'
runs-on: ubuntu-latest
steps:
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.DOCKERHUB_REPO }}
tags: |
@@ -28,22 +51,30 @@ jobs:
type=sha
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
-
name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Buildkit cache via the GitHub Actions cache backend (uses the
# automatic GITHUB_TOKEN, no extra secrets). mode=max keeps every
# stage's layers so the cargo-deps and ffmpeg layers are restored
# instead of re-downloaded/recompiled. The scope must be pinned to a
# fixed string: the gha backend defaults to the current git ref, which
# would give every new tag a cold cache on release builds.
-
name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
push: true
build-args: |
APP_NAME=${{ env.APP_NAME }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=tgxmb-build
cache-to: type=gha,mode=max,scope=tgxmb-build
+11 -1
View File
@@ -2,5 +2,15 @@
__pycache__/
cert/
data/
nginx-certs/
nginx-vhost.d/
nginx-html/
nginx-acme/
docker-compose.yml
x.py
.env
# Added by cargo
/target
+100
View File
@@ -0,0 +1,100 @@
# 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.2.1, 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 → workers lease (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); twitter adds `auth.rs` (logged-in GraphQL `TweetDetail` fallback for NSFW tweets, gated on `TWITTER_AUTH_TOKEN`) |
| `crates/xmedia-bot/src/main.rs` | Entry point: env/log init, command registration (`register_commands`), shared `send::BOT` force-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; per-URL work flows through a 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) |
| `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_id`s; 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, `Notify::notify_waiters` wakeup, `busy_timeout` on all connections |
| `crates/xmedia-bot/src/send.rs` | Media senders, upload fallback, error classification, queue task handlers |
## Development Commands
```bash
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
- **No anyhow/thiserror.** Errors are hand-rolled enums with manual `Display`/`source()`/`From` impls: `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::LazyLock` statics**, not DI: `CONFIG`, `CHAT_STORE`, `TASK_QUEUE` in `handlers.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** (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.rs` re-exports the site struct and `fetch_once` adds one guarded if-branch. Adding a site = new `site/<name>/{mod.rs,interface.rs,model.rs}` + one branch in `fetch_once`.
- **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). Queue retries are explicit `QueueError::Retryable` with computed delay (`retry_delay_seconds`).
- Logging via `log` macros (`pretty_env_logger`, level from `RUST_LOG`). Level convention: `info` = lifecycle + per-post business results (`sent`/`forwarded`/`copied`), admin/operator actions and anomalies (fallback, retry enqueue, dead-letter is `error`); `debug` = per-request detail (message/command/URL extraction, `fetching`/`fetched`, batch sends, queue processing, photo processing, inline queries). Full user-submitted URLs and message text only appear at `debug`; at `info` and above links are printed via the normalized cache key (`handlers::log_key`, e.g. `[key=twitter:123...]`) so logs stay short and do not echo user data.
## 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.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`; fallback chain; `classify_request_error`; download-and-reupload fallback triggered only by Telegram API errors (`is_media_fetch_failure` / `is_size_error`) |
| `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) |
| `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.example` | Deployment env reference (real `docker-compose.yml` is gitignored). 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; **no test step**; buildx gha cache (`cache-from`/`cache-to`, scope `tgxmb-build`, `mode=max`) so cargo deps + ffmpeg layers are restored across runs |
| `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-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): `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` 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).
- Config is **environment-variable driven** (dotenv loads `.env`, gitignored; no `.env.example` exists). 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), `BOT_ADMIN` (comma-separated ids), `EDIT_MESSAGE_TTL_SECONDS` (default 86400), `LINK_CACHE_TTL_SECONDS` (default 604800), `WEBHOOK`/`WEBHOOK_URL`/`WEBHOOK_LISTEN`/`WEBHOOK_PORT`/`WEBHOOK_CERT`/`WEBHOOK_SECRET_TOKEN` (webhook mode requires URL/listen/port, `.expect`ed; `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/task_queue.db` is CWD-relative — run from the workspace root, or `/app` in Docker. Mount `./data` and `./cert` volumes.
- `.gitattributes` enforces 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
- **~80 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` (3), `site/bsky/interface.rs` (2), `site/pixiv/interface.rs`/`api.rs`. 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). 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` runs `cargo fmt --check` + `cargo clippy --workspace --all-targets -- -D warnings` + `cargo test --workspace` (offline, no secrets, on every push/PR) and a `live` job (schedule/manual/tag only, `PIXIV_REFRESH_TOKEN`/`TWITTER_AUTH_TOKEN` from secrets, `continue-on-error`) for the `#[ignore]`d live + token tests. `.github/workflows/docker.yml` builds/pushes the image only.
- Untested and hard to test without a mock seam: `handlers.rs` (depends directly on teloxide `Bot`); `main.rs`, `config.rs`, `state.rs`; `media.rs`, `lib.rs`, all `model.rs`.
- No coverage tracking.
Generated
+3185
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
[workspace]
members = ["crates/x-media", "crates/xmedia-bot"]
resolver = "3"
# Smaller/faster production binary: strip debug symbols, link-time
# optimization across crates, and one codegen unit per crate (bigger LTO
# wins). panic=abort is intentionally NOT set: queue workers and db
# closures rely on JoinHandle catching panics, which abort would defeat.
[profile.release]
strip = true
lto = "thin"
codegen-units = 1
+74 -19
View File
@@ -1,26 +1,81 @@
FROM python:3.12-slim-bullseye
# ---------- build stage ----------
# rust:1-bookworm (full, not slim) ships the C toolchain needed by
# rusqlite's bundled SQLite, plus wget/unzip for the ffmpeg download.
FROM rust:1-bookworm AS builder
ARG APP_NAME=telegram-twitter-media-bot
# Prebuilt static ffmpeg (glibc-linked, includes libx264) for ugoira MP4
# encoding. Served from https://ffmpeg.martin-riedl.de (Cloudflare CDN,
# built on Debian 12 — glibc-compatible with the bookworm-slim runtime).
# johnvansickle.com throttles datacenter IPs and served garbage from GitHub
# runners. `/redirect/latest/` floats to the newest release build; each build
# also ships a .sha256. Swap `amd64` for `arm64` when building arm64 images.
ARG FFMPEG_URL=https://ffmpeg.martin-riedl.de/redirect/latest/linux/amd64/release/ffmpeg.zip
# Optional sha256 of ffmpeg.zip (pinned releases only): set to verify the
# download. The mirror publishes .sha256 sidecars next to pinned builds, e.g.
# https://ffmpeg.martin-riedl.de/download/linux/amd64/<id>_9.0/ffmpeg.zip.sha256
# (the /redirect/latest/ URL itself has no sidecar — pin the effective URL).
ARG FFMPEG_SHA256=
WORKDIR /build
# 1. Rust dependencies first: only the manifests plus stub sources, so the
# expensive dependency fetch + compile lives in a layer invalidated only by
# manifest/lock changes.
COPY Cargo.toml Cargo.lock ./
COPY crates/x-media/Cargo.toml crates/x-media/Cargo.toml
COPY crates/xmedia-bot/Cargo.toml crates/xmedia-bot/Cargo.toml
RUN mkdir -p crates/x-media/src crates/xmedia-bot/src \
&& printf 'fn main() {}\n' > crates/xmedia-bot/src/main.rs \
&& : > crates/x-media/src/lib.rs \
&& cargo build --release -p xmedia-bot
# 2. Static ffmpeg next (cached unless FFMPEG_URL changes), so source edits
# never re-download it. The zip contains a single `ffmpeg` binary at the
# root. `unzip -t` verifies the archive before extraction so a bad
# download fails loudly here instead of a cryptic later error.
RUN wget -q -O /tmp/ffmpeg.zip "$FFMPEG_URL" \
&& if [ -n "$FFMPEG_SHA256" ]; then echo "$FFMPEG_SHA256 /tmp/ffmpeg.zip" | sha256sum -c -; fi \
&& unzip -tq /tmp/ffmpeg.zip \
&& unzip -q /tmp/ffmpeg.zip -d /usr/local/bin \
&& chmod +x /usr/local/bin/ffmpeg \
&& rm /tmp/ffmpeg.zip \
&& /usr/local/bin/ffmpeg -version >/dev/null
# 3. Real sources last: only our crates recompile on source changes. Cargo's
# freshness check is mtime-based; the COPY'd host files usually predate the
# step-1 stub build, so cargo would consider the stub up to date and never
# compile the real sources. `touch` makes every .rs newer than the stub
# artifacts, forcing a rebuild of just the two crates while the compiled
# dependency layer stays cached. (`cargo clean -p` does NOT work here — it
# removes 0 files and the stub binary silently ships.)
COPY crates/ ./crates/
RUN find crates -type f -name '*.rs' -exec touch {} + \
&& cargo build --release -p xmedia-bot
# ---------- runtime stage ----------
FROM debian:bookworm-slim
# ARG scope is per-stage: re-declare for the label below.
ARG APP_NAME=telegram-twitter-media-bot
LABEL maintainer="admin@yoursfunny.top"
LABEL org.opencontainers.image.title="${APP_NAME}"
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
RUN set -eux; \
apt-get update; \
apt-get install -y gosu; \
rm -rf /var/lib/apt/lists/*; \
# verify that the binary works
gosu nobody true
# Everything is copied in — no apt in the runtime stage. Privilege dropping is
# done by docker-entrypoint.sh with setpriv (util-linux, already in
# bookworm-slim), so no gosu needed. TLS is rustls (webpki-roots baked in,
# see Cargo.toml feature `rustls`/`rustls-tls`), so no system CA bundle or
# libssl are needed; the static ffmpeg only processes local files (all
# downloads go through reqwest).
COPY --from=builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
WORKDIR /app
COPY --from=builder /build/target/release/xmedia-bot /usr/local/bin/xmedia-bot
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod a+x /app/docker-entrypoint.sh
COPY requirements.txt /app
RUN python -m pip install --no-cache-dir --upgrade -r requirements.txt
COPY . /app
RUN chmod a+x docker-entrypoint.sh
# State lives in /app/data (SQLite task queue + chat state); mount a volume
# there to keep it across restarts.
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["python", "main.py"]
CMD ["xmedia-bot"]
+123
View File
@@ -0,0 +1,123 @@
# TelegramXMediaBot
A Telegram bot that turns post links from X / Twitter, Pixiv, and Bluesky into media messages (images, video, GIF) with the post's title, author, and tags.
## Features
- Sending a link in a private chat fetches and sends the images, videos and GIFs automatically; oversized media is split into batches
- Text-only posts report "no media"; unsupported links are silently ignored
- Inline queries (`@bot <link>`)
- Bind a forward channel for automatic forwarding; edit the caption before forwarding and apply custom templates
- Failed sends are retried automatically with persistence; the user is notified after retries are exhausted
- Pixiv ugoira animations are transcoded to MP4; Bluesky videos are remuxed (HLS stream → MP4)
- Photos exceeding Telegram's size/dimension limits are compressed automatically (original format kept, JPEG fallback only when needed)
- Link-result cache: after a successful send the Telegram file ids and caption fields are cached locally, so a repeated link is re-sent from local state — no source-site request, no media file stored (expiry controlled by `LINK_CACHE_TTL_SECONDS`, default 7 days)
## Quick start
```bash
# Required: BotFather token; optional: PIXIV_REFRESH_TOKEN (Pixiv is disabled without it)
export TELOXIDE_TOKEN=<token>
export PIXIV_REFRESH_TOKEN=<token>
cargo run -p xmedia-bot
```
Docker deployment (see `docker-compose.yml.example`):
```bash
docker build -t tgxmb .
docker run --rm -d --name tgxmb --env-file .env -v ./data:/app/data tgxmb
```
Environment variables: `TELOXIDE_TOKEN` (required), `PIXIV_REFRESH_TOKEN`, `BOT_ADMIN`, `EDIT_MESSAGE_TTL_SECONDS`, `LINK_CACHE_TTL_SECONDS`, `RUST_LOG`, `WEBHOOK*`, `TWITTER_AUTH_TOKEN` (optional).
NSFW tweets: the public syndication endpoint does not return sensitive content. Setting `TWITTER_AUTH_TOKEN` (the `auth_token` cookie value of a logged-in x.com session) lets the bot fetch NSFW media in the logged-in state only when it hits a withheld tweet; without it, the bot reports no media.
### Webhook deployment (needs a reverse proxy)
`docker-compose.yml.example` ships an [nginx-proxy](https://github.com/nginx-proxy/nginx-proxy) + [acme-companion](https://github.com/nginx-proxy/acme-companion) reverse-proxy orchestration. Pick one deployment shape:
**With a domain**
1. Point a DNS A record at the server
2. In compose set `VIRTUAL_HOST` and `WEBHOOK_URL` to the domain, and uncomment `ACME_HOST` (set it to the domain)
3. acme-companion issues and renews certificates automatically — nothing manual
**IP only**
Let's Encrypt can issue certificates for public IPs (available since 2026, validity ~7 days, requires the `shortlived` profile). Use [acme.sh](https://github.com/acmesh-official/acme.sh) to issue and renew automatically, no manual certificates:
1. Add an acme-ip service to compose (issue + daily auto-renewal check):
```yaml
acme-ip:
image: neilpang/acme.sh
container_name: acme-ip
command: daemon
restart: always
volumes:
- certs:/acme.sh
- html:/usr/share/nginx/html
- /var/run/docker.sock:/var/run/docker.sock:ro
networks: [proxy]
```
2. First issuance (replace `<SERVER_IP>` with the server's public IP; IPv6 works too, repeat `-d` for more):
```bash
docker compose exec acme-ip acme.sh --issue --server letsencrypt \
-d <SERVER_IP> --cert-profile shortlived --days 3 \
--webroot /usr/share/nginx/html \
--install-cert --cert-file /acme.sh/<SERVER_IP>.crt \
--key-file /acme.sh/<SERVER_IP>.key \
--reloadcmd "curl --unix-socket /var/run/docker.sock -X POST http://localhost/containers/nginx-proxy/kill?signal=HUP"
```
3. In compose set `VIRTUAL_HOST: '<SERVER_IP>'` and `WEBHOOK_URL: 'https://<SERVER_IP>/'`; no `WEBHOOK_CERT` needed. Renewal is handled by the acme.sh daemon (`--days 3` = renew every 3 days, buffer against the 7-day validity), and a successful renewal HUP-notifies nginx-proxy to load the new certificate.
Limitations: certificate validity ~7 days; only http-01/tls-alpn-01 validation (port 80 must be publicly reachable); no DNS-01, private IPs or IP ranges; at most 5 certificates per 168 hours for the same IP set. It is recommended to trial-issue with `--server letsencrypt_test` first, then switch to the production server.
Telegram only accepts ports 443/80/88/8443.
<details>
<summary>Environment variables</summary>
| Variable | Description |
|---|---|
| `TELOXIDE_TOKEN` | Bot token (required) |
| `PIXIV_REFRESH_TOKEN` | Pixiv refresh token; Pixiv is disabled without it |
| `BOT_ADMIN` | Admin chat IDs, comma-separated; receives start/stop notifications |
| `EDIT_MESSAGE_TTL_SECONDS` | Edit-before-forward record expiry in seconds, default 86400 |
| `LINK_CACHE_TTL_SECONDS` | Link-result cache expiry in seconds, default 604800 (7 days) |
| `RUST_LOG` | Log level |
| `TELOXIDE_PROXY` | HTTP proxy (e.g. `http://127.0.0.1:10808`); applies to both the Telegram Bot API and site fetches — required on restricted networks (e.g. behind the GFW) |
| `LOCAL_USER_ID` | UID the container runs as, default 9001 |
| `VIRTUAL_HOST` | Public domain or IP; nginx-proxy routes by this |
| `VIRTUAL_PORT` | Port the bot listens on inside the container; nginx-proxy's forwarding target |
| `ACME_HOST` | Domain deployment: when set to the domain, acme-companion issues/renews certificates automatically |
| `DEFAULT_HOST` | nginx-proxy routes requests with unknown Host headers to this vhost (needed for IP access) |
| `DEFAULT_EMAIL` | acme-companion certificate notification email |
| `WEBHOOK` | `true` enables webhook mode (polling by default) |
| `WEBHOOK_LISTEN` / `WEBHOOK_PORT` | Listen address/port inside the bot container |
| `WEBHOOK_URL` | Public HTTPS URL (`https://domain/` or `https://IP/`) |
| `WEBHOOK_CERT` | Optional; self-signed certificate path, only used for Telegram-side validation (TLS is terminated by the reverse proxy) |
| `WEBHOOK_SECRET_TOKEN` | Update validation token (`X-Telegram-Bot-Api-Secret-Token`) |
</details>
## Commands
| Command | Description |
|---|---|
| `/start` | Welcome message |
| `/help` | List all commands and usage (this command table) |
| `/set_forward_channel <channel>` | Set the forward channel: `@channel` or channel ID; media messages are forwarded to it automatically afterwards |
| `/remove_forward_channel` | Remove the forward channel |
| `/edit_before_forward` | Toggle "edit before forward": when enabled, the bot posts a prompt after forwarding; replying to it edits the first forwarded message's caption (or taps a template button to apply one) |
| `/set_template <name>` | Reply to a message containing `[]` to save it as a named template; `[]` is replaced by the original post link when forwarding (used with "edit before forward") |
| `/set_format <site> <format>` | Customize the caption format for one site. Sites: `twitter` / `bsky` / `pixiv`. Placeholders: `{url}` `{author}` `{author_url}` `{title}` `{tags}` |
| `/clear_cache [link]` | Clear the link cache (admin only); with a link only that entry, otherwise everything |
| `/bot_dict` | Show the current chat state (debugging) |
Link processing works only in private chats; commands work in any chat.
## Notes
- State is persisted in `data/task_queue.db`; compose deployments use the bind mount `./data` (keep it a directory for easy backups)
- The runtime needs ffmpeg (built into the Docker image)
- Tests: `cargo test --workspace`
+123
View File
@@ -0,0 +1,123 @@
# TelegramXMediaBot
Telegram 机器人,将 X / Twitter、Pixiv、Bluesky 的帖子链接转换为媒体消息发送,附带帖子标题、作者与标签。
## 功能
- 私聊发送链接后自动抓取并发送图片、视频与 GIF,超量图片自动分批
- 纯文字帖提示无媒体;不支持的链接静默忽略
- 支持内联查询(`@机器人 <链接>`
- 可绑定转发频道自动转发;支持转发前编辑 caption 与自定义模板
- 发送失败自动重试并持久化,重试耗尽后通知用户
- Pixiv ugoira 动图自动转码为 MP4Bluesky 视频自动转码(HLS 流 → MP4)
- 超过 Telegram 尺寸/大小限制的图片自动压缩(保持原格式,必要时转 JPEG)
- 链接结果本地缓存:成功发送后缓存 Telegram file id 与 caption 等,再次收到相同链接直接本地重发,不再请求源站、不保存媒体文件(`LINK_CACHE_TTL_SECONDS` 控制过期,默认 7 天)
## 快速开始
```bash
# 必填:BotFather 的 token;可选:PIXIV_REFRESH_TOKEN(未设置则禁用 Pixiv
export TELOXIDE_TOKEN=<token>
export PIXIV_REFRESH_TOKEN=<token>
cargo run -p xmedia-bot
```
Docker 部署(参考 `docker-compose.yml.example`):
```bash
docker build -t tgxmb .
docker run --rm -d --name tgxmb --env-file .env -v ./data:/app/data tgxmb
```
环境变量:`TELOXIDE_TOKEN`(必填)、`PIXIV_REFRESH_TOKEN``BOT_ADMIN``EDIT_MESSAGE_TTL_SECONDS``LINK_CACHE_TTL_SECONDS``RUST_LOG``WEBHOOK*``TWITTER_AUTH_TOKEN`(可选)。
NSFW 推文:公开的 syndication 接口不返回敏感内容。设置 `TWITTER_AUTH_TOKEN`(登录 x.com 后浏览器 Cookie 里的 `auth_token` 值)后,bot 会仅在遇到 NSFW 推文时以登录态获取媒体;未设置则提示无媒体。
### Webhook 部署(需要反向代理)
`docker-compose.yml.example` 内置了 [nginx-proxy](https://github.com/nginx-proxy/nginx-proxy) + [acme-companion](https://github.com/nginx-proxy/acme-companion) 反向代理编排,按部署环境二选一:
**有域名**
1. DNS A 记录指向服务器
2. compose 里设 `VIRTUAL_HOST``WEBHOOK_URL` 为域名,并取消注释 `ACME_HOST`(设为域名)
3. acme-companion 自动签发与续期证书,无需手动处理
**只有 IP**
Let's Encrypt 支持为公网 IP 签发证书(2026 年起可用,有效期约 7 天,须 `shortlived` profile)。用 [acme.sh](https://github.com/acmesh-official/acme.sh) 自动签发与续期,无需手动证书:
1. compose 里增加 acme-ip 服务(签发 + 每日检查自动续期):
```yaml
acme-ip:
image: neilpang/acme.sh
container_name: acme-ip
command: daemon
restart: always
volumes:
- certs:/acme.sh
- html:/usr/share/nginx/html
- /var/run/docker.sock:/var/run/docker.sock:ro
networks: [proxy]
```
2. 首次签发(把 `<SERVER_IP>` 换成服务器公网 IP,IPv6 同样支持,多个 `-d` 可并列):
```bash
docker compose exec acme-ip acme.sh --issue --server letsencrypt \
-d <SERVER_IP> --cert-profile shortlived --days 3 \
--webroot /usr/share/nginx/html \
--install-cert --cert-file /acme.sh/<SERVER_IP>.crt \
--key-file /acme.sh/<SERVER_IP>.key \
--reloadcmd "curl --unix-socket /var/run/docker.sock -X POST http://localhost/containers/nginx-proxy/kill?signal=HUP"
```
3. compose 里设 `VIRTUAL_HOST: '<SERVER_IP>'`、`WEBHOOK_URL: 'https://<SERVER_IP>/'`,无需 `WEBHOOK_CERT`。续期由 acme.sh daemon 自动完成(`--days 3` = 每 3 天续一次,证书 7 天有效有缓冲),续期成功后自动 HUP 通知 nginx-proxy 加载新证书。
限制:证书约 7 天有效;验证仅支持 http-01/tls-alpn-0180 端口必须公网可达);不支持 DNS-01、私有 IP 与 IP 段;同一 IP 集合每 168 小时限签发 5 张。建议先用 `--server letsencrypt_test` 试签,成功后再切正式服务器。
Telegram 只接受 443/80/88/8443 端口。
<details>
<summary>环境变量说明</summary>
| 变量 | 说明 |
|---|---|
| `TELOXIDE_TOKEN` | Bot token(必填) |
| `PIXIV_REFRESH_TOKEN` | Pixiv 刷新令牌;未设置则禁用 Pixiv |
| `BOT_ADMIN` | 管理员聊天 ID,逗号分隔;接收启动/停止通知 |
| `EDIT_MESSAGE_TTL_SECONDS` | 转发前编辑记录过期秒数,默认 86400 |
| `LINK_CACHE_TTL_SECONDS` | 链接结果缓存过期秒数,默认 604800(7 天) |
| `RUST_LOG` | 日志级别 |
| `TELOXIDE_PROXY` | HTTP 代理(如 `http://127.0.0.1:10808`);同时作用于 Telegram Bot API 与站点抓取请求,网络受限环境(如 GFW)必需 |
| `LOCAL_USER_ID` | 容器内运行用户 UID,默认 9001 |
| `VIRTUAL_HOST` | 对外域名或 IPnginx-proxy 按此路由 |
| `VIRTUAL_PORT` | bot 容器内监听端口,nginx-proxy 的转发目标 |
| `ACME_HOST` | 域名部署:设为域名时由 acme-companion 自动签发/续期证书 |
| `DEFAULT_HOST` | nginx-proxy 将未知 Host 的请求路由到该 vhost(IP 访问时需要) |
| `DEFAULT_EMAIL` | acme-companion 证书通知邮箱 |
| `WEBHOOK` | `true` 启用 webhook 模式(默认轮询) |
| `WEBHOOK_LISTEN` / `WEBHOOK_PORT` | bot 容器内监听地址/端口 |
| `WEBHOOK_URL` | 对外公网 HTTPS 地址(`https://域名/` 或 `https://IP/` |
| `WEBHOOK_CERT` | 可选;自签名证书路径,仅用于 Telegram 侧验证(TLS 由反向代理终止) |
| `WEBHOOK_SECRET_TOKEN` | 更新校验令牌(`X-Telegram-Bot-Api-Secret-Token` |
</details>
## 命令
| 命令 | 说明 |
|---|---|
| `/start` | 欢迎语 |
| `/help` | 查看全部命令及用法(即本文档的命令表) |
| `/set_forward_channel <频道>` | 设置转发频道,参数为 `@频道名` 或频道 ID;设置后发送的媒体消息会自动转发到该频道 |
| `/remove_forward_channel` | 取消转发频道 |
| `/edit_before_forward` | 开关「转发前编辑」:开启后,转发成功后 bot 会发一条提示消息,回复它可修改第一条转发消息的 caption(或点击模板按钮套用模板) |
| `/set_template <名称>` | 回复一条含 `[]` 的消息,将其保存为命名模板;转发时 `[]` 会被替换为原帖链接(配合「转发前编辑」使用) |
| `/set_format <站点> <格式>` | 自定义某站点的 caption 格式。站点:`twitter` / `bsky` / `pixiv`。占位符:`{url}` `{author}` `{author_url}` `{title}` `{tags}` |
| `/clear_cache [链接]` | 清空链接缓存(仅管理员);带链接只清该条,否则清空全部 |
| `/bot_dict` | 查看当前聊天状态(调试用) |
链接处理仅限私聊;命令在任意聊天可用。
## 备注
- 数据持久化于 `data/task_queue.db`compose 部署使用 bind mount `./data`(保持目录形式便于备份)
- 运行环境需安装 ffmpeg(Docker 镜像已内置)
- 测试:`cargo test --workspace`
-25
View File
@@ -1,25 +0,0 @@
import logging
import os
import re
BOT_TOKEN = os.getenv("BOT_TOKEN")
ADMIN = os.getenv("BOT_ADMIN").split(",")
WEBHOOK = os.getenv("WEBHOOK", False)
if WEBHOOK:
WEBHOOK_LISTEN = os.getenv("WEBHOOK_LISTEN", "0.0.0.0")
WEBHOOK_PORT = int(os.getenv("WEBHOOK_PORT", 8443))
WEBHOOK_URL = os.getenv("WEBHOOK_URL")
WEBHOOK_KEY = os.getenv("WEBHOOK_KEY", "cert/private.key")
WEBHOOK_CERT = os.getenv("WEBHOOK_CERT", "cert/cert.pem")
WEBHOOK_SECRET_TOKEN = os.getenv("WEBHOOK_SECRET_TOKEN")
x_url_regex = re.compile(r"^(?:https?://)(?:www\.|mobile\.|)(?:x|twitter)\.com/(.+)/status/(\d+)")
x_media_regex = re.compile(r"^(?:https?://)(pbs|video)\.twimg\.com/(.*)")
x_tco_regex = re.compile(r"(?:https?://)t\.co/.+$", re.M)
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "WARNING"),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "x-media"
version = "1.2.1"
edition = "2024"
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
regex = "1.12"
html-escape = "0.2"
url = "2.5.2"
bytes = "1"
zip = "2"
tempfile = "3"
rand = "0.8"
log = "0.4"
tokio = { version = "1.40", features = ["time"] }
[dev-dependencies]
tokio = { version = "1.40", features = ["macros", "rt-multi-thread"] }
dotenv = "0.15"
+10
View File
@@ -0,0 +1,10 @@
use x_media::site;
#[tokio::main]
async fn main() {
let url = std::env::args()
.nth(1)
.expect("usage: cargo run -p x-media --example fetch -- <url>");
let result = site::fetch(&url).await;
println!("{result:#?}");
}
+2
View File
@@ -0,0 +1,2 @@
pub mod media;
pub mod site;
+55
View File
@@ -0,0 +1,55 @@
impl Media {
pub fn url(&self) -> &str {
match self {
Media::Illustration { url, .. } => url,
Media::Video { url, .. } => url,
Media::Animated { url, .. } => url,
}
}
pub fn thumbnail_url(&self) -> Option<&str> {
match self {
Media::Illustration { thumbnail_url, .. } => thumbnail_url.as_deref(),
Media::Video { thumbnail_url, .. } => Some(thumbnail_url),
Media::Animated { thumbnail_url, .. } => Some(thumbnail_url),
}
}
/// A smaller variant of this media's file (used as the fallback when the
/// primary URL or upload exceeds Telegram's size limits). None when no
/// smaller variant exists (videos, animated gifs).
pub fn smaller_url(&self) -> Option<&str> {
match self {
Media::Illustration {
url,
fallback_url,
thumbnail_url,
..
} => fallback_url
.as_deref()
.or(thumbnail_url.as_deref())
.filter(|smaller| *smaller != url),
Media::Video { .. } | Media::Animated { .. } => None,
}
}
}
#[derive(Debug)]
pub enum Media {
Illustration {
title: Option<String>,
url: String,
thumbnail_url: Option<String>,
fallback_url: Option<String>,
},
Video {
title: Option<String>,
url: String,
thumbnail_url: String,
},
Animated {
title: Option<String>,
url: String,
thumbnail_url: String,
},
}
+445
View File
@@ -0,0 +1,445 @@
use super::model;
use crate::media::Media;
use crate::site::{FetchError, Fetched};
use html_escape::{encode_double_quoted_attribute, encode_text};
use regex::Regex;
use std::sync::LazyLock;
pub static PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?:https?://)?bsky\.app/profile/([\w.\-:]+)/post/([\w.\-~]+)").unwrap()
});
pub fn enabled() -> bool {
true
}
pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
let caps = PATTERN.captures(url).ok_or(FetchError::NotFound)?;
let handle = caps
.get(1)
.map(|m| m.as_str())
.ok_or(FetchError::NotFound)?;
let rkey = caps
.get(2)
.map(|m| m.as_str())
.ok_or(FetchError::NotFound)?;
let post = fetch(handle, rkey).await?;
let mut fetched: Fetched = post.into();
// bsky video embeds expose only an HLS playlist URL, which Telegram
// cannot fetch; remux it to a single MP4 (mirrors the pixiv ugoira
// encode path — the temp file stays alive via `_keep_alive`). On any
// failure the video item is dropped and the post degrades to its text.
let mut media = Vec::with_capacity(fetched.media.len());
for item in fetched.media {
let is_hls = matches!(&item, Media::Video { url, .. }
if url.contains("playlist") || url.ends_with(".m3u8"));
if !is_hls {
media.push(item);
continue;
}
let url = item.url().to_string();
match resolve_bsky_video(&url).await {
Ok(Some((mp4_path, keep_alive))) => {
let thumbnail_url = match &item {
Media::Video { thumbnail_url, .. } => thumbnail_url.clone(),
_ => String::new(),
};
media.push(Media::Video {
title: None,
url: mp4_path.to_string_lossy().into_owned(),
thumbnail_url,
});
fetched._keep_alive = Some(keep_alive);
}
Ok(None) => log::warn!("bsky video remux unavailable for {url}"),
Err(e) => log::warn!("bsky video remux failed for {url}: {e}"),
}
}
fetched.media = media;
Ok(fetched)
}
/// Downloads an HLS playlist (master or media) and remuxes its segments to a
/// single MP4 via ffmpeg. Returns the MP4 path plus the temp dir that must
/// stay alive until the file is uploaded. `Ok(None)` when ffmpeg is missing.
///
/// Verified live (2026-08): bsky master playlists carry `#EXT-X-STREAM-INF`
/// variant lines (e.g. `720p/video.m3u8?session_id=…`), and the media
/// playlists are VOD MPEG-TS segments (`videoN.ts?…`) without EXT-X-MAP, so
/// a plain `-f concat -c copy` remux is valid.
async fn resolve_bsky_video(
playlist_url: &str,
) -> Result<Option<(std::path::PathBuf, tempfile::TempDir)>, String> {
if !crate::site::ffmpeg_available() {
crate::site::log_once_ffmpeg_missing();
return Ok(None);
}
let master = crate::site::download_media_limited(playlist_url, 1_048_576)
.await
.map_err(|e| format!("bsky video master playlist: {e}"))?;
let master = String::from_utf8_lossy(&master);
// Master playlist: pick the variant with the highest declared bandwidth.
let playlist_url = if master.contains("#EXT-X-STREAM-INF") {
let mut best: Option<(u64, String)> = None;
let mut lines = master.lines();
while let Some(line) = lines.next() {
if !line.starts_with("#EXT-X-STREAM-INF") {
continue;
}
let bandwidth = line
.split_once("BANDWIDTH=")
.and_then(|(_, rest)| rest.split(|c: char| !c.is_ascii_digit()).next())
.and_then(|n| n.parse::<u64>().ok())
.unwrap_or(0);
if let Some(uri) = lines.next().filter(|u| !u.starts_with('#'))
&& bandwidth >= best.as_ref().map(|(b, _)| *b).unwrap_or(0)
{
best = Some((bandwidth, uri.to_string()));
}
}
let Some((_, uri)) = best else {
return Err("bsky video master playlist has no variants".to_string());
};
url::Url::parse(playlist_url)
.and_then(|base| base.join(&uri))
.map_err(|e| format!("bsky video variant URL: {e}"))?
.to_string()
} else {
playlist_url.to_string()
};
let variant = crate::site::download_media_limited(&playlist_url, 1_048_576)
.await
.map_err(|e| format!("bsky video media playlist: {e}"))?;
let variant = String::from_utf8_lossy(&variant);
// Segment URIs: non-#, non-empty lines, resolved relative to the playlist.
let base = url::Url::parse(&playlist_url).map_err(|e| format!("bsky playlist URL: {e}"))?;
let segments: Vec<String> = variant
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(|l| base.join(l).map(|u| u.to_string()))
.collect::<Result<_, _>>()
.map_err(|e| format!("bsky segment URL: {e}"))?;
if segments.is_empty() {
return Err("bsky video playlist has no segments".to_string());
}
if segments.len() > 500 {
return Err("bsky video has too many segments".to_string());
}
let frames_dir = tempfile::tempdir().map_err(|e| e.to_string())?;
let out_dir = tempfile::tempdir().map_err(|e| e.to_string())?;
let mut total: u64 = 0;
let mut list = String::new();
for (i, seg) in segments.iter().enumerate() {
let bytes = crate::site::download_media_limited(seg, 20 * 1024 * 1024)
.await
.map_err(|e| format!("bsky segment {i}: {e}"))?;
total += bytes.len() as u64;
if total > 256 * 1024 * 1024 {
return Err("bsky video exceeds total size cap".to_string());
}
let path = frames_dir.path().join(format!("seg_{i:04}.ts"));
std::fs::write(&path, &bytes).map_err(|e| e.to_string())?;
list.push_str(&format!("file '{}'\n", path.to_string_lossy()));
}
let list_path = frames_dir.path().join("list.txt");
std::fs::write(&list_path, &list).map_err(|e| e.to_string())?;
let output = out_dir.path().join("video.mp4");
let list_str = list_path.to_string_lossy().into_owned();
let output_str = output.to_string_lossy().into_owned();
let status = tokio::task::spawn_blocking(move || {
std::process::Command::new("ffmpeg")
.args([
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
&list_str,
"-c",
"copy",
"-movflags",
"+faststart",
&output_str,
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
})
.await
.map_err(|e| format!("bsky remux worker panicked: {e}"))?;
match status {
Ok(s) if s.success() => Ok(Some((output, out_dir))),
Ok(s) => Err(format!("ffmpeg exited with {s}")),
Err(e) => Err(format!("ffmpeg spawn failed: {e}")),
}
}
/// Fetches a post thread by handle or DID (`at://` URIs work for both).
pub async fn fetch(handle: &str, rkey: &str) -> Result<Post, FetchError> {
let response = crate::site::CLIENT
.get(API_URL)
.query(&[
("uri", format!("at://{handle}/app.bsky.feed.post/{rkey}")),
("depth", "0".to_string()),
])
.send()
.await?;
// 404/410 = gone (permanent); 429/5xx = transient and retried by fetch.
let status = response.status();
if !status.is_success() {
return match status.as_u16() {
404 | 410 => Err(FetchError::NotFound),
_ => Err(FetchError::Transient(format!("bsky status {status}"))),
};
}
let text = response.text().await?;
Post::from_json(&text, rkey.to_string())
}
#[derive(Debug)]
pub struct Post {
id: String,
author: String,
author_id: String,
text: String,
media: Vec<Media>,
sensitive: bool,
}
impl Post {
fn url(&self) -> String {
format!("{}/post/{}", self.author_url(), self.id)
}
fn author_url(&self) -> String {
format!("https://bsky.app/profile/{}", self.author_id)
}
pub fn caption(&self) -> String {
format!(
"{url}\n<a href=\"{author_url}\">{author}</a>: {text}",
url = encode_double_quoted_attribute(&self.url()),
author_url = encode_double_quoted_attribute(&self.author_url()),
author = encode_text(&self.author),
text = encode_text(&self.text),
)
}
pub fn from_json(raw_json: &str, id: String) -> Result<Self, FetchError> {
let json: serde_json::Value = serde_json::from_str(raw_json).map_err(FetchError::Json)?;
let json: model::Info = serde_json::from_value(json).map_err(FetchError::Json)?;
match json.thread {
model::Thread::Post { post } => {
let text = post.record.text;
let author = post.author.display_name.unwrap_or_default();
let author_id = post.author.handle;
let mut media = vec![];
if let Some(embed) = post.embed {
match embed {
model::Media::Images { images } => {
media.extend(images.into_iter().map(|image| Media::Illustration {
title: None,
url: image.fullsize,
thumbnail_url: Some(image.thumb),
fallback_url: None,
}));
}
model::Media::Video {
playlist,
thumbnail,
} => {
media.push(Media::Video {
title: None,
url: playlist,
thumbnail_url: thumbnail,
});
}
model::Media::External => {}
}
}
let sensitive = post
.labels
.iter()
.any(|label| SENSITIVE_LABEL.contains(&label.val.as_str()));
Ok(Post {
id,
author,
author_id,
text,
media,
sensitive,
})
}
model::Thread::NotFound => Err(FetchError::NotFound),
model::Thread::Blocked => Err(FetchError::Blocked),
}
}
}
impl From<Post> for Fetched {
fn from(post: Post) -> Self {
let url = post.url();
let author_url = post.author_url();
let render_data = Some(crate::site::RenderData {
url: url.clone(),
author: encode_text(&post.author).into_owned(),
author_url: author_url.clone(),
title: encode_text(&post.text).into_owned(),
tags: String::new(),
});
Fetched {
source_url: url,
caption: post.caption(),
title: post.text.clone(),
media: post.media,
sensitive: post.sensitive,
render_data,
_keep_alive: None,
}
}
}
const API_URL: &str = "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread";
const SENSITIVE_LABEL: [&str; 4] = ["sexual", "nudity", "porn", "graphic-media"];
#[cfg(test)]
mod tests {
use super::*;
fn thread_json(post_json: serde_json::Value) -> serde_json::Value {
serde_json::json!({ "thread": post_json })
}
#[test]
fn pattern_matches_handle_and_did() {
let cases = [
(
"https://bsky.app/profile/user.bsky.social/post/3laoveufjv224",
"user.bsky.social",
"3laoveufjv224",
),
(
"https://bsky.app/profile/did:plc:abc123def/post/3xxxx",
"did:plc:abc123def",
"3xxxx",
),
];
for (url, handle, rkey) in cases {
let caps = PATTERN.captures(url).unwrap_or_else(|| panic!("{url}"));
assert_eq!(caps.get(1).unwrap().as_str(), handle);
assert_eq!(caps.get(2).unwrap().as_str(), rkey);
}
}
#[test]
fn pattern_rejects_non_post_urls() {
for url in [
"https://bsky.app/profile/user.bsky.social",
"https://bsky.app/profile/user.bsky.social/posts",
"https://x.com/user/status/123",
] {
assert!(!PATTERN.is_match(url), "{url}");
}
}
#[test]
fn from_json_images_with_missing_defaults() {
let raw = thread_json(serde_json::json!({
"$type": "app.bsky.feed.defs#threadViewPost",
"post": {
"author": { "handle": "user.bsky.social" },
"record": { "$type": "app.bsky.feed.post", "text": "hello <world>" },
"embed": {
"$type": "app.bsky.embed.images#view",
"images": [
{ "thumb": "https://cdn.bsky.app/img/thumb", "fullsize": "https://cdn.bsky.app/img/full", "alt": "" }
]
}
}
}));
let post = Post::from_json(&raw.to_string(), "3xxxx".into()).unwrap();
let fetched: Fetched = post.into();
assert_eq!(
fetched.source_url,
"https://bsky.app/profile/user.bsky.social/post/3xxxx"
);
assert_eq!(fetched.title, "hello <world>");
assert_eq!(fetched.media.len(), 1);
assert!(!fetched.sensitive);
// display_name absent -> empty fallback
assert!(
fetched.caption.contains("</a>: hello &lt;world&gt;"),
"caption: {}",
fetched.caption
);
}
#[test]
fn from_json_sensitive_labels() {
let raw = thread_json(serde_json::json!({
"$type": "app.bsky.feed.defs#threadViewPost",
"post": {
"author": { "handle": "u.bsky.social", "displayName": "U" },
"record": { "$type": "app.bsky.feed.post", "text": "x" },
"labels": [{ "val": "porn" }]
}
}));
let post = Post::from_json(&raw.to_string(), "3xxxx".into()).unwrap();
assert!(post.sensitive);
}
#[test]
fn from_json_blocked_and_not_found() {
let blocked = thread_json(serde_json::json!({
"$type": "app.bsky.feed.defs#blockedPost",
"blocked": true
}));
assert!(matches!(
Post::from_json(&blocked.to_string(), "3xxxx".into()),
Err(FetchError::Blocked)
));
let not_found = thread_json(serde_json::json!({
"$type": "app.bsky.feed.defs#notFoundPost",
"notFound": true
}));
assert!(matches!(
Post::from_json(&not_found.to_string(), "3xxxx".into()),
Err(FetchError::NotFound)
));
}
#[tokio::test]
#[ignore = "live network: requires outbound HTTPS to public.api.bsky.app"]
async fn live_fetch_with_photos() {
let fetched =
fetch_from_url("https://bsky.app/profile/asagi0398.bsky.social/post/3mqkhrq5w6k2m")
.await
.unwrap();
assert_eq!(
fetched.source_url,
"https://bsky.app/profile/asagi0398.bsky.social/post/3mqkhrq5w6k2m"
);
assert!(!fetched.caption.is_empty());
}
#[tokio::test]
#[ignore = "live network: requires outbound HTTPS to public.api.bsky.app"]
async fn live_fetch_smoke() {
let fetched =
fetch_from_url("https://bsky.app/profile/fu-futa.bsky.social/post/3laoveufjv224")
.await
.unwrap();
assert_eq!(
fetched.source_url,
"https://bsky.app/profile/fu-futa.bsky.social/post/3laoveufjv224"
);
assert!(!fetched.caption.is_empty());
}
}
+4
View File
@@ -0,0 +1,4 @@
mod interface;
mod model;
pub use interface::{PATTERN, Post, enabled, fetch_from_url};
+60
View File
@@ -0,0 +1,60 @@
use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub(crate) struct Info {
pub(crate) thread: Thread,
}
#[derive(Deserialize, Debug)]
#[serde(tag = "$type")]
pub(crate) enum Thread {
#[serde(rename = "app.bsky.feed.defs#threadViewPost")]
Post { post: Post },
#[serde(rename = "app.bsky.feed.defs#notFoundPost")]
NotFound,
#[serde(rename = "app.bsky.feed.defs#blockedPost")]
Blocked,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Post {
pub(crate) author: Author,
pub(crate) record: PostRecord,
pub(crate) embed: Option<Media>,
#[serde(default)]
pub(crate) labels: Vec<Label>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Author {
pub(crate) handle: String,
#[serde(rename = "displayName", default)]
pub(crate) display_name: Option<String>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct PostRecord {
pub(crate) text: String,
}
#[derive(Deserialize, Debug)]
#[serde(tag = "$type")]
pub(crate) enum Media {
#[serde(rename = "app.bsky.embed.images#view")]
Images { images: Vec<Image> },
#[serde(rename = "app.bsky.embed.video#view")]
Video { playlist: String, thumbnail: String },
#[serde(rename = "app.bsky.embed.external#view")]
External,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Image {
pub(crate) thumb: String,
pub(crate) fullsize: String,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Label {
pub(crate) val: String,
}
+623
View File
@@ -0,0 +1,623 @@
//! Site fetching dispatcher and unified result types.
//!
//! Dispatch order: twitter → bsky → pixiv. Each site module exports a
//! `PATTERN`, `enabled()` and `fetch_from_url()`; a future site plugs in by
//! adding one guarded entry in [`fetch_once`].
use std::fmt;
use std::sync::LazyLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
pub mod bsky;
pub mod pixiv;
pub mod twitter;
pub use pixiv::PixivError;
/// The result of fetching a post: canonical URL, HTML caption, raw text,
/// media list and spoiler flag. Produced by [`fetch`].
#[derive(Debug)]
pub struct Fetched {
/// Canonical URL: x.com/{author}/status/{id} |
/// https://www.pixiv.net/artworks/{id} |
/// https://bsky.app/profile/{handle}/post/{rkey}
pub source_url: String,
/// The exact HTML produced by the site's caption().
pub caption: String,
/// Raw post text (tweet text / bsky text / pixiv title).
pub title: String,
pub media: Vec<crate::media::Media>,
/// Spoiler flag for all media of this post.
pub sensitive: bool,
/// Raw values (pre-escaped) for user-customizable caption formats.
pub(crate) render_data: Option<RenderData>,
/// Keeps temp files (e.g. an encoded ugoira MP4) alive until the caller
/// finishes uploading; not part of the public contract.
pub(crate) _keep_alive: Option<tempfile::TempDir>,
}
/// Pre-escaped values for `{url} {author} {author_url} {title} {tags}`
/// placeholders in user-supplied caption formats.
#[derive(Debug)]
pub(crate) struct RenderData {
pub url: String,
pub author: String,
pub author_url: String,
pub title: String,
pub tags: String,
}
impl Fetched {
/// The site this post came from (used for per-site format overrides).
pub fn site_name(&self) -> &'static str {
if self.source_url.contains("x.com") || self.source_url.contains("twitter.com") {
"twitter"
} else if self.source_url.contains("bsky.app") {
"bsky"
} else if self.source_url.contains("pixiv.net") {
"pixiv"
} else {
"unknown"
}
}
/// Renders a user-supplied caption format. The format string is
/// HTML-escaped in full, then the (already-escaped) placeholder values
/// are substituted — users can structure text but never inject raw HTML
/// or attributes. An empty/unknown format falls back to the built-in
/// caption. The result is truncated to [`MAX_CAPTION_CHARS`] (Telegram's
/// caption limit for HTML parse mode).
pub fn caption_with(&self, format: &str) -> String {
match (&self.render_data, format.is_empty()) {
(Some(data), false) => caption_from_fields(
format,
"",
&data.url,
&data.author,
&data.author_url,
&data.title,
&data.tags,
),
_ => truncate_caption(&self.caption),
}
}
/// The pre-escaped placeholder values (author, author_url, title, tags)
/// a caller needs to rebuild a caption later, e.g. for a cached post
/// where the [`Fetched`] is no longer available.
pub fn render_fields(&self) -> Option<(&str, &str, &str, &str)> {
self.render_data.as_ref().map(|d| {
(
d.author.as_str(),
d.author_url.as_str(),
d.title.as_str(),
d.tags.as_str(),
)
})
}
/// Hands over the temp dir keeping locally produced media (ugoira MP4,
/// bsky remux MP4) alive. The bot keeps it while its task may still be
/// retried by the queue, which runs after this [`Fetched`] is dropped and
/// its temp files would otherwise be gone. `None` when no such dir exists.
pub fn take_keep_alive(&mut self) -> Option<tempfile::TempDir> {
self._keep_alive.take()
}
}
/// Telegram's caption length limit (chars) for HTML parse mode; longer
/// captions are rejected with a 400.
pub const MAX_CAPTION_CHARS: usize = 1024;
/// Truncates a caption to at most [`MAX_CAPTION_CHARS`] chars, appending an
/// ellipsis when cut. Backs off to before an unclosed HTML entity (`&amp`
/// without its `;` would be malformed HTML and rejected by Telegram).
pub fn truncate_caption(caption: &str) -> String {
if caption.chars().count() <= MAX_CAPTION_CHARS {
return caption.to_string();
}
// Leave one char for the ellipsis; floor_char_boundary lands on a char
// edge (byte index ≤ MAX-1, so chars ≤ MAX-1).
let mut end = caption.floor_char_boundary(MAX_CAPTION_CHARS - 1);
// Don't split an entity: if the last '&' before `end` has no closing ';'
// inside the kept part, cut before it.
if let Some(amp) = caption[..end].rfind('&')
&& !caption[amp..end].contains(';')
{
end = amp;
}
let mut s = caption[..end].to_string();
s.push('…');
s
}
/// Renders a user-supplied caption format from raw (already-escaped) field
/// values with the same escaping/substitution rules as
/// [`Fetched::caption_with`]. An empty format returns `built_in` unchanged.
/// The result is truncated to [`MAX_CAPTION_CHARS`] (Telegram's caption
/// limit for HTML parse mode).
pub fn caption_from_fields(
format: &str,
built_in: &str,
url: &str,
author: &str,
author_url: &str,
title: &str,
tags: &str,
) -> String {
if format.is_empty() {
return truncate_caption(built_in);
}
let escaped = html_escape::encode_text(format).into_owned();
truncate_caption(
&escaped
.replace("{url}", url)
.replace("{author}", author)
.replace("{author_url}", author_url)
.replace("{title}", title)
.replace("{tags}", tags),
)
}
/// Stable per-post cache key derived from any supported URL, so variant
/// domains (x.com / twitter.com / fxtwitter.com, mobile, `/photo/N`
/// suffixes) map to the same post. Returns `"twitter:<id>"`,
/// `"pixiv:<id>"` or `"bsky:<handle>/<rkey>"`.
pub fn cache_key(url: &str) -> Option<String> {
if let Some(caps) = twitter::PATTERN.captures(url) {
return Some(format!("twitter:{}", &caps[1]));
}
if let Some(caps) = pixiv::PATTERN.captures(url) {
return Some(format!("pixiv:{}", &caps[1]));
}
if let Some(caps) = bsky::PATTERN.captures(url) {
return Some(format!("bsky:{}/{}", &caps[1], &caps[2]));
}
None
}
#[derive(Debug)]
pub enum FetchError {
Http(reqwest::Error),
Json(serde_json::Error),
Pixiv(PixivError),
NotFound,
Blocked,
/// The post exists but its content is withheld (twitter NSFW /
/// age-restricted tweets come back as an empty `{}` from syndication).
Sensitive,
/// A download exceeded the caller's size cap (see [`download_media_limited`]).
TooLarge,
/// A transient server-side failure (429 / 5xx); [`fetch`] retries these.
Transient(String),
/// A local I/O failure while streaming a download to disk
/// (see [`download_media_to_file`]).
Io(std::io::Error),
}
impl fmt::Display for FetchError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FetchError::Http(e) => write!(f, "http error: {e}"),
FetchError::Json(e) => write!(f, "json error: {e}"),
FetchError::Pixiv(e) => write!(f, "pixiv error: {e}"),
FetchError::NotFound => write!(f, "not found"),
FetchError::Blocked => write!(f, "blocked"),
FetchError::Sensitive => write!(f, "content withheld (sensitive)"),
FetchError::TooLarge => write!(f, "media too large"),
FetchError::Transient(message) => write!(f, "transient: {message}"),
FetchError::Io(e) => write!(f, "io error: {e}"),
}
}
}
impl std::error::Error for FetchError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
FetchError::Http(e) => Some(e),
FetchError::Json(e) => Some(e),
FetchError::Pixiv(e) => Some(e),
FetchError::NotFound | FetchError::Blocked | FetchError::Sensitive => None,
FetchError::TooLarge => None,
FetchError::Transient(_) => None,
FetchError::Io(e) => Some(e),
}
}
}
impl From<reqwest::Error> for FetchError {
fn from(e: reqwest::Error) -> Self {
FetchError::Http(e)
}
}
impl From<serde_json::Error> for FetchError {
fn from(e: serde_json::Error) -> Self {
FetchError::Json(e)
}
}
impl From<PixivError> for FetchError {
fn from(e: PixivError) -> Self {
FetchError::Pixiv(e)
}
}
/// Shared HTTP client (browser User-Agent) for twitter/bsky fetches and
/// [`download_media`].
pub(crate) static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
let mut builder = reqwest::Client::builder()
.user_agent("Mozilla/5.0")
// reqwest has no total timeout by default; a stalled connection
// would otherwise pin a fetch/handler forever.
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10));
// Route site fetches through the same proxy the Bot API uses, so a
// network that needs TELOXIDE_PROXY (e.g. behind the GFW) does not
// leave site fetches dead while the bot itself works.
if let Some(proxy) = std::env::var("TELOXIDE_PROXY")
.ok()
.filter(|s| !s.is_empty())
&& let Ok(p) = reqwest::Proxy::all(&proxy)
{
builder = builder.proxy(p);
}
// Each `#[tokio::test]` runs on its own runtime; the connection pool is
// bound to the runtime that created it, so cross-runtime reuse of idle
// connections fails with DispatchGone. In test builds every request uses
// a fresh connection. Production runs on one runtime and keeps pooling.
#[cfg(test)]
let builder = builder.pool_max_idle_per_host(0);
builder.build().expect("failed to build HTTP client")
});
/// Whether a usable `ffmpeg` binary is on PATH (probed once). Shared by the
/// pixiv ugoira encoder and the bsky HLS remuxer.
static FFMPEG_AVAILABLE: LazyLock<bool> = LazyLock::new(|| {
std::process::Command::new("ffmpeg")
.arg("-version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
});
static FFMPEG_MISSING_LOGGED: AtomicBool = AtomicBool::new(false);
pub(crate) fn ffmpeg_available() -> bool {
*FFMPEG_AVAILABLE
}
pub(crate) fn log_once_ffmpeg_missing() {
if !FFMPEG_MISSING_LOGGED.swap(true, Ordering::Relaxed) {
log::warn!("ffmpeg not found; ugoira and bsky video posts stay unsupported");
}
}
/// Fetches a post from its URL. Returns `Ok(None)` when no site pattern
/// matches (unsupported links are silently ignored by the bot).
///
/// Transient failures are retried: 3 total attempts with 1s then 2s delays.
/// Retried classes: bare HTTP errors, [`FetchError::Transient`] (429/5xx
/// from any site), pixiv network errors, and pixiv HTTP statuses that are
/// actually transient (429 / 5xx). Permanent classes are returned
/// immediately: Json, NotFound, Blocked, Sensitive, pixiv 4xx statuses
/// (bad/expired token, forbidden, not found) and pixiv API/auth errors.
/// Whether [`fetch`] should retry `err` (3 total attempts, 1s then 2s
/// backoff). Permanent classes — 4xx statuses, invalid tokens, unparseable
/// bodies, not-found/blocked/sensitive — are returned immediately; retrying
/// them only wastes attempts against the source site.
fn fetch_error_is_retryable(err: &FetchError) -> bool {
match err {
FetchError::Http(_) | FetchError::Transient(_) => true,
FetchError::Pixiv(e) => match e {
PixivError::Http(_) => true,
PixivError::Status(code) if *code == 429 || *code >= 500 => true,
// 4xx, invalid token, unparseable body: retrying cannot help.
PixivError::Status(_)
| PixivError::Api(_)
| PixivError::Json(_)
| PixivError::NoAuth => false,
},
_ => false,
}
}
pub async fn fetch(url: &str) -> Result<Option<Fetched>, FetchError> {
for attempt in 0..3u32 {
match fetch_once(url).await {
Ok(Some(fetched)) => {
// Per-request detail: debug only, keyed by the post id.
log::debug!(
"fetched [key={}]: site {} returned {} media",
cache_key(url).unwrap_or_else(|| "?".into()),
fetched.site_name(),
fetched.media.len()
);
return Ok(Some(fetched));
}
Ok(None) => return Ok(None),
Err(err) => {
if fetch_error_is_retryable(&err) && attempt < 2 {
tokio::time::sleep(Duration::from_secs(1 << attempt)).await;
} else {
return Err(err);
}
}
}
}
unreachable!("retry loop always returns")
}
async fn fetch_once(url: &str) -> Result<Option<Fetched>, FetchError> {
if twitter::enabled() && twitter::PATTERN.is_match(url) {
return Ok(Some(twitter::fetch_from_url(url).await?));
}
if bsky::enabled() && bsky::PATTERN.is_match(url) {
return Ok(Some(bsky::fetch_from_url(url).await?));
}
if pixiv::enabled() && pixiv::PATTERN.is_match(url) {
return Ok(Some(pixiv::fetch_from_url(url).await?));
}
Ok(None)
}
/// Downloads media bytes for the bot's upload fallback: when Telegram's own
/// fetch of a media URL is blocked (hotlink protection), the bot downloads
/// the file itself and uploads it via multipart. Site-appropriate headers:
/// pixiv image hosts need the `Referer` header.
/// Returns the Content-Length of a media URL, or `None` when the server does
/// not report one. Used to check whether a file fits Telegram's size limits
/// before downloading/uploading it.
pub async fn media_size(url: &str) -> Result<Option<u64>, FetchError> {
let mut request = CLIENT.get(url);
let lower = url.to_ascii_lowercase();
if lower.contains("pximg.net") {
request = request.header("Referer", "https://www.pixiv.net/");
}
let response = request.send().await?.error_for_status()?;
Ok(response.content_length())
}
/// Downloads a media file with a hard size cap: the body is streamed and the
/// download aborts with [`FetchError::TooLarge`] the moment the cap is
/// crossed (or when a declared Content-Length already exceeds it). Keeps the
/// bot from buffering arbitrarily large bodies into memory.
pub async fn download_media_limited(url: &str, max_bytes: u64) -> Result<bytes::Bytes, FetchError> {
let mut request = CLIENT.get(url);
let lower = url.to_ascii_lowercase();
if lower.contains("pximg.net") {
request = request.header("Referer", "https://www.pixiv.net/");
}
let response = request.send().await?.error_for_status()?;
if let Some(len) = response.content_length()
&& len > max_bytes
{
return Err(FetchError::TooLarge);
}
let mut response = response;
let mut buf = Vec::new();
while let Some(chunk) = response.chunk().await? {
buf.extend_from_slice(&chunk);
if buf.len() as u64 > max_bytes {
return Err(FetchError::TooLarge);
}
}
Ok(bytes::Bytes::from(buf))
}
pub async fn download_media(url: &str) -> Result<bytes::Bytes, FetchError> {
download_media_limited(url, u64::MAX).await
}
/// Streams a download to `out`, aborting with [`FetchError::TooLarge`] the
/// moment the body crosses `max_bytes` (or when a declared Content-Length
/// already exceeds it). Unlike [`download_media_limited`] the body is never
/// buffered in memory — used for large files (e.g. the pixiv ugoira frame
/// zip, which can be hundreds of MB) that would otherwise spike RAM.
/// Returns the number of bytes written.
pub async fn download_media_to_file(
url: &str,
max_bytes: u64,
out: &mut std::fs::File,
) -> Result<u64, FetchError> {
use std::io::Write;
let mut request = CLIENT.get(url);
let lower = url.to_ascii_lowercase();
if lower.contains("pximg.net") {
request = request.header("Referer", "https://www.pixiv.net/");
}
let response = request.send().await?.error_for_status()?;
if let Some(len) = response.content_length()
&& len > max_bytes
{
return Err(FetchError::TooLarge);
}
let mut response = response;
let mut total: u64 = 0;
while let Some(chunk) = response.chunk().await? {
total += chunk.len() as u64;
if total > max_bytes {
return Err(FetchError::TooLarge);
}
out.write_all(&chunk).map_err(FetchError::Io)?;
}
Ok(total)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_key_normalizes_domain_variants() {
assert_eq!(
cache_key("https://x.com/user/status/1234567890/photo/1"),
Some("twitter:1234567890".into())
);
assert_eq!(
cache_key("https://mobile.twitter.com/user/status/1234567890"),
Some("twitter:1234567890".into())
);
assert_eq!(
cache_key("https://fxtwitter.com/user/status/1234567890"),
Some("twitter:1234567890".into())
);
assert_eq!(
cache_key("https://www.pixiv.net/artworks/123456"),
Some("pixiv:123456".into())
);
assert_eq!(
cache_key("https://bsky.app/profile/handle.example/post/3lorem"),
Some("bsky:handle.example/3lorem".into())
);
assert_eq!(cache_key("https://example.com/not-a-post"), None);
}
#[test]
fn fetch_error_retryability_classification() {
// Transient: network errors, explicit transient, pixiv 429/5xx.
assert!(fetch_error_is_retryable(&FetchError::Transient(
"429".into()
)));
assert!(fetch_error_is_retryable(&FetchError::Pixiv(
PixivError::Status(429)
)));
assert!(fetch_error_is_retryable(&FetchError::Pixiv(
PixivError::Status(500)
)));
assert!(fetch_error_is_retryable(&FetchError::Pixiv(
PixivError::Status(503)
)));
// Permanent: pixiv 4xx (bad/expired token, forbidden, not found),
// api/auth errors, unparseable bodies, not-found/blocked/sensitive.
assert!(!fetch_error_is_retryable(&FetchError::Pixiv(
PixivError::Status(400)
)));
assert!(!fetch_error_is_retryable(&FetchError::Pixiv(
PixivError::Status(401)
)));
assert!(!fetch_error_is_retryable(&FetchError::Pixiv(
PixivError::Status(403)
)));
assert!(!fetch_error_is_retryable(&FetchError::Pixiv(
PixivError::Status(404)
)));
assert!(!fetch_error_is_retryable(&FetchError::Pixiv(
PixivError::Api("invalid_grant".into())
)));
assert!(!fetch_error_is_retryable(&FetchError::Pixiv(
PixivError::NoAuth
)));
let json_err = serde_json::from_str::<serde_json::Value>("x").unwrap_err();
assert!(!fetch_error_is_retryable(&FetchError::Pixiv(
PixivError::Json(json_err)
)));
assert!(!fetch_error_is_retryable(&FetchError::NotFound));
assert!(!fetch_error_is_retryable(&FetchError::Blocked));
assert!(!fetch_error_is_retryable(&FetchError::Sensitive));
assert!(!fetch_error_is_retryable(&FetchError::TooLarge));
}
#[test]
fn caption_from_fields_substitutes_and_escapes() {
// The format string is escaped, the field values are substituted
// verbatim (callers pass the already-escaped render data).
let out = caption_from_fields(
"see {author} at {url} — {title}",
"",
"https://x.com/u/status/1",
"A &amp; B",
"https://x.com/u",
"hello <world>",
"",
);
assert_eq!(
out,
"see A &amp; B at https://x.com/u/status/1 — hello <world>"
);
// Empty format keeps the built-in caption untouched.
assert_eq!(
caption_from_fields("", "built-in", "u", "a", "au", "t", "g"),
"built-in"
);
}
#[test]
fn truncate_caption_keeps_short_text() {
assert_eq!(truncate_caption("short"), "short");
// Exactly at the limit: untouched.
let exact = "x".repeat(MAX_CAPTION_CHARS);
assert_eq!(truncate_caption(&exact), exact);
}
#[test]
fn truncate_caption_cuts_long_text_with_ellipsis() {
let long = "x".repeat(MAX_CAPTION_CHARS + 100);
let out = truncate_caption(&long);
assert!(
out.chars().count() <= MAX_CAPTION_CHARS,
"len {}",
out.chars().count()
);
assert!(out.ends_with('…'));
}
#[test]
fn truncate_caption_does_not_split_an_html_entity() {
// An entity crossing the cut must not be left half-open (&amp without ;).
let mut long = "a".repeat(MAX_CAPTION_CHARS - 4);
long.push_str("&amp;bbbb");
let out = truncate_caption(&long);
assert!(out.chars().count() <= MAX_CAPTION_CHARS);
assert!(!out.contains("&amp"), "half entity left: {out:?}");
assert!(!out.ends_with('&'));
}
#[test]
fn truncate_caption_handles_multibyte_boundary() {
// Multi-byte chars near the cut must not panic (char-boundary cut).
let long = "".repeat(MAX_CAPTION_CHARS + 10);
let out = truncate_caption(&long);
assert!(out.chars().count() <= MAX_CAPTION_CHARS);
}
#[tokio::test]
async fn unsupported_url_returns_none() {
let result = fetch("https://example.com/some/article").await;
assert!(matches!(result, Ok(None)), "got {result:?}");
}
#[tokio::test]
async fn unknown_scheme_returns_none() {
let result = fetch("not a url at all").await;
assert!(matches!(result, Ok(None)), "got {result:?}");
}
#[tokio::test]
async fn download_media_pixiv_original_with_referer() {
// Proves the Referer header is attached for i.pximg.net: a header-less
// GET to a pixiv original URL is rejected with 403.
// Empty-string check too: an unset CI secret arrives as "" (GitHub
// Actions), which would otherwise run the test tokenless and fail.
if std::env::var("PIXIV_REFRESH_TOKEN")
.ok()
.filter(|s| !s.is_empty())
.is_none()
{
eprintln!("skipping: no PIXIV_REFRESH_TOKEN");
return;
}
let illustration = pixiv::fetch(126839080).await.unwrap();
let fetched: Fetched = illustration.into();
let url = match fetched.media.first() {
Some(crate::media::Media::Illustration { url, .. }) => url.clone(),
other => panic!("expected illustration media, got {other:?}"),
};
assert!(url.contains("i.pximg.net"));
let bytes = download_media(&url).await.unwrap();
assert!(!bytes.is_empty());
}
}
+441
View File
@@ -0,0 +1,441 @@
//! Native pixiv app-API client (replaces pixiv3-rs).
//!
//! Token exchange against `oauth.secure.pixiv.net` and illust detail against
//! `app-api.pixiv.net`, deserialized with the kept `model.rs` types.
use super::interface::Illustration;
use super::model::{IllustrationModel, TypeModel, UgoiraMetadataModel};
use crate::media::Media;
use crate::site::FetchError;
use std::env;
use std::fmt;
use std::io::Read;
use std::sync::LazyLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime};
const AUTH_TOKEN_URL: &str = "https://oauth.secure.pixiv.net/auth/token";
const APP_API_URL: &str = "https://app-api.pixiv.net";
const CLIENT_ID: &str = "MOBrBDS8blbauoSck0ZfDbtuzpyT";
const CLIENT_SECRET: &str = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj";
const AUTH_USER_AGENT: &str = "PixivAndroidApp/5.0.234 (Android 11; Pixel 5)";
const APP_USER_AGENT: &str = "PixivIOSApp/7.13.3 (iOS 14.6; iPhone13,2)";
/// Token refresh safe margin (seconds).
const TOKEN_REFRESH_SAFE_MARGIN: u64 = 300;
#[derive(Debug)]
pub enum PixivError {
/// No refresh token available (PIXIV_REFRESH_TOKEN unset).
NoAuth,
Http(reqwest::Error),
Json(serde_json::Error),
/// Non-2xx HTTP status from the app API. The code lets [`crate::site::fetch`]
/// retry only transient classes (429 / 5xx) instead of burning attempts on
/// permanent 4xx (bad token, forbidden, not found).
Status(u16),
Api(String),
}
impl fmt::Display for PixivError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PixivError::NoAuth => write!(f, "pixiv: no authentication"),
PixivError::Http(e) => write!(f, "pixiv http error: {e}"),
PixivError::Json(e) => write!(f, "pixiv json error: {e}"),
PixivError::Status(code) => write!(f, "pixiv status {code}"),
PixivError::Api(message) => write!(f, "pixiv api error: {message}"),
}
}
}
impl std::error::Error for PixivError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
PixivError::Http(e) => Some(e),
PixivError::Json(e) => Some(e),
_ => None,
}
}
}
impl From<reqwest::Error> for PixivError {
fn from(e: reqwest::Error) -> Self {
PixivError::Http(e)
}
}
impl From<serde_json::Error> for PixivError {
fn from(e: serde_json::Error) -> Self {
PixivError::Json(e)
}
}
/// Native pixiv app-API client.
pub struct PixivAPI {
refresh_token: String,
access_token: tokio::sync::Mutex<Option<(String, SystemTime)>>,
}
impl PixivAPI {
pub fn new(refresh_token: String) -> Self {
Self {
refresh_token,
access_token: tokio::sync::Mutex::new(None),
}
}
/// Returns a valid access token, exchanging the refresh token when none
/// is cached or it has expired.
pub async fn get_access_token(&self) -> Result<String, PixivError> {
let mut guard = self.access_token.lock().await;
if let Some((token, expires_at)) = guard.as_ref()
&& *expires_at > SystemTime::now()
{
return Ok(token.clone());
}
let response = crate::site::CLIENT
.post(AUTH_TOKEN_URL)
.form(&[
("client_id", CLIENT_ID),
("client_secret", CLIENT_SECRET),
("grant_type", "refresh_token"),
("include_policy", "true"),
("refresh_token", &self.refresh_token),
])
.header("User-Agent", AUTH_USER_AGENT)
.send()
.await?;
let json: serde_json::Value = serde_json::from_str(&response.text().await?)?;
let access_token = json
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| {
let message = json
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("invalid token response");
PixivError::Api(message.to_string())
})?
.to_string();
let expires_in = json
.get("expires_in")
.and_then(|v| v.as_u64())
.filter(|&sec| sec > 0)
.unwrap_or(3600);
let expires_at = SystemTime::now()
+ Duration::from_secs(expires_in.saturating_sub(TOKEN_REFRESH_SAFE_MARGIN));
*guard = Some((access_token.clone(), expires_at));
Ok(access_token)
}
/// Fetches illust detail from the app API.
pub async fn illust_detail(&self, illust_id: u64) -> Result<IllustrationModel, PixivError> {
let access_token = self.get_access_token().await?;
let response = crate::site::CLIENT
.get(format!(
"{APP_API_URL}/v1/illust/detail?illust_id={illust_id}"
))
.header("app-os", "ios")
.header("app-os-version", "14.6")
.header("User-Agent", APP_USER_AGENT)
.bearer_auth(access_token)
.send()
.await?;
if !response.status().is_success() {
return Err(PixivError::Status(response.status().as_u16()));
}
let json: serde_json::Value = serde_json::from_str(&response.text().await?)?;
if json.get("error").is_some() {
let message = json
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("illust detail failed");
return Err(PixivError::Api(message.to_string()));
}
let illust = json
.get("illust")
.ok_or_else(|| PixivError::Api("missing illust in response".to_string()))?;
Ok(serde_json::from_value(illust.clone())?)
}
pub async fn fetch(&self, illust_id: u64) -> Result<Illustration, FetchError> {
let model = self.illust_detail(illust_id).await?;
let mut illustration = Illustration::from_model(&model);
if matches!(&model.r#type, TypeModel::Ugoira) {
// Real ugoira support: download the frame zip and encode an MP4.
// Without ffmpeg (or on encode failure) the post stays
// unsupported (empty media, like Python).
match self.ugoira_video(illust_id).await {
Ok(Some((mp4_path, _keep_alive))) => {
illustration.media.push(Media::Video {
title: None,
url: mp4_path,
thumbnail_url: model.image_urls.medium.clone(),
});
illustration._keep_alive = Some(_keep_alive);
}
Ok(None) => {}
Err(e) => log::error!("ugoira encode failed for {illust_id}: {e}"),
}
}
Ok(illustration)
}
/// Fetches ugoira metadata (frame zip + frame delays) from the app API.
pub async fn ugoira_metadata(&self, illust_id: u64) -> Result<UgoiraMetadataModel, PixivError> {
let access_token = self.get_access_token().await?;
let response = crate::site::CLIENT
.get(format!(
"{APP_API_URL}/v1/ugoira/metadata?illust_id={illust_id}"
))
.header("app-os", "ios")
.header("app-os-version", "14.6")
.header("User-Agent", APP_USER_AGENT)
.bearer_auth(access_token)
.send()
.await?;
if !response.status().is_success() {
return Err(PixivError::Status(response.status().as_u16()));
}
let json: serde_json::Value = serde_json::from_str(&response.text().await?)?;
if json.get("error").is_some() {
let message = json
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("ugoira metadata failed");
return Err(PixivError::Api(message.to_string()));
}
let metadata = json
.get("ugoira_metadata")
.ok_or_else(|| PixivError::Api("missing ugoira_metadata".to_string()))?;
Ok(serde_json::from_value(metadata.clone())?)
}
/// Downloads the frame zip and encodes one MP4 via ffmpeg. Returns the
/// MP4 path plus the temp directory that must stay alive until the file
/// is uploaded.
async fn ugoira_video(
&self,
illust_id: u64,
) -> Result<Option<(String, tempfile::TempDir)>, PixivError> {
if !crate::site::ffmpeg_available() {
crate::site::log_once_ffmpeg_missing();
return Ok(None);
}
let metadata = self.ugoira_metadata(illust_id).await?;
if metadata.frames.is_empty() {
return Ok(None);
}
let zip_url = metadata
.zip_url
.clone()
.or_else(|| metadata.zip_urls.as_ref().map(|z| z.medium.clone()));
let Some(zip_url) = zip_url else {
return Ok(None);
};
// Stream the frame zip to a temp file instead of buffering it in
// memory: ugoira zips can be hundreds of MB, and the old
// download_media_limited path spiked RAM up to the size cap.
let mut zip_file = tempfile::Builder::new()
.suffix(".zip")
.tempfile()
.map_err(|e| PixivError::Api(format!("temp zip failed: {e}")))?;
crate::site::download_media_to_file(&zip_url, 512 * 1024 * 1024, zip_file.as_file_mut())
.await
.map_err(|e| match e {
FetchError::Http(e) => PixivError::Http(e),
other => PixivError::Api(format!("frame zip download failed: {other}")),
})?;
let frame_delays = metadata.frames.iter().map(|f| f.delay).collect::<Vec<_>>();
let result =
tokio::task::spawn_blocking(move || -> Result<(String, tempfile::TempDir), String> {
let frames_dir = tempfile::tempdir().map_err(|e| e.to_string())?;
let out_dir = tempfile::tempdir().map_err(|e| e.to_string())?;
// Extract frames to canonical zero-padded names; pixiv ugoira
// frames are uniformly jpg or png per artwork. The zip is read
// from disk; `zip_file` stays alive for the whole extraction.
let mut archive = zip::ZipArchive::new(
std::fs::File::open(zip_file.path()).map_err(|e| e.to_string())?,
)
.map_err(|e| format!("unzip: {e}"))?;
if archive.is_empty() {
return Err("empty frame zip".to_string());
}
// Uniform jpg or png per artwork; sniff the first entry's
// magic bytes instead of trusting its filename.
let first = archive.by_index(0).map_err(|e| e.to_string())?;
let mut first_bytes = Vec::new();
first
.take(64 * 1024 * 1024 + 1)
.read_to_end(&mut first_bytes)
.map_err(|e| e.to_string())?;
if first_bytes.len() > 64 * 1024 * 1024 {
return Err("frame exceeds size cap".to_string());
}
let extension = if first_bytes.starts_with(&[0xFF, 0xD8]) {
"jpg"
} else if first_bytes.starts_with(b"\x89PNG") {
"png"
} else {
"jpg"
};
let mut count = 0usize;
{
let path = frames_dir
.path()
.join(format!("img_{count:05}.{extension}"));
std::fs::write(&path, &first_bytes).map_err(|e| e.to_string())?;
count += 1;
}
for i in 1..archive.len() {
let entry = archive.by_index(i).map_err(|e| e.to_string())?;
if entry.size() > 64 * 1024 * 1024 {
return Err(format!("frame {i} exceeds size cap"));
}
let mut bytes = Vec::new();
entry
.take(64 * 1024 * 1024 + 1)
.read_to_end(&mut bytes)
.map_err(|e| e.to_string())?;
if bytes.len() > 64 * 1024 * 1024 {
return Err(format!("frame {i} exceeds size cap"));
}
let path = frames_dir
.path()
.join(format!("img_{count:05}.{extension}"));
std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
count += 1;
}
if count == 0 {
return Err("empty frame zip".to_string());
}
// Constant rate from the median frame delay (ms).
let mut delays = frame_delays;
delays.sort_unstable();
let median = delays[delays.len() / 2].max(1);
let framerate = 1000.0 / median as f64;
let output = out_dir.path().join("ugoira.mp4");
let status = std::process::Command::new("ffmpeg")
.args([
"-y",
"-framerate",
&framerate.to_string(),
"-i",
&frames_dir
.path()
.join(format!("img_%05d.{extension}"))
.to_string_lossy(),
// libx264 needs even dimensions; pixiv ugoira frames can
// be odd-sized (e.g. 277x405).
"-vf",
"scale=trunc(iw/2)*2:trunc(ih/2)*2",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
&output.to_string_lossy(),
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map_err(|e| format!("ffmpeg spawn failed: {e}"))?;
if !status.success() {
return Err(format!("ffmpeg exited with {status}"));
}
Ok((output.to_string_lossy().into_owned(), out_dir))
})
.await
.map_err(|e| {
log::error!("ugoira encode worker panicked for {illust_id}: {e}");
PixivError::Api(format!("ugoira worker failed: {e}"))
})?;
match result {
Ok(pair) => Ok(Some(pair)),
Err(message) => {
log::error!("ugoira encode failed for {illust_id}: {message}");
Ok(None)
}
}
}
}
/// pixiv3-rs replacement: `None` when `PIXIV_REFRESH_TOKEN` is unset.
static PIXIV_CLIENT: LazyLock<Option<PixivAPI>> =
LazyLock::new(|| env::var("PIXIV_REFRESH_TOKEN").ok().map(PixivAPI::new));
/// Set at startup when the login validation fails; pixiv stays disabled until
/// the next process start.
static DISABLED: AtomicBool = AtomicBool::new(false);
pub fn enabled() -> bool {
!DISABLED.load(Ordering::Relaxed) && env::var("PIXIV_REFRESH_TOKEN").is_ok()
}
/// Permanently disables pixiv until the next process start.
pub fn disable() {
DISABLED.store(true, Ordering::Relaxed);
}
/// Forces the refresh-token → access-token exchange now, surfacing invalid
/// tokens and network errors. Called once at bot startup; on failure the bot
/// calls [`disable`].
pub async fn validate() -> Result<(), PixivError> {
match PIXIV_CLIENT.as_ref() {
None => Err(PixivError::NoAuth),
Some(client) => {
client.get_access_token().await?;
Ok(())
}
}
}
pub async fn fetch(illust_id: u64) -> Result<Illustration, FetchError> {
let client = PIXIV_CLIENT
.as_ref()
.filter(|_| enabled())
.ok_or(FetchError::Pixiv(PixivError::NoAuth))?;
client.fetch(illust_id).await
}
#[cfg(test)]
mod tests {
use super::*;
use dotenv::dotenv;
/// Skips when `PIXIV_REFRESH_TOKEN` is absent or empty (CI without the
/// secret must stay green; GitHub Actions exposes an unset secret as an
/// empty string, so `is_err()` alone is not enough).
fn require_pixiv_token() -> bool {
std::env::var("PIXIV_REFRESH_TOKEN")
.ok()
.filter(|s| !s.is_empty())
.is_some()
}
#[tokio::test]
async fn test_fetch() {
dotenv().ok();
if !require_pixiv_token() {
eprintln!("skipping: no PIXIV_REFRESH_TOKEN");
return;
}
let result = fetch(126839080).await;
assert!(result.is_ok());
println!("{:#?}", result);
}
#[tokio::test]
#[ignore = "live network: requires outbound HTTPS to oauth.secure.pixiv.net"]
async fn live_validate_with_bogus_token_fails() {
dotenv().ok();
// A bogus token must surface as Api error (invalid_grant), not panic.
let client = PixivAPI::new("bogus_token_for_testing".to_string());
let result = client.get_access_token().await;
assert!(matches!(result, Err(PixivError::Api(_))), "got {result:?}");
}
}
+425
View File
@@ -0,0 +1,425 @@
use super::model::{IllustrationModel, TypeModel};
use crate::media::Media;
use crate::site::{FetchError, Fetched};
use html_escape::{encode_double_quoted_attribute, encode_text};
use regex::Regex;
use std::sync::LazyLock;
pub static PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?:https?://)?(?:www\.)?pixiv\.net/(?:en/)?(?:(?:i|artworks)/|member_illust\.php\?(?:mode=[a-z_]*&)?illust_id=)(\d+)").unwrap()
});
pub fn enabled() -> bool {
super::api::enabled()
}
pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
let id = PATTERN
.captures(url)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str())
.ok_or(FetchError::NotFound)?;
let id = id.parse::<u64>().map_err(|_| FetchError::NotFound)?;
Ok(super::api::fetch(id).await?.into())
}
#[derive(Debug)]
pub struct Illustration {
id: String,
title: String,
author: String,
author_id: String,
tags: Vec<String>,
pub(crate) media: Vec<Media>,
nsfw: bool,
/// Keeps a temp dir (ugoira MP4) alive until the send completes.
pub(crate) _keep_alive: Option<tempfile::TempDir>,
}
impl Illustration {
fn url(&self) -> String {
format!("https://www.pixiv.net/artworks/{}", self.id)
}
fn author_url(&self) -> String {
format!("https://www.pixiv.net/users/{}", self.author_id)
}
pub fn caption(&self) -> String {
format!(
"<a href=\"{url}\">{title}</a> / <a href=\"{author_url}\">{author}</a>\n{tags}",
url = encode_double_quoted_attribute(&self.url()),
title = encode_text(&self.title),
author_url = encode_double_quoted_attribute(&self.author_url()),
author = encode_text(&self.author),
tags = encode_text(
&self
.tags
.iter()
.map(|tag| format!("#{tag}"))
.collect::<Vec<_>>()
.join(" ")
),
)
}
pub fn from_model(model: &IllustrationModel) -> Self {
let id = model.id.to_string();
let title = model.title.clone();
let author = model.user.name.clone();
let author_id = model.user.id.to_string();
let mut tags: Vec<String> = model.tags.iter().map(|tag| tag.name.clone()).collect();
// illust_ai_type: 0 = undefined, 1 = not AI, 2 = AI-generated.
// Mark AI works with a leading #AI tag (rendered via the `#{tag}`
// caption format).
if model.illust_ai_type == 2 {
tags.insert(0, "AI".to_string());
}
let mut media = vec![];
if matches!(&model.r#type, TypeModel::Ugoira) {
// No static images for ugoira; the fetch path encodes an MP4 via
// ffmpeg and appends it as a Video item (api.rs). This fallback
// keeps media empty when encoding fails or ffmpeg is missing.
} else if model.page_count > 1 {
media.extend(model.meta_pages.iter().filter_map(|page| {
page.image_urls
.original
.clone()
.map(|original| Media::Illustration {
title: None,
url: original,
thumbnail_url: Some(page.image_urls.medium.clone()),
fallback_url: Some(page.image_urls.large.clone()),
})
}));
} else if let Some(original) = model
.meta_single_page
.original_image_url
.clone()
.or(model.image_urls.original.clone())
{
media.push(Media::Illustration {
title: None,
url: original,
thumbnail_url: Some(model.image_urls.medium.clone()),
fallback_url: Some(model.image_urls.large.clone()),
});
}
let nsfw = model.sanity_level > 5;
Self {
id,
title,
author,
author_id,
tags,
media,
nsfw,
_keep_alive: None,
}
}
}
impl From<Illustration> for Fetched {
fn from(illustration: Illustration) -> Self {
let url = illustration.url();
let author_url = illustration.author_url();
let tags = illustration
.tags
.iter()
.map(|tag| format!("#{tag}"))
.collect::<Vec<_>>()
.join(" ");
let render_data = Some(crate::site::RenderData {
url: url.clone(),
author: encode_text(&illustration.author).into_owned(),
author_url: author_url.clone(),
title: encode_text(&illustration.title).into_owned(),
tags: encode_text(&tags).into_owned(),
});
Fetched {
source_url: url,
caption: illustration.caption(),
title: illustration.title.clone(),
media: illustration.media,
sensitive: illustration.nsfw,
render_data,
_keep_alive: illustration._keep_alive,
}
}
}
#[cfg(test)]
mod tests {
use super::super::model::IllustrationModel;
use super::*;
fn illust_json(
type_: &str,
page_count: u8,
single_original: Option<&str>,
image_urls_original: Option<&str>,
pages: Vec<(Option<&str>, &str, &str)>,
ai_type: i32,
) -> serde_json::Value {
let meta_pages: Vec<serde_json::Value> = pages
.into_iter()
.map(|(original, medium, large)| {
serde_json::json!({
"image_urls": {
"medium": medium,
"large": large,
"original": original
}
})
})
.collect();
serde_json::json!({
"illust": {
"id": 123,
"title": "Art <title>",
"type": type_,
"image_urls": {
"medium": "medium.jpg",
"large": "large.jpg",
"original": image_urls_original
},
"user": { "id": 456, "name": "Artist" },
"tags": [{ "name": "tag1" }, { "name": "tag2" }],
"page_count": page_count,
"sanity_level": 6,
"illust_ai_type": ai_type,
"meta_single_page": { "original_image_url": single_original },
"meta_pages": meta_pages
}
})
}
fn parse(v: serde_json::Value) -> Illustration {
let model: IllustrationModel = serde_json::from_value(v["illust"].clone()).unwrap();
Illustration::from_model(&model)
}
#[test]
fn pattern_matches_all_forms() {
let cases = [
("https://www.pixiv.net/artworks/123456", "123456"),
("https://pixiv.net/artworks/123456", "123456"),
("https://www.pixiv.net/en/artworks/123456", "123456"),
("https://www.pixiv.net/i/123456", "123456"),
(
"https://www.pixiv.net/member_illust.php?mode=medium&illust_id=123456",
"123456",
),
(
"https://www.pixiv.net/en/member_illust.php?illust_id=123456",
"123456",
),
];
for (url, id) in cases {
let caps = PATTERN.captures(url).unwrap_or_else(|| panic!("{url}"));
assert_eq!(caps.get(1).unwrap().as_str(), id);
}
}
#[test]
fn pattern_rejects_non_artwork_urls() {
for url in [
"https://www.pixiv.net/users/123",
"https://x.com/user/status/123",
"https://bsky.app/profile/u/post/3xxxx",
] {
assert!(!PATTERN.is_match(url), "{url}");
}
}
#[test]
fn ugoira_yields_empty_media() {
let v = illust_json(
"ugoira",
1,
Some("https://i.pximg.net/orig.jpg"),
None,
vec![],
0,
);
let illustration = parse(v);
let fetched: Fetched = illustration.into();
assert!(fetched.media.is_empty());
assert!(fetched.sensitive, "sanity_level 6 > 5");
assert_eq!(fetched.title, "Art <title>");
}
#[test]
fn single_page_with_single_original() {
let v = illust_json(
"illust",
1,
Some("https://i.pximg.net/single.jpg"),
None,
vec![],
0,
);
let fetched: Fetched = parse(v).into();
assert_eq!(fetched.media.len(), 1);
match &fetched.media[0] {
Media::Illustration { url, .. } => {
assert_eq!(url, "https://i.pximg.net/single.jpg")
}
other => panic!("expected Illustration, got {other:?}"),
}
}
#[test]
fn single_page_falls_back_to_image_urls_original() {
let v = illust_json(
"illust",
1,
None,
Some("https://i.pximg.net/fallback.jpg"),
vec![],
0,
);
let fetched: Fetched = parse(v).into();
assert_eq!(fetched.media.len(), 1);
match &fetched.media[0] {
Media::Illustration { url, .. } => {
assert_eq!(url, "https://i.pximg.net/fallback.jpg")
}
other => panic!("expected Illustration, got {other:?}"),
}
}
#[test]
fn single_page_without_any_original_is_empty() {
let v = illust_json("illust", 1, None, None, vec![], 0);
let fetched: Fetched = parse(v).into();
assert!(fetched.media.is_empty());
}
#[test]
fn multi_page_skips_pages_without_original() {
let v = illust_json(
"illust",
2,
None,
None,
vec![
(None, "m1.jpg", "l1.jpg"),
(Some("https://i.pximg.net/p2.jpg"), "m2.jpg", "l2.jpg"),
],
0,
);
let fetched: Fetched = parse(v).into();
assert_eq!(fetched.media.len(), 1);
match &fetched.media[0] {
Media::Illustration {
url,
thumbnail_url,
fallback_url,
..
} => {
assert_eq!(url, "https://i.pximg.net/p2.jpg");
assert_eq!(thumbnail_url.as_deref(), Some("m2.jpg"));
assert_eq!(fallback_url.as_deref(), Some("l2.jpg"));
}
other => panic!("expected Illustration, got {other:?}"),
}
}
#[test]
fn caption_with_escapes_format_and_substitutes() {
let v = illust_json(
"illust",
1,
Some("https://i.pximg.net/o.jpg"),
None,
vec![],
0,
);
let fetched: Fetched = parse(v).into();
// Format string is escaped in full, then placeholders substituted.
let out = fetched.caption_with("{title} by {author} <script> {tags}");
assert!(
out.contains("Art &lt;title&gt; by Artist &lt;script&gt; #tag1 #tag2"),
"got: {out}"
);
assert!(!out.contains("<script>"), "no raw HTML injection: {out}");
// {url} and {author_url} carry the site's own URLs.
let out = fetched.caption_with("{url} {author_url}");
assert_eq!(
out,
"https://www.pixiv.net/artworks/123 https://www.pixiv.net/users/456"
);
// Empty format falls back to the built-in caption.
assert_eq!(fetched.caption_with(""), fetched.caption);
assert_eq!(fetched.site_name(), "pixiv");
}
#[test]
fn ai_work_gets_leading_ai_tag() {
// illust_ai_type == 2 is the only AI marker.
let v = illust_json(
"illust",
1,
Some("https://i.pximg.net/o.jpg"),
None,
vec![],
2,
);
let fetched: Fetched = parse(v).into();
assert!(
fetched.caption.contains("#AI #tag1 #tag2"),
"caption: {}",
fetched.caption
);
// The {tags} placeholder reflects the tag array too.
assert!(
fetched.caption_with("{tags}").starts_with("#AI "),
"got: {}",
fetched.caption_with("{tags}")
);
}
#[test]
fn non_ai_work_has_no_ai_tag() {
// 1 = explicitly not AI, 0 = undefined: neither gets the #AI tag.
for ai_type in [0, 1] {
let v = illust_json(
"illust",
1,
Some("https://i.pximg.net/o.jpg"),
None,
vec![],
ai_type,
);
let fetched: Fetched = parse(v).into();
assert!(
!fetched.caption.contains("#AI"),
"ai_type={ai_type} got: {}",
fetched.caption
);
}
}
#[test]
fn caption_escapes_and_links() {
let v = illust_json(
"illust",
1,
Some("https://i.pximg.net/o.jpg"),
None,
vec![],
0,
);
let fetched: Fetched = parse(v).into();
assert!(
fetched
.caption
.contains("<a href=\"https://www.pixiv.net/artworks/123\">Art &lt;title&gt;</a>"),
"caption: {}",
fetched.caption
);
assert!(fetched.caption.contains("#tag1 #tag2"));
assert_eq!(fetched.source_url, "https://www.pixiv.net/artworks/123");
}
}
+6
View File
@@ -0,0 +1,6 @@
mod api;
mod interface;
mod model;
pub use api::{PixivAPI, PixivError, disable, fetch, validate};
pub use interface::{Illustration, PATTERN, enabled, fetch_from_url};
+79
View File
@@ -0,0 +1,79 @@
// Model set for the native pixiv app-API client (app-api.pixiv.net).
use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub struct IllustrationModel {
pub id: u64,
pub title: String,
pub r#type: TypeModel,
pub image_urls: ImageUrlsModel,
pub user: UserInfoModel,
pub tags: Vec<IllustrationTagModel>,
pub page_count: u8,
pub sanity_level: u8,
/// 0 = undefined (unlabeled), 1 = not AI, 2 = AI-generated.
pub illust_ai_type: i32,
pub meta_single_page: MetaSinglePageModel,
pub meta_pages: Vec<MetaPageModel>,
}
#[derive(Deserialize, Debug)]
pub enum TypeModel {
#[serde(rename = "illust")]
Illust,
#[serde(rename = "manga")]
Manga,
#[serde(rename = "ugoira")]
Ugoira,
}
#[derive(Deserialize, Debug)]
pub struct UserInfoModel {
pub id: u64,
pub name: String,
}
#[derive(Deserialize, Debug)]
pub struct ImageUrlsModel {
pub medium: String,
pub large: String,
#[serde(default)]
pub original: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct IllustrationTagModel {
pub name: String,
}
#[derive(Deserialize, Debug)]
pub struct MetaSinglePageModel {
#[serde(default)]
pub original_image_url: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct MetaPageModel {
pub image_urls: ImageUrlsModel,
}
#[derive(Deserialize, Debug)]
pub struct UgoiraMetadataModel {
/// Older API shape (`zip_url`); newer responses use `zip_urls.medium`.
#[serde(default)]
pub zip_url: Option<String>,
#[serde(default)]
pub zip_urls: Option<UgoiraZipUrlsModel>,
pub frames: Vec<UgoiraFrameModel>,
}
#[derive(Deserialize, Debug)]
pub struct UgoiraZipUrlsModel {
pub medium: String,
}
#[derive(Deserialize, Debug)]
pub struct UgoiraFrameModel {
pub delay: u32,
}
+386
View File
@@ -0,0 +1,386 @@
//! Authenticated fallback for tweets the public syndication endpoint refuses
//! to serve (NSFW / age-restricted tweets come back as an empty `{}`).
//!
//! Mirrors nazurin's web API client ([`web.py`]) and is used *only* when
//! syndication reports [`FetchError::Sensitive`]: the private GraphQL
//! `TweetDetail` endpoint, authenticated with a browser session cookie from
//! `TWITTER_AUTH_TOKEN` (the `auth_token` cookie value of a logged-in x.com
//! session). A fresh random `ct0` is generated per call; X checks that the
//! `x-csrf-token` header matches the cookie, not that it issued the value.
//!
//! [`web.py`]: https://github.com/y-young/nazurin/blob/master/nazurin/sites/twitter/api/web.py
//!
//! # Caveats
//! - X rotates the GraphQL query id when it rolls the web app; if requests
//! start failing, update [`TWEET_DETAIL_QUERY_ID`]. Fresh references from
//! the actively maintained FxEmbed/FxEmbed: TweetDetail
//! `R9IzzyzQBV87-DOWpcvDmw`, TweetResultByRestId `f2sagi1jweVHFkTUIHzmMQ`
//! (the latter is anonymous and surfaces NSFW tweets as
//! `reason: NsfwLoggedOut`).
//! - `x-client-transaction-id` is only required for `SearchTimeline`
//! (verified against FxEmbed's `proxy/allowlist.ts`) — TweetDetail works
//! without it; no need for the nazurin home-page/JS-bundle derivation.
use std::sync::LazyLock;
use serde_json::{Value, json};
use crate::site::FetchError;
use super::interface::Tweet;
/// `auth_token` cookie of a logged-in x.com session; enables the fallback.
/// Trimmed: a CRLF `.env` (Windows) leaves a trailing `\r` on the value,
/// which would make the Cookie header invalid.
static AUTH_TOKEN: LazyLock<Option<String>> = LazyLock::new(|| {
std::env::var("TWITTER_AUTH_TOKEN")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
});
/// Public "logged in" client token used by the x.com web app.
const LOGGED_IN_BEARER: &str = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
/// `TweetDetail` query id (from nazurin; still valid as of 2026-08,
/// corroborated by the current FxEmbed build — see module caveats).
const TWEET_DETAIL_QUERY_ID: &str = "_8aYOgEDz35BrBcBal1-_w";
fn variables(id: &str) -> Value {
json!({
"focalTweetId": id,
"with_rux_injections": false,
"includePromotedContent": false,
"withCommunity": true,
"withQuickPromoteEligibilityTweetFields": false,
"withBirdwatchNotes": false,
"withVoice": true,
})
}
fn features() -> Value {
json!({
"rweb_video_screen_enabled": false,
"profile_label_improvements_pcf_label_in_post_enabled": true,
"rweb_tipjar_consumption_enabled": true,
"verified_phone_label_enabled": false,
"creator_subscriptions_tweet_preview_api_enabled": true,
"responsive_web_graphql_timeline_navigation_enabled": true,
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
"premium_content_api_read_enabled": false,
"communities_web_enable_tweet_community_results_fetch": true,
"c9s_tweet_anatomy_moderator_badge_enabled": true,
"responsive_web_grok_analyze_button_fetch_trends_enabled": false,
"responsive_web_grok_analyze_post_followups_enabled": true,
"responsive_web_jetfuel_frame": false,
"responsive_web_grok_share_attachment_enabled": true,
"articles_preview_enabled": true,
"responsive_web_edit_tweet_api_enabled": true,
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
"view_counts_everywhere_api_enabled": true,
"longform_notetweets_consumption_enabled": true,
"responsive_web_twitter_article_tweet_consumption_enabled": true,
"tweet_awards_web_tipping_enabled": false,
"responsive_web_grok_show_grok_translated_post": false,
"responsive_web_grok_analysis_button_from_backend": true,
"creator_subscriptions_quote_tweet_preview_enabled": false,
"freedom_of_speech_not_reach_fetch_enabled": true,
"standardized_nudges_misinfo": true,
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
"longform_notetweets_rich_text_read_enabled": true,
"longform_notetweets_inline_media_enabled": true,
"responsive_web_grok_image_annotation_enabled": true,
"responsive_web_enhance_cards_enabled": false,
})
}
/// Whether the authenticated fallback is available.
pub fn enabled() -> bool {
AUTH_TOKEN.is_some()
}
/// Fetches a tweet as the logged-in user via the private GraphQL API.
/// Returns the syndication-shaped [`Tweet`] (media included for NSFW posts).
pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
let token = AUTH_TOKEN.as_deref().ok_or(FetchError::Sensitive)?;
// 16 random bytes as 32 hex chars: X rejects ct0 values of any other
// length with 403 code 353 ("matching csrf cookie and header").
let ct0: String = (0..16)
.map(|_| format!("{:02x}", rand::random::<u8>()))
.collect();
let response = crate::site::CLIENT
.get(format!(
"https://x.com/i/api/graphql/{TWEET_DETAIL_QUERY_ID}/TweetDetail"
))
.query(&[
("variables", variables(id).to_string()),
("features", features().to_string()),
])
.header("authorization", LOGGED_IN_BEARER)
.header("x-csrf-token", &ct0)
.header("x-twitter-auth-type", "OAuth2Session")
.header("cookie", format!("auth_token={token}; ct0={ct0}"))
.header("x-twitter-client-language", "en")
.header("x-twitter-active-user", "yes")
.header("referer", "https://x.com/")
.send()
.await?;
// 404/410 = gone (permanent); 429/5xx = transient and retried by fetch.
let status = response.status();
if !status.is_success() {
log::warn!("twitter auth fetch {id}: HTTP {status}");
return match status.as_u16() {
404 | 410 => Err(FetchError::NotFound),
_ => Err(FetchError::Transient(format!(
"twitter auth status {status}"
))),
};
}
let text = response.text().await?;
let json: Value = serde_json::from_str(&text)?;
let result = parse_tweet_result(&json, id)?;
let syndication_shape = to_syndication_shape(&result).ok_or_else(|| {
FetchError::Json(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"missing tweet fields in GraphQL response",
)))
})?;
Tweet::from_syndication_json(&syndication_shape.to_string()).map_err(FetchError::Json)
}
/// Locates the tweet for `id` in a `TweetDetail` response and unwraps
/// visibility wrappers / retweets, mirroring nazurin's `_process_response`.
fn parse_tweet_result(json: &Value, id: &str) -> Result<Value, FetchError> {
if let Some(errors) = json.get("errors").and_then(|e| e.as_array()) {
let messages: Vec<&str> = errors
.iter()
.filter_map(|e| e.get("message").and_then(|m| m.as_str()))
.collect();
log::warn!("twitter auth fetch {id} failed: {}", messages.join("; "));
return Err(FetchError::NotFound);
}
let instructions = json
.pointer("/data/threaded_conversation_with_injections_v2/instructions")
.and_then(|v| v.as_array())
.ok_or(FetchError::NotFound)?;
for instruction in instructions {
if instruction.get("type").and_then(|t| t.as_str()) != Some("TimelineAddEntries") {
continue;
}
let entries = instruction
.get("entries")
.and_then(|e| e.as_array())
.ok_or(FetchError::NotFound)?;
let wanted = format!("tweet-{id}");
for entry in entries {
if entry.get("entryId").and_then(|i| i.as_str()) == Some(wanted.as_str()) {
let result = entry
.pointer("/content/itemContent/tweet_results/result")
.ok_or(FetchError::NotFound)?;
return normalize_tweet_result(result);
}
}
}
Err(FetchError::NotFound)
}
/// Unwraps TweetTombstone/TweetUnavailable errors, the
/// TweetWithVisibilityResults wrapper and retweets, returning the
/// `{core, legacy, ...}` tweet object.
fn normalize_tweet_result(result: &Value) -> Result<Value, FetchError> {
match result.get("__typename").and_then(|t| t.as_str()) {
Some("TweetTombstone") => {
let text = result
.pointer("/tombstone/text/text")
.and_then(|t| t.as_str())
.unwrap_or("tweet is unavailable");
log::warn!("twitter auth fetch: tombstone: {text}");
return Err(FetchError::NotFound);
}
Some("TweetUnavailable") => {
let reason = result
.get("reason")
.and_then(|r| r.as_str())
.unwrap_or("unknown");
log::warn!("twitter auth fetch: tweet unavailable: {reason}");
return Err(FetchError::NotFound);
}
_ => {}
}
// TweetWithVisibilityResults (e.g. limited replies) nests the real tweet.
let tweet = result.get("tweet").unwrap_or(result);
// A retweet's media lives on the original tweet.
if let Some(original) = tweet.pointer("/legacy/retweeted_status_result/result") {
return Ok(original.clone());
}
Ok(tweet.clone())
}
/// Maps a GraphQL `{core, legacy, ...}` tweet onto the syndication JSON
/// shape [`Tweet::from_syndication_json`] parses, so the existing text /
/// media handling (t.co expansion, `name=orig`, mp4 variant) is reused.
fn to_syndication_shape(tweet: &Value) -> Option<Value> {
let legacy = tweet.get("legacy")?;
let user = tweet.pointer("/core/user_results/result/legacy")?;
Some(json!({
"id_str": legacy.get("id_str"),
"text": legacy.get("full_text"),
"user": {
"name": user.get("name"),
"screen_name": user.get("screen_name"),
},
"possibly_sensitive": legacy.get("possibly_sensitive"),
"entities": legacy.get("entities"),
"mediaDetails": legacy.pointer("/extended_entities/media"),
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn tweet_result() -> Value {
json!({
"__typename": "Tweet",
"core": {
"user_results": {
"result": {
"legacy": { "name": "Display Name", "screen_name": "nsfw_author" }
}
}
},
"legacy": {
"id_str": "2083868672721039569",
"full_text": "nsfw content https://t.co/abc123",
"possibly_sensitive": true,
"entities": {
// The appended media link lives in extended_entities.media,
// not entities.urls, so it has no expansion mapping and the
// content-based strip removes it.
"urls": []
},
"extended_entities": {
"media": [
{
"type": "photo",
"media_url_https": "https://pbs.twimg.com/media/nsfw.jpg",
"original_info": { "width": 1200, "height": 800 }
},
{
"type": "video",
"media_url_https": "https://pbs.twimg.com/thumb.jpg",
"video_info": {
"variants": [
{ "content_type": "application/x-mpegURL", "url": "https://x.com/pl.m3u8" },
{ "content_type": "video/mp4", "url": "https://video.twimg.com/nsfw.mp4" }
]
}
}
]
}
}
})
}
fn conversation(tweet: Value) -> Value {
json!({
"data": {
"threaded_conversation_with_injections_v2": {
"instructions": [
{ "type": "TimelineAddEntries", "entries": [
{ "entryId": "tweet-2083868672721039569",
"content": { "itemContent": { "tweet_results": { "result": tweet } } } }
]}
]
}
}
})
}
#[test]
fn parses_graphql_tweet_into_fetched() {
let json = conversation(tweet_result());
let result = parse_tweet_result(&json, "2083868672721039569").unwrap();
let shape = to_syndication_shape(&result).unwrap();
let tweet = Tweet::from_syndication_json(&shape.to_string()).unwrap();
let fetched: crate::site::Fetched = tweet.into();
assert!(fetched.sensitive);
assert_eq!(fetched.media.len(), 2);
match &fetched.media[0] {
crate::media::Media::Illustration { url, .. } => {
assert_eq!(url, "https://pbs.twimg.com/media/nsfw.jpg?name=orig");
}
other => panic!("expected illustration, got {other:?}"),
}
match &fetched.media[1] {
crate::media::Media::Video { url, .. } => {
assert_eq!(url, "https://video.twimg.com/nsfw.mp4");
}
other => panic!("expected video, got {other:?}"),
}
assert_eq!(
fetched.source_url,
"https://x.com/nsfw_author/status/2083868672721039569"
);
// The appended media short link (no URL-entity mapping) is stripped.
assert_eq!(fetched.title, "nsfw content");
}
#[test]
fn unwraps_retweet_to_original() {
let original = tweet_result();
let mut rt = tweet_result();
rt["legacy"]["retweeted_status_result"] = json!({ "result": original });
let json = conversation(rt);
let result = parse_tweet_result(&json, "2083868672721039569").unwrap();
assert!(result.pointer("/legacy/retweeted_status_result").is_none());
assert_eq!(
result.pointer("/legacy/id_str").unwrap(),
"2083868672721039569"
);
}
#[test]
fn error_response_maps_to_not_found() {
let json = json!({ "errors": [{ "message": "NsfwLoggedOut" }] });
assert!(matches!(
parse_tweet_result(&json, "1"),
Err(FetchError::NotFound)
));
}
#[test]
fn missing_entry_maps_to_not_found() {
let json = conversation(json!({ "__typename": "Tweet" }));
assert!(matches!(
parse_tweet_result(&json, "999"),
Err(FetchError::NotFound)
));
}
#[test]
fn tombstone_maps_to_not_found() {
let tombstone = json!({
"__typename": "TweetTombstone",
"tombstone": { "text": { "text": "Age-restricted adult content" } }
});
let json = conversation(tombstone);
assert!(matches!(
parse_tweet_result(&json, "2083868672721039569"),
Err(FetchError::NotFound)
));
}
#[test]
fn visibility_wrapper_unwraps() {
let inner = tweet_result();
let wrapped = json!({ "__typename": "TweetWithVisibilityResults", "tweet": inner });
let json = conversation(wrapped);
let result = parse_tweet_result(&json, "2083868672721039569").unwrap();
assert_eq!(result.get("__typename").unwrap(), "Tweet");
}
}
@@ -0,0 +1,670 @@
use super::model;
use crate::media::Media;
use crate::site::{FetchError, Fetched};
use html_escape::{encode_double_quoted_attribute, encode_text};
use regex::Regex;
use std::sync::LazyLock;
pub static PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?:https?://)?(?:www\.|mobile\.)?(?:x|twitter|fixvx|vxtwitter|fixupx|fxtwitter)\.com/[^.]+/status/(\d+)").unwrap()
});
pub fn enabled() -> bool {
true
}
pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
let id = PATTERN
.captures(url)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str())
.ok_or(FetchError::NotFound)?;
match fetch(id).await {
Ok(tweet) => Ok(tweet.into()),
// Syndication withholds NSFW/age-restricted tweets (empty `{}`).
// Retry as the logged-in user when TWITTER_AUTH_TOKEN is set;
// otherwise degrade to an empty result (the bot replies
// "No media found").
Err(FetchError::Sensitive) => {
if super::auth::enabled() {
match super::auth::fetch(id).await {
Ok(tweet) => Ok(tweet.into()),
// The tweet is genuinely gone (deleted / suspended /
// tombstoned): report it instead of degrading to an
// empty result ("No media found"). Only unexpected
// fallback failures (network, parse) keep the NSFW
// placeholder.
Err(FetchError::NotFound) => Err(FetchError::NotFound),
Err(e) => {
log::warn!("twitter auth fallback failed for {id}: {e}");
Ok(empty_fetched(url))
}
}
} else {
log::debug!("tweet {id} is sensitive; set TWITTER_AUTH_TOKEN to fetch NSFW media");
Ok(empty_fetched(url))
}
}
Err(e) => Err(e),
}
}
/// A Fetched with no media for withheld tweets: the bot replies
/// "No media found" and moves on instead of erroring.
fn empty_fetched(url: &str) -> Fetched {
Fetched {
source_url: url.to_string(),
// The raw user-supplied URL goes into an HTML caption; escape it so
// crafted links cannot break the parse (Telegram 400).
caption: encode_text(url).into_owned(),
title: String::new(),
media: vec![],
sensitive: true,
render_data: None,
_keep_alive: None,
}
}
/// Fetches a tweet from the syndication endpoint. Deleted/blocked/tombstoned
/// tweets surface as `FetchError::NotFound`.
pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
let id_num = id.parse::<u64>().map_err(|_| FetchError::NotFound)?;
let response = crate::site::CLIENT
.get(format!(
"https://cdn.syndication.twimg.com/tweet-result?id={id}&lang=en&token={}",
syndication_token(id_num)
))
.send()
.await?;
// 404/410 = gone (permanent); 429/5xx = transient and retried by fetch.
let status = response.status();
if !status.is_success() {
return match status.as_u16() {
404 | 410 => Err(FetchError::NotFound),
_ => Err(FetchError::Transient(format!("twitter status {status}"))),
};
}
let text = response.text().await?;
// Deleted/blocked tweets answer with an `errors` array or a
// TweetTombstone (HTTP 200, no `id_str`); NSFW withholding is an empty
// `{}`. Both classes are permanent — classify before parsing the tweet.
parse_syndication_body(&text)?;
Tweet::from_syndication_json(&text).map_err(FetchError::Json)
}
/// Parses and classifies a syndication response body. `Ok` means the body is
/// a real tweet payload; `Err` carries the permanent error class:
/// - `NotFound`: an `errors` array (deleted/blocked) or a `TweetTombstone`
/// (deleted by the author / suspended — HTTP 200, no `errors`, no `id_str`).
/// - `Sensitive`: an empty `{}` (NSFW / age-restricted withholding).
/// - `Json`: an unparseable body.
///
/// The tombstone shape must NOT fall through to `Sensitive`: the bot would
/// otherwise answer "No media found" for a deleted tweet instead of failing.
fn parse_syndication_body(text: &str) -> Result<serde_json::Value, FetchError> {
let body: serde_json::Value = serde_json::from_str(text)?;
let tombstoned = body.get("tombstone").is_some()
|| body.get("__typename").and_then(|t| t.as_str()) == Some("TweetTombstone");
if body.get("errors").is_some() || tombstoned {
return Err(FetchError::NotFound);
}
if body.get("id_str").is_none() {
return Err(FetchError::Sensitive);
}
Ok(body)
}
/// The syndication token: JS `((id / 1e15) * PI).toString(36)` (the
/// `replace('0.','')` is a no-op for realistic tweet ids). The endpoint
/// currently serves public tweets regardless of the token; the formula is
/// kept for parity with the known-good client behavior.
fn syndication_token(id: u64) -> String {
let value = (id as f64 / 1e15) * std::f64::consts::PI;
let integer = value.trunc() as u64;
let mut fraction = value.fract();
let mut digits = String::new();
if integer == 0 {
digits.push('0');
} else {
let mut n = integer;
let mut buf = Vec::new();
while n > 0 {
buf.push(char::from_digit((n % 36) as u32, 36).unwrap());
n /= 36;
}
digits.extend(buf.into_iter().rev());
}
digits.push('.');
for _ in 0..10 {
fraction *= 36.0;
let digit = fraction.trunc() as u32;
digits.push(char::from_digit(digit.min(35), 36).unwrap());
fraction -= digit as f64;
if fraction == 0.0 {
break;
}
}
digits
}
#[derive(Debug)]
pub struct Tweet {
id: String,
text: String,
author: String,
author_id: String,
media: Vec<Media>,
sensitive: bool,
}
impl Tweet {
fn url(&self) -> String {
format!("{}/status/{}", self.author_url(), self.id)
}
fn author_url(&self) -> String {
format!("https://x.com/{}", self.author_id)
}
pub fn caption(&self) -> String {
format!(
"{url}\n<a href=\"{author_url}\">{author}</a>: {text}",
url = encode_double_quoted_attribute(&self.url()),
author_url = encode_double_quoted_attribute(&self.author_url()),
author = encode_text(&self.author),
text = encode_text(&self.text),
)
}
pub fn from_syndication_json(raw_json: &str) -> Result<Self, serde_json::Error> {
let json: model::SyndicationTweet = serde_json::from_str(raw_json)?;
let id = json.id_str;
// Expand the user's t.co short links to their real destinations and
// strip the appended media short link, mirroring FxEmbed's linkFixer
// (no display_text_range arithmetic — see expand_links).
let text = expand_links(&json.text, &json.entities.urls);
// `name` is the display name, `screen_name` the handle (Python's
// vxtwitter mapping: author = display name, author_id = handle).
let author = json.user.name;
let author_id = json.user.screen_name;
let mut media = vec![];
for item in json.media_details {
match item.media_type.as_str() {
"photo" => media.push(Media::Illustration {
title: None,
url: original_twimg_url(&item.media_url_https),
thumbnail_url: None,
// The param-less base URL is a reduced-size variant;
// used as the fallback when the original is too large.
fallback_url: Some(item.media_url_https.clone()),
}),
"video" => media.push(Media::Video {
title: None,
url: mp4_variant(&item),
thumbnail_url: item.media_url_https,
}),
"animated_gif" => media.push(Media::Animated {
title: None,
url: mp4_variant(&item),
thumbnail_url: item.media_url_https,
}),
_ => {}
}
}
let sensitive = json.possibly_sensitive.unwrap_or(false);
Ok(Self {
id,
text,
author,
author_id,
media,
sensitive,
})
}
}
/// Mirrors FxEmbed's `linkFixer` (link-fixer.ts): expand every t.co short
/// link that has an entity mapping to its real destination, drop internal
/// `x.com/i/web/status/…` plumbing links, then strip any remaining t.co
/// short link (the appended media link and other unmapped short links).
/// Pure content matching — no `display_text_range` arithmetic, so the
/// endpoint's inconsistent index units (UTF-16 vs code points, see the
/// deleted `strip_trailing_short_links`) never matter.
fn expand_links(text: &str, urls: &[model::SyndicationEntityUrl]) -> String {
let mut out = text.to_string();
for entity in urls {
let Some(expanded) = &entity.expanded_url else {
continue;
};
let replacement = if WEB_STATUS_URL.is_match(expanded) {
""
} else {
expanded
};
out = out.replace(&entity.url, replacement);
}
TCO_LINK.replace_all(&out, "").into_owned()
}
/// Internal x.com page links (reply / quote plumbing) expand to
/// `x.com/i/web/status/<id>`; FxEmbed drops them — the tweet's own content
/// already carries the information.
static WEB_STATUS_URL: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^https://(?:x\.com|twitter\.com)/i/web/status/\w+").unwrap());
/// A t.co short link, optionally preceded by a space. Any leftover
/// occurrence (unmapped — e.g. the appended media link) is removed,
/// mirroring FxEmbed. Real short-link codes are 10 alphanumerics; the
/// length-agnostic class keeps fixtures and hypothetical odd lengths safe.
static TCO_LINK: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r" ?https?://t\.co/[A-Za-z0-9]+").unwrap());
/// pbs.twimg.com serves a reduced default size without size params; `name=orig`
/// returns the original file (fxtwitter used to hand out the original
/// directly, the syndication API does not). Non-twimg URLs pass through
/// unchanged.
fn original_twimg_url(url: &str) -> String {
if url.starts_with("https://pbs.twimg.com/") && (url.ends_with(".jpg") || url.ends_with(".png"))
{
format!("{url}?name=orig")
} else {
url.to_string()
}
}
fn mp4_variant(item: &model::SyndicationMedia) -> String {
item.video_info
.as_ref()
.and_then(|info| {
info.variants
.iter()
.find(|variant| variant.content_type == "video/mp4")
})
.map(|variant| variant.url.clone())
.unwrap_or_else(|| item.media_url_https.clone())
}
impl From<Tweet> for Fetched {
fn from(tweet: Tweet) -> Self {
let url = tweet.url();
let author_url = tweet.author_url();
let render_data = Some(crate::site::RenderData {
url: url.clone(),
author: encode_text(&tweet.author).into_owned(),
author_url: author_url.clone(),
title: encode_text(&tweet.text).into_owned(),
tags: String::new(),
});
Fetched {
source_url: url,
caption: tweet.caption(),
title: tweet.text.clone(),
media: tweet.media,
sensitive: tweet.sensitive,
render_data,
_keep_alive: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(media_details: serde_json::Value) -> serde_json::Value {
serde_json::json!({
"__typename": "Tweet",
"id_str": "861627479294746624",
"text": "a & b <c>",
"user": { "name": "Display Name", "screen_name": "author_handle" },
"possibly_sensitive": true,
"mediaDetails": media_details
})
}
#[test]
fn pattern_matches_all_domains() {
for url in [
"https://x.com/user/status/1234567890",
"https://twitter.com/user/status/1234567890",
"https://mobile.twitter.com/user/status/1234567890",
"https://www.x.com/user/status/1234567890",
"https://fxtwitter.com/user/status/1234567890",
"https://fixupx.com/user/status/1234567890",
"https://fixvx.com/user/status/1234567890",
"https://vxtwitter.com/user/status/1234567890",
] {
let caps = PATTERN.captures(url).unwrap_or_else(|| panic!("{url}"));
assert_eq!(caps.get(1).unwrap().as_str(), "1234567890");
}
}
#[test]
fn pattern_rejects_non_tweet_urls() {
for url in [
"https://x.com/user",
"https://x.com/user/status/abc",
"https://bsky.app/profile/u/post/3xxxx",
"https://pixiv.net/artworks/123",
"https://example.com/x.com/user/status/123",
] {
assert!(!PATTERN.is_match(url), "{url}");
}
}
#[test]
fn syndication_json_converts_to_fetched() {
let raw = fixture(serde_json::json!([
{ "type": "photo", "media_url_https": "https://pbs.twimg.com/media/photo.jpg" },
{
"type": "video",
"media_url_https": "https://pbs.twimg.com/thumb.jpg",
"video_info": {
"variants": [
{ "content_type": "application/x-mpegURL", "url": "https://x.com/pl.m3u8" },
{ "content_type": "video/mp4", "url": "https://video.twimg.com/v.mp4" }
]
}
}
]));
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap();
let fetched: Fetched = tweet.into();
assert_eq!(
fetched.source_url,
"https://x.com/author_handle/status/861627479294746624"
);
assert_eq!(fetched.title, "a & b <c>");
assert!(fetched.sensitive);
assert_eq!(fetched.media.len(), 2);
match &fetched.media[0] {
Media::Illustration { url, .. } => {
// Photo URL is rewritten to request the original file.
assert_eq!(url, "https://pbs.twimg.com/media/photo.jpg?name=orig");
}
other => panic!("expected illustration, got {other:?}"),
}
match &fetched.media[1] {
Media::Video {
url, thumbnail_url, ..
} => {
assert_eq!(url, "https://video.twimg.com/v.mp4");
assert_eq!(thumbnail_url, "https://pbs.twimg.com/thumb.jpg");
}
other => panic!("expected video, got {other:?}"),
}
assert!(
fetched.caption.contains(
"<a href=\"https://x.com/author_handle\">Display Name</a>: a &amp; b &lt;c&gt;"
),
"caption: {}",
fetched.caption
);
}
#[test]
fn syndication_text_only_has_no_media() {
let raw = fixture(serde_json::json!([]));
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap();
let fetched: Fetched = tweet.into();
assert!(fetched.media.is_empty());
}
#[test]
fn syndication_gif_maps_to_animated() {
let raw = fixture(serde_json::json!([
{
"type": "animated_gif",
"media_url_https": "https://pbs.twimg.com/g.jpg",
"video_info": {
"variants": [{ "content_type": "video/mp4", "url": "https://video.twimg.com/g.mp4" }]
}
}
]));
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap();
assert!(matches!(&tweet.media[0], Media::Animated { .. }));
}
#[test]
fn syndication_text_strips_trailing_media_short_link() {
// Real syndication shape: the appended media short link sits after the
// visible text; the unmapped t.co link is stripped by content.
let raw = serde_json::json!({
"__typename": "Tweet",
"id_str": "1",
"text": "hello world https://t.co/abc123",
"user": { "name": "N", "screen_name": "h" },
"mediaDetails": []
});
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap();
assert_eq!(tweet.text, "hello world");
assert!(!tweet.caption().contains("t.co"));
}
#[test]
fn syndication_text_strips_trailing_link_regardless_of_index_units() {
// Real tweet 2084567054481571919: the visible text is 30 code points
// but 41 UTF-16 units, and the two endpoints historically reported
// display_text_range in different units (UTF-16 on syndication, code
// points on GraphQL). The FxEmbed-style content-based strip ignores
// the range entirely, so the appended media link is removed for any
// response shape.
let text = "妄想𝑨𝒅𝒅𝒊𝒄𝒕𝒊𝒐𝒏…🩷💚❤️\n#ゼンゼロ #zzzero https://t.co/XnIi83EkEB";
let visible = "妄想𝑨𝒅𝒅𝒊𝒄𝒕𝒊𝒐𝒏…🩷💚❤️\n#ゼンゼロ #zzzero";
let raw = serde_json::json!({
"__typename": "Tweet",
"id_str": "2084567054481571919",
"text": text,
"user": { "name": "N", "screen_name": "h" },
"mediaDetails": []
});
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap();
assert_eq!(tweet.text, visible, "left a partial link");
assert!(!tweet.caption().contains("t.co"));
}
#[test]
fn syndication_text_strips_trailing_short_link_without_entities() {
// No URL entities at all: the leftover t.co link is stripped by the
// content regex.
let raw = serde_json::json!({
"__typename": "Tweet",
"id_str": "1",
"text": "hello https://t.co/abc123",
"user": { "name": "N", "screen_name": "h" },
"mediaDetails": []
});
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap();
assert_eq!(tweet.text, "hello");
}
#[test]
fn syndication_text_expands_url_entities() {
// Real FloodSocial shape: the user's own link is a t.co short link in
// the text; the entity mapping expands it, the trailing media short
// link is stripped.
let raw = serde_json::json!({
"__typename": "Tweet",
"id_str": "1",
"text": "Test Tweet with @mentionThis $twtr https://t.co/RzmrQ6wAzD #hashtag https://t.co/9r69akA484",
"user": { "name": "N", "screen_name": "h" },
"entities": {
"urls": [{
"url": "https://t.co/RzmrQ6wAzD",
"expanded_url": "http://bit.ly/2pUk4be",
"display_url": "bit.ly/2pUk4be"
}]
},
"mediaDetails": []
});
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap();
assert_eq!(
tweet.text,
"Test Tweet with @mentionThis $twtr http://bit.ly/2pUk4be #hashtag"
);
assert!(!tweet.caption().contains("t.co"));
}
#[test]
fn syndication_text_strips_unmapped_short_links() {
// FxEmbed parity: short links without an entity mapping (appended
// media link, embedded unmapped links) are stripped, not kept.
let raw = serde_json::json!({
"__typename": "Tweet",
"id_str": "1",
"text": "check https://t.co/abc123 #tag https://t.co/def456",
"user": { "name": "N", "screen_name": "h" },
"mediaDetails": []
});
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap();
assert_eq!(tweet.text, "check #tag");
}
#[test]
fn syndication_text_drops_internal_web_status_links() {
// FxEmbed parity: a mapped link expanding to an internal
// x.com/i/web/status/... page (reply/quote plumbing) is removed
// instead of being shown.
let raw = serde_json::json!({
"__typename": "Tweet",
"id_str": "1",
"text": "see https://t.co/xyz1234567 for context",
"user": { "name": "N", "screen_name": "h" },
"entities": {
"urls": [{
"url": "https://t.co/xyz1234567",
"expanded_url": "https://x.com/i/web/status/9876543210",
"display_url": "x.com/i/web/status/9876543210"
}]
},
"mediaDetails": []
});
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap();
assert_eq!(tweet.text, "see for context");
assert!(!tweet.caption().contains("t.co"));
}
#[test]
fn syndication_text_keeps_multibyte_text() {
// Text-only tweet: no short links, the multibyte text is untouched.
let text = "コミティア落ちたので、明日は行きません。🙏ごめんなさい";
let units: Vec<u16> = text.encode_utf16().collect();
assert_eq!(units.len(), 28);
let raw = serde_json::json!({
"__typename": "Tweet",
"id_str": "1",
"text": text,
"user": { "name": "N", "screen_name": "h" },
"mediaDetails": []
});
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap();
assert_eq!(tweet.text, text, "full text kept intact");
}
#[test]
fn original_twimg_url_rewrites_photo_urls() {
assert_eq!(
original_twimg_url("https://pbs.twimg.com/media/C_UdnvPUwAE3Dnn.jpg"),
"https://pbs.twimg.com/media/C_UdnvPUwAE3Dnn.jpg?name=orig"
);
assert_eq!(
original_twimg_url("https://pbs.twimg.com/media/abc.png"),
"https://pbs.twimg.com/media/abc.png?name=orig"
);
// Non-twimg URLs (videos, animated gifs) pass through unchanged.
assert_eq!(
original_twimg_url("https://video.twimg.com/v.mp4"),
"https://video.twimg.com/v.mp4"
);
assert_eq!(
original_twimg_url("https://pbs.twimg.com/media/abc.webp"),
"https://pbs.twimg.com/media/abc.webp"
);
}
#[test]
fn syndication_token_matches_js_formula() {
// JS: ((861627479294746624 / 1e15) * PI).toString(36) == "236.vrsocvda"
let token = syndication_token(861627479294746624);
assert!(token.starts_with("236.v"), "got {token}");
}
#[test]
fn syndication_tombstone_maps_to_not_found() {
// Deleted tweets answer HTTP 200 with a TweetTombstone (no `errors`,
// no `id_str`); it must not fall through to Sensitive, which would
// make the bot reply "No media found" for a deleted tweet.
let raw = serde_json::json!({
"__typename": "TweetTombstone",
"tombstone": {
"text": { "rtl": false, "text": "This Post was deleted by the Post author. Learn more" }
}
});
assert!(matches!(
parse_syndication_body(&raw.to_string()),
Err(FetchError::NotFound)
));
}
#[test]
fn syndication_errors_maps_to_not_found() {
// The classic gone shape: {"errors": [...]}.
let raw = serde_json::json!({ "errors": [{ "message": "Couldn't find Tweet" }] });
assert!(matches!(
parse_syndication_body(&raw.to_string()),
Err(FetchError::NotFound)
));
}
#[test]
fn syndication_empty_object_maps_to_sensitive() {
// NSFW / age-restricted withholding: an empty `{}`.
assert!(matches!(
parse_syndication_body("{}"),
Err(FetchError::Sensitive)
));
}
#[test]
fn syndication_tweet_body_passes() {
let raw = fixture(serde_json::json!([]));
assert!(parse_syndication_body(&raw.to_string()).is_ok());
}
#[tokio::test]
#[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"]
async fn live_fetch_with_photos() {
let fetched = fetch("861627479294746624").await.unwrap();
assert_eq!(fetched.media.len(), 4);
}
#[tokio::test]
#[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"]
async fn live_fetch_text_only() {
let fetched = fetch("1992471125734142256").await.unwrap();
assert!(fetched.media.is_empty());
}
#[tokio::test]
#[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"]
async fn live_fetch_deleted_tweet_is_not_found() {
// Deleted tweet: the syndication endpoint answers with errors.
let result = fetch("0").await;
assert!(
matches!(result, Err(FetchError::NotFound)),
"got {result:?}"
);
}
#[tokio::test]
#[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"]
async fn live_fetch_tombstone_deleted_tweet_is_not_found() {
// Regression: a real deleted tweet answering with a TweetTombstone
// (HTTP 200, no errors/id_str) used to surface as Sensitive and
// degrade to an empty result ("No media found").
let result = fetch("2085948045967986859").await;
assert!(
matches!(result, Err(FetchError::NotFound)),
"got {result:?}"
);
}
}
+5
View File
@@ -0,0 +1,5 @@
mod auth;
mod interface;
mod model;
pub use interface::{PATTERN, Tweet, enabled, fetch_from_url};
+58
View File
@@ -0,0 +1,58 @@
use serde::Deserialize;
/// Response shape of the syndication endpoint
/// (`cdn.syndication.twimg.com/tweet-result`).
#[derive(Deserialize, Debug)]
pub struct SyndicationTweet {
pub id_str: String,
pub text: String,
pub user: SyndicationUser,
#[serde(default)]
pub possibly_sensitive: Option<bool>,
#[serde(default)]
pub entities: SyndicationEntities,
#[serde(default, rename = "mediaDetails")]
pub media_details: Vec<SyndicationMedia>,
}
#[derive(Deserialize, Debug, Default)]
pub struct SyndicationEntities {
#[serde(default)]
pub urls: Vec<SyndicationEntityUrl>,
}
/// A URL entity: `url` is the t.co short link as it appears in the text,
/// `expanded_url` the real destination.
#[derive(Deserialize, Debug)]
pub struct SyndicationEntityUrl {
pub url: String,
#[serde(default)]
pub expanded_url: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct SyndicationUser {
pub name: String,
pub screen_name: String,
}
#[derive(Deserialize, Debug)]
pub struct SyndicationMedia {
#[serde(rename = "type")]
pub media_type: String,
pub media_url_https: String,
#[serde(default)]
pub video_info: Option<SyndicationVideoInfo>,
}
#[derive(Deserialize, Debug)]
pub struct SyndicationVideoInfo {
#[serde(default)]
pub variants: Vec<SyndicationVariant>,
}
#[derive(Deserialize, Debug)]
pub struct SyndicationVariant {
pub content_type: String,
pub url: String,
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "xmedia-bot"
version = "1.2.1"
edition = "2024"
[dependencies]
teloxide = { version = "0.17", default-features = false, features = ["webhooks-axum", "macros", "rustls", "ctrlc_handler"] }
tokio = { version = "1.40", features = ["rt-multi-thread", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = "0.4"
pretty_env_logger = "0.5"
dotenv = "0.15"
url = "2.5.2"
html-escape = "0.2"
rusqlite = { version = "0.32", features = ["bundled"] }
rand = "0.8"
tempfile = "3"
parking_lot = "0.12"
bytes = "1"
png = "0.18"
zune-jpeg = "0.5"
fast_image_resize = "6"
jpeg-encoder = "0.7"
x-media = { path = "../x-media" }
+103
View File
@@ -0,0 +1,103 @@
//! Central env handling. The only other places that read env are
//! `Bot::from_env` (TELOXIDE_TOKEN) and x-media (PIXIV_REFRESH_TOKEN).
use std::env;
use std::net::IpAddr;
use std::time::Duration;
pub struct Config {
/// BOT_ADMIN: comma-separated ints; empty when unset.
pub admin_ids: Vec<i64>,
/// EDIT_MESSAGE_TTL_SECONDS, default 86400 (24h).
pub edit_message_ttl: Duration,
/// LINK_CACHE_TTL_SECONDS, default 604800 (7 days).
pub link_cache_ttl: Duration,
// Webhook settings (moved out of main; names/defaults unchanged).
pub webhook_enabled: bool,
pub webhook_url: Option<url::Url>,
pub webhook_listen: Option<IpAddr>,
pub webhook_port: Option<u16>,
pub webhook_cert: Option<String>,
pub webhook_secret_token: Option<String>,
}
impl Config {
pub fn load() -> Config {
// Fail-fast helpers: a misspelled value must not silently fall back
// to a default and run with different behavior than the operator
// intended — log a loud warning naming the variable instead.
fn parse_u64(name: &str, default: u64) -> u64 {
match env::var(name) {
Ok(v) => v.parse::<u64>().unwrap_or_else(|_| {
log::warn!("invalid {name}={v:?}; using default {default}");
default
}),
Err(_) => default,
}
}
let admin_ids = match env::var("BOT_ADMIN") {
Ok(s) => {
let (ids, bad): (Vec<_>, Vec<_>) = s
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.partition(|part| part.parse::<i64>().is_ok());
if !bad.is_empty() {
log::warn!("BOT_ADMIN: ignoring non-numeric ids: {bad:?}");
}
ids.into_iter()
.filter_map(|p| p.parse::<i64>().ok())
.collect()
}
Err(_) => Vec::new(),
};
let edit_message_ttl =
Duration::from_secs(parse_u64("EDIT_MESSAGE_TTL_SECONDS", 24 * 3600));
let link_cache_ttl =
Duration::from_secs(parse_u64("LINK_CACHE_TTL_SECONDS", 7 * 24 * 3600));
let webhook_enabled = env::var("WEBHOOK")
.is_ok_and(|v| matches!(v.to_lowercase().as_str(), "true" | "yes" | "1"));
// The webhook settings are consumed by `.expect()` in main when
// WEBHOOK=true, so an unparseable value fails fast at startup with a
// clear message; still log here for the WEBHOOK=false case.
let webhook_url = env::var("WEBHOOK_URL").ok().and_then(|s| {
s.parse::<url::Url>().ok().or_else(|| {
log::warn!("invalid WEBHOOK_URL={s:?}");
None
})
});
let webhook_listen = env::var("WEBHOOK_LISTEN").ok().and_then(|s| {
s.parse::<IpAddr>().ok().or_else(|| {
log::warn!("invalid WEBHOOK_LISTEN={s:?}");
None
})
});
let webhook_port = env::var("WEBHOOK_PORT").ok().and_then(|s| {
s.parse::<u16>().ok().or_else(|| {
log::warn!("invalid WEBHOOK_PORT={s:?}");
None
})
});
// Empty strings count as unset (e.g. `-e WEBHOOK_CERT=` to disable a
// value that would otherwise come from `.env`).
let webhook_cert = env::var("WEBHOOK_CERT").ok().filter(|s| !s.is_empty());
let webhook_secret_token = env::var("WEBHOOK_SECRET_TOKEN")
.ok()
.filter(|s| !s.is_empty());
Config {
admin_ids,
edit_message_ttl,
link_cache_ttl,
webhook_enabled,
webhook_url,
webhook_listen,
webhook_port,
webhook_cert,
webhook_secret_token,
}
}
}
+123
View File
@@ -0,0 +1,123 @@
//! Shared SQLite plumbing for the three tables in `data/task_queue.db`
//! (`tasks` in queue.rs, `chat_state` in state.rs, `link_cache` in
//! link_cache.rs).
//!
//! All I/O runs inside `spawn_blocking` via [`DbPool::with_conn`] — rusqlite
//! connections are not Send-friendly to hold across an await point, and
//! blocking the async executor stalls every handler. Connections are reused
//! through a small per-store pool instead of opening a fresh connection per
//! operation: WAL lets readers run alongside writer leases, and the pool's
//! semaphore bounds how many DB operations run concurrently, giving natural
//! backpressure on hot paths (every message / URL / callback touches
//! chat_state or the link cache).
use parking_lot::Mutex;
use rusqlite::Connection;
use std::sync::Arc;
use std::time::Duration;
/// Upper bound on pooled (reused) connections and on concurrent DB
/// operations per store. Small on purpose: the queue's `BEGIN IMMEDIATE`
/// leases serialize writes anyway, and WAL readers rarely need more.
const POOL_SIZE: usize = 4;
/// A tiny connection pool for one SQLite file. Connections are checked out
/// on a blocking thread and returned afterwards; `acquire` opens a new
/// connection only when the idle list is empty, so the steady-state cost of
/// an operation is a list pop instead of a fresh open (+ busy timeout + WAL
/// pragma). The semaphore caps the number of concurrent operations, so a
/// burst of handlers queues up instead of opening unbounded connections.
pub struct DbPool {
// Arc so [`DbPool::with_conn`] can hand an owned handle to
// `spawn_blocking` without borrowing across the await point.
inner: Arc<PoolInner>,
}
struct PoolInner {
path: String,
permits: tokio::sync::Semaphore,
idle: Mutex<Vec<Connection>>,
}
impl DbPool {
pub fn new(path: &str) -> Self {
DbPool {
inner: Arc::new(PoolInner {
path: path.to_string(),
permits: tokio::sync::Semaphore::new(POOL_SIZE),
idle: Mutex::new(Vec::new()),
}),
}
}
/// Runs `f` against a pooled connection on a blocking thread, returning
/// the closure's result. Owns the semaphore + `spawn_blocking` +
/// `expect` ceremony shared by every table access; the caller maps
/// errors to its own log line.
pub async fn with_conn<T, F>(&self, f: F) -> rusqlite::Result<T>
where
T: Send + 'static,
F: FnOnce(&mut Connection) -> rusqlite::Result<T> + Send + 'static,
{
let _permit = self
.inner
.permits
.acquire()
.await
.expect("db pool semaphore closed");
let inner = Arc::clone(&self.inner);
tokio::task::spawn_blocking(move || {
let mut conn = inner.acquire()?;
let result = f(&mut conn);
inner.release(conn);
result
})
.await
.expect("db worker panicked")
}
/// The database file this pool serves (used by tests that need a raw
/// connection, e.g. to seed rows directly).
#[cfg(test)]
pub fn path(&self) -> &str {
&self.inner.path
}
}
impl PoolInner {
/// Reuses an idle connection or opens a fresh one.
fn acquire(&self) -> rusqlite::Result<Connection> {
if let Some(conn) = self.idle.lock().pop() {
return Ok(conn);
}
open_db(&self.path)
}
/// Returns a connection to the pool (dropped when the pool is full).
fn release(&self, conn: Connection) {
let mut idle = self.idle.lock();
if idle.len() < POOL_SIZE {
idle.push(conn);
}
}
}
/// Opens the shared DB with a busy timeout.
pub fn open_db(path: &str) -> rusqlite::Result<Connection> {
let conn = Connection::open(path)?;
conn.busy_timeout(Duration::from_secs(5))?;
// WAL lets readers run alongside writer leases instead of blocking on
// the rollback journal; the mode persists in the DB header, so the
// idempotent pragma here and in ensure_schema only needs to win once.
conn.pragma_update(None, "journal_mode", "WAL")?;
Ok(conn)
}
/// Unix timestamp in fractional seconds. Shared by the queue, chat store and
/// link cache (previously four private copies).
pub fn now_f64() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
File diff suppressed because it is too large Load Diff
+297
View File
@@ -0,0 +1,297 @@
//! Persistent cache of successfully sent posts.
//!
//! After a media send succeeds, the raw render data plus the Telegram
//! `file_id`s of the sent items are stored keyed by [`crate::site` cache
//! key]. A repeated link is then answered entirely from local state — no
//! re-fetch of the source site, no re-upload — and no media file is stored
//! on disk (the file ids point at Telegram's servers). Entries expire after
//! [`Config::link_cache_ttl`]; a stale entry is dropped lazily on read and
//! by the periodic prune in `main`.
use crate::db::now_f64;
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum CachedMediaKind {
Photo,
Video,
Animation,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct CachedMedia {
pub kind: CachedMediaKind,
pub file_id: String,
}
/// Everything needed to re-send a post without touching the source site:
/// the canonical URL, pre-escaped caption fields, and the file ids produced
/// by the original successful send.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct CachedPost {
pub url: String,
/// The site's built-in caption (used when the chat has no format
/// override).
pub caption: String,
pub title: String,
pub author: String,
pub author_url: String,
pub tags: String,
pub sensitive: bool,
pub media: Vec<CachedMedia>,
}
/// SQLite-backed cache sharing `data/task_queue.db` with the queue and chat
/// state (same `open_db` pattern: busy timeout, `spawn_blocking` I/O).
pub struct LinkCache {
pool: crate::db::DbPool,
}
impl LinkCache {
pub fn open(db_path: &str) -> Self {
if let Ok(conn) = Connection::open(db_path)
&& let Err(e) = conn.execute_batch(
"CREATE TABLE IF NOT EXISTS link_cache (url TEXT PRIMARY KEY, \
payload TEXT NOT NULL, created_at REAL NOT NULL);",
)
{
log::error!("failed to initialize link cache schema: {e}");
}
Self {
pool: crate::db::DbPool::new(db_path),
}
}
/// Returns the cached post if present and not expired; a stale entry is
/// removed on the spot.
pub async fn get(&self, key: &str, ttl: Duration) -> Option<CachedPost> {
let key = key.to_string();
let ttl = ttl.as_secs_f64();
let result = self
.pool
.with_conn(move |conn| {
let mut stmt =
conn.prepare("SELECT payload, created_at FROM link_cache WHERE url = ?1")?;
let mut rows = stmt.query(params![key])?;
let Some(row) = rows.next()? else {
return Ok(None);
};
let payload: String = row.get(0)?;
let created_at: f64 = row.get(1)?;
if now_f64() - created_at > ttl {
conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key])?;
return Ok(None);
}
Ok(Some(serde_json::from_str::<CachedPost>(&payload).map_err(
|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)),
)?))
})
.await;
match result {
Ok(v) => v,
Err(e) => {
log::error!("link cache read failed: {e}");
None
}
}
}
pub async fn put(&self, key: &str, post: &CachedPost) {
let key = key.to_string();
let payload = serde_json::to_string(post).expect("cached post serializes");
let result = self
.pool
.with_conn(move |conn| {
conn.execute(
"INSERT OR REPLACE INTO link_cache (url, payload, created_at) VALUES (?1, ?2, ?3)",
params![key, payload, now_f64()],
)?;
Ok(())
})
.await;
if let Err(e) = result {
log::error!("link cache write failed: {e}");
}
}
/// Drops an entry (e.g. a cached file id that turned out invalid).
pub async fn remove(&self, key: &str) {
let key = key.to_string();
let result = self
.pool
.with_conn(move |conn| {
conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key])?;
Ok(())
})
.await;
if let Err(e) = result {
log::error!("link cache delete failed: {e}");
}
}
/// Removes expired entries; returns how many were deleted.
pub async fn prune(&self, ttl: Duration) -> usize {
let cutoff = now_f64() - ttl.as_secs_f64();
let result = self
.pool
.with_conn(move |conn| {
conn.execute(
"DELETE FROM link_cache WHERE created_at < ?1",
params![cutoff],
)
})
.await;
match result {
Ok(n) => n,
Err(e) => {
log::error!("link cache prune failed: {e}");
0
}
}
}
/// Deletes one entry (by normalized cache key) or the whole cache when
/// `key` is `None`. Returns how many rows were removed.
pub async fn clear(&self, key: Option<&str>) -> usize {
let key = key.map(str::to_string);
let result = self
.pool
.with_conn(move |conn| match &key {
Some(key) => conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key]),
None => conn.execute("DELETE FROM link_cache", []),
})
.await;
match result {
Ok(n) => n,
Err(e) => {
log::error!("link cache clear failed: {e}");
0
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry() -> CachedPost {
CachedPost {
url: "https://x.com/u/status/1".into(),
caption: "cap".into(),
title: "t".into(),
author: "a".into(),
author_url: "au".into(),
tags: "".into(),
sensitive: true,
media: vec![CachedMedia {
kind: CachedMediaKind::Photo,
file_id: "AgAC...".into(),
}],
}
}
#[tokio::test]
async fn put_get_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let cache = LinkCache::open(dir.path().join("c.db").to_str().unwrap());
cache.put("twitter:1", &entry()).await;
let got = cache.get("twitter:1", Duration::from_secs(3600)).await;
assert!(got.is_some());
let got = got.unwrap();
assert_eq!(got.url, "https://x.com/u/status/1");
assert_eq!(got.media[0].file_id, "AgAC...");
}
#[tokio::test]
async fn expired_entry_removed_on_read() {
let dir = tempfile::tempdir().unwrap();
let cache = LinkCache::open(dir.path().join("c.db").to_str().unwrap());
cache.put("twitter:1", &entry()).await;
// Force the row into the past so a 1s TTL expires it.
{
let conn = Connection::open(dir.path().join("c.db")).unwrap();
conn.execute("UPDATE link_cache SET created_at = created_at - 100", [])
.unwrap();
}
assert!(
cache
.get("twitter:1", Duration::from_secs(1))
.await
.is_none()
);
assert!(
cache
.get("twitter:1", Duration::from_secs(3600))
.await
.is_none()
);
}
#[tokio::test]
async fn remove_and_prune() {
let dir = tempfile::tempdir().unwrap();
let cache = LinkCache::open(dir.path().join("c.db").to_str().unwrap());
cache.put("twitter:1", &entry()).await;
cache.put("pixiv:2", &entry()).await;
cache.remove("twitter:1").await;
assert!(
cache
.get("twitter:1", Duration::from_secs(3600))
.await
.is_none()
);
assert!(
cache
.get("pixiv:2", Duration::from_secs(3600))
.await
.is_some()
);
{
let conn = Connection::open(dir.path().join("c.db")).unwrap();
conn.execute("UPDATE link_cache SET created_at = created_at - 100", [])
.unwrap();
}
assert_eq!(cache.prune(Duration::from_secs(1)).await, 1);
assert!(
cache
.get("pixiv:2", Duration::from_secs(3600))
.await
.is_none()
);
}
#[tokio::test]
async fn clear_one_entry_or_all() {
let dir = tempfile::tempdir().unwrap();
let cache = LinkCache::open(dir.path().join("c.db").to_str().unwrap());
cache.put("twitter:1", &entry()).await;
cache.put("pixiv:2", &entry()).await;
// By key: only the matching row is removed.
assert_eq!(cache.clear(Some("twitter:1")).await, 1);
assert!(
cache
.get("twitter:1", Duration::from_secs(3600))
.await
.is_none()
);
assert!(
cache
.get("pixiv:2", Duration::from_secs(3600))
.await
.is_some()
);
// Whole cache: nothing left; removing an absent key deletes 0 rows.
assert_eq!(cache.clear(None).await, 1);
assert!(
cache
.get("pixiv:2", Duration::from_secs(3600))
.await
.is_none()
);
assert_eq!(cache.clear(None).await, 0);
}
}
+202
View File
@@ -0,0 +1,202 @@
use dotenv::dotenv;
use teloxide::dptree::endpoint;
use teloxide::prelude::*;
use teloxide::stop::StopToken;
use teloxide::types::{ChatId, InputFile, MessageId};
use teloxide::update_listeners::{self, UpdateListener, webhooks};
use tokio::sync::watch;
use x_media::site;
mod config;
mod db;
mod handlers;
mod link_cache;
mod photo;
mod queue;
mod send;
mod state;
use handlers::{CHAT_STORE, CONFIG, LINK_CACHE, TASK_QUEUE};
/// Docker `stop` / `compose down` delivers SIGTERM, which teloxide's ctrlc
/// handler (SIGINT only) never sees — without this the process would die
/// before the graceful shutdown below (admin notice, queue drain). Stopping
/// the token unwinds the dispatcher exactly like Ctrl+C does.
#[cfg(unix)]
fn spawn_sigterm_handler(stop_token: StopToken) {
tokio::spawn(async move {
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler");
sigterm.recv().await;
log::info!("SIGTERM received, stopping the dispatcher");
stop_token.stop();
});
}
#[cfg(not(unix))]
fn spawn_sigterm_handler(_stop_token: StopToken) {}
#[tokio::main]
async fn main() {
dotenv().ok();
pretty_env_logger::init();
log::info!("Starting bot");
let bot = Bot::from_env();
// Force the queue workers' shared Bot to initialize now so a missing
// token fails at startup, not on the first queued task.
let _ = &*send::BOT;
// Register the command list with Telegram (client `/` menu).
if let Err(e) = handlers::register_commands(&bot).await {
log::warn!("failed to register commands: {e}");
}
log::info!(
"config: {} admin(s), edit-message TTL {}s",
CONFIG.admin_ids.len(),
CONFIG.edit_message_ttl.as_secs()
);
// Queue worker: handles typed tasks, dead-letters failed sends to the
// task's chat.
TASK_QUEUE
.start(send::handle_task, send::dead_letter_notify)
.await;
log::info!("task queue worker started");
// URL job workers: bounded channel + fixed pool for per-URL work.
handlers::start_url_workers().await;
log::info!("url workers started");
// Pixiv login validation (user request): a failed login notifies the
// admin and disables pixiv for this process.
if site::pixiv::enabled() {
match site::pixiv::validate().await {
Ok(()) => log::info!("pixiv login validated"),
Err(e) => {
log::error!("pixiv login failed: {e}");
if let Some(admin) = CONFIG.admin_ids.first() {
let _ = bot
.send_message(ChatId(*admin), format!("Pixiv login failed: {e}"))
.await;
}
site::pixiv::disable();
}
}
}
// Edit-expiry sweep: clears the prompt's buttons once the record expires.
log::info!(
"edit-expiry sweep: every 300s, ttl {}",
CONFIG.edit_message_ttl.as_secs()
);
let (stop_tx, stop_rx) = watch::channel(false);
{
let bot = bot.clone();
let mut stop_rx = stop_rx;
tokio::spawn(async move {
loop {
tokio::select! {
_ = stop_rx.changed() => break,
_ = tokio::time::sleep(std::time::Duration::from_secs(300)) => {}
}
let ttl = CONFIG.edit_message_ttl;
let removed = CHAT_STORE.prune_expired(ttl).await;
let pruned = LINK_CACHE.prune(CONFIG.link_cache_ttl).await;
if pruned > 0 {
log::info!("link cache: pruned {pruned} expired entr(ies)");
}
for (chat_id, prompt_message_id) in removed {
// If the prompt was already deleted, this fails with a
// 400 "message to edit not found" — log and ignore.
if let Err(e) = bot
.edit_message_reply_markup(
ChatId(chat_id),
MessageId(prompt_message_id as i32),
)
.await
{
log::info!("edit-expiry sweep: prompt message gone: {e}");
}
}
}
});
}
let handler = dptree::entry()
.branch(Update::filter_message().branch(endpoint(handlers::message_handler)))
.branch(Update::filter_inline_query().branch(endpoint(handlers::inline_query_handler)))
.branch(Update::filter_callback_query().branch(endpoint(handlers::callback_query_handler)));
let mut dispatcher = Dispatcher::builder(bot.clone(), handler)
.dependencies(dptree::deps![""])
.enable_ctrlc_handler()
.build();
if CONFIG.webhook_enabled {
log::info!("running in webhook mode");
let url = CONFIG.webhook_url.clone().expect("WEBHOOK_URL is not set");
// `webhooks::axum` calls set_webhook itself (with the full options,
// secret token included) — no explicit registration here.
let listen = CONFIG.webhook_listen.expect("WEBHOOK_LISTEN is not set");
let port = CONFIG.webhook_port.expect("WEBHOOK_PORT is not set");
let mut options = webhooks::Options::new((listen, port).into(), url);
if let Some(cert) = &CONFIG.webhook_cert {
options = options.certificate(InputFile::file(cert));
}
if let Some(secret) = &CONFIG.webhook_secret_token {
options = options.secret_token(secret.clone());
}
let mut listener = webhooks::axum(bot.clone(), options)
.await
.expect("Failed to create webhook listener");
let stop_token = listener.stop_token();
spawn_sigterm_handler(stop_token);
dispatcher
.dispatch_with_listener(
listener,
LoggingErrorHandler::with_custom_text("Error from update listener"),
)
.await;
} else {
log::info!("running in polling mode");
// Same listener `dispatch()` builds internally — using
// `dispatch_with_listener` just exposes its stop token so SIGTERM can
// unwind the dispatcher before the graceful shutdown below.
let mut listener = update_listeners::polling_default(bot.clone()).await;
let stop_token = listener.stop_token();
spawn_sigterm_handler(stop_token);
dispatcher
.dispatch_with_listener(
listener,
LoggingErrorHandler::with_custom_text("Error from update listener"),
)
.await;
}
// Graceful stop (Ctrl+C / SIGTERM): stop the sweep, notify the admin,
// drain the queue. Bounded: a worker mid-download (30 s timeout) or a
// long ugoira encode must not hold the shutdown hostage forever.
log::info!("Stopping bot");
const SHUTDOWN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
let shutdown = async {
let _ = stop_tx.send(true);
handlers::stop_url_workers().await;
if let Some(admin) = CONFIG.admin_ids.first() {
let _ = bot.send_message(ChatId(*admin), "Shutting down...").await;
}
TASK_QUEUE.stop().await;
};
if tokio::time::timeout(SHUTDOWN_TIMEOUT, shutdown)
.await
.is_err()
{
log::warn!("graceful shutdown timed out after {SHUTDOWN_TIMEOUT:?}; exiting");
} else {
log::info!("Bot stopped");
}
}
+529
View File
@@ -0,0 +1,529 @@
//! Pure-Rust photo processing: brings a downloaded photo within Telegram's
//! limits (width + height ≤ 10000 px, bytes ≤ 10 MiB) without ffmpeg.
//!
//! Stack: `png` (image-png) for PNG decode/encode, `zune-jpeg` for JPEG
//! decode, `fast_image_resize` (Lanczos3) for downsampling, `jpeg-encoder`
//! for JPEG output.
//!
//! Bit-depth rule: a PNG above 24 bits (32-bit RGBA or 16-bit per channel)
//! is reduced to 24-bit RGB; 24-bit and lower depths are left untouched —
//! gray stays gray, never upconverted. The only upconversion is palette
//! expansion, which resampling requires. Alpha is flattened onto white (JPEG
//! and 24-bit RGB have no alpha channel).
use std::io::Write;
use fast_image_resize as fir;
use tempfile::NamedTempFile;
/// Telegram rejects photos whose width + height exceed this limit
/// (PHOTO_INVALID_DIMENSIONS). Verified empirically: 6300x3730 (sum 10030)
/// fails, 6100x3900 (sum 10000) passes.
pub const PHOTO_MAX_DIMENSION_SUM: u32 = 10000;
/// Resize target with a safety margin so rounding cannot cross the cap.
pub const PHOTO_TARGET_DIMENSION_SUM: u32 = 9900;
/// Upload cap (bytes): files above this are not uploaded; the bot falls back
/// to a smaller media URL instead.
pub const MAX_UPLOAD_BYTES: u64 = 10 * 1024 * 1024;
/// Decode budget (bytes): a larger intermediate buffer is not worth the peak
/// memory; the photo degrades to the smaller URL instead. Also the cap for
/// downloading photos in the send fallback (they must be downloaded whole).
pub(crate) const MAX_DECODE_BYTES: u64 = 512 * 1024 * 1024;
/// JPEG output quality (1-100).
const JPEG_QUALITY: u8 = 90;
/// What to upload for a downloaded photo.
pub enum PhotoPrep {
/// Upload this file (the original when within limits, else the processed
/// copy).
Upload(NamedTempFile),
/// The photo cannot be brought within Telegram's limits — the caller
/// falls back to the item's smaller URL.
UseFallback,
}
/// A decoded image buffer tagged with its channel layout.
#[derive(Debug)]
enum PixBuf {
Gray(Vec<u8>),
GrayAlpha(Vec<u8>),
Rgb(Vec<u8>),
}
impl PixBuf {
fn pixel_type(&self) -> fir::PixelType {
match self {
PixBuf::Gray(_) => fir::PixelType::U8,
PixBuf::GrayAlpha(_) => fir::PixelType::U8x2,
PixBuf::Rgb(_) => fir::PixelType::U8x3,
}
}
fn into_vec(self) -> Vec<u8> {
match self {
PixBuf::Gray(v) | PixBuf::GrayAlpha(v) | PixBuf::Rgb(v) => v,
}
}
}
/// Entry point: detects the format and processes the photo if needed.
/// The caller hands in the already-downloaded bytes (they are in memory from
/// the download anyway; re-reading the temp file would double the I/O).
pub fn prepare_photo(file: NamedTempFile, bytes: &[u8]) -> Result<PhotoPrep, String> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
prepare_png(file, bytes)
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
prepare_jpeg(file, bytes)
} else {
log::warn!("photo in unsupported format; falling back to smaller media");
Ok(PhotoPrep::UseFallback)
}
}
/// Parses the PNG IHDR (bytes 8..26: signature + length + "IHDR" + width +
/// height + bit depth + color type).
fn parse_png_header(bytes: &[u8]) -> Option<(u32, u32, png::BitDepth, png::ColorType)> {
if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 26 {
return None;
}
let w = u32::from_be_bytes(bytes.get(16..20)?.try_into().ok()?);
let h = u32::from_be_bytes(bytes.get(20..24)?.try_into().ok()?);
let depth = match *bytes.get(24)? {
1 => png::BitDepth::One,
2 => png::BitDepth::Two,
4 => png::BitDepth::Four,
8 => png::BitDepth::Eight,
16 => png::BitDepth::Sixteen,
_ => return None,
};
let color = match *bytes.get(25)? {
0 => png::ColorType::Grayscale,
2 => png::ColorType::Rgb,
3 => png::ColorType::Indexed,
4 => png::ColorType::GrayscaleAlpha,
6 => png::ColorType::Rgba,
_ => return None,
};
Some((w, h, depth, color))
}
/// Output channels of a decoded frame for the given color type (post
/// STRIP_16; palette expands to RGB).
fn output_channels(color: png::ColorType) -> usize {
match color {
png::ColorType::Grayscale => 1,
png::ColorType::GrayscaleAlpha => 2,
png::ColorType::Rgb | png::ColorType::Indexed => 3,
png::ColorType::Rgba => 4,
}
}
/// The 32→24 rule: RGBA (32-bit) becomes RGB with alpha composited onto
/// white; 16-bit per channel was already stripped to 8-bit at decode.
fn flatten_rgba_to_rgb(rgba: &[u8]) -> Vec<u8> {
let mut rgb = Vec::with_capacity(rgba.len() / 4 * 3);
for px in rgba.chunks_exact(4) {
let a = px[3] as u32;
for v in &px[..3] {
// Over white: C = C*a/255 + 255*(1 - a/255).
let v = (*v as u32 * a + 255 * (255 - a)) / 255;
rgb.push(v.min(255) as u8);
}
}
rgb
}
/// Lanczos3 downsampling via fast_image_resize.
fn resize_pix(pix: PixBuf, w: u32, h: u32, nw: u32, nh: u32) -> Result<PixBuf, String> {
let pixel_type = pix.pixel_type();
let src = fir::images::Image::from_vec_u8(w, h, pix.into_vec(), pixel_type)
.map_err(|e| format!("resize input: {e}"))?;
let mut dst = fir::images::Image::new(nw, nh, pixel_type);
let mut resizer = fir::Resizer::new();
let options = fir::ResizeOptions::default()
.resize_alg(fir::ResizeAlg::Convolution(fir::FilterType::Lanczos3));
resizer
.resize(&src, &mut dst, &options)
.map_err(|e| format!("resize: {e}"))?;
let buf = dst.into_vec();
Ok(match pixel_type {
fir::PixelType::U8 => PixBuf::Gray(buf),
fir::PixelType::U8x2 => PixBuf::GrayAlpha(buf),
_ => PixBuf::Rgb(buf),
})
}
fn encode_png(out: &mut Vec<u8>, pix: &PixBuf, w: u32, h: u32) -> Result<(), png::EncodingError> {
let (color, buf) = match pix {
PixBuf::Gray(v) => (png::ColorType::Grayscale, v.as_slice()),
PixBuf::GrayAlpha(v) => (png::ColorType::GrayscaleAlpha, v.as_slice()),
PixBuf::Rgb(v) => (png::ColorType::Rgb, v.as_slice()),
};
let mut encoder = png::Encoder::new(out, w, h);
encoder.set_color(color);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header()?;
writer.write_image_data(buf)?;
Ok(())
}
fn encode_jpeg(pix: &PixBuf, w: u32, h: u32) -> Result<Vec<u8>, String> {
use jpeg_encoder::{ColorType, Encoder};
let mut out = Vec::new();
let encoder = Encoder::new(&mut out, JPEG_QUALITY);
match pix {
PixBuf::Gray(v) => encoder
.encode(v, w as u16, h as u16, ColorType::Luma)
.map_err(|e| format!("jpeg encode: {e}"))?,
PixBuf::GrayAlpha(v) => {
// JPEG has no alpha: composite onto white, output as gray.
let gray: Vec<u8> = v
.chunks_exact(2)
.map(|px| {
let (g, a) = (px[0] as u32, px[1] as u32);
((g * a + 255 * (255 - a)) / 255).min(255) as u8
})
.collect();
encoder
.encode(&gray, w as u16, h as u16, ColorType::Luma)
.map_err(|e| format!("jpeg encode: {e}"))?;
}
PixBuf::Rgb(v) => encoder
.encode(v, w as u16, h as u16, ColorType::Rgb)
.map_err(|e| format!("jpeg encode: {e}"))?,
}
Ok(out)
}
fn write_temp(bytes: &[u8], ext: &str) -> Result<NamedTempFile, String> {
let mut file = tempfile::Builder::new()
.suffix(&format!(".{ext}"))
.tempfile()
.map_err(|e| format!("temp file failed: {e}"))?;
file.as_file_mut()
.write_all(bytes)
.map_err(|e| format!("temp file write failed: {e}"))?;
Ok(file)
}
fn target_dims(w: u32, h: u32) -> (u32, u32) {
let scale = PHOTO_TARGET_DIMENSION_SUM as f64 / (w + h) as f64;
(
((w as f64 * scale).round() as u32).max(1),
((h as f64 * scale).round() as u32).max(1),
)
}
/// PNG branch: decode (16→8, palette→RGB; gray/GA stay), flatten RGBA to
/// RGB, Lanczos-downscale beyond the dimension cap, encode PNG — a PNG still
/// over the upload cap afterwards becomes JPEG.
fn prepare_png(file: NamedTempFile, bytes: &[u8]) -> Result<PhotoPrep, String> {
let (w, h, _bit_depth, color_type) = parse_png_header(bytes).ok_or("invalid PNG header")?;
let size_over = bytes.len() as u64 > MAX_UPLOAD_BYTES;
if w + h <= PHOTO_MAX_DIMENSION_SUM && !size_over {
return Ok(PhotoPrep::Upload(file));
}
log::debug!(
"photo {w}x{h} ({_bit_depth:?} {color_type:?}, {} bytes) needs processing",
bytes.len()
);
let channels = output_channels(color_type);
if (w as u64) * (h as u64) * channels as u64 > MAX_DECODE_BYTES {
log::warn!("photo decode buffer exceeds the memory budget; falling back to smaller media");
return Ok(PhotoPrep::UseFallback);
}
// STRIP_16 drops 16-bit to 8-bit (the depth-reduction step); palette
// expands to RGB (resampling requires it). Gray and gray-alpha are kept.
let transforms = match color_type {
png::ColorType::Indexed => png::Transformations::EXPAND,
_ => png::Transformations::STRIP_16,
};
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
decoder.set_transformations(transforms);
let mut reader = decoder
.read_info()
.map_err(|e| format!("png decode: {e}"))?;
let out_w = reader.info().width;
let out_h = reader.info().height;
let mut buf = vec![
0u8;
reader
.output_buffer_size()
.ok_or("png output buffer size")?
];
reader
.next_frame(&mut buf)
.map_err(|e| format!("png frame: {e}"))?;
let mut pix = match color_type {
png::ColorType::Rgba => PixBuf::Rgb(flatten_rgba_to_rgb(&buf)),
png::ColorType::Grayscale => PixBuf::Gray(buf),
png::ColorType::GrayscaleAlpha => PixBuf::GrayAlpha(buf),
png::ColorType::Rgb | png::ColorType::Indexed => PixBuf::Rgb(buf),
};
let (mut w, mut h) = (out_w, out_h);
if w + h > PHOTO_MAX_DIMENSION_SUM {
let (nw, nh) = target_dims(w, h);
pix = resize_pix(pix, w, h, nw, nh)?;
(w, h) = (nw, nh);
log::debug!("downscaled photo to {w}x{h} (Lanczos3)");
}
let mut png_bytes = Vec::new();
encode_png(&mut png_bytes, &pix, w, h).map_err(|e| format!("png encode: {e}"))?;
if png_bytes.len() as u64 <= MAX_UPLOAD_BYTES {
return Ok(PhotoPrep::Upload(write_temp(&png_bytes, "png")?));
}
log::debug!("PNG still over the upload cap after processing; transcoding to JPEG");
let jpeg_bytes = encode_jpeg(&pix, w, h)?;
if jpeg_bytes.len() as u64 <= MAX_UPLOAD_BYTES {
return Ok(PhotoPrep::Upload(write_temp(&jpeg_bytes, "jpg")?));
}
log::warn!("processed photo still exceeds the upload cap; falling back to smaller media");
Ok(PhotoPrep::UseFallback)
}
/// JPEG branch: zune-jpeg decode → Lanczos downscale → jpeg-encoder output.
fn prepare_jpeg(file: NamedTempFile, bytes: &[u8]) -> Result<PhotoPrep, String> {
let mut decoder = zune_jpeg::JpegDecoder::new(std::io::Cursor::new(bytes));
// Decodes to RGB by default. Headers first so dimensions are known before
// the (potentially huge) pixel decode.
decoder
.decode_headers()
.map_err(|e| format!("jpeg headers: {e}"))?;
let info = decoder.info().ok_or("jpeg info unavailable")?;
let (w, h) = (info.width as u32, info.height as u32);
let size_over = bytes.len() as u64 > MAX_UPLOAD_BYTES;
if w + h <= PHOTO_MAX_DIMENSION_SUM && !size_over {
return Ok(PhotoPrep::Upload(file));
}
if (w as u64) * (h as u64) * 3 > MAX_DECODE_BYTES {
log::warn!("photo decode buffer exceeds the memory budget; falling back to smaller media");
return Ok(PhotoPrep::UseFallback);
}
let pixels = decoder.decode().map_err(|e| format!("jpeg decode: {e}"))?;
let mut pix = PixBuf::Rgb(pixels);
let (mut w, mut h) = (w, h);
if w + h > PHOTO_MAX_DIMENSION_SUM {
let (nw, nh) = target_dims(w, h);
pix = resize_pix(pix, w, h, nw, nh)?;
(w, h) = (nw, nh);
log::debug!("downscaled jpeg to {w}x{h} (Lanczos3)");
}
let jpeg_bytes = encode_jpeg(&pix, w, h)?;
if jpeg_bytes.len() as u64 <= MAX_UPLOAD_BYTES {
return Ok(PhotoPrep::Upload(write_temp(&jpeg_bytes, "jpg")?));
}
log::warn!("processed photo still exceeds the upload cap; falling back to smaller media");
Ok(PhotoPrep::UseFallback)
}
#[cfg(test)]
mod tests {
use super::*;
fn png_header(w: u32, h: u32, depth: u8, color: u8) -> Vec<u8> {
let mut bytes = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".to_vec();
bytes.extend(w.to_be_bytes());
bytes.extend(h.to_be_bytes());
bytes.extend([depth, color, 0, 0, 0]);
bytes
}
#[test]
fn parses_png_header() {
let bytes = png_header(8979, 5316, 16, 6); // 16-bit RGBA
let (w, h, depth, color) = parse_png_header(&bytes).unwrap();
assert_eq!((w, h), (8979, 5316));
assert_eq!(depth, png::BitDepth::Sixteen);
assert_eq!(color, png::ColorType::Rgba);
let (_, _, depth, color) = parse_png_header(&png_header(10, 10, 8, 0)).unwrap();
assert_eq!(depth, png::BitDepth::Eight);
assert_eq!(color, png::ColorType::Grayscale);
assert!(parse_png_header(b"not a png").is_none());
}
#[test]
fn flatten_rgba_to_rgb_composites_over_white() {
// opaque red stays red
assert_eq!(flatten_rgba_to_rgb(&[255, 0, 0, 255]), vec![255, 0, 0]);
// fully transparent → white
assert_eq!(flatten_rgba_to_rgb(&[0, 0, 0, 0]), vec![255, 255, 255]);
// half alpha red → (255+255)/2 = 255, (0*128 + 255*127)/255 = 127
let out = flatten_rgba_to_rgb(&[255, 0, 0, 128]);
assert_eq!(out[0], 255);
assert_eq!(out[1], 127);
assert_eq!(out[2], 127);
}
#[test]
fn target_dims_stay_under_the_cap() {
for (w, h) in [(12000u32, 7000u32), (10000, 10000), (8979, 5316)] {
let (nw, nh) = target_dims(w, h);
assert!(nw + nh <= PHOTO_MAX_DIMENSION_SUM, "{w}x{h} -> {nw}x{nh}");
assert!(nw >= 1 && nh >= 1);
}
// already within limits: no change expected from the caller, but the
// helper must not produce zero dimensions.
let (nw, nh) = target_dims(500, 400);
assert!(nw >= 1 && nh >= 1);
}
#[test]
fn resize_pix_changes_dimensions() {
// 300x200 RGB → 100x66
let buf: Vec<u8> = (0..300 * 200 * 3).map(|i| (i % 251) as u8).collect();
let resized = resize_pix(PixBuf::Rgb(buf), 300, 200, 100, 66).unwrap();
match resized {
PixBuf::Rgb(v) => assert_eq!(v.len(), 100 * 66 * 3),
other => panic!("expected rgb, got {other:?}"),
}
}
#[test]
fn png_encode_roundtrip_keeps_gray() {
let gray = vec![128u8; 4 * 4];
let mut out = Vec::new();
encode_png(&mut out, &PixBuf::Gray(gray), 4, 4).unwrap();
assert!(!out.is_empty());
let (_, _, depth, color) = parse_png_header(&out).unwrap();
assert_eq!(depth, png::BitDepth::Eight);
assert_eq!(color, png::ColorType::Grayscale);
}
#[test]
fn jpeg_encode_produces_bytes() {
let rgb = vec![128u8; 8 * 8 * 3];
let out = encode_jpeg(&PixBuf::Rgb(rgb), 8, 8).unwrap();
assert!(out.len() > 100);
assert!(out.starts_with(&[0xFF, 0xD8]));
}
/// Writes a small dimension-oversized PNG (9999x2 → sum 10001) to a temp
/// file and runs the full pipeline.
fn run_pipeline(w: u32, h: u32, color: png::ColorType, fill: u8) -> Result<PhotoPrep, String> {
let (channels, data): (usize, Vec<u8>) = match color {
png::ColorType::Grayscale => (1, vec![fill; (w * h) as usize]),
png::ColorType::Rgb => (3, vec![fill; (w * h * 3) as usize]),
_ => unreachable!(),
};
let mut bytes = Vec::new();
{
let mut encoder = png::Encoder::new(&mut bytes, w, h);
encoder.set_color(color);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&data).unwrap();
}
assert_eq!(data.len(), channels * (w * h) as usize);
let mut file = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
std::io::Write::write_all(file.as_file_mut(), &bytes).unwrap();
prepare_photo(file, &bytes)
}
#[test]
fn pipeline_downscales_oversized_png_keeping_format() {
let prep = run_pipeline(9999, 2, png::ColorType::Rgb, 128).unwrap();
match prep {
PhotoPrep::Upload(file) => {
let out = std::fs::read(file.path()).unwrap();
let (w, h, depth, color) = parse_png_header(&out).unwrap();
assert!(w + h <= PHOTO_MAX_DIMENSION_SUM, "{w}x{h}");
assert_eq!(depth, png::BitDepth::Eight);
assert_eq!(color, png::ColorType::Rgb);
}
PhotoPrep::UseFallback => panic!("over-dimension PNG should have been resized"),
}
}
#[test]
fn pipeline_keeps_gray_png_gray() {
let prep = run_pipeline(9999, 2, png::ColorType::Grayscale, 200).unwrap();
match prep {
PhotoPrep::Upload(file) => {
let out = std::fs::read(file.path()).unwrap();
let (_, _, _, color) = parse_png_header(&out).unwrap();
assert_eq!(color, png::ColorType::Grayscale, "gray must not upconvert");
}
PhotoPrep::UseFallback => panic!("over-dimension gray PNG should have been resized"),
}
}
#[test]
fn pipeline_resizes_oversized_jpeg() {
// Build a small over-dimension JPEG with jpeg-encoder.
let (w, h) = (9999u16, 2u16);
let rgb = vec![90u8; (w as usize) * (h as usize) * 3];
let mut bytes = Vec::new();
{
let encoder = jpeg_encoder::Encoder::new(&mut bytes, 90);
encoder
.encode(&rgb, w, h, jpeg_encoder::ColorType::Rgb)
.unwrap();
}
let mut file = tempfile::Builder::new().suffix(".jpg").tempfile().unwrap();
std::io::Write::write_all(file.as_file_mut(), &bytes).unwrap();
match prepare_photo(file, &bytes).unwrap() {
PhotoPrep::Upload(file) => {
let out = std::fs::read(file.path()).unwrap();
assert!(out.starts_with(&[0xFF, 0xD8]), "output must stay jpeg");
// 9999x2 downscaled: the buffer length tells the new dims.
assert!(out.len() > 100);
}
PhotoPrep::UseFallback => panic!("over-dimension JPEG should have been resized"),
}
}
#[test]
#[ignore = "heavy: generates a >10 MiB PNG (run explicitly)"]
fn pipeline_transcodes_oversized_png_to_jpeg() {
// 6000x4000 (sum 10000 — under the dimension cap) smooth gradient with
// small per-pixel noise: PNG-incompressible (delta filters defeated)
// but JPEG-friendly (DCT smooths the small noise). Verified with
// ffmpeg: 8000x6000 amp-5 variant is a 59 MB PNG / 3.3 MB JPEG.
let (w, h) = (6000u32, 4000u32);
let mut rng = 0x1234_5678_9abc_def0u64;
let mut data = Vec::with_capacity((w * h * 3) as usize);
for y in 0..h {
for x in 0..w {
let base = (x + y) * 255 / (w + h);
rng = rng
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let n = ((rng >> 33) % 11) as i32 - 5; // noise in [-5, 5]
let v = (base as i32 + n).clamp(0, 255) as u8;
data.extend_from_slice(&[v, v, v]);
}
}
let mut bytes = Vec::new();
{
let mut encoder = png::Encoder::new(&mut bytes, w, h);
encoder.set_color(png::ColorType::Rgb);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&data).unwrap();
}
assert!(
bytes.len() as u64 > MAX_UPLOAD_BYTES,
"test needs a >10MiB PNG, got {}",
bytes.len()
);
let mut file = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
std::io::Write::write_all(file.as_file_mut(), &bytes).unwrap();
match prepare_photo(file, &bytes).unwrap() {
PhotoPrep::Upload(file) => {
let out = std::fs::read(file.path()).unwrap();
assert!(out.starts_with(&[0xFF, 0xD8]), "must transcode to JPEG");
assert!(out.len() as u64 <= MAX_UPLOAD_BYTES);
}
PhotoPrep::UseFallback => panic!("PNG over the byte cap must transcode to JPEG"),
}
}
}
+599
View File
@@ -0,0 +1,599 @@
//! Generic persistent task queue backed by SQLite (table `tasks`).
//!
//! Concepts kept from the Python `utils/task_queue.py` (untrusted, redesigned):
//! the table schema, the lease/lock/recovery model, and the retry→dead-letter
//! flow. The Python dict-mutation hack (attempts inside the payload) is
//! replaced by dedicated columns.
use crate::db::now_f64;
use parking_lot::Mutex;
use rusqlite::{Connection, TransactionBehavior, params};
use serde_json::Value;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Duration;
use tokio::sync::Notify;
use tokio::task::JoinHandle;
pub const MAX_RETRIES: u32 = 2;
pub const LOCK_TTL_SECONDS: f64 = 120.0;
/// Number of concurrent worker loops. Tasks are independent (retries and
/// forward resumes); leases serialize row claims via SQLite transactions, so
/// extra workers drain backlogs faster. Each worker can be mid-send to
/// Telegram at the same time as handler tasks, so keep this modest.
const QUEUE_WORKERS: usize = 4;
/// What a handler returns instead of throwing. The payload it carries is the
/// (possibly updated) task state to persist for the next attempt.
pub enum QueueError {
/// Reschedule with the given delay; after `MAX_RETRIES` attempts the task
/// is dead-lettered instead.
Retryable { delay_seconds: f64, payload: Value },
/// Give up now.
Permanent { message: String, payload: Value },
}
type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
type Handler = dyn Fn(Value) -> BoxFuture<'static, Result<(), QueueError>> + Send + Sync;
type DeadLetter = dyn Fn(Value, String) -> BoxFuture<'static, ()> + Send + Sync;
pub struct PersistentTaskQueue {
pool: std::sync::Arc<crate::db::DbPool>,
notify: Arc<Notify>,
stop: Arc<AtomicBool>,
worker: Mutex<Vec<JoinHandle<()>>>,
counter: AtomicU64,
}
struct LeasedRow {
id: String,
payload: String,
attempts: i32,
}
/// Owned worker state so the spawned loop does not borrow the queue handle.
#[derive(Clone)]
struct QueueWorker {
pool: std::sync::Arc<crate::db::DbPool>,
notify: Arc<Notify>,
stop: Arc<AtomicBool>,
handler: Arc<Handler>,
dead_letter: Arc<DeadLetter>,
}
/// Resets rows left `in_progress` with an expired lock TTL back to `pending`
/// so they can be leased again (crash/panic recovery).
fn recover_update(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
conn.execute(
"UPDATE tasks SET status='pending', locked_until=0 WHERE status='in_progress' AND locked_until < ?1",
params![now_f64()],
)?;
Ok(())
}
/// Base delay × 2^attempts (attempts = retries already done), capped at 300s.
/// Applied at the queue layer so the attempt count actually reaches the
/// backoff computation; Telegram `RetryAfter` delays get the same treatment
/// (conservatively larger wait, no API change needed).
fn scaled_retry_delay(base: f64, attempts: i32) -> f64 {
(base * 2f64.powi(attempts)).min(300.0)
}
fn ensure_schema(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
conn.execute_batch(
"PRAGMA journal_mode=WAL; \
CREATE TABLE IF NOT EXISTS tasks (id TEXT PRIMARY KEY, payload TEXT NOT NULL, \
run_after REAL NOT NULL, attempts INTEGER NOT NULL, status TEXT NOT NULL, \
locked_until REAL NOT NULL, created_at REAL NOT NULL); \
CREATE INDEX IF NOT EXISTS idx_tasks_pending ON tasks(status, run_after);",
)
}
impl PersistentTaskQueue {
pub fn new(db_path: &str) -> Self {
// Ensure the parent dir and table exist even if only the queue (not
// ChatStore) is used — a fresh container without a mounted data dir
// must still be able to open the DB.
if let Some(parent) = std::path::Path::new(db_path).parent()
&& !parent.as_os_str().is_empty()
&& let Err(e) = std::fs::create_dir_all(parent)
{
log::error!("failed to create queue dir: {e}");
}
if let Ok(conn) = Connection::open(db_path)
&& let Err(e) = ensure_schema(&conn)
{
log::error!("failed to initialize queue schema: {e}");
}
Self {
pool: std::sync::Arc::new(crate::db::DbPool::new(db_path)),
notify: Arc::new(Notify::new()),
stop: Arc::new(AtomicBool::new(false)),
worker: Mutex::new(Vec::new()),
counter: AtomicU64::new(0),
}
}
/// Starts the worker loops. Also recovers rows left `in_progress` by a
/// previous process (lease expired).
pub async fn start<H, F, D, G>(&self, handler: H, dead_letter: D)
where
H: Fn(Value) -> F + Send + Sync + 'static,
F: Future<Output = Result<(), QueueError>> + Send + 'static,
D: Fn(Value, String) -> G + Send + Sync + 'static,
G: Future<Output = ()> + Send + 'static,
{
let handler: Arc<Handler> = Arc::new(move |payload| Box::pin(handler(payload)));
let dead_letter: Arc<DeadLetter> =
Arc::new(move |payload, message| Box::pin(dead_letter(payload, message)));
self.recover_stale().await;
let mut handles = Vec::with_capacity(QUEUE_WORKERS + 1);
for _ in 0..QUEUE_WORKERS {
let worker = QueueWorker {
pool: std::sync::Arc::clone(&self.pool),
notify: Arc::clone(&self.notify),
stop: Arc::clone(&self.stop),
handler: Arc::clone(&handler),
dead_letter: Arc::clone(&dead_letter),
};
handles.push(tokio::spawn(worker.run_loop_supervised()));
}
// Periodic lease-expiry sweep: recovers rows a crashed/panicked
// worker left `in_progress` (the lock TTL bounds the wait). Woken by
// the same notify as the workers, so enqueue and stop interrupt the
// sleep; the first interval tick fires immediately (harmless extra
// recovery at startup).
let sweep_pool = std::sync::Arc::clone(&self.pool);
let sweep_notify = Arc::clone(&self.notify);
let sweep_stop = Arc::clone(&self.stop);
handles.push(tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(30));
loop {
let notified = sweep_notify.notified();
tokio::pin!(notified);
tokio::select! {
_ = &mut notified => {}
_ = interval.tick() => {}
}
if sweep_stop.load(Ordering::Relaxed) {
break;
}
let result = sweep_pool.with_conn(move |conn| recover_update(conn)).await;
if let Err(e) = result {
log::error!("queue sweep failed: {e}");
}
}
}));
*self.worker.lock() = handles;
}
pub async fn stop(&self) {
self.stop.store(true, Ordering::Relaxed);
self.notify.notify_waiters();
let handles = std::mem::take(&mut *self.worker.lock());
for handle in handles {
let _ = handle.await;
}
}
/// Persists a task. `run_after` is an absolute unix timestamp (seconds).
/// Notifies the worker only after the insert has committed, so the worker
/// never wakes to an invisible row.
pub async fn enqueue(&self, payload: Value, run_after: f64) -> rusqlite::Result<()> {
let id = format!(
"task_{}_{}",
(now_f64() * 1000.0) as u64,
self.counter.fetch_add(1, Ordering::Relaxed)
);
let payload = payload.to_string();
log::debug!("enqueued {id} (run_after {run_after:.1})");
self.pool.with_conn(move |conn| {
conn.execute(
"INSERT OR REPLACE INTO tasks (id, payload, run_after, attempts, status, locked_until, created_at) \
VALUES (?1, ?2, ?3, 0, 'pending', 0, ?4)",
params![id, payload, run_after, now_f64()],
)?;
Ok(())
})
.await?;
// `notify_one` stores a permit when no worker is registered, so a
// notification fired between a worker's DB reads and its `notified()`
// registration is not lost (notify_waiters would drop it). The
// awakened worker re-leases and finds the new row.
self.notify.notify_one();
Ok(())
}
async fn recover_stale(&self) {
self.recover_sweep().await;
}
async fn recover_sweep(&self) {
let result = self.pool.with_conn(move |conn| recover_update(conn)).await;
if let Err(e) = result {
log::error!("queue recovery failed: {e}");
}
}
}
impl QueueWorker {
/// Supervised worker: the inner loop runs in its own task so a panic
/// (e.g. inside a handler or a DB closure) kills only that task; the
/// supervisor respawns it until stop is set. The row a dead worker had
/// leased is recovered by the periodic sweep once its lock TTL expires.
async fn run_loop_supervised(self) {
while !self.stop.load(Ordering::Relaxed) {
let worker = self.clone();
if let Err(e) = tokio::spawn(async move { worker.run_loop().await }).await {
log::error!("queue worker panicked, restarting: {e}");
}
}
}
async fn run_loop(self) {
while !self.stop.load(Ordering::Relaxed) {
match self.lease_next().await {
Ok(Some(row)) => self.process(row).await,
Ok(None) => {
let wait_until = self.earliest_run_after().await;
let notified = self.notify.notified();
tokio::pin!(notified);
match wait_until {
Some(until) => {
let delay = (until - now_f64()).max(0.0);
tokio::select! {
_ = &mut notified => {}
_ = tokio::time::sleep(Duration::from_secs_f64(delay)) => {}
}
}
None => {
notified.await;
}
}
}
// A lease failure while rows are due would otherwise loop
// with sleep(0) and hammer SQLite; back off briefly.
Err(e) => {
log::error!("queue lease failed: {e}");
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
}
/// Leases the oldest due row (sets it `in_progress` with a lock TTL).
/// Errors are surfaced so the caller can back off instead of spinning.
async fn lease_next(&self) -> Result<Option<LeasedRow>, rusqlite::Error> {
self.pool.with_conn(|conn| {
// BEGIN IMMEDIATE: with several workers, a deferred transaction
// that read before another worker's lease commit would fail with
// SQLITE_BUSY_SNAPSHOT. Taking the write lock up front serializes
// leases and re-reads the freshest committed state.
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let now = now_f64();
let row = tx.query_row(
"SELECT id, payload, attempts FROM tasks WHERE status='pending' AND run_after <= ?1 AND locked_until <= ?1 \
ORDER BY run_after LIMIT 1",
params![now],
|r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, i32>(2)?,
))
},
);
let (id, payload, attempts) = match row {
Ok(row) => row,
Err(rusqlite::Error::QueryReturnedNoRows) => {
tx.commit()?;
return Ok(None);
}
Err(e) => return Err(e),
};
tx.execute(
"UPDATE tasks SET status='in_progress', locked_until=?1 WHERE id=?2",
params![now + LOCK_TTL_SECONDS, id],
)?;
tx.commit()?;
Ok(Some(LeasedRow {
id,
payload,
attempts,
}))
})
.await
}
async fn earliest_run_after(&self) -> Option<f64> {
let result = self
.pool
.with_conn(|conn| {
let mut stmt =
conn.prepare("SELECT MIN(run_after) FROM tasks WHERE status='pending'")?;
let mut rows = stmt.query([])?;
match rows.next()? {
Some(row) => Ok(row.get::<_, Option<f64>>(0)?),
None => Ok(None),
}
})
.await;
match result {
Ok(v) => v,
Err(e) => {
log::error!("queue timing query failed: {e}");
None
}
}
}
async fn process(&self, row: LeasedRow) {
let payload: Value = match serde_json::from_str(&row.payload) {
Ok(value) => value,
Err(e) => {
log::error!("queue: unparseable payload for {}: {e}", row.id);
self.delete_row(&row.id).await;
(self.dead_letter)(Value::Null, format!("invalid stored payload: {e}")).await;
return;
}
};
log::debug!("processing {} (attempt {})", row.id, row.attempts + 1);
match (self.handler)(payload).await {
Ok(()) => {
log::debug!("task {} completed", row.id);
self.delete_row(&row.id).await;
}
Err(QueueError::Retryable {
delay_seconds,
payload,
}) => {
if row.attempts as u32 >= MAX_RETRIES {
let message = format!("task failed after {MAX_RETRIES} retries");
log::error!("dead-lettering {}: {message}", row.id);
self.delete_row(&row.id).await;
(self.dead_letter)(payload, message).await;
} else {
let delay = scaled_retry_delay(delay_seconds, row.attempts);
log::debug!(
"task {} rescheduled in {delay:.1}s (attempt {})",
row.id,
row.attempts + 1
);
self.reschedule(&row.id, payload, delay, row.attempts + 1)
.await;
}
}
Err(QueueError::Permanent { message, payload }) => {
log::error!("dead-lettering {}: {message}", row.id);
self.delete_row(&row.id).await;
(self.dead_letter)(payload, message).await;
}
}
}
async fn delete_row(&self, id: &str) {
let id = id.to_string();
let result = self
.pool
.with_conn(move |conn| {
conn.execute("DELETE FROM tasks WHERE id = ?1", params![id])?;
Ok(())
})
.await;
if let Err(e) = result {
log::error!("queue delete failed: {e}");
}
}
async fn reschedule(&self, id: &str, payload: Value, delay_seconds: f64, attempts: i32) {
let id = id.to_string();
let payload = payload.to_string();
let result = self.pool.with_conn(move |conn| {
conn.execute(
"UPDATE tasks SET payload=?1, run_after=?2, attempts=?3, status='pending', locked_until=0 WHERE id=?4",
params![payload, now_f64() + delay_seconds, attempts, id],
)?;
Ok(())
})
.await;
if let Err(e) = result {
log::error!("queue reschedule failed: {e}");
}
// Same permit semantics as enqueue: never lose the wakeup.
self.notify.notify_one();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
#[test]
fn scaled_retry_delay_scales_and_caps() {
assert_eq!(scaled_retry_delay(1.0, 0), 1.0);
assert_eq!(scaled_retry_delay(1.0, 1), 2.0);
assert_eq!(scaled_retry_delay(1.0, 2), 4.0);
assert_eq!(scaled_retry_delay(1.5, 1), 3.0);
assert_eq!(scaled_retry_delay(1.0, 10), 300.0, "capped at 300s");
assert_eq!(scaled_retry_delay(300.0, 0), 300.0);
}
async fn new_queue() -> (PersistentTaskQueue, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("queue.db");
let queue = PersistentTaskQueue::new(path.to_str().unwrap());
(queue, dir)
}
#[tokio::test]
async fn enqueue_runs_handler_once() {
let (queue, _dir) = new_queue().await;
let calls = Arc::new(AtomicUsize::new(0));
let calls_worker = calls.clone();
queue
.start(
move |payload| {
assert_eq!(payload["n"], 42);
calls_worker.fetch_add(1, AtomicOrdering::SeqCst);
async { Ok(()) }
},
|_payload, _message| async {},
)
.await;
queue
.enqueue(serde_json::json!({"n": 42}), now_f64())
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(calls.load(AtomicOrdering::SeqCst), 1);
queue.stop().await;
}
#[tokio::test]
async fn retryable_reschedules_then_dead_letters() {
let (queue, _dir) = new_queue().await;
let calls = Arc::new(AtomicUsize::new(0));
let dead_calls = Arc::new(AtomicUsize::new(0));
let c = calls.clone();
let d = dead_calls.clone();
queue
.start(
move |payload| {
c.fetch_add(1, AtomicOrdering::SeqCst);
let payload = payload.clone();
async move {
Err(QueueError::Retryable {
delay_seconds: 0.001,
payload,
})
}
},
move |_payload, _message| {
d.fetch_add(1, AtomicOrdering::SeqCst);
async {}
},
)
.await;
queue
.enqueue(serde_json::json!({"a": 1}), now_f64())
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(600)).await;
assert_eq!(
calls.load(AtomicOrdering::SeqCst),
MAX_RETRIES as usize + 1,
"handler should run once per attempt"
);
assert_eq!(dead_calls.load(AtomicOrdering::SeqCst), 1);
queue.stop().await;
}
#[tokio::test]
async fn permanent_error_dead_letters_immediately() {
let (queue, _dir) = new_queue().await;
let calls = Arc::new(AtomicUsize::new(0));
let dead_calls = Arc::new(AtomicUsize::new(0));
let c = calls.clone();
let d = dead_calls.clone();
queue
.start(
move |payload| {
c.fetch_add(1, AtomicOrdering::SeqCst);
let payload = payload.clone();
async move {
Err(QueueError::Permanent {
message: "nope".into(),
payload,
})
}
},
move |_payload, message| {
assert_eq!(message, "nope");
d.fetch_add(1, AtomicOrdering::SeqCst);
async {}
},
)
.await;
queue
.enqueue(serde_json::json!({"a": 1}), now_f64())
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(calls.load(AtomicOrdering::SeqCst), 1);
assert_eq!(dead_calls.load(AtomicOrdering::SeqCst), 1);
queue.stop().await;
}
#[tokio::test]
async fn stale_in_progress_row_is_recovered_on_start() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("queue.db");
// Insert a stale leased row directly (lease expired).
{
let conn = Connection::open(&path).unwrap();
ensure_schema(&conn).unwrap();
conn.execute(
"INSERT INTO tasks (id, payload, run_after, attempts, status, locked_until, created_at) \
VALUES ('task_stale', '{\"s\":1}', 0, 0, 'in_progress', ?1, 0)",
params![now_f64() - 10.0],
)
.unwrap();
}
let queue = PersistentTaskQueue::new(path.to_str().unwrap());
let calls = Arc::new(AtomicUsize::new(0));
let c = calls.clone();
queue
.start(
move |payload| {
assert_eq!(payload["s"], 1);
c.fetch_add(1, AtomicOrdering::SeqCst);
async { Ok(()) }
},
|_payload, _message| async {},
)
.await;
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(calls.load(AtomicOrdering::SeqCst), 1);
queue.stop().await;
}
#[tokio::test]
async fn runtime_sweep_recovers_expired_lease() {
let (queue, _dir) = new_queue().await;
let calls = Arc::new(AtomicUsize::new(0));
let c = calls.clone();
queue
.start(
move |payload| {
assert_eq!(payload["s"], 1);
c.fetch_add(1, AtomicOrdering::SeqCst);
async { Ok(()) }
},
|_payload, _message| async {},
)
.await;
// Insert a stale leased row AFTER startup: without a runtime sweep it
// would stay `in_progress` forever (only start() used to recover).
{
let conn = Connection::open(queue.pool.path()).unwrap();
ensure_schema(&conn).unwrap();
conn.execute(
"INSERT INTO tasks (id, payload, run_after, attempts, status, locked_until, created_at) \
VALUES ('task_stale_runtime', '{\"s\":1}', 0, 0, 'in_progress', ?1, 0)",
params![now_f64() - 1000.0],
)
.unwrap();
}
queue.recover_sweep().await;
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(
calls.load(AtomicOrdering::SeqCst),
1,
"expired lease must be recovered and processed exactly once"
);
queue.stop().await;
}
}
File diff suppressed because it is too large Load Diff
+245
View File
@@ -0,0 +1,245 @@
//! Per-chat state with SQLite persistence (table `chat_state` in
//! `data/task_queue.db`, shared with the task queue).
use parking_lot::Mutex;
use rusqlite::params;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Serialize, Deserialize, Default, Clone, Debug)]
pub struct ChatData {
pub forward_channel_id: Option<i64>,
pub edit_before_forward: bool,
/// Key: prompt message id.
pub edit_message: HashMap<i64, EditMessage>,
/// name -> HTML template containing "[]"
pub template: HashMap<String, String>,
/// site name (twitter/bsky/pixiv) -> user-supplied caption format with
/// {url} {author} {author_url} {title} {tags} placeholders.
pub message_format: HashMap<String, String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct EditMessage {
pub url: String,
pub chat_id: i64,
pub forward_message_ids: Vec<i64>,
pub template: String,
/// Unix seconds at registration; expiry = created_at + ttl.
pub created_at: i64,
}
pub struct ChatStore {
/// In-memory cache; the DB is the source of truth on first access.
cache: Mutex<HashMap<i64, ChatData>>,
/// Per-chat async locks serializing get→mutate→set so concurrent handler
/// tasks (batch-forwards, callbacks) cannot clobber each other's writes.
locks: Mutex<HashMap<i64, Arc<tokio::sync::Mutex<()>>>>,
pool: crate::db::DbPool,
}
pub fn unix_now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
impl ChatStore {
/// Creates the parent directory and the `chat_state` table (idempotent).
/// The shared `tasks` / `link_cache` tables are owned by `queue.rs` and
/// `link_cache.rs` respectively.
pub fn open(path: &str) -> rusqlite::Result<Self> {
if let Some(parent) = Path::new(path).parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
}
let conn = crate::db::open_db(path)?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS chat_state (chat_id TEXT PRIMARY KEY, payload TEXT NOT NULL);",
)?;
drop(conn);
Ok(ChatStore {
cache: Mutex::new(HashMap::new()),
locks: Mutex::new(HashMap::new()),
pool: crate::db::DbPool::new(path),
})
}
pub async fn get(&self, chat_id: i64) -> ChatData {
if let Some(data) = self.cache.lock().get(&chat_id) {
return data.clone();
}
let chat_key = chat_id.to_string();
let payload = self
.pool
.with_conn(move |conn| {
// Concurrent handler tasks (batch-forwards) may write chat_state
// while this read runs; the shared busy timeout handles the
// write-lock collision instead of failing the query.
let mut stmt = conn.prepare("SELECT payload FROM chat_state WHERE chat_id = ?1")?;
let mut rows = stmt.query(params![chat_key])?;
match rows.next()? {
Some(row) => Ok(Some(row.get::<_, String>(0)?)),
None => Ok(None),
}
})
.await
.unwrap_or_else(|e| {
log::error!("chat_state read failed: {e}");
None
})
.unwrap_or_default();
let data: ChatData = serde_json::from_str(&payload).unwrap_or_default();
self.cache.lock().insert(chat_id, data.clone());
data
}
/// Write-through: update the cache and the DB.
pub async fn set(&self, chat_id: i64, data: &ChatData) {
self.cache.lock().insert(chat_id, data.clone());
let payload = serde_json::to_string(data).expect("chat state serializes");
let chat_id = chat_id.to_string();
let result = self
.pool
.with_conn(move |conn| {
conn.execute(
"INSERT OR REPLACE INTO chat_state (chat_id, payload) VALUES (?1, ?2)",
params![chat_id, payload],
)?;
Ok(())
})
.await;
if let Err(e) = result {
log::error!("chat_state write failed: {e}");
}
}
/// Serializes a get→mutate→set cycle per chat: concurrent handler tasks
/// (the batch-forward design spawns several per chat) each snapshot the
/// same `ChatData` and last-writer-wins would silently drop mutations,
/// e.g. a second `edit_message` record. The per-chat lock makes the
/// cycle atomic. Returns the closure's result.
pub async fn update<R>(&self, chat_id: i64, f: impl FnOnce(&mut ChatData) -> R) -> R {
let lock = {
let mut locks = self.locks.lock();
locks
.entry(chat_id)
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
};
let _guard = lock.lock().await;
let mut data = self.get(chat_id).await;
let r = f(&mut data);
self.set(chat_id, &data).await;
r
}
/// Removes edit-before-forward records whose `created_at + ttl` is in the
/// past. Returns the removed `(chat_id, prompt_message_id)` pairs so the
/// caller can clear the prompt's buttons.
pub async fn prune_expired(&self, ttl: Duration) -> Vec<(i64, i64)> {
let now = unix_now();
let ttl_secs = ttl.as_secs() as i64;
let mut removed = Vec::new();
// Chats with no live edit records: evicted from the cache (and their
// per-chat lock) so the cache stays bounded to active prompts. The DB
// keeps the row; the next get() reloads it.
let mut evicted_chats = Vec::new();
let changed: Vec<(i64, ChatData)> = {
let mut cache = self.cache.lock();
let mut out = Vec::new();
for (chat_id, data) in cache.iter_mut() {
let keys: Vec<i64> = data.edit_message.keys().copied().collect();
let mut kept = HashMap::new();
for key in keys {
if let Some(entry) = data.edit_message.get(&key) {
if entry.created_at + ttl_secs > now {
kept.insert(key, entry.clone());
} else {
removed.push((*chat_id, key));
}
}
}
if kept.len() != data.edit_message.len() {
// Persist the pruned row (removes expired records from
// the DB too, not just the cache).
data.edit_message = kept;
out.push((*chat_id, data.clone()));
}
if data.edit_message.is_empty() {
evicted_chats.push(*chat_id);
}
}
// Lock order: update() takes the per-chat lock before the cache
// lock, so prune must not hold the cache lock while taking locks.
drop(cache);
out
};
for (chat_id, data) in changed {
self.set(chat_id, &data).await;
}
if !evicted_chats.is_empty() {
let mut cache = self.cache.lock();
let mut locks = self.locks.lock();
for chat_id in &evicted_chats {
cache.remove(chat_id);
locks.remove(chat_id);
}
}
if !removed.is_empty() {
log::info!(
"pruned {} expired edit-before-forward record(s)",
removed.len()
);
}
removed
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn concurrent_updates_do_not_lose_edit_records() {
let dir = tempfile::tempdir().unwrap();
let store = std::sync::Arc::new(
ChatStore::open(dir.path().join("s.db").to_str().unwrap()).unwrap(),
);
let mut handles = Vec::new();
for i in 0..4 {
let store = Arc::clone(&store);
handles.push(tokio::spawn(async move {
store
.update(1001, |data| {
data.edit_message.insert(
i,
EditMessage {
url: format!("https://x.com/u/status/{i}"),
chat_id: 1001,
forward_message_ids: vec![i],
template: String::new(),
created_at: 0,
},
);
})
.await;
}));
}
for h in handles {
h.await.unwrap();
}
let data = store.get(1001).await;
assert_eq!(
data.edit_message.len(),
4,
"concurrent get→mutate→set must not drop records"
);
}
}
+65 -12
View File
@@ -1,21 +1,74 @@
services:
tgxmb: image: yoursfunny/telegram-twitter-media-bot: latest
nginx-proxy:
image: nginxproxy/nginx-proxy:1.11.6-alpine
restart: always
ports:
- "8443:8443"
- '80:80'
- '443:443'
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
- certs:/etc/nginx/certs:ro
- html:/usr/share/nginx/html:ro
networks: [proxy]
labels:
- 'com.github.nginx-proxy.nginx'
container_name: nginx-proxy
acme-companion:
image: nginxproxy/acme-companion
restart: always
environment:
DEFAULT_EMAIL: ''
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- certs:/etc/nginx/certs:rw
- html:/usr/share/nginx/html:rw
- acme:/etc/acme.sh
networks: [proxy]
container_name: acme-companion
depends_on:
- nginx-proxy
tgxmb:
image: yoursfunny/telegram-twitter-media-bot:latest
restart: always
environment:
LOCAL_USER_ID: '1000'
BOT_TOKEN: ''
TELOXIDE_TOKEN: ''
BOT_ADMIN: ''
WEBHOOK: false
WEBHOOK_LISTEN: '127.0.0.1'
WEBHOOK_PORT: 8443
WEBHOOK_URL: 'https://example.com'
WEBHOOK_KEY: './cert/private.key'
WEBHOOK_CERT: './cert/cert.pem'
WEBHOOK_SECRET_TOKEN: 'secret-token'
# LOG_LEVEL: 'WARNING'
PIXIV_REFRESH_TOKEN: ''
TWITTER_AUTH_TOKEN: ''
EDIT_MESSAGE_TTL_SECONDS: '86400'
LINK_CACHE_TTL_SECONDS: '604800'
RUST_LOG: 'info'
VIRTUAL_HOST: '<YOUR_DOMAIN>'
VIRTUAL_PORT: '8443'
# ACME_HOST: 'your.domain.com'
WEBHOOK: 'true'
WEBHOOK_LISTEN: '0.0.0.0'
WEBHOOK_PORT: '8443'
WEBHOOK_URL: 'https://<YOUR_DOMAIN>/'
WEBHOOK_SECRET_TOKEN: ''
volumes:
- ./data:/app/data
# - ./cert:/app/cert
networks: [proxy]
depends_on:
- nginx-proxy
container_name: tgxmb
# Webhook mode only: the bot listens on WEBHOOK_PORT; nginx-proxy shows
# 502s while this is down, so surface it to the orchestrator.
healthcheck:
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8443'"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
certs:
html:
acme:
networks:
proxy:
name: proxy
+17 -4
View File
@@ -5,12 +5,25 @@ if [ "$(id -u)" -eq '0' ]
then
USER_ID=${LOCAL_USER_ID:-9001}
useradd --shell /bin/bash -u ${USER_ID} -o -c "" -m user > /dev/null 2>&1
usermod -a -G root user > /dev/null 2>&1
chown -R `id -u user`:`id -u user` /app > /dev/null 2>&1
# `docker compose restart` / `docker restart` reuse the same container, so
# the overlay fs keeps the user created on first boot. A second `useradd`
# then fails with exit code 9, which would trip `set -e` and kill the
# container on every restart. Create only if missing; align the UID
# otherwise so LOCAL_USER_ID changes still apply.
if ! id user > /dev/null 2>&1
then
useradd --shell /bin/bash -u ${USER_ID} -o -c "" -m user > /dev/null 2>&1 || true
else
usermod -u ${USER_ID} -o user > /dev/null 2>&1 || true
fi
# Bind-mounted volumes may not support chown; a failure here must not kill
# the container either.
chown -R `id -u user`:`id -u user` /app > /dev/null 2>&1 || true
export HOME=/home/user
exec gosu user "$0" "$@"
# setpriv (util-linux, present in bookworm-slim) replaces gosu: drop to the
# target user and exec, keeping the process as PID 1.
exec setpriv --reuid=`id -u user` --regid=`id -g user` --init-groups "$@"
fi
exec "$@"
+215
View File
@@ -0,0 +1,215 @@
# 站点适配器重构方案:让新增站点变成"新模块 + 注册一行"
> 状态:设计稿(未实施)。目标:把"加一个新站点"从改 8-9 处收敛到 3 处,
> 并让站点身份、重试策略、下载 header 等站点能力归位到站点模块自身。
> 本文只改文档,不动代码;每阶段均可独立合入、独立回滚。
---
## 1. 现状摩擦清单
以现有三站(twitter / bsky / pixiv)为基线,新增第 4 个站点(代号 `example`
今天需要触碰的位置:
| # | 位置(当前行号) | 改动 | 必改? |
|---|---|---|---|
| 1 | 新目录 `crates/x-media/src/site/example/{mod,interface,model}.rs` | 新模块 | 必改 |
| 2 | `site/mod.rs:354-365` `fetch_once` | 加一个 `if` 分派分支 | 必改 |
| 3 | `site/mod.rs:167-178` `cache_key` | 加一个 `if` 分支 + 约定 key 前缀 `"example:..."` | 必改 |
| 4 | `site/mod.rs:53-63` `site_name()` | 加一个 URL `contains` 嗅探分支 | 必改 |
| 5 | `handlers.rs:405` `SetFormat` 白名单 | `["twitter","bsky","pixiv"]` 加字符串 | 必改 |
| 6 | `config.rs` / `main.rs:74-84` | 仿 pixiv 加启动校验(token、`disable()` | 视站点 |
| 7 | `site/mod.rs:312-326` `fetch_error_is_retryable` | 若重试策略特殊,改中央分类函数 | 视站点 |
| 8 | `site/mod.rs:377/391/430` 三个下载函数 | 若媒体有防盗链,加 header(现在是硬编码 pximg 判断) | 视站点 |
| 9 | `site/mod.rs:16,180-197` `FetchError` | 若错误类型特殊,加嵌套 variant(仿 `Pixiv(PixivError)` | 视站点 |
**根因**:仓库里没有"站点"这个实体。站点的四类能力——URL 识别(PATTERN +
cache_key)、抓取、重试策略、下载 header——分别散落在中央 if 链、URL 字符串嗅探、
魔法字符串 key 和 bot crate 的白名单里。`AGENTS.md` 现行约定 "no trait, no enum
dispatch" 是刻意的简单性选择;本方案的目标是在**不推翻它精神的前提下**收敛摩擦,
并在阶段 3 提供完整的 trait 注册表选项。
## 2. 目标架构
```
crates/x-media/src/site/mod.rs
├─ SITES: LazyLock<Vec<Box<dyn Site>>> ← 注册表(唯一的"站点列表")
├─ find_site(url) / fetch(url) / cache_key(url) / site_ids()
└─ 通用类型:Fetched { site_id, ... } / FetchError(通用类 + Site 变体)
├─ site/twitter/{mod,interface,model}.rs impl Site
├─ site/bsky/… impl Site
└─ site/pixiv/… impl Site (download_headers: pximg Referer)
(validate: token 校验)
crates/xmedia-bot
├─ handlers.rs SetFormat 白名单 ← x_media::site::ids()(不再写死)
├─ handlers.rs site 格式查找 ← fetched.site_id(缓存/新鲜两条路径同口径)
└─ main.rs 启动校验 ← site::validate_all()(不再特判 pixiv
```
## 3. 分阶段迁移
每个阶段是一个独立提交,保持 `cargo fmt` / `cargo clippy -- -D warnings` /
`cargo test --workspace` 全绿;行为完全不变,只挪代码、不换语义。
### 阶段 1:站点身份单一来源(低风险,推荐先做)
**动机**:同一概念目前有两个来源——缓存命中路径用 `key.split(':').next()`
`handlers.rs:639`),新鲜抓取路径用 `fetched.site_name()``handlers.rs:724`);
`site_name()` 又是对 `source_url``contains` 字符串嗅探,还有 `"unknown"`
兜底分支。
**改动**
1. `site/mod.rs``Fetched` 增加字段 `site_id: &'static str`(由各站点的
`impl From<SiteStruct> for Fetched` 填充;`empty_fetched` 同步填)。
`Fetched::site_name()` 改为 `return self.site_id`(保留方法名,删除
`source_url.contains` 嗅探与 `"unknown"` 分支)。
2. `site/mod.rs`:新增 `pub fn site_id_from_key(key: &str) -> &'static str`
(解析 `"example:..."` 前缀,未知前缀返回 `"unknown"`),bot 缓存命中路径改用它,
`fetched.site_id` 口径统一。
3. `handlers.rs:405``SetFormat` 白名单改为 `x_media::site::ids()`——阶段 1 先实现
`ids()``["twitter","bsky","pixiv"]` 的常量函数(数据源仍集中,行为不变),
阶段 3 再改为遍历注册表。
4. `twitter/interface.rs:48-60` / `bsky` / `pixiv``From<SiteStruct> for Fetched`
各补 `site_id` 字段。
**风险**:低。纯增量字段;`site_name()` 语义不变(测试 `pixiv/interface.rs:355`
已断言 `"pixiv"`)。
**验证**:现有全部单测;`cache_key_normalizes_domain_variants` 等不变。
**回滚**revert 该提交。
### 阶段 2:站点能力下沉(不引入 trait,静态分派)
**动机**:把"每个站点自己才知道"的逻辑搬回站点模块,中央只做迭代。这是
`AGENTS.md` 现有约定(无 trait)与完整注册表之间的折中,可独立交付。
**改动**:每个站点模块新增并 `mod.rs` 重新导出:
```rust
// site/twitter/interface.rsbsky/pixiv 同构)
pub fn cache_key(url: &str) -> Option<String>; // 用自身 PATTERN,返回 "twitter:<id>"
pub fn is_retryable(err: &FetchError) -> bool; // 默认 Http|Transientpixiv 覆盖 PixivError 分支
pub fn media_headers(url: &str) -> Option<Vec<(&'static str, String)>>;
// pixiv: url 含 "pximg.net" → Referer
```
`site/mod.rs` 相应改为迭代三站:
- `cache_key`:逐个调 `site::cache_key`,不再自己写 key 格式;
- `fetch_error_is_retryable`:删除,`fetch()` 重试循环改调 `current_site::is_retryable`
`fetch_once` 已能确定站点,把站点传下去);
- `media_size` / `download_media_limited` / `download_media_to_file` 里的
`pximg.net → Referer` 硬编码删除,改为遍历 `SITES`(阶段 2 是遍历
`[twitter, bsky, pixiv]` 静态列表)取 `media_headers(url)` 合并。
**注意**Referer 判定依据是媒体 URL 的 host(`pximg.net`),**不是**站点
PATTERNpixiv 的 PATTERN 只匹配 `pixiv.net/artworks/...`),所以 `media_headers`
不能挂在 PATTERN 匹配上,必须按 URL 独立匹配——这正是把它做成独立函数的原因。
**风险**:中。下载函数签名不变,行为必须逐字节不变;新增单元测试覆盖
`media_headers("https://i.pximg.net/...") == Some(Referer)`
`cache_key` 等价性(对全部既有用例断言新旧结果一致)。
**回滚**revert。
### 阶段 3Site trait + SITES 注册表(完整方案,可选)
**动机**:加站点时 bot crate 与中央分派零改动;站点列表成为唯一注册点。
**新增**`site/mod.rs`):
```rust
pub trait Site: Send + Sync {
fn id(&self) -> &'static str;
fn pattern(&self) -> &'static Regex;
fn enabled(&self) -> bool;
fn cache_key(&self, url: &str) -> Option<String>; // 默认: id + 捕获组1
fn fetch_from_url(&self, url: &str)
-> Pin<Box<dyn Future<Output = Result<Fetched, FetchError>> + Send>>;
fn is_retryable(&self, err: &FetchError) -> bool; // 默认: Http|Transient
fn media_headers(&self, url: &str) -> Option<Vec<(&'static str, String)>>; // 默认: None
fn validate(&self) -> Option<BoxFuture<'static, Result<(), String>>>; // 默认: None
}
static SITES: LazyLock<Vec<Box<dyn Site>>> = LazyLock::new(|| vec![
Box::new(twitter::TwitterSite), Box::new(bsky::BskySite), Box::new(pixiv::PixivSite),
]);
```
- `fetch_once``find_site(url)`(首个 PATTERN 命中且 `enabled()` 的站点)
`site.fetch_from_url(url).await`
- `cache_key` / `site_ids()` / `media_headers` / `validate_all()` 全部遍历 `SITES`
- `fetch_error_is_retryable` 删除,重试判定走 `site.is_retryable`
- `main.rs:74-84` 的 pixiv 特判 → `site::validate_all()`pixiv 的 `validate` 失败时
内部调用现有 `pixiv::disable()`,行为保持);
- 保留各站点的 `PATTERN`/`enabled()`/`fetch_from_url()` 顶层导出(兼容现有
`fetch_once` 及测试),trait 只是包一层薄壳。
**async 形态**:仓库没有 `async-trait` 依赖。两个选择:
(a) 手写 `Pin<Box<dyn Future>>` 返回类型(零新依赖,契合仓库手写风格,签名略丑);
(b) 引入 `async-trait`(可读性好,新增一个依赖)。
建议先 (a),理由:仓库显式偏好手写错误/状态机,且 `BoxFuture` 已有先例
`queue.rs:38``BoxFuture`)。
**风险**:中。动中央分派,但每站点行为不变;注册表迭代 + `find_site` 补单测
`fetch`/`cache_key` 对既有 URL 集合的结果与阶段 2 完全一致)。
**回滚**revert。
### 阶段 4FetchError 泛化(可选,配合阶段 3)
**动机**`FetchError::Pixiv(PixivError)``site/mod.rs:16,184,241-245`)是站点特有
错误嵌进通用枚举;第 4 个站点要么再加变体,要么用泛化变体。
**改动**`FetchError` 增加 `Site { site: &'static str, error: Box<dyn std::error::Error + Send + Sync> }`
`Pixiv(PixivError)` 变体保留但内部迁移到 `Site`(或直接替换并更新
`is_retryable`/`Display`/`source()` 与测试)。重试判定在阶段 3 已归站点,
中央枚举只剩通用类(Http/Json/NotFound/Blocked/Sensitive/TooLarge/Transient/Io)。
**风险**:中。`Display`/`source()`/`From<PixivError>``fetch_error_is_retryable`
测试(`site/mod.rs:480-522`)需同步。
**回滚**revert。
### 阶段 5:收尾
- 更新 `AGENTS.md` 的 "Site adapter convention" 段:写新约定(注册表 + `impl Site` +
每站点 `cache_key`/`is_retryable`/`media_headers`),删除 "no trait" 表述;
- `examples/fetch.rs` 不变(走 `site::fetch`);
- 新增站点 checklist 见 §4。
## 4. 重构后新增站点 checklist
```
1. crates/x-media/src/site/example/{mod,interface,model}.rs // 新模块
2. impl Site for ExampleSite 并注册进 SITES // 注册一行
3. (可选)token 读取 + validate() 实现 // 启动校验自动生效
── bot crate 零改动 ──
```
对比现状的 8-9 处,bot crate 完全不碰:`SetFormat` 白名单、格式查找口径、
缓存 key、启动校验全部自动跟随注册表。
## 5. 权衡与明确不做的事
- **不做**Media 类型扩展(`media.rs` + `MediaItemPayload` + `CachedMediaKind` +
send.rs 约 10+ 处 match 的 blast radius)——这是"新增媒体类型"的摩擦,与"新增
站点"正交,优先级低,保持现状。
- **不做**DI/全局注入改造(`CHAT_STORE`/`TASK_QUEUE`/`CONFIG``LazyLock` 静态
模式是仓库惯例,与站点扩展无关)。
- **不做**:schema 迁移——新站点只产生新的 cache key 前缀与 `message_format` JSON
key`link_cache`/`chat_state` 表结构均无需变化。
- **代价**:阶段 3 引入 `dyn Site` 与(选择 (a) 时)手写 `BoxFuture` 签名;若站点
数量长期 ≤5 且无新增迹象,阶段 2 的折中方案已够用,阶段 3/4 可无限期推迟。
## 6. 建议的提交序列
| 阶段 | 提交消息(建议) |
|---|---|
| 1 | `refactor(site): carry site_id on Fetched; unify cache-key site lookup` |
| 2 | `refactor(site): move cache_key/is_retryable/media_headers into site modules` |
| 3 | `refactor(site): introduce Site trait and SITES registry` |
| 4 | `refactor(site): genericize FetchError::Site` |
| 5 | `docs: update site adapter convention in AGENTS.md` |
每阶段独立合入、独立回滚;阶段 2 完成后即可认为"加站点"摩擦已收敛,
3/4 为可选深化。
-145
View File
@@ -1,145 +0,0 @@
from aiohttp import ClientSession
from telegram import Update, Chat
from telegram.constants import ParseMode, ChatAction, ChatType
from telegram.ext import (
Application,
ApplicationBuilder,
ContextTypes,
Defaults,
filters,
InlineQueryHandler,
PicklePersistence,
MessageHandler,
CommandHandler
)
import common
from tweet import TGTweet
async def inline_query(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.inline_query.query
if query == "":
return
common.logger.info(f"Query: {query}")
async with TGTweet(query) as tweet:
result = list(tweet.inline_query_generator)
await update.inline_query.answer(result)
async def reply_media(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.effective_chat.send_action(ChatAction.UPLOAD_PHOTO)
# url = update.message.text.split(" ")[0]
url = update.message.text
common.logger.info(f"Receiving url: {url}")
async with TGTweet(url) as tweet:
media = list(tweet.pm_media_generator)
message_sent = await update.effective_message.reply_media_group(
media,
caption=tweet.message_text,
reply_to_message_id=update.message.message_id
)
if 'forward_channel_id' in context.user_data:
try:
await update.effective_chat.copy_messages(
chat_id=context.user_data['forward_channel_id'],
message_ids=[m.id for m in message_sent],
)
except Exception as e:
await update.effective_message.reply_text(str(e))
async def cmd_set_forward_channel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.effective_chat.send_action(ChatAction.TYPING)
if not context.args:
await update.effective_message.reply_text("Please provide a channel username or id.")
return
channel = context.args[0]
try:
channel: Chat = await context.bot.get_chat(channel)
except Exception as e:
await update.effective_message.reply_text(str(e))
return
if channel.type != ChatType.CHANNEL:
await update.effective_message.reply_text("That is not a channel.")
return
try:
channel_admin = await channel.get_administrators()
except Exception as e:
await update.effective_message.reply_text(str(e) + "\nPlease add the bot to the channel and set as admin")
return
user_bot = filter(lambda x: x.user.id == context.bot.id, channel_admin)
user_bot = next(user_bot, None)
if user_bot.can_post_messages:
context.user_data['forward_channel_id'] = channel.id
await update.effective_message.reply_text("Add successfully.")
async def cmd_remove_forward_channel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.effective_chat.send_action(ChatAction.TYPING)
if 'forward_channel_id' in context.user_data:
del context.user_data['forward_channel_id']
await update.effective_message.reply_text("Remove successfully.")
return
await update.effective_message.reply_text("No channel to remove.")
async def post_init(application: Application) -> None:
# commands = [
# BotCommand('start', CMD_START),
# ]
# await application.bot.set_my_commands(commands)
DESCRIPTION = "A bot to fetch tweets from Twitter."
await application.bot.set_my_description(DESCRIPTION)
await application.bot.set_my_short_description(DESCRIPTION)
TGTweet.set_session(ClientSession())
async def post_stop(application: Application) -> None:
await application.bot.send_message(common.ADMIN[0], "Shutting down...")
async def post_shutdown(application: Application) -> None:
await TGTweet.close_session()
def main():
defaults = Defaults(parse_mode=ParseMode.HTML, allow_sending_without_reply=True)
persistence = PicklePersistence(filepath='data/pers.pkl')
application = (ApplicationBuilder()
.token(common.BOT_TOKEN)
.defaults(defaults)
.persistence(persistence)
.post_init(post_init)
.post_stop(post_stop)
.post_shutdown(post_shutdown)
.build()
)
# user_filter = filters.User()
# user_filter.add_user_ids(common.admin)
handlers = [
MessageHandler(filters.Regex(common.x_url_regex) & filters.ChatType.PRIVATE, reply_media),
InlineQueryHandler(inline_query, common.x_url_regex),
CommandHandler("set_forward_channel", cmd_set_forward_channel),
CommandHandler("remove_forward_channel", cmd_remove_forward_channel),
]
application.add_handlers(handlers)
if common.WEBHOOK:
application.run_webhook(
listen=common.WEBHOOK_LISTEN,
port=common.WEBHOOK_PORT,
secret_token=common.WEBHOOK_SECRET_TOKEN,
key=common.WEBHOOK_KEY,
cert=common.WEBHOOK_CERT,
webhook_url=common.WEBHOOK_URL
)
else:
application.run_polling()
if __name__ == '__main__':
main()
-2
View File
@@ -1,2 +0,0 @@
python-telegram-bot[webhooks]~=21.0.1
aiohttp[speedups]~=3.9.3
-227
View File
@@ -1,227 +0,0 @@
from typing import Generator
from aiohttp import ClientSession
from telegram import (
InlineQueryResultPhoto,
InlineQueryResultVideo,
InputMediaPhoto,
InputMediaVideo, InputMediaAnimation, InlineQueryResultMpeg4Gif
)
from common import x_url_regex, x_media_regex, x_tco_regex, logger
twimg_url = 'https://pbs.twimg.com/'
vx_api_url = 'https://api.vxtwitter.com/{0}/status/{1}'
message_raw_text = """{url}
<a href="{author_url}">{author}</a>: {text}
"""
async def fetch_json(session: ClientSession, url: str) -> dict:
logger.info(f"Fetching {url}")
async with session.get(url) as response:
assert response.status == 200, f"Failed to fetch {url}, status code {response.status}"
return await response.json()
class TweetMedia:
def __init__(self, url: str, thumb: str, media_type: str):
self._url: str = url
self._thumb: str = thumb
self._type: str = media_type
def __str__(self):
return f"Media[url: {self.url} thumb: {self.thumb} type: {self.type}]"
@property
def _uri(self) -> str | None:
match = x_media_regex.match(self._url)
if match:
return match.group(2).removesuffix('.jpg').removesuffix('.png')
return None
@property
def url(self) -> str:
match self._type:
case "image":
return f"{twimg_url}{self._uri}?format=jpg&name=4096x4096"
case "video":
return self._url
@property
def thumb(self) -> str:
match self._type:
case "image":
return f"{twimg_url}{self._uri}?format=jpg&name=thumb"
case "video":
return self._thumb
@property
def type(self) -> str:
return self._type
class Tweet:
def __init__(
self,
tweet_id: str,
author: str,
author_id: str,
text: str,
media: list[TweetMedia],
sensitive: bool = False
):
self._id: str = tweet_id
self._author: str = author
self._author_id: str = author_id
self._text: str = text
self._media: list[TweetMedia] = media
self._sensitive: bool = sensitive
@property
def id(self) -> str:
return self._id
@property
def url(self) -> str:
return f"https://twitter.com/{self._author_id}/status/{self._id}"
@property
def author(self) -> str:
return self._author
@property
def author_url(self) -> str:
return f"https://twitter.com/{self._author_id}"
@property
def text(self) -> str:
return self._text
@property
def media(self) -> list[TweetMedia]:
return self._media
@property
def sensitive(self) -> bool:
return self._sensitive
class TGTweet(Tweet):
_session: ClientSession
def __init__(self, url: str):
self._url: str = url
self._api_param: tuple[str] = self._tweet_id
assert self._api_param
async def __aenter__(self):
self._tweet: dict = await self._fetch_tweet(self._api_param)
super().__init__(*self._init_properties)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
@classmethod
def set_session(cls, session: ClientSession) -> None:
cls._session = session
@classmethod
async def close_session(cls) -> None:
await cls._session.close()
async def _fetch_tweet(self, api_param: tuple[str]) -> dict:
return await fetch_json(self._session, vx_api_url.format(*api_param))
@property
def _tweet_id(self) -> tuple[str] | None:
match = x_url_regex.match(self._url)
if match:
return match.groups()
return None
@property
def _tweet_text(self) -> str:
match = x_tco_regex.search(self._tweet['text'])
return self._tweet['text'][:match.start()].strip(" ") if match else self._tweet['text']
@property
def _tweet_media(self) -> list[TweetMedia]:
return [
TweetMedia(
url=x['url'],
thumb=x['thumbnail_url'],
media_type=x['type']
)
for x in self._tweet['media_extended']
]
@property
def _init_properties(self) -> tuple:
id = self._tweet['tweetID']
author = self._tweet['user_name']
author_id = self._tweet['user_screen_name']
text = self._tweet_text
media = self._tweet_media
sensitive = self._tweet['possibly_sensitive']
return id, author, author_id, text, media, sensitive
@property
def message_text(self) -> str:
return message_raw_text.format(
url=self.url,
author_url=self.author_url,
author=self.author,
text=self.text
)
@property
def inline_query_generator(self) -> Generator[InlineQueryResultPhoto | InlineQueryResultVideo, None, None]:
for i, tweet_media in enumerate(self.media):
logger.info(str(tweet_media))
if tweet_media.type == "image":
yield InlineQueryResultPhoto(
id=str(i),
photo_url=tweet_media.url,
thumbnail_url=tweet_media.thumb,
caption=self.message_text if not i else None
)
elif tweet_media.type == "video":
yield InlineQueryResultVideo(
id=str(i),
video_url=tweet_media.url,
mime_type="video/mp4",
thumbnail_url=tweet_media.thumb,
caption=self.message_text if not i else None
)
elif tweet_media.type == "gif":
yield InlineQueryResultMpeg4Gif(
id=str(i),
mpeg4_url=tweet_media.url,
thumbnail_url=tweet_media.thumb,
caption=self.message_text if not i else None
)
@property
def pm_media_generator(self) -> Generator[InputMediaPhoto | InputMediaVideo, None, None]:
for tweet_media in self.media:
logger.info(str(tweet_media))
if tweet_media.type == "image":
yield InputMediaPhoto(
media=tweet_media.url,
has_spoiler=self.sensitive
)
elif tweet_media.type == "video":
yield InputMediaVideo(
media=tweet_media.url,
has_spoiler=self.sensitive,
thumbnail=tweet_media.thumb
)
elif tweet_media.type == "gif":
yield InputMediaAnimation(
media=tweet_media.url,
has_spoiler=self.sensitive,
thumbnail=tweet_media.thumb
)