Compare commits

...
69 Commits
Author SHA1 Message Date
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
32 changed files with 4057 additions and 1093 deletions
+4
View File
@@ -28,5 +28,9 @@ LICENSE
README.md README.md
data/ data/
cert/ cert/
nginx-certs/
nginx-vhost.d/
nginx-html/
nginx-acme/
**/target/ **/target/
.idea/ .idea/
+2
View File
@@ -0,0 +1,2 @@
# Shell scripts must stay LF: CRLF breaks the shebang inside containers.
*.sh text eol=lf
+36 -5
View File
@@ -12,12 +12,35 @@ env:
DOCKERHUB_REPO: yoursfunny/telegram-twitter-media-bot DOCKERHUB_REPO: yoursfunny/telegram-twitter-media-bot
jobs: 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: docker:
needs: should-build
if: needs.should-build.outputs.build == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: ${{ env.DOCKERHUB_REPO }} images: ${{ env.DOCKERHUB_REPO }}
tags: | tags: |
@@ -28,22 +51,30 @@ jobs:
type=sha type=sha
- -
name: Set up QEMU name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
- -
name: Set up Docker Buildx name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- -
name: Login to Docker Hub name: Login to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} 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 name: Build and push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v7
with: with:
push: true push: true
build-args: | build-args: |
APP_NAME=${{ env.APP_NAME }} APP_NAME=${{ env.APP_NAME }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=tgxmb-build
cache-to: type=gha,mode=max,scope=tgxmb-build
+4
View File
@@ -2,6 +2,10 @@
__pycache__/ __pycache__/
cert/ cert/
data/ data/
nginx-certs/
nginx-vhost.d/
nginx-html/
nginx-acme/
docker-compose.yml docker-compose.yml
.env .env
+99
View File
@@ -0,0 +1,99 @@
# Repository Guidelines
## Project Overview
Telegram bot (teloxide) that turns post links from X/Twitter, Pixiv, and Bluesky into media messages (images, video, GIF) with the post's title, author, and tags. It supports batch media splitting, retry with persistence, inline queries, forward-channel rebinding with caption templates, and Pixiv ugoira→MP4 transcoding. README and user-facing strings are in Chinese. The project is a Rust port of a Python predecessor (see `queue.rs` comments referencing `utils/task_queue.py`).
Two-crate Cargo workspace (both v1.0.3, edition 2024, resolver 3):
- **`crates/x-media`** — library that fetches and normalizes media from the three sites. Pure, no Telegram knowledge.
- **`crates/xmedia-bot`** — the bot binary: teloxide dispatcher, SQLite-backed chat state, persistent task queue.
## Architecture & Data Flow
```
Telegram update → Dispatcher (polling or axum webhook) → dptree branches
├─ message → commands (any chat) / URL links (private chat only)
├─ inline_query → InlineQueryResult Photo/Video/Mpeg4Gif
└─ callback_query → "forward" (copy to channel) / "template|<name>" (apply caption template)
```
Message flow: `message_handler` extracts URLs (from `url`/`text_link` entities, text + caption, deduped) → `x_media::site::fetch(url)` → `Fetched` → builds a `Task` → `send::send_media_sequence` (media groups ≤ 9, caption on first item) or `send::send_animation`. On Telegram URL-fetch failure or size error (`send_batch_via_upload`): download via `x_media::site::download_media` to a temp file (≤ 10 MiB), sniff magic bytes (`sniff_ext`), upload via multipart; oversized items fall back to `fallback_url`. On failure: `enqueue_retry` persists resume-state `Task` into the SQLite queue → single worker leases (120 s lock TTL) → retry with exponential backoff (≤ 30 s, `MAX_RETRIES = 2`) → dead-letter → `notify_failure`. Success → `post_send_actions`: edit-before-forward prompt with inline buttons, or `copy_messages` to the bound forward channel.
The `x-media` library: `site::fetch(url)` dispatches (in order) twitter → bsky → pixiv via per-site regex `PATTERN` and returns `Ok(None)` for unmatched URLs. `Fetched { source_url, caption, title, media: Vec<Media>, sensitive, … }`; `caption_with(format)` substitutes `{url} {author} {author_url} {title} {tags}`.
## Key Directories
| Path | Purpose |
|---|---|
| `crates/x-media/src/` | Fetch library. `site/mod.rs` = dispatcher + `Fetched`/`FetchError`/`download_media`/`media_size`; `media.rs` = `Media` enum; `examples/fetch.rs` = end-to-end usage sample |
| `crates/x-media/src/site/<twitter\|pixiv\|bsky>/` | One directory per site: `mod.rs` (re-exports), `interface.rs` (PATTERN, `enabled()`, `fetch_from_url()`, site struct, `From<SiteStruct> for Fetched`), `model.rs` (serde DTOs). Pixiv adds `api.rs` (auth + transport); 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, 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 spawned with a `Semaphore(8)` cap (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).
## 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 rebuild `Bot::from_env()`.
- **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`).
## 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 hack, static ffmpeg from ffmpeg.martin-riedl.de (`FFMPEG_URL` arg, `unzip -t` integrity check), `debian:bookworm-slim` runtime, entrypoint |
| `docker-entrypoint.sh` | Privilege drop: `useradd` with `LOCAL_USER_ID` (default 9001) + `setpriv` (no gosu on bookworm-slim) |
| `docker-compose.yml.example` | Deployment env reference (real `docker-compose.yml` is gitignored). 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.
- **Two reqwest versions coexist in the lock** (0.12.28 via teloxide, 0.13.3 in x-media) — don't unify casually.
- Config is **environment-variable driven** (dotenv loads `.env`, gitignored; no `.env.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
- **~51 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` (env-gated on `PIXIV_REFRESH_TOKEN`/dotenv, skip by early return). Run the full 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 runs no tests** — `.github/workflows/docker.yml` only builds/pushes the image; verification is a local responsibility.
- 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, no lint gate in CI.
Generated
+88 -363
View File
@@ -99,28 +99,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]] [[package]]
name = "axum" name = "axum"
version = "0.8.9" version = "0.8.9"
@@ -264,12 +242,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.44"
@@ -292,15 +264,6 @@ dependencies = [
"inout", "inout",
] ]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "colored" name = "colored"
version = "3.1.1" version = "3.1.1"
@@ -310,16 +273,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]] [[package]]
name = "constant_time_eq" name = "constant_time_eq"
version = "0.3.1" version = "0.3.1"
@@ -505,6 +458,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]] [[package]]
name = "dotenv" name = "dotenv"
version = "0.15.0" version = "0.15.0"
@@ -521,12 +483,6 @@ dependencies = [
"futures", "futures",
] ]
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]] [[package]]
name = "dyn-clone" name = "dyn-clone"
version = "1.0.20" version = "1.0.20"
@@ -599,12 +555,33 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fast_image_resize"
version = "6.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9c50201dc184ba6553da1695aac20a042efffbe2d84542cee31917c86c3ab1e"
dependencies = [
"cfg-if",
"document-features",
"num-traits",
"thiserror",
]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.4.1" version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -657,12 +634,6 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]] [[package]]
name = "futures" name = "futures"
version = "0.3.32" version = "0.3.32"
@@ -768,10 +739,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1246,55 +1215,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys",
"log",
"simd_cesu8",
"thiserror",
"walkdir",
"windows-link",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn",
]
[[package]] [[package]]
name = "jobserver" name = "jobserver"
version = "0.1.34" version = "0.1.34"
@@ -1305,6 +1225,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "jpeg-encoder"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0370574b86f7eca156b9f298392b5e69a23f8c86f3f865add60bbc2e79467a6"
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.98" version = "0.3.98"
@@ -1352,6 +1278,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -1367,12 +1299,6 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "lzma-rs" name = "lzma-rs"
version = "0.3.0" version = "0.3.0"
@@ -1604,6 +1530,19 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@@ -1688,62 +1627,6 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.4",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@@ -1772,18 +1655,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha 0.3.1", "rand_chacha",
"rand_core 0.6.4", "rand_core",
]
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
] ]
[[package]] [[package]]
@@ -1793,17 +1666,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core 0.6.4", "rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
] ]
[[package]] [[package]]
@@ -1815,15 +1678,6 @@ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
] ]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "rc-box" name = "rc-box"
version = "1.3.0" version = "1.3.0"
@@ -1899,16 +1753,20 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
"encoding_rs",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-tls", "hyper-tls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime",
"mime_guess", "mime_guess",
"native-tls", "native-tls",
"percent-encoding", "percent-encoding",
@@ -1931,47 +1789,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "reqwest"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
dependencies = [
"base64",
"bytes",
"encoding_rs",
"futures-core",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "rgb" name = "rgb"
version = "0.8.53" version = "0.8.53"
@@ -2009,21 +1826,6 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.4" version = "1.1.4"
@@ -2043,7 +1845,6 @@ version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [ dependencies = [
"aws-lc-rs",
"once_cell", "once_cell",
"rustls-pki-types", "rustls-pki-types",
"rustls-webpki", "rustls-webpki",
@@ -2051,62 +1852,21 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.14.1" version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.13" version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [ dependencies = [
"aws-lc-rs",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
"untrusted", "untrusted",
@@ -2124,15 +1884,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "schannel" name = "schannel"
version = "0.1.29" version = "0.1.29"
@@ -2332,22 +2083,6 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -2481,7 +2216,7 @@ dependencies = [
"log", "log",
"mime", "mime",
"pin-project", "pin-project",
"rand 0.8.6", "rand",
"serde", "serde",
"serde_json", "serde_json",
"teloxide-core", "teloxide-core",
@@ -2512,7 +2247,7 @@ dependencies = [
"once_cell", "once_cell",
"pin-project", "pin-project",
"rc-box", "rc-box",
"reqwest 0.12.28", "reqwest",
"rgb", "rgb",
"serde", "serde",
"serde_json", "serde_json",
@@ -2859,16 +2594,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"
@@ -3014,25 +2739,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"
@@ -3296,14 +3002,15 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]] [[package]]
name = "x-media" name = "x-media"
version = "1.0.0" version = "1.1.0"
dependencies = [ dependencies = [
"bytes", "bytes",
"dotenv", "dotenv",
"html-escape", "html-escape",
"log", "log",
"rand",
"regex", "regex",
"reqwest 0.13.3", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
@@ -3314,15 +3021,17 @@ dependencies = [
[[package]] [[package]]
name = "xmedia-bot" name = "xmedia-bot"
version = "1.0.0" version = "1.1.0"
dependencies = [ dependencies = [
"dotenv", "dotenv",
"fast_image_resize",
"html-escape", "html-escape",
"jpeg-encoder",
"log", "log",
"parking_lot", "parking_lot",
"png",
"pretty_env_logger", "pretty_env_logger",
"rand 0.8.6", "rand",
"regex",
"rusqlite", "rusqlite",
"serde", "serde",
"serde_json", "serde_json",
@@ -3331,6 +3040,7 @@ dependencies = [
"tokio", "tokio",
"url", "url",
"x-media", "x-media",
"zune-jpeg",
] ]
[[package]] [[package]]
@@ -3534,3 +3244,18 @@ dependencies = [
"cc", "cc",
"pkg-config", "pkg-config",
] ]
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+4
View File
@@ -1,3 +1,7 @@
[workspace] [workspace]
members = ["crates/x-media", "crates/xmedia-bot"] members = ["crates/x-media", "crates/xmedia-bot"]
resolver = "3" resolver = "3"
# Smaller production binary; debug symbols are not shipped anyway.
[profile.release]
strip = true
+30 -13
View File
@@ -1,12 +1,21 @@
# ---------- build stage ---------- # ---------- build stage ----------
# rust:1-bookworm (full, not slim) ships the C toolchain needed by # rust:1-bookworm (full, not slim) ships the C toolchain needed by
# rusqlite's bundled SQLite, plus wget/xz for the ffmpeg download. # rusqlite's bundled SQLite, plus wget/unzip for the ffmpeg download.
FROM rust:1-bookworm AS builder FROM rust:1-bookworm AS builder
ARG APP_NAME=telegram-twitter-media-bot ARG APP_NAME=telegram-twitter-media-bot
# Statically compiled ffmpeg (ugoira MP4 encoding). amd64 by default; override # Prebuilt static ffmpeg (glibc-linked, includes libx264) for ugoira MP4
# for other platforms or pin a different johnvansickle build. # encoding. Served from https://ffmpeg.martin-riedl.de (Cloudflare CDN,
ARG FFMPEG_URL=https://johnvansickle.com/ffmpeg/releases/ffmpeg-7.0.2-amd64-static.tar.xz # 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 WORKDIR /build
@@ -22,16 +31,25 @@ RUN mkdir -p crates/x-media/src crates/xmedia-bot/src \
&& cargo build --release -p xmedia-bot && cargo build --release -p xmedia-bot
# 2. Static ffmpeg next (cached unless FFMPEG_URL changes), so source edits # 2. Static ffmpeg next (cached unless FFMPEG_URL changes), so source edits
# never re-download it. The johnvansickle tarball has a # never re-download it. The zip contains a single `ffmpeg` binary at the
# `{build}/ffmpeg` layout, so strip one path component. # root. `unzip -t` verifies the archive before extraction so a bad
RUN wget -q -O /tmp/ffmpeg.tar.xz "$FFMPEG_URL" \ # download fails loudly here instead of a cryptic later error.
&& tar -xJf /tmp/ffmpeg.tar.xz -C /usr/local/bin --strip-components=1 --wildcards '*/ffmpeg' \ RUN wget -q -O /tmp/ffmpeg.zip "$FFMPEG_URL" \
&& rm /tmp/ffmpeg.tar.xz \ && 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 && /usr/local/bin/ffmpeg -version >/dev/null
# 3. Real sources last: only our crates recompile on source changes. # 3. Real sources last: only our crates recompile on source changes.
# `cargo clean -p` drops the two crates' artifacts while keeping the
# compiled dependency layer, forcing a deterministic rebuild of the real
# sources. (The previous `touch`-mtimes hack silently shipped the stub
# binary when host files carried future timestamps.)
COPY crates/ ./crates/ COPY crates/ ./crates/
RUN cargo build --release -p xmedia-bot RUN cargo clean -p xmedia-bot -p x-media \
&& cargo build --release -p xmedia-bot
# ---------- runtime stage ---------- # ---------- runtime stage ----------
FROM debian:bookworm-slim FROM debian:bookworm-slim
@@ -44,10 +62,9 @@ LABEL org.opencontainers.image.title="${APP_NAME}"
# Everything is copied in — no apt in the runtime stage. Privilege dropping is # Everything is copied in — no apt in the runtime stage. Privilege dropping is
# done by docker-entrypoint.sh with setpriv (util-linux, already in # done by docker-entrypoint.sh with setpriv (util-linux, already in
# bookworm-slim), so no gosu needed. # bookworm-slim), so no gosu needed. (libssl3/libcrypto are already in
# bookworm-slim; only ca-certificates and ffmpeg need copying.)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /usr/lib/x86_64-linux-gnu/libssl.so.3* /usr/lib/x86_64-linux-gnu/
COPY --from=builder /usr/lib/x86_64-linux-gnu/libcrypto.so.3* /usr/lib/x86_64-linux-gnu/
COPY --from=builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg COPY --from=builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
WORKDIR /app WORKDIR /app
+76 -7
View File
@@ -10,6 +10,7 @@ Telegram 机器人,将 X / Twitter、Pixiv、Bluesky 的帖子链接转换为
- 可绑定转发频道自动转发;支持转发前编辑 caption 与自定义模板 - 可绑定转发频道自动转发;支持转发前编辑 caption 与自定义模板
- 发送失败自动重试并持久化,重试耗尽后通知用户 - 发送失败自动重试并持久化,重试耗尽后通知用户
- Pixiv ugoira 动图自动转码为 MP4 - Pixiv ugoira 动图自动转码为 MP4
- 链接结果本地缓存:成功发送后缓存 Telegram file id 与 caption 等,再次收到相同链接直接本地重发,不再请求源站、不保存媒体文件(`LINK_CACHE_TTL_SECONDS` 控制过期,默认 7 天)
## 快速开始 ## 快速开始
@@ -28,23 +29,91 @@ docker build -t tgxmb .
docker run --rm -d --name tgxmb --env-file .env -v ./data:/app/data 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`、`RUST_LOG`、`WEBHOOK*`。 环境变量:`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-01(80 端口必须公网可达);不支持 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` | 日志级别 |
| `LOCAL_USER_ID` | 容器内运行用户 UID,默认 9001 |
| `VIRTUAL_HOST` | 对外域名或 IP,nginx-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_SECRET_TOKEN` | 更新校验令牌(`X-Telegram-Bot-Api-Secret-Token`) |
</details>
## 命令 ## 命令
| 命令 | 说明 | | 命令 | 说明 |
|---|---| |---|---|
| `/set_forward_channel <频道>` | 设置转发频道 | | `/start` | 欢迎语 |
| `/help` | 查看全部命令及用法(即本文档的命令表) |
| `/set_forward_channel <频道>` | 设置转发频道,参数为 `@频道名` 或频道 ID;设置后发送的媒体消息会自动转发到该频道 |
| `/remove_forward_channel` | 取消转发频道 | | `/remove_forward_channel` | 取消转发频道 |
| `/edit_before_forward` | 开关转发前编辑 | | `/edit_before_forward` | 开关「转发前编辑」:开启后,转发成功后 bot 会发一条提示消息,回复它可修改第一条转发消息的 caption(或点击模板按钮套用模板) |
| `/set_template <名称>` | 将回复的消息(含 `[]`)保存为模板 | | `/set_template <名称>` | 回复一条含 `[]` 的消息,将其保存为命名模板;转发时 `[]` 会被替换为原帖链接(配合「转发前编辑」使用) |
| `/set_format <站点> <格式>` | 自定义 caption 格式(占位符 `{url}` `{title}` `{tags}` 等) | | `/set_format <站点> <格式>` | 自定义某站点的 caption 格式。站点:`twitter` / `bsky` / `pixiv`。占位符:`{url}` `{author}` `{author_url}` `{title}` `{tags}` |
| `/bot_dict` | 查看聊天状态 | | `/bot_dict` | 查看当前聊天状态(调试用) |
链接处理仅限私聊;命令在任意聊天可用。 链接处理仅限私聊;命令在任意聊天可用。
## 备注 ## 备注
- 数据持久化于 `data/task_queue.db`,容器部署需挂载该目录 - 数据持久化于 `data/task_queue.db`,compose 部署使用 bind mount `./data`(保持目录形式便于备份)
- 运行环境需安装 ffmpeg(Docker 镜像已内置) - 运行环境需安装 ffmpeg(Docker 镜像已内置)
- 测试:`cargo test --workspace` - 测试:`cargo test --workspace`
+3 -2
View File
@@ -1,10 +1,10 @@
[package] [package]
name = "x-media" name = "x-media"
version = "1.0.0" version = "1.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
reqwest = { version = "0.13", features = ["json", "query", "form"] } reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
regex = "1.12" regex = "1.12"
@@ -13,6 +13,7 @@ url = "2.5.2"
bytes = "1" bytes = "1"
zip = "2" zip = "2"
tempfile = "3" tempfile = "3"
rand = "0.8"
log = "0.4" log = "0.4"
tokio = { version = "1.40", features = ["time"] } tokio = { version = "1.40", features = ["time"] }
+18
View File
@@ -14,6 +14,24 @@ impl Media {
Media::Animated { 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)] #[derive(Debug)]
+184 -13
View File
@@ -1,12 +1,12 @@
use super::model; use super::model;
use crate::media::Media; use crate::media::Media;
use crate::site::{FetchError, Fetched}; use crate::site::{FetchError, Fetched};
use html_escape::encode_text; use html_escape::{encode_double_quoted_attribute, encode_text};
use regex::Regex; use regex::Regex;
use std::sync::LazyLock; use std::sync::LazyLock;
pub static PATTERN: LazyLock<Regex> = LazyLock::new(|| { pub static PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"bsky\.app/profile/([\w.\-:]+)/post/([\w.\-~]+)").unwrap() Regex::new(r"^(?:https?://)?bsky\.app/profile/([\w.\-:]+)/post/([\w.\-~]+)").unwrap()
}); });
pub fn enabled() -> bool { pub fn enabled() -> bool {
@@ -15,9 +15,169 @@ pub fn enabled() -> bool {
pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> { pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
let caps = PATTERN.captures(url).ok_or(FetchError::NotFound)?; let caps = PATTERN.captures(url).ok_or(FetchError::NotFound)?;
let handle = caps.get(1).map(|m| m.as_str()).ok_or(FetchError::NotFound)?; let handle = caps
let rkey = caps.get(2).map(|m| m.as_str()).ok_or(FetchError::NotFound)?; .get(1)
Ok(fetch(handle, rkey).await?.into()) .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). /// Fetches a post thread by handle or DID (`at://` URIs work for both).
@@ -30,8 +190,16 @@ pub async fn fetch(handle: &str, rkey: &str) -> Result<Post, FetchError> {
]) ])
.send() .send()
.await?; .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?; let text = response.text().await?;
Ok(Post::from_json(&text, rkey.to_string())?) Post::from_json(&text, rkey.to_string())
} }
#[derive(Debug)] #[derive(Debug)]
@@ -56,8 +224,8 @@ impl Post {
pub fn caption(&self) -> String { pub fn caption(&self) -> String {
format!( format!(
"{url}\n<a href=\"{author_url}\">{author}</a>: {text}", "{url}\n<a href=\"{author_url}\">{author}</a>: {text}",
url = self.url(), url = encode_double_quoted_attribute(&self.url()),
author_url = self.author_url(), author_url = encode_double_quoted_attribute(&self.author_url()),
author = encode_text(&self.author), author = encode_text(&self.author),
text = encode_text(&self.text), text = encode_text(&self.text),
) )
@@ -197,7 +365,10 @@ mod tests {
})); }));
let post = Post::from_json(&raw.to_string(), "3xxxx".into()).unwrap(); let post = Post::from_json(&raw.to_string(), "3xxxx".into()).unwrap();
let fetched: Fetched = post.into(); let fetched: Fetched = post.into();
assert_eq!(fetched.source_url, "https://bsky.app/profile/user.bsky.social/post/3xxxx"); assert_eq!(
fetched.source_url,
"https://bsky.app/profile/user.bsky.social/post/3xxxx"
);
assert_eq!(fetched.title, "hello <world>"); assert_eq!(fetched.title, "hello <world>");
assert_eq!(fetched.media.len(), 1); assert_eq!(fetched.media.len(), 1);
assert!(!fetched.sensitive); assert!(!fetched.sensitive);
@@ -246,9 +417,8 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn live_fetch_with_photos() { async fn live_fetch_with_photos() {
let fetched = fetch_from_url( let fetched =
"https://bsky.app/profile/asagi0398.bsky.social/post/3mqkhrq5w6k2m", fetch_from_url("https://bsky.app/profile/asagi0398.bsky.social/post/3mqkhrq5w6k2m")
)
.await .await
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -260,7 +430,8 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn live_fetch_smoke() { async fn live_fetch_smoke() {
let fetched = fetch_from_url("https://bsky.app/profile/fu-futa.bsky.social/post/3laoveufjv224") let fetched =
fetch_from_url("https://bsky.app/profile/fu-futa.bsky.social/post/3laoveufjv224")
.await .await
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
+211 -22
View File
@@ -6,6 +6,7 @@
use std::fmt; use std::fmt;
use std::sync::LazyLock; use std::sync::LazyLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::time::Duration;
pub mod bsky; pub mod bsky;
@@ -68,18 +69,73 @@ impl Fetched {
/// caption. /// caption.
pub fn caption_with(&self, format: &str) -> String { pub fn caption_with(&self, format: &str) -> String {
match (&self.render_data, format.is_empty()) { match (&self.render_data, format.is_empty()) {
(Some(data), false) => { (Some(data), false) => caption_from_fields(
let escaped = html_escape::encode_text(format).into_owned(); format,
escaped "",
.replace("{url}", &data.url) &data.url,
.replace("{author}", &data.author) &data.author,
.replace("{author_url}", &data.author_url) &data.author_url,
.replace("{title}", &data.title) &data.title,
.replace("{tags}", &data.tags) &data.tags,
} ),
_ => self.caption.clone(), _ => self.caption.clone(),
} }
} }
/// 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(),
)
})
}
}
/// 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.
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 built_in.to_string();
}
let escaped = html_escape::encode_text(format).into_owned();
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)] #[derive(Debug)]
@@ -89,6 +145,13 @@ pub enum FetchError {
Pixiv(PixivError), Pixiv(PixivError),
NotFound, NotFound,
Blocked, 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),
} }
impl fmt::Display for FetchError { impl fmt::Display for FetchError {
@@ -99,6 +162,9 @@ impl fmt::Display for FetchError {
FetchError::Pixiv(e) => write!(f, "pixiv error: {e}"), FetchError::Pixiv(e) => write!(f, "pixiv error: {e}"),
FetchError::NotFound => write!(f, "not found"), FetchError::NotFound => write!(f, "not found"),
FetchError::Blocked => write!(f, "blocked"), 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}"),
} }
} }
} }
@@ -109,7 +175,9 @@ impl std::error::Error for FetchError {
FetchError::Http(e) => Some(e), FetchError::Http(e) => Some(e),
FetchError::Json(e) => Some(e), FetchError::Json(e) => Some(e),
FetchError::Pixiv(e) => Some(e), FetchError::Pixiv(e) => Some(e),
FetchError::NotFound | FetchError::Blocked => None, FetchError::NotFound | FetchError::Blocked | FetchError::Sensitive => None,
FetchError::TooLarge => None,
FetchError::Transient(_) => None,
} }
} }
} }
@@ -120,7 +188,6 @@ impl From<reqwest::Error> for FetchError {
} }
} }
impl From<serde_json::Error> for FetchError { impl From<serde_json::Error> for FetchError {
fn from(e: serde_json::Error) -> Self { fn from(e: serde_json::Error) -> Self {
FetchError::Json(e) FetchError::Json(e)
@@ -136,7 +203,22 @@ impl From<PixivError> for FetchError {
/// Shared HTTP client (browser User-Agent) for twitter/bsky fetches and /// Shared HTTP client (browser User-Agent) for twitter/bsky fetches and
/// [`download_media`]. /// [`download_media`].
pub(crate) static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| { pub(crate) static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
let builder = reqwest::Client::builder().user_agent("Mozilla/5.0"); 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 // 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 // bound to the runtime that created it, so cross-runtime reuse of idle
// connections fails with DispatchGone. In test builds every request uses // connections fails with DispatchGone. In test builds every request uses
@@ -146,13 +228,38 @@ pub(crate) static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
builder.build().expect("failed to build HTTP client") 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 /// Fetches a post from its URL. Returns `Ok(None)` when no site pattern
/// matches (unsupported links are silently ignored by the bot). /// matches (unsupported links are silently ignored by the bot).
/// ///
/// Transient network failures are retried: 3 total attempts with 1s then 2s /// Transient network failures are retried: 3 total attempts with 1s then 2s
/// delays. Non-Http errors (Json/NotFound/Blocked/Pixiv) are not retried. /// delays. Retried classes: bare HTTP errors, [`FetchError::Transient`]
/// (429/5xx from any site), and pixiv errors (its network failures arrive
/// wrapped as `PixivError`). Non-retried: Json/NotFound/Blocked/Sensitive.
pub async fn fetch(url: &str) -> Result<Option<Fetched>, FetchError> { pub async fn fetch(url: &str) -> Result<Option<Fetched>, FetchError> {
let mut last_http_error = None;
for attempt in 0..3u32 { for attempt in 0..3u32 {
match fetch_once(url).await { match fetch_once(url).await {
Ok(Some(fetched)) => { Ok(Some(fetched)) => {
@@ -164,18 +271,17 @@ pub async fn fetch(url: &str) -> Result<Option<Fetched>, FetchError> {
return Ok(Some(fetched)); return Ok(Some(fetched));
} }
Ok(None) => return Ok(None), Ok(None) => return Ok(None),
Err(FetchError::Http(e)) => { Err(e @ (FetchError::Http(_) | FetchError::Transient(_) | FetchError::Pixiv(_))) => {
last_http_error = Some(e);
if attempt < 2 { if attempt < 2 {
tokio::time::sleep(Duration::from_secs(1 << attempt)).await; tokio::time::sleep(Duration::from_secs(1 << attempt)).await;
} else {
return Err(e);
} }
} }
Err(other) => return Err(other), Err(other) => return Err(other),
} }
} }
Err(FetchError::Http( unreachable!("retry loop always returns")
last_http_error.expect("retry loop always ran 3 attempts"),
))
} }
async fn fetch_once(url: &str) -> Result<Option<Fetched>, FetchError> { async fn fetch_once(url: &str) -> Result<Option<Fetched>, FetchError> {
@@ -195,20 +301,103 @@ async fn fetch_once(url: &str) -> Result<Option<Fetched>, FetchError> {
/// fetch of a media URL is blocked (hotlink protection), the bot downloads /// fetch of a media URL is blocked (hotlink protection), the bot downloads
/// the file itself and uploads it via multipart. Site-appropriate headers: /// the file itself and uploads it via multipart. Site-appropriate headers:
/// pixiv image hosts need the `Referer` header. /// pixiv image hosts need the `Referer` header.
pub async fn download_media(url: &str) -> Result<bytes::Bytes, FetchError> { /// 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 mut request = CLIENT.get(url);
let lower = url.to_ascii_lowercase(); let lower = url.to_ascii_lowercase();
if lower.contains("pximg.net") { if lower.contains("pximg.net") {
request = request.header("Referer", "https://www.pixiv.net/"); request = request.header("Referer", "https://www.pixiv.net/");
} }
let response = request.send().await?; let response = request.send().await?.error_for_status()?;
Ok(response.bytes().await?) 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
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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 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"
);
}
#[tokio::test] #[tokio::test]
async fn unsupported_url_returns_none() { async fn unsupported_url_returns_none() {
let result = fetch("https://example.com/some/article").await; let result = fetch("https://example.com/some/article").await;
+72 -55
View File
@@ -10,8 +10,8 @@ use crate::site::FetchError;
use std::env; use std::env;
use std::fmt; use std::fmt;
use std::io::{Cursor, Read}; use std::io::{Cursor, Read};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::LazyLock; use std::sync::LazyLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
const AUTH_TOKEN_URL: &str = "https://oauth.secure.pixiv.net/auth/token"; const AUTH_TOKEN_URL: &str = "https://oauth.secure.pixiv.net/auth/token";
@@ -127,13 +127,18 @@ impl PixivAPI {
pub async fn illust_detail(&self, illust_id: u64) -> Result<IllustrationModel, PixivError> { pub async fn illust_detail(&self, illust_id: u64) -> Result<IllustrationModel, PixivError> {
let access_token = self.get_access_token().await?; let access_token = self.get_access_token().await?;
let response = crate::site::CLIENT let response = crate::site::CLIENT
.get(format!("{APP_API_URL}/v1/illust/detail?illust_id={illust_id}")) .get(format!(
"{APP_API_URL}/v1/illust/detail?illust_id={illust_id}"
))
.header("app-os", "ios") .header("app-os", "ios")
.header("app-os-version", "14.6") .header("app-os-version", "14.6")
.header("User-Agent", APP_USER_AGENT) .header("User-Agent", APP_USER_AGENT)
.bearer_auth(access_token) .bearer_auth(access_token)
.send() .send()
.await?; .await?;
if !response.status().is_success() {
return Err(PixivError::Api(format!("status {}", response.status())));
}
let json: serde_json::Value = serde_json::from_str(&response.text().await?)?; let json: serde_json::Value = serde_json::from_str(&response.text().await?)?;
if json.get("error").is_some() { if json.get("error").is_some() {
let message = json let message = json
@@ -175,13 +180,18 @@ impl PixivAPI {
pub async fn ugoira_metadata(&self, illust_id: u64) -> Result<UgoiraMetadataModel, PixivError> { pub async fn ugoira_metadata(&self, illust_id: u64) -> Result<UgoiraMetadataModel, PixivError> {
let access_token = self.get_access_token().await?; let access_token = self.get_access_token().await?;
let response = crate::site::CLIENT let response = crate::site::CLIENT
.get(format!("{APP_API_URL}/v1/ugoira/metadata?illust_id={illust_id}")) .get(format!(
"{APP_API_URL}/v1/ugoira/metadata?illust_id={illust_id}"
))
.header("app-os", "ios") .header("app-os", "ios")
.header("app-os-version", "14.6") .header("app-os-version", "14.6")
.header("User-Agent", APP_USER_AGENT) .header("User-Agent", APP_USER_AGENT)
.bearer_auth(access_token) .bearer_auth(access_token)
.send() .send()
.await?; .await?;
if !response.status().is_success() {
return Err(PixivError::Api(format!("status {}", response.status())));
}
let json: serde_json::Value = serde_json::from_str(&response.text().await?)?; let json: serde_json::Value = serde_json::from_str(&response.text().await?)?;
if json.get("error").is_some() { if json.get("error").is_some() {
let message = json let message = json
@@ -203,8 +213,8 @@ impl PixivAPI {
&self, &self,
illust_id: u64, illust_id: u64,
) -> Result<Option<(String, tempfile::TempDir)>, PixivError> { ) -> Result<Option<(String, tempfile::TempDir)>, PixivError> {
if !ffmpeg_available() { if !crate::site::ffmpeg_available() {
log_once_ffmpeg_missing(); crate::site::log_once_ffmpeg_missing();
return Ok(None); return Ok(None);
} }
let metadata = self.ugoira_metadata(illust_id).await?; let metadata = self.ugoira_metadata(illust_id).await?;
@@ -218,13 +228,15 @@ impl PixivAPI {
let Some(zip_url) = zip_url else { let Some(zip_url) = zip_url else {
return Ok(None); return Ok(None);
}; };
let zip_bytes = crate::site::download_media(&zip_url).await.map_err(|e| match e { let zip_bytes = crate::site::download_media_limited(&zip_url, 512 * 1024 * 1024)
.await
.map_err(|e| match e {
FetchError::Http(e) => PixivError::Http(e), FetchError::Http(e) => PixivError::Http(e),
other => PixivError::Api(format!("frame zip download failed: {other}")), other => PixivError::Api(format!("frame zip download failed: {other}")),
})?; })?;
let frame_delays = metadata.frames.iter().map(|f| f.delay).collect::<Vec<_>>(); let frame_delays = metadata.frames.iter().map(|f| f.delay).collect::<Vec<_>>();
let result = tokio::task::spawn_blocking( let result =
move || -> Result<(String, tempfile::TempDir), String> { tokio::task::spawn_blocking(move || -> Result<(String, tempfile::TempDir), String> {
let frames_dir = tempfile::tempdir().map_err(|e| e.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 out_dir = tempfile::tempdir().map_err(|e| e.to_string())?;
@@ -232,28 +244,51 @@ impl PixivAPI {
// frames are uniformly jpg or png per artwork. // frames are uniformly jpg or png per artwork.
let mut archive = zip::ZipArchive::new(Cursor::new(zip_bytes)) let mut archive = zip::ZipArchive::new(Cursor::new(zip_bytes))
.map_err(|e| format!("unzip: {e}"))?; .map_err(|e| format!("unzip: {e}"))?;
// pixiv ugoira frames are uniformly jpg or png per artwork; take if archive.is_empty() {
// the extension from the first entry. return Err("empty frame zip".to_string());
let extension = if archive.len() > 0 { }
let first_name = archive // Uniform jpg or png per artwork; sniff the first entry's
.by_index(0) // magic bytes instead of trusting its filename.
.map_err(|e| e.to_string())? let first = archive.by_index(0).map_err(|e| e.to_string())?;
.name() let mut first_bytes = Vec::new();
.to_string(); first
first_name .take(64 * 1024 * 1024 + 1)
.rsplit('.') .read_to_end(&mut first_bytes)
.next() .map_err(|e| e.to_string())?;
.unwrap_or("jpg") if first_bytes.len() > 64 * 1024 * 1024 {
.to_string() 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 { } else {
"jpg".to_string() "jpg"
}; };
let mut count = 0usize; let mut count = 0usize;
for i in 0..archive.len() { {
let mut entry = archive.by_index(i).map_err(|e| e.to_string())?; 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(); let mut bytes = Vec::new();
entry.read_to_end(&mut bytes).map_err(|e| e.to_string())?; entry
let path = frames_dir.path().join(format!("img_{count:05}.{extension}")); .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())?; std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
count += 1; count += 1;
} }
@@ -274,7 +309,10 @@ impl PixivAPI {
"-framerate", "-framerate",
&framerate.to_string(), &framerate.to_string(),
"-i", "-i",
&frames_dir.path().join(format!("img_%05d.{extension}")).to_string_lossy(), &frames_dir
.path()
.join(format!("img_%05d.{extension}"))
.to_string_lossy(),
// libx264 needs even dimensions; pixiv ugoira frames can // libx264 needs even dimensions; pixiv ugoira frames can
// be odd-sized (e.g. 277x405). // be odd-sized (e.g. 277x405).
"-vf", "-vf",
@@ -295,10 +333,12 @@ impl PixivAPI {
return Err(format!("ffmpeg exited with {status}")); return Err(format!("ffmpeg exited with {status}"));
} }
Ok((output.to_string_lossy().into_owned(), out_dir)) Ok((output.to_string_lossy().into_owned(), out_dir))
}, })
)
.await .await
.expect("ugoira encode worker panicked"); .map_err(|e| {
log::error!("ugoira encode worker panicked for {illust_id}: {e}");
PixivError::Api(format!("ugoira worker failed: {e}"))
})?;
match result { match result {
Ok(pair) => Ok(Some(pair)), Ok(pair) => Ok(Some(pair)),
Err(message) => { Err(message) => {
@@ -309,32 +349,9 @@ impl PixivAPI {
} }
} }
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);
fn ffmpeg_available() -> bool {
*FFMPEG_AVAILABLE
}
fn log_once_ffmpeg_missing() {
if !FFMPEG_MISSING_LOGGED.swap(true, Ordering::Relaxed) {
log::warn!("ffmpeg not found; pixiv ugoira posts stay unsupported");
}
}
/// pixiv3-rs replacement: `None` when `PIXIV_REFRESH_TOKEN` is unset. /// pixiv3-rs replacement: `None` when `PIXIV_REFRESH_TOKEN` is unset.
static PIXIV_CLIENT: LazyLock<Option<PixivAPI>> = LazyLock::new(|| { static PIXIV_CLIENT: LazyLock<Option<PixivAPI>> =
env::var("PIXIV_REFRESH_TOKEN").ok().map(PixivAPI::new) LazyLock::new(|| env::var("PIXIV_REFRESH_TOKEN").ok().map(PixivAPI::new));
});
/// Set at startup when the login validation fails; pixiv stays disabled until /// Set at startup when the login validation fails; pixiv stays disabled until
/// the next process start. /// the next process start.
+69 -19
View File
@@ -1,12 +1,12 @@
use super::model::{IllustrationModel, TypeModel}; use super::model::{IllustrationModel, TypeModel};
use crate::media::Media; use crate::media::Media;
use crate::site::{FetchError, Fetched}; use crate::site::{FetchError, Fetched};
use html_escape::encode_text; use html_escape::{encode_double_quoted_attribute, encode_text};
use regex::Regex; use regex::Regex;
use std::sync::LazyLock; use std::sync::LazyLock;
pub static PATTERN: LazyLock<Regex> = LazyLock::new(|| { pub static PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?:www\.)?pixiv\.net/(?:en/)?(?:(?:i|artworks)/|member_illust\.php\?(?:mode=[a-z_]*&)?illust_id=)(\d+)").unwrap() Regex::new(r"^(?:https?://)?(?:www\.)?pixiv\.net/(?:en/)?(?:(?:i|artworks)/|member_illust\.php\?(?:mode=[a-z_]*&)?illust_id=)(\d+)").unwrap()
}); });
pub fn enabled() -> bool { pub fn enabled() -> bool {
@@ -48,9 +48,9 @@ impl Illustration {
pub fn caption(&self) -> String { pub fn caption(&self) -> String {
format!( format!(
"<a href=\"{url}\">{title}</a> / <a href=\"{author_url}\">{author}</a>\n{tags}", "<a href=\"{url}\">{title}</a> / <a href=\"{author_url}\">{author}</a>\n{tags}",
url = self.url(), url = encode_double_quoted_attribute(&self.url()),
title = encode_text(&self.title), title = encode_text(&self.title),
author_url = self.author_url(), author_url = encode_double_quoted_attribute(&self.author_url()),
author = encode_text(&self.author), author = encode_text(&self.author),
tags = encode_text( tags = encode_text(
&self &self
@@ -82,7 +82,10 @@ impl Illustration {
// keeps media empty when encoding fails or ffmpeg is missing. // keeps media empty when encoding fails or ffmpeg is missing.
} else if model.page_count > 1 { } else if model.page_count > 1 {
media.extend(model.meta_pages.iter().filter_map(|page| { media.extend(model.meta_pages.iter().filter_map(|page| {
page.image_urls.original.clone().map(|original| Media::Illustration { page.image_urls
.original
.clone()
.map(|original| Media::Illustration {
title: None, title: None,
url: original, url: original,
thumbnail_url: Some(page.image_urls.medium.clone()), thumbnail_url: Some(page.image_urls.medium.clone()),
@@ -147,8 +150,8 @@ impl From<Illustration> for Fetched {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*;
use super::super::model::IllustrationModel; use super::super::model::IllustrationModel;
use super::*;
fn illust_json( fn illust_json(
type_: &str, type_: &str,
@@ -203,8 +206,14 @@ mod tests {
("https://pixiv.net/artworks/123456", "123456"), ("https://pixiv.net/artworks/123456", "123456"),
("https://www.pixiv.net/en/artworks/123456", "123456"), ("https://www.pixiv.net/en/artworks/123456", "123456"),
("https://www.pixiv.net/i/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"), "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 { for (url, id) in cases {
let caps = PATTERN.captures(url).unwrap_or_else(|| panic!("{url}")); let caps = PATTERN.captures(url).unwrap_or_else(|| panic!("{url}"));
@@ -225,7 +234,14 @@ mod tests {
#[test] #[test]
fn ugoira_yields_empty_media() { fn ugoira_yields_empty_media() {
let v = illust_json("ugoira", 1, Some("https://i.pximg.net/orig.jpg"), None, vec![], 0); let v = illust_json(
"ugoira",
1,
Some("https://i.pximg.net/orig.jpg"),
None,
vec![],
0,
);
let illustration = parse(v); let illustration = parse(v);
let fetched: Fetched = illustration.into(); let fetched: Fetched = illustration.into();
assert!(fetched.media.is_empty()); assert!(fetched.media.is_empty());
@@ -296,7 +312,12 @@ mod tests {
let fetched: Fetched = parse(v).into(); let fetched: Fetched = parse(v).into();
assert_eq!(fetched.media.len(), 1); assert_eq!(fetched.media.len(), 1);
match &fetched.media[0] { match &fetched.media[0] {
Media::Illustration { url, thumbnail_url, fallback_url, .. } => { Media::Illustration {
url,
thumbnail_url,
fallback_url,
..
} => {
assert_eq!(url, "https://i.pximg.net/p2.jpg"); assert_eq!(url, "https://i.pximg.net/p2.jpg");
assert_eq!(thumbnail_url.as_deref(), Some("m2.jpg")); assert_eq!(thumbnail_url.as_deref(), Some("m2.jpg"));
assert_eq!(fallback_url.as_deref(), Some("l2.jpg")); assert_eq!(fallback_url.as_deref(), Some("l2.jpg"));
@@ -307,7 +328,14 @@ mod tests {
#[test] #[test]
fn caption_with_escapes_format_and_substitutes() { fn caption_with_escapes_format_and_substitutes() {
let v = illust_json("illust", 1, Some("https://i.pximg.net/o.jpg"), None, vec![], 0); let v = illust_json(
"illust",
1,
Some("https://i.pximg.net/o.jpg"),
None,
vec![],
0,
);
let fetched: Fetched = parse(v).into(); let fetched: Fetched = parse(v).into();
// Format string is escaped in full, then placeholders substituted. // Format string is escaped in full, then placeholders substituted.
let out = fetched.caption_with("{title} by {author} <script> {tags}"); let out = fetched.caption_with("{title} by {author} <script> {tags}");
@@ -330,7 +358,14 @@ mod tests {
#[test] #[test]
fn ai_work_gets_leading_ai_tag() { fn ai_work_gets_leading_ai_tag() {
// illust_ai_type == 2 is the only AI marker. // 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 v = illust_json(
"illust",
1,
Some("https://i.pximg.net/o.jpg"),
None,
vec![],
2,
);
let fetched: Fetched = parse(v).into(); let fetched: Fetched = parse(v).into();
assert!( assert!(
fetched.caption.contains("#AI #tag1 #tag2"), fetched.caption.contains("#AI #tag1 #tag2"),
@@ -338,14 +373,25 @@ mod tests {
fetched.caption fetched.caption
); );
// The {tags} placeholder reflects the tag array too. // The {tags} placeholder reflects the tag array too.
assert!(fetched.caption_with("{tags}").starts_with("#AI "), "got: {}", fetched.caption_with("{tags}")); assert!(
fetched.caption_with("{tags}").starts_with("#AI "),
"got: {}",
fetched.caption_with("{tags}")
);
} }
#[test] #[test]
fn non_ai_work_has_no_ai_tag() { fn non_ai_work_has_no_ai_tag() {
// 1 = explicitly not AI, 0 = undefined: neither gets the #AI tag. // 1 = explicitly not AI, 0 = undefined: neither gets the #AI tag.
for ai_type in [0, 1] { for ai_type in [0, 1] {
let v = illust_json("illust", 1, Some("https://i.pximg.net/o.jpg"), None, vec![], ai_type); let v = illust_json(
"illust",
1,
Some("https://i.pximg.net/o.jpg"),
None,
vec![],
ai_type,
);
let fetched: Fetched = parse(v).into(); let fetched: Fetched = parse(v).into();
assert!( assert!(
!fetched.caption.contains("#AI"), !fetched.caption.contains("#AI"),
@@ -357,7 +403,14 @@ mod tests {
#[test] #[test]
fn caption_escapes_and_links() { fn caption_escapes_and_links() {
let v = illust_json("illust", 1, Some("https://i.pximg.net/o.jpg"), None, vec![], 0); let v = illust_json(
"illust",
1,
Some("https://i.pximg.net/o.jpg"),
None,
vec![],
0,
);
let fetched: Fetched = parse(v).into(); let fetched: Fetched = parse(v).into();
assert!( assert!(
fetched fetched
@@ -367,9 +420,6 @@ mod tests {
fetched.caption fetched.caption
); );
assert!(fetched.caption.contains("#tag1 #tag2")); assert!(fetched.caption.contains("#tag1 #tag2"));
assert_eq!( assert_eq!(fetched.source_url, "https://www.pixiv.net/artworks/123");
fetched.source_url,
"https://www.pixiv.net/artworks/123"
);
} }
} }
+1 -1
View File
@@ -3,4 +3,4 @@ mod interface;
mod model; mod model;
pub use api::{PixivAPI, PixivError, disable, fetch, validate}; pub use api::{PixivAPI, PixivError, disable, fetch, validate};
pub use interface::{PATTERN, Illustration, enabled, fetch_from_url}; pub use interface::{Illustration, PATTERN, enabled, fetch_from_url};
+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");
}
}
+203 -70
View File
@@ -1,7 +1,7 @@
use super::model; use super::model;
use crate::media::Media; use crate::media::Media;
use crate::site::{FetchError, Fetched}; use crate::site::{FetchError, Fetched};
use html_escape::encode_text; use html_escape::{encode_double_quoted_attribute, encode_text};
use regex::Regex; use regex::Regex;
use std::sync::LazyLock; use std::sync::LazyLock;
@@ -19,7 +19,44 @@ pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
.and_then(|caps| caps.get(1)) .and_then(|caps| caps.get(1))
.map(|m| m.as_str()) .map(|m| m.as_str())
.ok_or(FetchError::NotFound)?; .ok_or(FetchError::NotFound)?;
Ok(fetch(id).await?.into()) 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()),
Err(e) => {
log::warn!("twitter auth fallback failed for {id}: {e}");
Ok(empty_fetched(url))
}
}
} else {
log::info!("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 tweets /// Fetches a tweet from the syndication endpoint. Deleted/blocked tweets
@@ -33,8 +70,13 @@ pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
)) ))
.send() .send()
.await?; .await?;
if !response.status().is_success() { // 404/410 = gone (permanent); 429/5xx = transient and retried by fetch.
return Err(FetchError::NotFound); 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?; let text = response.text().await?;
// Deleted tweets answer with {"errors": [...]} instead of a tweet. // Deleted tweets answer with {"errors": [...]} instead of a tweet.
@@ -44,7 +86,16 @@ pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
{ {
return Err(FetchError::NotFound); return Err(FetchError::NotFound);
} }
Ok(Tweet::from_syndication_json(&text).map_err(FetchError::Json)?) // NSFW / age-restricted tweets exist but are served as an empty `{}` —
// they surface as FetchError::Sensitive so the caller can retry as a
// logged-in user.
if serde_json::from_str::<serde_json::Value>(&text)
.map(|v| v.get("id_str").is_none())
.unwrap_or(false)
{
return Err(FetchError::Sensitive);
}
Tweet::from_syndication_json(&text).map_err(FetchError::Json)
} }
/// The syndication token: JS `((id / 1e15) * PI).toString(36)` (the /// The syndication token: JS `((id / 1e15) * PI).toString(36)` (the
@@ -102,8 +153,8 @@ impl Tweet {
pub fn caption(&self) -> String { pub fn caption(&self) -> String {
format!( format!(
"{url}\n<a href=\"{author_url}\">{author}</a>: {text}", "{url}\n<a href=\"{author_url}\">{author}</a>: {text}",
url = self.url(), url = encode_double_quoted_attribute(&self.url()),
author_url = self.author_url(), author_url = encode_double_quoted_attribute(&self.author_url()),
author = encode_text(&self.author), author = encode_text(&self.author),
text = encode_text(&self.text), text = encode_text(&self.text),
) )
@@ -112,12 +163,10 @@ impl Tweet {
pub fn from_syndication_json(raw_json: &str) -> Result<Self, serde_json::Error> { pub fn from_syndication_json(raw_json: &str) -> Result<Self, serde_json::Error> {
let json: model::SyndicationTweet = serde_json::from_str(raw_json)?; let json: model::SyndicationTweet = serde_json::from_str(raw_json)?;
let id = json.id_str; let id = json.id_str;
// Strip the appended media short link first, then expand the remaining // Expand the user's t.co short links to their real destinations and
// t.co short links (the user's own URLs) to their real destinations. // strip the appended media short link, mirroring FxEmbed's linkFixer
let text = expand_links( // (no display_text_range arithmetic — see expand_links).
&strip_trailing_short_links(&json.text, json.display_text_range), let text = expand_links(&json.text, &json.entities.urls);
&json.entities.urls,
);
// `name` is the display name, `screen_name` the handle (Python's // `name` is the display name, `screen_name` the handle (Python's
// vxtwitter mapping: author = display name, author_id = handle). // vxtwitter mapping: author = display name, author_id = handle).
let author = json.user.name; let author = json.user.name;
@@ -127,9 +176,11 @@ impl Tweet {
match item.media_type.as_str() { match item.media_type.as_str() {
"photo" => media.push(Media::Illustration { "photo" => media.push(Media::Illustration {
title: None, title: None,
url: item.media_url_https, url: original_twimg_url(&item.media_url_https),
thumbnail_url: None, thumbnail_url: None,
fallback_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 { "video" => media.push(Media::Video {
title: None, title: None,
@@ -156,46 +207,53 @@ impl Tweet {
} }
} }
/// The raw syndication `text` ends with the appended media short link /// Mirrors FxEmbed's `linkFixer` (link-fixer.ts): expand every t.co short
/// (" https://t.co/wmI8McgXul"). `display_text_range` (UTF-16 indices) marks /// link that has an entity mapping to its real destination, drop internal
/// the visible text; a regex strips any remaining trailing t.co link when the /// `x.com/i/web/status/…` plumbing links, then strip any remaining t.co
/// range is absent or a tweet ends in a URL short link. /// short link (the appended media link and other unmapped short links).
fn strip_trailing_short_links(text: &str, display_text_range: Option<[usize; 2]>) -> String { /// Pure content matching — no `display_text_range` arithmetic, so the
let mut out = match display_text_range { /// endpoint's inconsistent index units (UTF-16 vs code points, see the
Some([start, end]) if start < end => { /// deleted `strip_trailing_short_links`) never matter.
let units: Vec<u16> = text
.encode_utf16()
.skip(start)
.take(end - start)
.collect();
// Drop the replacement char that a surrogate cut at the boundary
// would produce (the range end is a valid UTF-16 boundary in
// practice, so this is just a safety net).
String::from_utf16_lossy(&units).replace('\u{FFFD}', "")
}
_ => text.to_string(),
};
while TRAILING_TCO.is_match(&out) {
out = TRAILING_TCO.replace(&out, "").into_owned();
}
out
}
/// Trailing Twitter short link, optionally preceded by whitespace.
static TRAILING_TCO: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\s*https?://t\.co/[A-Za-z0-9]+$").unwrap()
});
/// Replaces every t.co short link that has an entity mapping with its
/// expanded URL. Short links without a mapping stay untouched.
fn expand_links(text: &str, urls: &[model::SyndicationEntityUrl]) -> String { fn expand_links(text: &str, urls: &[model::SyndicationEntityUrl]) -> String {
let mut out = text.to_string(); let mut out = text.to_string();
for entity in urls { for entity in urls {
if let Some(expanded) = &entity.expanded_url { let Some(expanded) = &entity.expanded_url else {
out = out.replace(&entity.url, expanded); 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()
} }
out
} }
fn mp4_variant(item: &model::SyndicationMedia) -> String { fn mp4_variant(item: &model::SyndicationMedia) -> String {
@@ -302,17 +360,26 @@ mod tests {
assert_eq!(fetched.title, "a & b <c>"); assert_eq!(fetched.title, "a & b <c>");
assert!(fetched.sensitive); assert!(fetched.sensitive);
assert_eq!(fetched.media.len(), 2); 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] { match &fetched.media[1] {
Media::Video { url, thumbnail_url, .. } => { Media::Video {
url, thumbnail_url, ..
} => {
assert_eq!(url, "https://video.twimg.com/v.mp4"); assert_eq!(url, "https://video.twimg.com/v.mp4");
assert_eq!(thumbnail_url, "https://pbs.twimg.com/thumb.jpg"); assert_eq!(thumbnail_url, "https://pbs.twimg.com/thumb.jpg");
} }
other => panic!("expected video, got {other:?}"), other => panic!("expected video, got {other:?}"),
} }
assert!( assert!(
fetched fetched.caption.contains(
.caption "<a href=\"https://x.com/author_handle\">Display Name</a>: a &amp; b &lt;c&gt;"
.contains("<a href=\"https://x.com/author_handle\">Display Name</a>: a &amp; b &lt;c&gt;"), ),
"caption: {}", "caption: {}",
fetched.caption fetched.caption
); );
@@ -343,13 +410,12 @@ mod tests {
#[test] #[test]
fn syndication_text_strips_trailing_media_short_link() { fn syndication_text_strips_trailing_media_short_link() {
// Real syndication shape: the media short link sits after the visible // Real syndication shape: the appended media short link sits after the
// text, and display_text_range marks where it begins. // visible text; the unmapped t.co link is stripped by content.
let raw = serde_json::json!({ let raw = serde_json::json!({
"__typename": "Tweet", "__typename": "Tweet",
"id_str": "1", "id_str": "1",
"text": "hello world https://t.co/abc123", "text": "hello world https://t.co/abc123",
"display_text_range": [0, 11],
"user": { "name": "N", "screen_name": "h" }, "user": { "name": "N", "screen_name": "h" },
"mediaDetails": [] "mediaDetails": []
}); });
@@ -359,8 +425,31 @@ mod tests {
} }
#[test] #[test]
fn syndication_text_strips_trailing_short_link_without_range() { fn syndication_text_strips_trailing_link_regardless_of_index_units() {
// No display_text_range: the regex fallback removes the trailing link. // 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!({ let raw = serde_json::json!({
"__typename": "Tweet", "__typename": "Tweet",
"id_str": "1", "id_str": "1",
@@ -381,7 +470,6 @@ mod tests {
"__typename": "Tweet", "__typename": "Tweet",
"id_str": "1", "id_str": "1",
"text": "Test Tweet with @mentionThis $twtr https://t.co/RzmrQ6wAzD #hashtag https://t.co/9r69akA484", "text": "Test Tweet with @mentionThis $twtr https://t.co/RzmrQ6wAzD #hashtag https://t.co/9r69akA484",
"display_text_range": [0, 67],
"user": { "name": "N", "screen_name": "h" }, "user": { "name": "N", "screen_name": "h" },
"entities": { "entities": {
"urls": [{ "urls": [{
@@ -401,25 +489,47 @@ mod tests {
} }
#[test] #[test]
fn syndication_text_keeps_unmapped_short_links() { fn syndication_text_strips_unmapped_short_links() {
// No entity mapping for the embedded link: it stays as-is. Only the // FxEmbed parity: short links without an entity mapping (appended
// trailing media link is stripped. // media link, embedded unmapped links) are stripped, not kept.
let raw = serde_json::json!({ let raw = serde_json::json!({
"__typename": "Tweet", "__typename": "Tweet",
"id_str": "1", "id_str": "1",
"text": "check https://t.co/abc123 #tag https://t.co/def456", "text": "check https://t.co/abc123 #tag https://t.co/def456",
"display_text_range": [0, 30],
"user": { "name": "N", "screen_name": "h" }, "user": { "name": "N", "screen_name": "h" },
"mediaDetails": [] "mediaDetails": []
}); });
let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap(); let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap();
assert_eq!(tweet.text, "check https://t.co/abc123 #tag"); assert_eq!(tweet.text, "check #tag");
} }
#[test] #[test]
fn syndication_text_utf16_display_range_keeps_multibyte() { fn syndication_text_drops_internal_web_status_links() {
// display_text_range is in UTF-16 units; a Japanese text must not be // FxEmbed parity: a mapped link expanding to an internal
// sliced by UTF-8 bytes. // 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 text = "コミティア落ちたので、明日は行きません。🙏ごめんなさい";
let units: Vec<u16> = text.encode_utf16().collect(); let units: Vec<u16> = text.encode_utf16().collect();
assert_eq!(units.len(), 28); assert_eq!(units.len(), 28);
@@ -427,7 +537,6 @@ mod tests {
"__typename": "Tweet", "__typename": "Tweet",
"id_str": "1", "id_str": "1",
"text": text, "text": text,
"display_text_range": [0, 28],
"user": { "name": "N", "screen_name": "h" }, "user": { "name": "N", "screen_name": "h" },
"mediaDetails": [] "mediaDetails": []
}); });
@@ -435,6 +544,27 @@ mod tests {
assert_eq!(tweet.text, text, "full text kept intact"); 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] #[test]
fn syndication_token_matches_js_formula() { fn syndication_token_matches_js_formula() {
// JS: ((861627479294746624 / 1e15) * PI).toString(36) == "236.vrsocvda" // JS: ((861627479294746624 / 1e15) * PI).toString(36) == "236.vrsocvda"
@@ -458,6 +588,9 @@ mod tests {
async fn live_fetch_deleted_tweet_is_not_found() { async fn live_fetch_deleted_tweet_is_not_found() {
// Deleted tweet: the syndication endpoint answers with errors. // Deleted tweet: the syndication endpoint answers with errors.
let result = fetch("0").await; let result = fetch("0").await;
assert!(matches!(result, Err(FetchError::NotFound)), "got {result:?}"); assert!(
matches!(result, Err(FetchError::NotFound)),
"got {result:?}"
);
} }
} }
+1
View File
@@ -1,3 +1,4 @@
mod auth;
mod interface; mod interface;
mod model; mod model;
-4
View File
@@ -9,10 +9,6 @@ pub struct SyndicationTweet {
pub user: SyndicationUser, pub user: SyndicationUser,
#[serde(default)] #[serde(default)]
pub possibly_sensitive: Option<bool>, pub possibly_sensitive: Option<bool>,
/// Visible-text span; the raw `text` field has the appended media short
/// link after it. Indices are UTF-16 code units.
#[serde(default, rename = "display_text_range")]
pub display_text_range: Option<[usize; 2]>,
#[serde(default)] #[serde(default)]
pub entities: SyndicationEntities, pub entities: SyndicationEntities,
#[serde(default, rename = "mediaDetails")] #[serde(default, rename = "mediaDetails")]
+5 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "xmedia-bot" name = "xmedia-bot"
version = "1.0.0" version = "1.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
@@ -12,10 +12,13 @@ log = "0.4"
pretty_env_logger = "0.5" pretty_env_logger = "0.5"
dotenv = "0.15" dotenv = "0.15"
url = "2.5.2" url = "2.5.2"
regex = "1.12"
html-escape = "0.2" html-escape = "0.2"
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
rand = "0.8" rand = "0.8"
tempfile = "3" tempfile = "3"
parking_lot = "0.12" parking_lot = "0.12"
png = "0.18"
zune-jpeg = "0.5"
fast_image_resize = "6"
jpeg-encoder = "0.7"
x-media = { path = "../x-media" } x-media = { path = "../x-media" }
+15 -2
View File
@@ -10,6 +10,8 @@ pub struct Config {
pub admin_ids: Vec<i64>, pub admin_ids: Vec<i64>,
/// EDIT_MESSAGE_TTL_SECONDS, default 86400 (24h). /// EDIT_MESSAGE_TTL_SECONDS, default 86400 (24h).
pub edit_message_ttl: Duration, 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). // Webhook settings (moved out of main; names/defaults unchanged).
pub webhook_enabled: bool, pub webhook_enabled: bool,
pub webhook_url: Option<url::Url>, pub webhook_url: Option<url::Url>,
@@ -36,17 +38,28 @@ impl Config {
.map(Duration::from_secs) .map(Duration::from_secs)
.unwrap_or(Duration::from_secs(86400)); .unwrap_or(Duration::from_secs(86400));
let link_cache_ttl = env::var("LINK_CACHE_TTL_SECONDS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(7 * 24 * 3600));
let webhook_enabled = env::var("WEBHOOK") let webhook_enabled = env::var("WEBHOOK")
.is_ok_and(|v| matches!(v.to_lowercase().as_str(), "true" | "yes" | "1")); .is_ok_and(|v| matches!(v.to_lowercase().as_str(), "true" | "yes" | "1"));
let webhook_url = env::var("WEBHOOK_URL").ok().and_then(|s| s.parse().ok()); let webhook_url = env::var("WEBHOOK_URL").ok().and_then(|s| s.parse().ok());
let webhook_listen = env::var("WEBHOOK_LISTEN").ok().and_then(|s| s.parse().ok()); let webhook_listen = env::var("WEBHOOK_LISTEN").ok().and_then(|s| s.parse().ok());
let webhook_port = env::var("WEBHOOK_PORT").ok().and_then(|s| s.parse().ok()); let webhook_port = env::var("WEBHOOK_PORT").ok().and_then(|s| s.parse().ok());
let webhook_cert = env::var("WEBHOOK_CERT").ok(); // Empty strings count as unset (e.g. `-e WEBHOOK_CERT=` to disable a
let webhook_secret_token = env::var("WEBHOOK_SECRET_TOKEN").ok(); // 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 { Config {
admin_ids, admin_ids,
edit_message_ttl, edit_message_ttl,
link_cache_ttl,
webhook_enabled, webhook_enabled,
webhook_url, webhook_url,
webhook_listen, webhook_listen,
+50
View File
@@ -0,0 +1,50 @@
//! 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).
//!
//! Every operation opens its own short-lived connection with a busy timeout:
//! handler tasks enqueue while workers lease/update rows concurrently, and
//! without the timeout a concurrent write fails immediately with SQLITE_BUSY
//! and the operation is lost. All I/O runs inside `spawn_blocking` via
//! [`with_conn`] — rusqlite connections are not Send-friendly to hold across
//! an await point, and blocking the async executor stalls every handler.
use rusqlite::Connection;
use std::time::Duration;
/// 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)
}
/// Runs `f` against a fresh connection on a blocking thread, returning the
/// closure's result. Owns the `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>(path: &str, f: F) -> rusqlite::Result<T>
where
T: Send + 'static,
F: FnOnce(&mut Connection) -> rusqlite::Result<T> + Send + 'static,
{
let path = path.to_string();
tokio::task::spawn_blocking(move || {
let mut conn = open_db(&path)?;
f(&mut conn)
})
.await
.expect("db worker panicked")
}
+442 -129
View File
@@ -1,9 +1,12 @@
use crate::config::Config; use crate::config::Config;
use crate::db::now_f64;
use crate::link_cache::{CachedMediaKind, CachedPost, LinkCache};
use crate::queue::PersistentTaskQueue; use crate::queue::PersistentTaskQueue;
use crate::send::{self, MediaItemPayload, Task}; use crate::send::{self, MediaItemPayload, Task};
use crate::state::{ChatStore, unix_now}; use crate::state::{ChatData, ChatStore, unix_now};
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::LazyLock; use std::sync::LazyLock;
use teloxide::RequestError;
use teloxide::prelude::*; use teloxide::prelude::*;
use teloxide::types::{ use teloxide::types::{
CallbackQuery, ChatAction, ChatId, ChatKind, InlineQuery, InlineQueryResult, CallbackQuery, ChatAction, ChatId, ChatKind, InlineQuery, InlineQueryResult,
@@ -11,35 +14,92 @@ use teloxide::types::{
MessageEntityKind, MessageId, ParseMode, Recipient, ReplyParameters, MessageEntityKind, MessageId, ParseMode, Recipient, ReplyParameters,
}; };
use teloxide::utils::command::BotCommands; use teloxide::utils::command::BotCommands;
use teloxide::RequestError;
use x_media::media::Media; use x_media::media::Media;
pub static CHAT_STORE: LazyLock<ChatStore> = LazyLock::new(|| { /// One URL job: bot handle + the message + the extracted URL.
ChatStore::open("data/task_queue.db").expect("failed to open chat store") type UrlJob = (Bot, Message, String);
}); /// Bounded channel of URL jobs drained by [`start_url_workers`]. The bound
/// caps both queued memory and shutdown backlog; a full channel applies
/// backpressure to the per-chat handler instead of spawning unbounded tasks.
static URL_JOBS: LazyLock<parking_lot::Mutex<Option<tokio::sync::mpsc::Sender<UrlJob>>>> =
LazyLock::new(|| parking_lot::Mutex::new(None));
/// Set by main's shutdown sequence; workers stop pulling new jobs.
static URL_STOP: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Worker count draining URL jobs; keeps the old 8-permit concurrency cap
/// while bounding how many jobs can be queued at all.
const URL_WORKERS: usize = 8;
/// Starts the URL job workers (called once from main after the queue starts).
/// teloxide dispatches updates to a per-chat worker that handles them
/// sequentially, so a batch-forward of many messages would otherwise be
/// processed one at a time (fetch + send each, roughly a second per
/// message); the workers add throughput, and FIFO order preserves per-message
/// URL order.
pub async fn start_url_workers() {
let (tx, rx) = tokio::sync::mpsc::channel::<UrlJob>(256);
*URL_JOBS.lock() = Some(tx);
let rx = std::sync::Arc::new(tokio::sync::Mutex::new(rx));
for _ in 0..URL_WORKERS {
let rx = std::sync::Arc::clone(&rx);
tokio::spawn(async move {
while !URL_STOP.load(std::sync::atomic::Ordering::Relaxed) {
let job = rx.lock().await.recv().await;
match job {
Some((bot, message, url)) => url_media(bot, &message, &url).await,
None => break,
}
}
});
}
}
/// Stops URL workers (drains up to the 256 queued jobs, then exits).
pub fn stop_url_workers() {
URL_STOP.store(true, std::sync::atomic::Ordering::Relaxed);
}
pub static CHAT_STORE: LazyLock<ChatStore> =
LazyLock::new(|| ChatStore::open("data/task_queue.db").expect("failed to open chat store"));
pub static TASK_QUEUE: LazyLock<PersistentTaskQueue> = pub static TASK_QUEUE: LazyLock<PersistentTaskQueue> =
LazyLock::new(|| PersistentTaskQueue::new("data/task_queue.db")); LazyLock::new(|| PersistentTaskQueue::new("data/task_queue.db"));
pub static LINK_CACHE: LazyLock<LinkCache> =
LazyLock::new(|| LinkCache::open("data/task_queue.db"));
pub static CONFIG: LazyLock<Config> = LazyLock::new(Config::load); pub static CONFIG: LazyLock<Config> = LazyLock::new(Config::load);
#[derive(BotCommands, Clone)] #[derive(BotCommands, Clone)]
#[command(rename_rule = "snake_case", description = "")] #[command(
rename_rule = "snake_case",
description = "Turn X/Pixiv/Bluesky links into media messages"
)]
enum Command { enum Command {
#[command(description = "")] #[command(description = "Get started")]
Start, Start,
#[command(description = "")] #[command(description = "Show command help")]
Help, Help,
#[command(description = "", parse_with = "split")] #[command(
description = "Set forward channel (@channel or ID)",
parse_with = "split"
)]
SetForwardChannel(String), SetForwardChannel(String),
#[command(description = "")] #[command(description = "Remove forward channel")]
RemoveForwardChannel, RemoveForwardChannel,
#[command(description = "")] #[command(description = "Toggle edit-before-forward")]
EditBeforeForward, EditBeforeForward,
#[command(description = "", parse_with = "split")] #[command(
description = "Reply with [] to save as template",
parse_with = "split"
)]
SetTemplate(String), SetTemplate(String),
#[command(description = "")] #[command(description = "Show chat state (debug)")]
BotDict, BotDict,
#[command(description = "", parse_with = "split")] #[command(description = "Set site caption format", parse_with = "split")]
SetFormat(String), SetFormat(String),
#[command(
description = "Clear link cache (admin; optional URL, else all)",
parse_with = "split"
)]
ClearCache(String),
} }
async fn reply<T>(bot: Bot, message: Message, text: T) -> Result<Message, RequestError> async fn reply<T>(bot: Bot, message: Message, text: T) -> Result<Message, RequestError>
@@ -51,13 +111,6 @@ where
.await .await
} }
fn now_f64() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
/// Extracts URL and text-link entities (text + caption), deduped in order. /// Extracts URL and text-link entities (text + caption), deduped in order.
pub fn extract_urls(message: &Message) -> Vec<String> { pub fn extract_urls(message: &Message) -> Vec<String> {
let mut urls = Vec::new(); let mut urls = Vec::new();
@@ -76,7 +129,10 @@ pub fn extract_urls(message: &Message) -> Vec<String> {
} }
} }
let mut seen = HashSet::new(); let mut seen = HashSet::new();
urls.retain(|url| seen.insert(url.clone())); // Dedup by the normalized post id so variant URLs of the same post
// (/status/1 vs /status/1/photo/1) are sent once; unsupported URLs fall
// back to exact-string dedup.
urls.retain(|url| seen.insert(x_media::site::cache_key(url).unwrap_or_else(|| url.clone())));
urls urls
} }
@@ -99,7 +155,7 @@ async fn edit_message_handler(bot: &Bot, message: &Message) -> bool {
}; };
let link = format!( let link = format!(
"<a href=\"{0}\">{1}</a>", "<a href=\"{0}\">{1}</a>",
edit.url, html_escape::encode_double_quoted_attribute(&edit.url),
html_escape::encode_text(text) html_escape::encode_text(text)
); );
let new_text = if edit.template.is_empty() { let new_text = if edit.template.is_empty() {
@@ -165,19 +221,31 @@ async fn set_forward_channel_handler(
return Err(SetForwardChannelError::NotChannel); return Err(SetForwardChannelError::NotChannel);
} }
let channel_id = chat.id.0; let channel_id = chat.id.0;
// The sender must be a channel administrator. Compare against the
// sender's user id, NOT the chat id (they only coincide in private
// chats, so the old check broke group usage).
let Some(sender) = message.from.as_ref() else {
return Err(SetForwardChannelError::NotAdmin);
};
match bot.get_chat_administrators(channel.clone()).await { match bot.get_chat_administrators(channel.clone()).await {
Err(e) => { Err(e) => {
log::error!("Failed to get channel administrators {}: {}", channel, e); log::error!("Failed to get channel administrators {}: {}", channel, e);
return Err(SetForwardChannelError::NotBotAdmin(e)); return Err(SetForwardChannelError::NotBotAdmin(e));
} }
Ok(admins) => { Ok(admins) => {
if !admins.iter().any(|admin| admin.user.id == message.chat.id) { if !admins.iter().any(|admin| admin.user.id == sender.id) {
return Err(SetForwardChannelError::NotAdmin); return Err(SetForwardChannelError::NotAdmin);
} }
let bot_id = bot.get_me().await.expect("Failed get bot id").user.id; // The bot itself must be an admin that can post; a missing
if let Some(bot_admin) = admins.iter().find(|admin| admin.user.id == bot_id) // bot entry must not pass silently (copy would fail later).
&& !bot_admin.can_post_messages() let bot_id = match bot.get_me().await {
{ Ok(me) => me.user.id,
Err(e) => return Err(SetForwardChannelError::NotBotAdmin(e)),
};
let bot_ok = admins
.iter()
.any(|admin| admin.user.id == bot_id && admin.can_post_messages());
if !bot_ok {
return Err(SetForwardChannelError::NotBotCanPost); return Err(SetForwardChannelError::NotBotCanPost);
} }
} }
@@ -185,7 +253,11 @@ async fn set_forward_channel_handler(
Ok(channel_id) Ok(channel_id)
} }
async fn execute_command(bot: &Bot, message: &Message, command: Command) -> Result<(), RequestError> { async fn execute_command(
bot: &Bot,
message: &Message,
command: Command,
) -> Result<(), RequestError> {
match command { match command {
Command::Start => { Command::Start => {
bot.send_message(message.chat.id, "Hello!").await?; bot.send_message(message.chat.id, "Hello!").await?;
@@ -197,13 +269,16 @@ async fn execute_command(bot: &Bot, message: &Message, command: Command) -> Resu
Command::SetForwardChannel(channel) => { Command::SetForwardChannel(channel) => {
let result = match set_forward_channel_handler(bot, message, channel).await { let result = match set_forward_channel_handler(bot, message, channel).await {
Ok(channel_id) => { Ok(channel_id) => {
let mut chat_data = CHAT_STORE.get(message.chat.id.0).await; CHAT_STORE
chat_data.forward_channel_id = Some(channel_id); .update(message.chat.id.0, |data| {
CHAT_STORE.set(message.chat.id.0, &chat_data).await; data.forward_channel_id = Some(channel_id);
})
.await;
"Add successfully.".to_string() "Add successfully.".to_string()
} }
Err(SetForwardChannelError::EmptyParameter) => { Err(SetForwardChannelError::EmptyParameter) => {
"Receive empty parameter.\nYou should enter a channel id or username".to_string() "Receive empty parameter.\nYou should enter a channel id or username"
.to_string()
} }
Err(SetForwardChannelError::NotChannel) => { Err(SetForwardChannelError::NotChannel) => {
"Given id / username is not a channel".to_string() "Given id / username is not a channel".to_string()
@@ -222,31 +297,34 @@ async fn execute_command(bot: &Bot, message: &Message, command: Command) -> Resu
} }
Command::RemoveForwardChannel => { Command::RemoveForwardChannel => {
let chat_id = message.chat.id.0; let chat_id = message.chat.id.0;
let mut chat_data = CHAT_STORE.get(chat_id).await; let text = CHAT_STORE
let text = if chat_data.forward_channel_id.is_some() { .update(chat_id, |data| {
chat_data.forward_channel_id = None; if data.forward_channel_id.is_some() {
CHAT_STORE.set(chat_id, &chat_data).await; data.forward_channel_id = None;
"Remove successfully.".to_string() "Remove successfully.".to_string()
} else { } else {
"No channel to remove.".to_string() "No channel to remove.".to_string()
}; }
})
.await;
reply(bot.clone(), message.clone(), text).await?; reply(bot.clone(), message.clone(), text).await?;
} }
Command::EditBeforeForward => { Command::EditBeforeForward => {
let chat_id = message.chat.id.0; let chat_id = message.chat.id.0;
let mut chat_data = CHAT_STORE.get(chat_id).await; let text = CHAT_STORE
let text = if chat_data.forward_channel_id.is_none() { .update(chat_id, |data| {
if data.forward_channel_id.is_none() {
"Please enable forward channel first.".to_string() "Please enable forward channel first.".to_string()
} else if chat_data.edit_before_forward { } else if data.edit_before_forward {
chat_data.edit_before_forward = false; data.edit_before_forward = false;
chat_data.edit_message.clear(); data.edit_message.clear();
CHAT_STORE.set(chat_id, &chat_data).await;
"Disable edit before forward.".to_string() "Disable edit before forward.".to_string()
} else { } else {
chat_data.edit_before_forward = true; data.edit_before_forward = true;
CHAT_STORE.set(chat_id, &chat_data).await;
"Enable edit before forward.".to_string() "Enable edit before forward.".to_string()
}; }
})
.await;
reply(bot.clone(), message.clone(), text).await?; reply(bot.clone(), message.clone(), text).await?;
} }
Command::SetTemplate(name) => { Command::SetTemplate(name) => {
@@ -260,11 +338,14 @@ async fn execute_command(bot: &Bot, message: &Message, command: Command) -> Resu
} else if name.is_empty() { } else if name.is_empty() {
"Please provide a name for the template.".to_string() "Please provide a name for the template.".to_string()
} else { } else {
let mut chat_data = CHAT_STORE.get(chat_id).await; CHAT_STORE
chat_data .update(chat_id, |data| {
.template data.template.insert(
.insert(name, html_escape::encode_text(reply_text).into_owned()); name,
CHAT_STORE.set(chat_id, &chat_data).await; html_escape::encode_text(reply_text).into_owned(),
);
})
.await;
"Template set.".to_string() "Template set.".to_string()
} }
} }
@@ -280,7 +361,9 @@ async fn execute_command(bot: &Bot, message: &Message, command: Command) -> Resu
Command::SetFormat(arg) => { Command::SetFormat(arg) => {
let chat_id = message.chat.id.0; let chat_id = message.chat.id.0;
let (site, format) = match arg.split_once(char::is_whitespace) { let (site, format) = match arg.split_once(char::is_whitespace) {
Some((site, format)) if !format.trim().is_empty() => (site.trim(), format.trim().to_string()), Some((site, format)) if !format.trim().is_empty() => {
(site.trim(), format.trim().to_string())
}
_ => { _ => {
reply( reply(
bot.clone(), bot.clone(),
@@ -300,12 +383,75 @@ async fn execute_command(bot: &Bot, message: &Message, command: Command) -> Resu
.await?; .await?;
return Ok(()); return Ok(());
} }
let mut chat_data = CHAT_STORE.get(chat_id).await; CHAT_STORE
chat_data.message_format.insert(site.to_string(), format); .update(chat_id, |data| {
CHAT_STORE.set(chat_id, &chat_data).await; data.message_format.insert(site.to_string(), format);
})
.await;
reply(bot.clone(), message.clone(), "Format set.").await?; reply(bot.clone(), message.clone(), "Format set.").await?;
} }
Command::ClearCache(arg) => {
let sender_id = message
.from
.as_ref()
.map(|user| user.id.0 as i64)
.unwrap_or(-1);
if !CONFIG.admin_ids.contains(&sender_id) {
reply(bot.clone(), message.clone(), "Admin only.").await?;
return Ok(());
} }
let arg = arg.trim();
if arg.is_empty() {
let removed = LINK_CACHE.clear(None).await;
log::info!("cache cleared by {sender_id}: {removed} entries");
reply(
bot.clone(),
message.clone(),
format!("Cleared {removed} cached entr{}.", plural(removed)),
)
.await?;
} else {
let key = match x_media::site::cache_key(arg) {
Some(key) => key,
None => {
reply(
bot.clone(),
message.clone(),
"Unrecognized link. Use a twitter/x, pixiv or bsky post URL.",
)
.await?;
return Ok(());
}
};
let removed = LINK_CACHE.clear(Some(&key)).await;
log::info!("cache entry cleared by {sender_id}: {key} ({removed} rows)");
reply(
bot.clone(),
message.clone(),
format!(
"Cleared cache for {arg} ({} entr{}).",
removed,
plural(removed)
),
)
.await?;
}
}
}
Ok(())
}
/// `"y"` for one, `"ies"` for anything else — "1 entry" / "2 entries".
fn plural(n: usize) -> &'static str {
if n == 1 { "y" } else { "ies" }
}
/// Registers the bot's command list with Telegram so clients show it in the
/// `/` menu (Bot API `setMyCommands`).
pub async fn register_commands(bot: &Bot) -> Result<(), RequestError> {
let commands = Command::bot_commands();
bot.set_my_commands(commands.clone()).await?;
log::info!("registered {} commands", commands.len());
Ok(()) Ok(())
} }
@@ -322,22 +468,29 @@ fn thumbnail_for(media: &Media) -> Option<String> {
} }
fn media_to_payload(media: &Media, sensitive: bool) -> MediaItemPayload { fn media_to_payload(media: &Media, sensitive: bool) -> MediaItemPayload {
let fallback_url = media.smaller_url().map(str::to_string);
match media { match media {
// A gif inside a group becomes a video item; a lone gif takes the // A gif inside a group becomes a video item; a lone gif takes the
// animation path (see url_media). // animation path (see url_media).
Media::Illustration { .. } => MediaItemPayload::Photo { Media::Illustration { .. } => MediaItemPayload::Photo {
media: media.url().to_string(), media: media.url().to_string(),
has_spoiler: sensitive, has_spoiler: sensitive,
fallback_url,
file_id: false,
}, },
Media::Video { .. } => MediaItemPayload::Video { Media::Video { .. } => MediaItemPayload::Video {
media: media.url().to_string(), media: media.url().to_string(),
has_spoiler: sensitive, has_spoiler: sensitive,
thumbnail: thumbnail_for(media), thumbnail: thumbnail_for(media),
fallback_url,
file_id: false,
}, },
Media::Animated { .. } => MediaItemPayload::Video { Media::Animated { .. } => MediaItemPayload::Video {
media: media.url().to_string(), media: media.url().to_string(),
has_spoiler: sensitive, has_spoiler: sensitive,
thumbnail: thumbnail_for(media), thumbnail: thumbnail_for(media),
fallback_url,
file_id: false,
}, },
} }
} }
@@ -350,11 +503,154 @@ async fn enqueue_retry(task: Task, delay_seconds: f64) {
} }
} }
/// Sends a task and handles the outcome: post-send actions on success, retry
/// enqueue on retryable failure, reply + link-cache invalidation on
/// permanent failure (a stale cached file id must not repeat forever).
async fn dispatch_send(bot: Bot, message: &Message, task: &Task, url: &str) {
let result = match task {
Task::SendAnimation { .. } => send::send_animation(&bot, task).await,
Task::SendMediaSequence { .. } => send::send_media_sequence(&bot, task).await,
Task::ForwardMessages { .. } => unreachable!(),
};
match result {
Ok(message_ids) => {
log::info!("sent {} message(s) for {url}", message_ids.len());
send::post_send_actions(&bot, task, message_ids).await;
}
Err(send::SendError::Retryable {
delay_seconds,
task,
}) => {
log::info!("send for {url} failed, queued for retry in {delay_seconds:.1}s");
enqueue_retry(task, delay_seconds).await;
let _ = reply(bot, message.clone(), "Send failed. Task queued for retry.").await;
}
Err(send::SendError::Permanent {
message: err_message,
task,
}) => {
send::invalidate_cache(&task).await;
log::error!("send for {url} failed permanently: {err_message}");
let _ = reply(bot, message.clone(), format!("Send failed: {err_message}")).await;
}
}
}
/// Builds the send task from ready-made items, sharing the payload shape
/// between the fresh-fetch and link-cache paths.
#[allow(clippy::too_many_arguments)]
fn build_send_task(
chat_data: &ChatData,
message: &Message,
source_url: String,
caption: String,
items: Vec<MediaItemPayload>,
cache_data: Option<CachedPost>,
) -> Task {
let chat_id = message.chat.id.0;
if items.len() == 1 && matches!(items[0], MediaItemPayload::Animation { .. }) {
Task::SendAnimation {
chat_id,
reply_to_message_id: message.id.0 as i64,
caption,
animation: items.into_iter().next().unwrap(),
source_url,
edit_before_forward: chat_data.edit_before_forward,
forward_channel_id: chat_data.forward_channel_id,
notify_chat_id: Some(chat_id),
notify_message_id: Some(message.id.0 as i64),
cache_data,
}
} else {
Task::SendMediaSequence {
chat_id,
reply_to_message_id: message.id.0 as i64,
caption,
media_batches: send::chunk_media_items(items),
batch_index: 0,
sent_message_ids: vec![],
source_url,
edit_before_forward: chat_data.edit_before_forward,
forward_channel_id: chat_data.forward_channel_id,
notify_chat_id: Some(chat_id),
notify_message_id: Some(message.id.0 as i64),
cache_data,
}
}
}
async fn url_media(bot: Bot, message: &Message, url: &str) { async fn url_media(bot: Bot, message: &Message, url: &str) {
let chat_id = message.chat.id.0; let chat_id = message.chat.id.0;
if let Err(e) = bot.send_chat_action(ChatId(chat_id), ChatAction::Typing).await { if let Err(e) = bot
.send_chat_action(ChatId(chat_id), ChatAction::Typing)
.await
{
log::error!("send_chat_action failed: {e}"); log::error!("send_chat_action failed: {e}");
} }
// Link cache: a post sent before is re-sent from Telegram file ids —
// no source-site request, no download, no upload. Keyed by the
// normalized post id so x.com / fxtwitter / /photo/N variants collide.
if let Some(key) = x_media::site::cache_key(url)
&& let Some(cached) = LINK_CACHE.get(&key, CONFIG.link_cache_ttl).await
{
log::info!("link cache hit for {url}");
let chat_data = CHAT_STORE.get(chat_id).await;
let site = key.split(':').next().unwrap_or("unknown");
let format = chat_data
.message_format
.get(site)
.cloned()
.unwrap_or_default();
let caption = if format.is_empty() {
cached.caption.clone()
} else {
x_media::site::caption_from_fields(
&format,
"",
&cached.url,
&cached.author,
&cached.author_url,
&cached.title,
&cached.tags,
)
};
let items: Vec<MediaItemPayload> = cached
.media
.iter()
.map(|m| match m.kind {
CachedMediaKind::Photo => MediaItemPayload::Photo {
media: m.file_id.clone(),
has_spoiler: cached.sensitive,
fallback_url: None,
file_id: true,
},
CachedMediaKind::Video => MediaItemPayload::Video {
media: m.file_id.clone(),
has_spoiler: cached.sensitive,
thumbnail: None,
fallback_url: None,
file_id: true,
},
CachedMediaKind::Animation => MediaItemPayload::Animation {
media: m.file_id.clone(),
has_spoiler: cached.sensitive,
file_id: true,
},
})
.collect();
let task = build_send_task(
&chat_data,
message,
cached.url.clone(),
caption,
items,
Some(cached),
);
dispatch_send(bot, message, &task, url).await;
return;
}
log::info!("fetching {url}"); log::info!("fetching {url}");
match x_media::site::fetch(url).await { match x_media::site::fetch(url).await {
// Unsupported links are ignored silently (Python parity). // Unsupported links are ignored silently (Python parity).
@@ -364,7 +660,12 @@ async fn url_media(bot: Bot, message: &Message, url: &str) {
// Retries exhausted: notify the user (Rust-only requirement 3). // Retries exhausted: notify the user (Rust-only requirement 3).
Err(e) => { Err(e) => {
log::error!("fetch {url}: {e}"); log::error!("fetch {url}: {e}");
let _ = reply(bot, message.clone(), "Failed to fetch media from this link.").await; let _ = reply(
bot,
message.clone(),
"Failed to fetch media from this link.",
)
.await;
} }
Ok(Some(fetched)) => { Ok(Some(fetched)) => {
if fetched.media.is_empty() { if fetched.media.is_empty() {
@@ -384,66 +685,34 @@ async fn url_media(bot: Bot, message: &Message, url: &str) {
.cloned() .cloned()
.unwrap_or_default(); .unwrap_or_default();
let caption = fetched.caption_with(&format); let caption = fetched.caption_with(&format);
let task = if fetched.media.len() == 1 // Raw render data for the link cache; the send fills in the
&& matches!(fetched.media[0], Media::Animated { .. }) // Telegram file ids and persists the entry.
{ let cache_data = fetched
Task::SendAnimation { .render_fields()
chat_id, .map(|(author, author_url, title, tags)| CachedPost {
reply_to_message_id: message.id.0 as i64, url: fetched.source_url.clone(),
caption: caption.clone(), caption: fetched.caption.clone(),
animation: MediaItemPayload::Animation { title: title.to_string(),
media: fetched.media[0].url().to_string(), author: author.to_string(),
has_spoiler: fetched.sensitive, author_url: author_url.to_string(),
}, tags: tags.to_string(),
source_url: fetched.source_url.clone(), sensitive: fetched.sensitive,
edit_before_forward: chat_data.edit_before_forward, media: vec![],
forward_channel_id: chat_data.forward_channel_id, });
notify_chat_id: Some(chat_id),
notify_message_id: Some(message.id.0 as i64),
}
} else {
let items: Vec<MediaItemPayload> = fetched let items: Vec<MediaItemPayload> = fetched
.media .media
.iter() .iter()
.map(|media| media_to_payload(media, fetched.sensitive)) .map(|media| media_to_payload(media, fetched.sensitive))
.collect(); .collect();
Task::SendMediaSequence { let task = build_send_task(
chat_id, &chat_data,
reply_to_message_id: message.id.0 as i64, message,
caption: caption.clone(), fetched.source_url.clone(),
media_batches: send::chunk_media_items(items), caption,
batch_index: 0, items,
sent_message_ids: vec![], cache_data,
source_url: fetched.source_url.clone(), );
edit_before_forward: chat_data.edit_before_forward, dispatch_send(bot, message, &task, url).await;
forward_channel_id: chat_data.forward_channel_id,
notify_chat_id: Some(chat_id),
notify_message_id: Some(message.id.0 as i64),
}
};
let result = match &task {
Task::SendAnimation { .. } => send::send_animation(&bot, &task).await,
Task::SendMediaSequence { .. } => send::send_media_sequence(&bot, &task).await,
Task::ForwardMessages { .. } => unreachable!(),
};
match result {
Ok(message_ids) => {
log::info!("sent {} message(s) for {url}", message_ids.len());
send::post_send_actions(&bot, &task, message_ids).await;
}
Err(send::SendError::Retryable { delay_seconds, task }) => {
log::info!("send for {url} failed, queued for retry in {delay_seconds:.1}s");
enqueue_retry(task, delay_seconds).await;
let _ = reply(bot, message.clone(), "Send failed. Task queued for retry.").await;
}
Err(send::SendError::Permanent {
message: err_message,
..
}) => {
log::error!("send for {url} failed permanently: {err_message}");
let _ = reply(bot, message.clone(), format!("Send failed: {err_message}")).await;
}
}
} }
} }
} }
@@ -457,9 +726,15 @@ pub async fn message_handler(bot: Bot, message: Message) -> Result<(), RequestEr
.unwrap_or_else(|| "unknown".to_string()); .unwrap_or_else(|| "unknown".to_string());
let text_preview = message let text_preview = message
.text() .text()
.map(|t| if t.len() > 120 { &t[..120] } else { t }) .map(|t| {
let end = t.floor_char_boundary(120.min(t.len()));
&t[..end]
})
.unwrap_or("<no text>"); .unwrap_or("<no text>");
log::info!("message from {sender} in {} (private={is_private}): {text_preview}", message.chat.id); log::info!(
"message from {sender} in {} (private={is_private}): {text_preview}",
message.chat.id
);
// URL/edit flows only run in private chats; commands run in any chat. // URL/edit flows only run in private chats; commands run in any chat.
if is_private && edit_message_handler(&bot, &message).await { if is_private && edit_message_handler(&bot, &message).await {
return respond(()); return respond(());
@@ -477,7 +752,13 @@ pub async fn message_handler(bot: Bot, message: Message) -> Result<(), RequestEr
log::info!("extracted {} URL(s): {urls:?}", urls.len()); log::info!("extracted {} URL(s): {urls:?}", urls.len());
} }
for url in urls { for url in urls {
url_media(bot.clone(), &message, &url).await; // Clone out of the lock: the parking_lot guard is !Send and must
// not be held across the await below.
let Some(tx) = URL_JOBS.lock().clone() else {
log::warn!("url workers not started; dropping link");
break;
};
let _ = tx.send((bot.clone(), message.clone(), url)).await;
} }
} }
respond(()) respond(())
@@ -487,6 +768,12 @@ pub async fn inline_query_handler(bot: Bot, query: InlineQuery) -> Result<(), Re
if query.query.is_empty() { if query.query.is_empty() {
return respond(()); return respond(());
} }
// Telegram fires an inline query on every keystroke; only run a fetch
// (3 attempts!) for something that is actually a supported post URL, so
// typing does not hammer the source sites.
if x_media::site::cache_key(&query.query).is_none() {
return respond(());
}
log::info!("inline query: {}", query.query); log::info!("inline query: {}", query.query);
match x_media::site::fetch(&query.query).await { match x_media::site::fetch(&query.query).await {
Ok(Some(fetched)) => { Ok(Some(fetched)) => {
@@ -502,11 +789,19 @@ pub async fn inline_query_handler(bot: Bot, query: InlineQuery) -> Result<(), Re
.unwrap_or_else(|| url.clone()); .unwrap_or_else(|| url.clone());
let caption = fetched.caption.clone(); let caption = fetched.caption.clone();
let result = match media { let result = match media {
Media::Illustration { .. } => InlineQueryResult::Photo( Media::Illustration { .. } => {
InlineQueryResultPhoto::new(id, url, thumbnail) // Inline photo results have their own (smaller) size
// cap; use the reduced variant when one exists.
let photo_url = media
.smaller_url()
.and_then(|u| url::Url::parse(u).ok())
.unwrap_or_else(|| url.clone());
InlineQueryResult::Photo(
InlineQueryResultPhoto::new(id, photo_url, thumbnail)
.caption(caption) .caption(caption)
.parse_mode(ParseMode::Html), .parse_mode(ParseMode::Html),
), )
}
Media::Video { .. } => InlineQueryResult::Video( Media::Video { .. } => InlineQueryResult::Video(
InlineQueryResultVideo::new( InlineQueryResultVideo::new(
id, id,
@@ -545,10 +840,13 @@ pub async fn callback_query_handler(bot: Bot, query: CallbackQuery) -> Result<()
let chat_id = message.chat().id.0; let chat_id = message.chat().id.0;
let prompt_message_id = message.id().0 as i64; let prompt_message_id = message.id().0 as i64;
let ttl_secs = CONFIG.edit_message_ttl.as_secs() as i64; let ttl_secs = CONFIG.edit_message_ttl.as_secs() as i64;
let mut chat_data = CHAT_STORE.get(chat_id).await; let chat_data = CHAT_STORE.get(chat_id).await;
let edit = chat_data.edit_message.get(&prompt_message_id).cloned(); let edit = chat_data.edit_message.get(&prompt_message_id).cloned();
let Some(edit) = edit else { let Some(edit) = edit else {
log::info!("callback from {}: no edit record for prompt {prompt_message_id}", chat_id); log::info!(
"callback from {}: no edit record for prompt {prompt_message_id}",
chat_id
);
bot.answer_callback_query(callback_query_id) bot.answer_callback_query(callback_query_id)
.text("Expired") .text("Expired")
.await?; .await?;
@@ -556,8 +854,11 @@ pub async fn callback_query_handler(bot: Bot, query: CallbackQuery) -> Result<()
}; };
// Lazy expiry: a stale record (past the TTL, not yet swept) is dropped. // Lazy expiry: a stale record (past the TTL, not yet swept) is dropped.
if edit.created_at + ttl_secs <= unix_now() { if edit.created_at + ttl_secs <= unix_now() {
chat_data.edit_message.remove(&prompt_message_id); CHAT_STORE
CHAT_STORE.set(chat_id, &chat_data).await; .update(chat_id, |data| {
data.edit_message.remove(&prompt_message_id);
})
.await;
bot.answer_callback_query(callback_query_id) bot.answer_callback_query(callback_query_id)
.text("Expired") .text("Expired")
.await?; .await?;
@@ -567,7 +868,10 @@ pub async fn callback_query_handler(bot: Bot, query: CallbackQuery) -> Result<()
let Some(data) = data else { let Some(data) = data else {
return respond(()); return respond(());
}; };
log::info!("callback from {} on prompt {prompt_message_id}: {data}", chat_id); log::info!(
"callback from {} on prompt {prompt_message_id}: {data}",
chat_id
);
if data == "forward" { if data == "forward" {
match chat_data.forward_channel_id { match chat_data.forward_channel_id {
Some(channel_id) => { Some(channel_id) => {
@@ -590,10 +894,16 @@ pub async fn callback_query_handler(bot: Bot, query: CallbackQuery) -> Result<()
let _ = bot let _ = bot
.delete_message(ChatId(chat_id), MessageId(prompt_message_id as i32)) .delete_message(ChatId(chat_id), MessageId(prompt_message_id as i32))
.await; .await;
chat_data.edit_message.remove(&prompt_message_id); CHAT_STORE
CHAT_STORE.set(chat_id, &chat_data).await; .update(chat_id, |data| {
data.edit_message.remove(&prompt_message_id);
})
.await;
} }
Err(send::SendError::Retryable { delay_seconds, task }) => { Err(send::SendError::Retryable {
delay_seconds,
task,
}) => {
log::info!("forward queued for retry in {delay_seconds:.1}s"); log::info!("forward queued for retry in {delay_seconds:.1}s");
enqueue_retry(task, delay_seconds).await; enqueue_retry(task, delay_seconds).await;
bot.answer_callback_query(callback_query_id) bot.answer_callback_query(callback_query_id)
@@ -627,10 +937,13 @@ pub async fn callback_query_handler(bot: Bot, query: CallbackQuery) -> Result<()
.caption(template_html) .caption(template_html)
.parse_mode(ParseMode::Html) .parse_mode(ParseMode::Html)
.await; .await;
if let Some(entry) = chat_data.edit_message.get_mut(&prompt_message_id) { CHAT_STORE
.update(chat_id, |data| {
if let Some(entry) = data.edit_message.get_mut(&prompt_message_id) {
entry.template = name.to_string(); entry.template = name.to_string();
} }
CHAT_STORE.set(chat_id, &chat_data).await; })
.await;
log::info!("template '{name}' applied to prompt {prompt_message_id}"); log::info!("template '{name}' applied to prompt {prompt_message_id}");
} }
bot.answer_callback_query(callback_query_id).await?; bot.answer_callback_query(callback_query_id).await?;
+287
View File
@@ -0,0 +1,287 @@
//! 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 {
db_path: String,
}
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 {
db_path: db_path.to_string(),
}
}
/// 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 = crate::db::with_conn(&self.db_path, 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 = crate::db::with_conn(&self.db_path, 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 = crate::db::with_conn(&self.db_path, 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 = crate::db::with_conn(&self.db_path, 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 = crate::db::with_conn(&self.db_path, 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);
}
}
+74 -15
View File
@@ -1,18 +1,40 @@
use dotenv::dotenv; use dotenv::dotenv;
use teloxide::dptree::endpoint; use teloxide::dptree::endpoint;
use teloxide::types::{ChatId, InputFile, MessageId};
use teloxide::update_listeners::webhooks;
use teloxide::prelude::*; 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 tokio::sync::watch;
use x_media::site; use x_media::site;
mod config; mod config;
mod db;
mod handlers; mod handlers;
mod link_cache;
mod photo;
mod queue; mod queue;
mod send; mod send;
mod state; mod state;
use handlers::{CHAT_STORE, CONFIG, TASK_QUEUE}; 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] #[tokio::main]
async fn main() { async fn main() {
@@ -21,6 +43,14 @@ async fn main() {
log::info!("Starting bot"); log::info!("Starting bot");
let bot = Bot::from_env(); 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!( log::info!(
"config: {} admin(s), edit-message TTL {}s", "config: {} admin(s), edit-message TTL {}s",
@@ -35,6 +65,10 @@ async fn main() {
.await; .await;
log::info!("task queue worker started"); 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 // Pixiv login validation (user request): a failed login notifies the
// admin and disables pixiv for this process. // admin and disables pixiv for this process.
if site::pixiv::enabled() { if site::pixiv::enabled() {
@@ -53,7 +87,10 @@ async fn main() {
} }
// Edit-expiry sweep: clears the prompt's buttons once the record expires. // 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()); log::info!(
"edit-expiry sweep: every 300s, ttl {}",
CONFIG.edit_message_ttl.as_secs()
);
let (stop_tx, stop_rx) = watch::channel(false); let (stop_tx, stop_rx) = watch::channel(false);
{ {
let bot = bot.clone(); let bot = bot.clone();
@@ -66,11 +103,18 @@ async fn main() {
} }
let ttl = CONFIG.edit_message_ttl; let ttl = CONFIG.edit_message_ttl;
let removed = CHAT_STORE.prune_expired(ttl).await; 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 { for (chat_id, prompt_message_id) in removed {
// If the prompt was already deleted, this fails with a // If the prompt was already deleted, this fails with a
// 400 "message to edit not found" — log and ignore. // 400 "message to edit not found" — log and ignore.
if let Err(e) = bot if let Err(e) = bot
.edit_message_reply_markup(ChatId(chat_id), MessageId(prompt_message_id as i32)) .edit_message_reply_markup(
ChatId(chat_id),
MessageId(prompt_message_id as i32),
)
.await .await
{ {
log::info!("edit-expiry sweep: prompt message gone: {e}"); log::info!("edit-expiry sweep: prompt message gone: {e}");
@@ -92,11 +136,9 @@ async fn main() {
if CONFIG.webhook_enabled { if CONFIG.webhook_enabled {
log::info!("running in webhook mode"); log::info!("running in webhook mode");
let url = CONFIG let url = CONFIG.webhook_url.clone().expect("WEBHOOK_URL is not set");
.webhook_url // `webhooks::axum` calls set_webhook itself (with the full options,
.clone() // secret token included) — no explicit registration here.
.expect("WEBHOOK_URL is not set");
bot.set_webhook(url.clone()).await.unwrap();
let listen = CONFIG.webhook_listen.expect("WEBHOOK_LISTEN is not set"); let listen = CONFIG.webhook_listen.expect("WEBHOOK_LISTEN is not set");
let port = CONFIG.webhook_port.expect("WEBHOOK_PORT is not set"); let port = CONFIG.webhook_port.expect("WEBHOOK_PORT is not set");
let mut options = webhooks::Options::new((listen, port).into(), url); let mut options = webhooks::Options::new((listen, port).into(), url);
@@ -107,22 +149,39 @@ async fn main() {
options = options.secret_token(secret.clone()); 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 dispatcher
.dispatch_with_listener( .dispatch_with_listener(
webhooks::axum(bot.clone(), options) listener,
.await
.expect("Failed to create webhook listener"),
LoggingErrorHandler::with_custom_text("Error from update listener"), LoggingErrorHandler::with_custom_text("Error from update listener"),
) )
.await; .await;
} else { } else {
log::info!("running in polling mode"); log::info!("running in polling mode");
dispatcher.dispatch().await; // 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): stop the sweep, notify the admin, drain the queue. // Graceful stop (Ctrl+C / SIGTERM): stop the sweep, notify the admin, drain the queue.
log::info!("Stopping bot"); log::info!("Stopping bot");
let _ = stop_tx.send(true); let _ = stop_tx.send(true);
handlers::stop_url_workers();
if let Some(admin) = CONFIG.admin_ids.first() { if let Some(admin) = CONFIG.admin_ids.first() {
let _ = bot.send_message(ChatId(*admin), "Shutting down...").await; let _ = bot.send_message(ChatId(*admin), "Shutting down...").await;
} }
+528
View File
@@ -0,0 +1,528 @@
//! 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.
pub fn prepare_photo(file: NamedTempFile) -> Result<PhotoPrep, String> {
let bytes = std::fs::read(file.path()).map_err(|e| format!("prepare read failed: {e}"))?;
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: Vec<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::info!(
"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::info!("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::info!("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: Vec<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::info!("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)
}
#[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).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).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"),
}
}
}
+189 -81
View File
@@ -5,33 +5,34 @@
//! flow. The Python dict-mutation hack (attempts inside the payload) is //! flow. The Python dict-mutation hack (attempts inside the payload) is
//! replaced by dedicated columns. //! replaced by dedicated columns.
use crate::db::now_f64;
use parking_lot::Mutex; use parking_lot::Mutex;
use rusqlite::{params, Connection}; use rusqlite::{Connection, TransactionBehavior, params};
use serde_json::Value; use serde_json::Value;
use std::pin::Pin; use std::pin::Pin;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Duration;
use tokio::sync::Notify; use tokio::sync::Notify;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
pub const MAX_RETRIES: u32 = 2; pub const MAX_RETRIES: u32 = 2;
pub const LOCK_TTL_SECONDS: f64 = 120.0; 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 /// What a handler returns instead of throwing. The payload it carries is the
/// (possibly updated) task state to persist for the next attempt. /// (possibly updated) task state to persist for the next attempt.
pub enum QueueError { pub enum QueueError {
/// Reschedule with the given delay; after `MAX_RETRIES` attempts the task /// Reschedule with the given delay; after `MAX_RETRIES` attempts the task
/// is dead-lettered instead. /// is dead-lettered instead.
Retryable { Retryable { delay_seconds: f64, payload: Value },
delay_seconds: f64,
payload: Value,
},
/// Give up now. /// Give up now.
Permanent { Permanent { message: String, payload: Value },
message: String,
payload: Value,
},
} }
type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>; type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
@@ -42,7 +43,7 @@ pub struct PersistentTaskQueue {
db_path: String, db_path: String,
notify: Arc<Notify>, notify: Arc<Notify>,
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
worker: Mutex<Option<JoinHandle<()>>>, worker: Mutex<Vec<JoinHandle<()>>>,
counter: AtomicU64, counter: AtomicU64,
} }
@@ -53,6 +54,7 @@ struct LeasedRow {
} }
/// Owned worker state so the spawned loop does not borrow the queue handle. /// Owned worker state so the spawned loop does not borrow the queue handle.
#[derive(Clone)]
struct QueueWorker { struct QueueWorker {
db_path: String, db_path: String,
notify: Arc<Notify>, notify: Arc<Notify>,
@@ -61,18 +63,31 @@ struct QueueWorker {
dead_letter: Arc<DeadLetter>, dead_letter: Arc<DeadLetter>,
} }
fn now_f64() -> f64 { /// Resets rows left `in_progress` with an expired lock TTL back to `pending`
SystemTime::now() /// so they can be leased again (crash/panic recovery).
.duration_since(UNIX_EPOCH) fn recover_update(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
.map(|d| d.as_secs_f64()) conn.execute(
.unwrap_or(0.0) "UPDATE tasks SET status='pending', locked_until=0 WHERE status='in_progress' AND locked_until < ?1",
params![now_f64()],
)?;
Ok(())
} }
fn ensure_schema(conn: &Connection) -> rusqlite::Result<()> { /// 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( conn.execute_batch(
"CREATE TABLE IF NOT EXISTS tasks (id TEXT PRIMARY KEY, payload TEXT NOT NULL, \ "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, \ run_after REAL NOT NULL, attempts INTEGER NOT NULL, status TEXT NOT NULL, \
locked_until REAL NOT NULL, created_at REAL 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);",
) )
} }
@@ -96,12 +111,12 @@ impl PersistentTaskQueue {
db_path: db_path.to_string(), db_path: db_path.to_string(),
notify: Arc::new(Notify::new()), notify: Arc::new(Notify::new()),
stop: Arc::new(AtomicBool::new(false)), stop: Arc::new(AtomicBool::new(false)),
worker: Mutex::new(None), worker: Mutex::new(Vec::new()),
counter: AtomicU64::new(0), counter: AtomicU64::new(0),
} }
} }
/// Starts the worker loop. Also recovers rows left `in_progress` by a /// Starts the worker loops. Also recovers rows left `in_progress` by a
/// previous process (lease expired). /// previous process (lease expired).
pub async fn start<H, F, D, G>(&self, handler: H, dead_letter: D) pub async fn start<H, F, D, G>(&self, handler: H, dead_letter: D)
where where
@@ -114,21 +129,52 @@ impl PersistentTaskQueue {
let dead_letter: Arc<DeadLetter> = let dead_letter: Arc<DeadLetter> =
Arc::new(move |payload, message| Box::pin(dead_letter(payload, message))); Arc::new(move |payload, message| Box::pin(dead_letter(payload, message)));
self.recover_stale().await; self.recover_stale().await;
let mut handles = Vec::with_capacity(QUEUE_WORKERS + 1);
for _ in 0..QUEUE_WORKERS {
let worker = QueueWorker { let worker = QueueWorker {
db_path: self.db_path.clone(), db_path: self.db_path.clone(),
notify: Arc::clone(&self.notify), notify: Arc::clone(&self.notify),
stop: Arc::clone(&self.stop), stop: Arc::clone(&self.stop),
handler, handler: Arc::clone(&handler),
dead_letter, dead_letter: Arc::clone(&dead_letter),
}; };
let worker = tokio::spawn(worker.run_loop()); handles.push(tokio::spawn(worker.run_loop_supervised()));
*self.worker.lock() = Some(worker); }
// 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_db_path = self.db_path.clone();
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 =
crate::db::with_conn(&sweep_db_path, 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) { pub async fn stop(&self) {
self.stop.store(true, Ordering::Relaxed); self.stop.store(true, Ordering::Relaxed);
self.notify.notify_one(); self.notify.notify_waiters();
if let Some(handle) = self.worker.lock().take() { let handles = std::mem::take(&mut *self.worker.lock());
for handle in handles {
let _ = handle.await; let _ = handle.await;
} }
} }
@@ -143,10 +189,8 @@ impl PersistentTaskQueue {
self.counter.fetch_add(1, Ordering::Relaxed) self.counter.fetch_add(1, Ordering::Relaxed)
); );
let payload = payload.to_string(); let payload = payload.to_string();
let db_path = self.db_path.clone();
log::info!("enqueued {id} (run_after {run_after:.1})"); log::info!("enqueued {id} (run_after {run_after:.1})");
let result = tokio::task::spawn_blocking(move || -> rusqlite::Result<()> { crate::db::with_conn(&self.db_path, move |conn| {
let conn = Connection::open(&db_path)?;
conn.execute( conn.execute(
"INSERT OR REPLACE INTO tasks (id, payload, run_after, attempts, status, locked_until, created_at) \ "INSERT OR REPLACE INTO tasks (id, payload, run_after, attempts, status, locked_until, created_at) \
VALUES (?1, ?2, ?3, 0, 'pending', 0, ?4)", VALUES (?1, ?2, ?3, 0, 'pending', 0, ?4)",
@@ -154,34 +198,46 @@ impl PersistentTaskQueue {
)?; )?;
Ok(()) Ok(())
}) })
.await .await?;
.expect("queue insert worker panicked")?; // `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(); self.notify.notify_one();
Ok(result) Ok(())
} }
async fn recover_stale(&self) { async fn recover_stale(&self) {
let db_path = self.db_path.clone(); self.recover_sweep().await;
tokio::task::spawn_blocking(move || -> rusqlite::Result<()> { }
let conn = Connection::open(&db_path)?;
conn.execute( async fn recover_sweep(&self) {
"UPDATE tasks SET status='pending', locked_until=0 WHERE status='in_progress' AND locked_until < ?1", let result = crate::db::with_conn(&self.db_path, move |conn| recover_update(conn)).await;
params![now_f64()], if let Err(e) = result {
)?; log::error!("queue recovery failed: {e}");
Ok(()) }
})
.await
.expect("queue recovery worker panicked")
.unwrap_or_else(|e| log::error!("queue recovery failed: {e}"));
} }
} }
impl QueueWorker { 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) { async fn run_loop(self) {
while !self.stop.load(Ordering::Relaxed) { while !self.stop.load(Ordering::Relaxed) {
match self.lease_next().await { match self.lease_next().await {
Some(row) => self.process(row).await, Ok(Some(row)) => self.process(row).await,
None => { Ok(None) => {
let wait_until = self.earliest_run_after().await; let wait_until = self.earliest_run_after().await;
let notified = self.notify.notified(); let notified = self.notify.notified();
tokio::pin!(notified); tokio::pin!(notified);
@@ -198,19 +254,28 @@ impl QueueWorker {
} }
} }
} }
// 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). /// Leases the oldest due row (sets it `in_progress` with a lock TTL).
async fn lease_next(&self) -> Option<LeasedRow> { /// Errors are surfaced so the caller can back off instead of spinning.
let db_path = self.db_path.clone(); async fn lease_next(&self) -> Result<Option<LeasedRow>, rusqlite::Error> {
tokio::task::spawn_blocking(move || -> rusqlite::Result<Option<LeasedRow>> { crate::db::with_conn(&self.db_path, |conn| {
let mut conn = Connection::open(&db_path)?; // BEGIN IMMEDIATE: with several workers, a deferred transaction
let tx = conn.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 now = now_f64();
let row = tx.query_row( let row = tx.query_row(
"SELECT id, payload, attempts FROM tasks WHERE status='pending' AND run_after <= ?1 \ "SELECT id, payload, attempts FROM tasks WHERE status='pending' AND run_after <= ?1 AND locked_until <= ?1 \
ORDER BY run_after LIMIT 1", ORDER BY run_after LIMIT 1",
params![now], params![now],
|r| { |r| {
@@ -241,30 +306,26 @@ impl QueueWorker {
})) }))
}) })
.await .await
.expect("queue lease worker panicked")
.unwrap_or_else(|e| {
log::error!("queue lease failed: {e}");
None
})
} }
async fn earliest_run_after(&self) -> Option<f64> { async fn earliest_run_after(&self) -> Option<f64> {
let db_path = self.db_path.clone(); let result = crate::db::with_conn(&self.db_path, |conn| {
tokio::task::spawn_blocking(move || -> rusqlite::Result<Option<f64>> { let mut stmt =
let conn = Connection::open(&db_path)?; conn.prepare("SELECT MIN(run_after) FROM tasks WHERE status='pending'")?;
let mut stmt = conn.prepare("SELECT MIN(run_after) FROM tasks WHERE status='pending'")?;
let mut rows = stmt.query([])?; let mut rows = stmt.query([])?;
match rows.next()? { match rows.next()? {
Some(row) => Ok(row.get::<_, Option<f64>>(0)?), Some(row) => Ok(row.get::<_, Option<f64>>(0)?),
None => Ok(None), None => Ok(None),
} }
}) })
.await .await;
.expect("queue timing worker panicked") match result {
.unwrap_or_else(|e| { Ok(v) => v,
Err(e) => {
log::error!("queue timing query failed: {e}"); log::error!("queue timing query failed: {e}");
None None
}) }
}
} }
async fn process(&self, row: LeasedRow) { async fn process(&self, row: LeasedRow) {
@@ -293,12 +354,13 @@ impl QueueWorker {
self.delete_row(&row.id).await; self.delete_row(&row.id).await;
(self.dead_letter)(payload, message).await; (self.dead_letter)(payload, message).await;
} else { } else {
let delay = scaled_retry_delay(delay_seconds, row.attempts);
log::info!( log::info!(
"task {} rescheduled in {delay_seconds:.1}s (attempt {})", "task {} rescheduled in {delay:.1}s (attempt {})",
row.id, row.id,
row.attempts + 1 row.attempts + 1
); );
self.reschedule(&row.id, payload, delay_seconds, row.attempts + 1) self.reschedule(&row.id, payload, delay, row.attempts + 1)
.await; .await;
} }
} }
@@ -311,33 +373,32 @@ impl QueueWorker {
} }
async fn delete_row(&self, id: &str) { async fn delete_row(&self, id: &str) {
let db_path = self.db_path.clone();
let id = id.to_string(); let id = id.to_string();
tokio::task::spawn_blocking(move || -> rusqlite::Result<()> { let result = crate::db::with_conn(&self.db_path, move |conn| {
let conn = Connection::open(&db_path)?;
conn.execute("DELETE FROM tasks WHERE id = ?1", params![id])?; conn.execute("DELETE FROM tasks WHERE id = ?1", params![id])?;
Ok(()) Ok(())
}) })
.await .await;
.expect("queue delete worker panicked") if let Err(e) = result {
.unwrap_or_else(|e| log::error!("queue delete failed: {e}")); log::error!("queue delete failed: {e}");
}
} }
async fn reschedule(&self, id: &str, payload: Value, delay_seconds: f64, attempts: i32) { async fn reschedule(&self, id: &str, payload: Value, delay_seconds: f64, attempts: i32) {
let db_path = self.db_path.clone();
let id = id.to_string(); let id = id.to_string();
let payload = payload.to_string(); let payload = payload.to_string();
tokio::task::spawn_blocking(move || -> rusqlite::Result<()> { let result = crate::db::with_conn(&self.db_path, move |conn| {
let conn = Connection::open(&db_path)?;
conn.execute( conn.execute(
"UPDATE tasks SET payload=?1, run_after=?2, attempts=?3, status='pending', locked_until=0 WHERE id=?4", "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], params![payload, now_f64() + delay_seconds, attempts, id],
)?; )?;
Ok(()) Ok(())
}) })
.await .await;
.expect("queue reschedule worker panicked") if let Err(e) = result {
.unwrap_or_else(|e| log::error!("queue reschedule failed: {e}")); log::error!("queue reschedule failed: {e}");
}
// Same permit semantics as enqueue: never lose the wakeup.
self.notify.notify_one(); self.notify.notify_one();
} }
} }
@@ -347,6 +408,16 @@ mod tests {
use super::*; use super::*;
use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; 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) { async fn new_queue() -> (PersistentTaskQueue, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("queue.db"); let path = dir.path().join("queue.db");
@@ -485,4 +556,41 @@ mod tests {
assert_eq!(calls.load(AtomicOrdering::SeqCst), 1); assert_eq!(calls.load(AtomicOrdering::SeqCst), 1);
queue.stop().await; 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.db_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
+111 -21
View File
@@ -2,10 +2,11 @@
//! `data/task_queue.db`, shared with the task queue). //! `data/task_queue.db`, shared with the task queue).
use parking_lot::Mutex; use parking_lot::Mutex;
use rusqlite::{params, Connection}; use rusqlite::params;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::Path; use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Serialize, Deserialize, Default, Clone, Debug)] #[derive(Serialize, Deserialize, Default, Clone, Debug)]
@@ -34,6 +35,9 @@ pub struct EditMessage {
pub struct ChatStore { pub struct ChatStore {
/// In-memory cache; the DB is the source of truth on first access. /// In-memory cache; the DB is the source of truth on first access.
cache: Mutex<HashMap<i64, ChatData>>, 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<()>>>>,
db_path: String, db_path: String,
} }
@@ -45,7 +49,9 @@ pub fn unix_now() -> i64 {
} }
impl ChatStore { impl ChatStore {
/// Creates the parent directory and both tables (idempotent). /// 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> { pub fn open(path: &str) -> rusqlite::Result<Self> {
if let Some(parent) = Path::new(path).parent() if let Some(parent) = Path::new(path).parent()
&& !parent.as_os_str().is_empty() && !parent.as_os_str().is_empty()
@@ -53,16 +59,14 @@ impl ChatStore {
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
} }
let conn = Connection::open(path)?; let conn = crate::db::open_db(path)?;
conn.execute_batch( conn.execute_batch(
"CREATE TABLE IF NOT EXISTS tasks (id TEXT PRIMARY KEY, payload TEXT NOT NULL, \ "CREATE TABLE IF NOT EXISTS chat_state (chat_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 TABLE IF NOT EXISTS chat_state (chat_id TEXT PRIMARY KEY, payload TEXT NOT NULL);",
)?; )?;
drop(conn); drop(conn);
Ok(ChatStore { Ok(ChatStore {
cache: Mutex::new(HashMap::new()), cache: Mutex::new(HashMap::new()),
locks: Mutex::new(HashMap::new()),
db_path: path.to_string(), db_path: path.to_string(),
}) })
} }
@@ -71,18 +75,19 @@ impl ChatStore {
if let Some(data) = self.cache.lock().get(&chat_id) { if let Some(data) = self.cache.lock().get(&chat_id) {
return data.clone(); return data.clone();
} }
let db_path = self.db_path.clone(); let chat_key = chat_id.to_string();
let payload = tokio::task::spawn_blocking(move || -> rusqlite::Result<Option<String>> { let payload = crate::db::with_conn(&self.db_path, move |conn| {
let conn = Connection::open(&db_path)?; // 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 stmt = conn.prepare("SELECT payload FROM chat_state WHERE chat_id = ?1")?;
let mut rows = stmt.query(params![chat_id.to_string()])?; let mut rows = stmt.query(params![chat_key])?;
match rows.next()? { match rows.next()? {
Some(row) => Ok(Some(row.get(0)?)), Some(row) => Ok(Some(row.get::<_, String>(0)?)),
None => Ok(None), None => Ok(None),
} }
}) })
.await .await
.expect("chat_state worker panicked")
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
log::error!("chat_state read failed: {e}"); log::error!("chat_state read failed: {e}");
None None
@@ -97,18 +102,38 @@ impl ChatStore {
pub async fn set(&self, chat_id: i64, data: &ChatData) { pub async fn set(&self, chat_id: i64, data: &ChatData) {
self.cache.lock().insert(chat_id, data.clone()); self.cache.lock().insert(chat_id, data.clone());
let payload = serde_json::to_string(data).expect("chat state serializes"); let payload = serde_json::to_string(data).expect("chat state serializes");
let db_path = self.db_path.clone(); let chat_id = chat_id.to_string();
tokio::task::spawn_blocking(move || -> rusqlite::Result<()> { let result = crate::db::with_conn(&self.db_path, move |conn| {
let conn = Connection::open(&db_path)?;
conn.execute( conn.execute(
"INSERT OR REPLACE INTO chat_state (chat_id, payload) VALUES (?1, ?2)", "INSERT OR REPLACE INTO chat_state (chat_id, payload) VALUES (?1, ?2)",
params![chat_id.to_string(), payload], params![chat_id, payload],
)?; )?;
Ok(()) Ok(())
}) })
.await .await;
.expect("chat_state worker panicked") if let Err(e) = result {
.unwrap_or_else(|e| log::error!("chat_state write failed: {e}")); 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 /// Removes edit-before-forward records whose `created_at + ttl` is in the
@@ -118,6 +143,10 @@ impl ChatStore {
let now = unix_now(); let now = unix_now();
let ttl_secs = ttl.as_secs() as i64; let ttl_secs = ttl.as_secs() as i64;
let mut removed = Vec::new(); 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 changed: Vec<(i64, ChatData)> = {
let mut cache = self.cache.lock(); let mut cache = self.cache.lock();
let mut out = Vec::new(); let mut out = Vec::new();
@@ -134,18 +163,79 @@ impl ChatStore {
} }
} }
if kept.len() != data.edit_message.len() { 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; data.edit_message = kept;
out.push((*chat_id, data.clone())); 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 out
}; };
for (chat_id, data) in changed { for (chat_id, data) in changed {
self.set(chat_id, &data).await; 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() { if !removed.is_empty() {
log::info!("pruned {} expired edit-before-forward record(s)", removed.len()); log::info!(
"pruned {} expired edit-before-forward record(s)",
removed.len()
);
} }
removed 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"
);
}
}
+58 -14
View File
@@ -1,30 +1,74 @@
services: services:
nginx-proxy:
image: nginxproxy/nginx-proxy:1.11.6-alpine
restart: always
ports:
- '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: tgxmb:
image: yoursfunny/telegram-twitter-media-bot:latest image: yoursfunny/telegram-twitter-media-bot:latest
restart: always restart: always
# ports:
# - "8443:8443"
environment: environment:
# docker-entrypoint.sh drops privileges to this uid.
LOCAL_USER_ID: '1000' LOCAL_USER_ID: '1000'
# Bot token (BotFather). Required.
TELOXIDE_TOKEN: '' TELOXIDE_TOKEN: ''
# Comma-separated admin chat ids; receives startup/shutdown notices.
BOT_ADMIN: '' BOT_ADMIN: ''
# Required for pixiv support; pixiv is disabled when unset.
PIXIV_REFRESH_TOKEN: '' PIXIV_REFRESH_TOKEN: ''
# Edit-before-forward records expire after this many seconds (default 86400 = 24h). TWITTER_AUTH_TOKEN: ''
EDIT_MESSAGE_TTL_SECONDS: '86400' EDIT_MESSAGE_TTL_SECONDS: '86400'
LINK_CACHE_TTL_SECONDS: '604800'
RUST_LOG: 'info' RUST_LOG: 'info'
# Webhook mode is off by default (polling). The listener binds inside the VIRTUAL_HOST: '<YOUR_DOMAIN>'
# container, so use 0.0.0.0 and publish the port if you enable it. VIRTUAL_PORT: '8443'
WEBHOOK: 'false' # ACME_HOST: 'your.domain.com'
WEBHOOK: 'true'
WEBHOOK_LISTEN: '0.0.0.0' WEBHOOK_LISTEN: '0.0.0.0'
WEBHOOK_PORT: '8443' WEBHOOK_PORT: '8443'
WEBHOOK_URL: 'https://example.com' WEBHOOK_URL: 'https://<YOUR_DOMAIN>/'
WEBHOOK_CERT: './cert/cert.pem' WEBHOOK_SECRET_TOKEN: ''
WEBHOOK_SECRET_TOKEN: 'secret-token'
volumes: volumes:
- ./data:/app/data - ./data:/app/data
# - ./cert:/app/cert networks: [proxy]
depends_on:
- nginx-proxy
container_name: tgxmb 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
+14 -3
View File
@@ -5,9 +5,20 @@ if [ "$(id -u)" -eq '0' ]
then then
USER_ID=${LOCAL_USER_ID:-9001} USER_ID=${LOCAL_USER_ID:-9001}
useradd --shell /bin/bash -u ${USER_ID} -o -c "" -m user > /dev/null 2>&1 # `docker compose restart` / `docker restart` reuse the same container, so
usermod -a -G root user > /dev/null 2>&1 # the overlay fs keeps the user created on first boot. A second `useradd`
chown -R `id -u user`:`id -u user` /app > /dev/null 2>&1 # 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 export HOME=/home/user
# setpriv (util-linux, present in bookworm-slim) replaces gosu: drop to the # setpriv (util-linux, present in bookworm-slim) replaces gosu: drop to the