YoursFunny 0a82ca5a42 fix(retry): let slow downloads finish, and never re-run a finished task
P1 of the retry audit, from the report's "reliability and diagnosis" batch.

- Media downloads no longer share the 30s *total* timeout of metadata
  fetches. The size caps allowed 10 MiB (reupload fallback) and 512 MiB
  (ugoira frame zip) while the clock allowed 30s, so a slow link made those
  posts impossible: `MEDIA_CLIENT` has no total timeout and instead bounds
  the response head and every chunk with a 30s *idle* window, which keeps the
  stalled-connection protection. Verified against a local probe: the old
  policy aborts a 40s download at 30.0s, the new one completes it (2 MiB,
  40.1s), and a body that stops delivering still fails after exactly 30s.
- A finished row's write-back is no longer best-effort. `delete_row` failing
  left the row `in_progress` with a live lease, so the next sweep flipped it
  back to `pending` and re-ran a completed task — a second album, a second
  prompt, a second channel copy. Both terminal writes are now retried, and a
  delete that still fails falls back to a `done` tombstone that neither the
  lease query nor the sweep looks at; reschedule (no safe tombstone: marking
  it done would drop the retry silently) logs what the sweep will do.
- bsky and pixiv no longer present a *failed* video conversion as a post with
  no media: the remux/ugoira error propagates (pixiv keeps its retry class,
  bsky reports Transient), so the user sees the real cause and `fetch` gets
  its retries. bsky's "no ffmpeg" case stays a degradation — retrying a
  deployment gap cannot help.
- pixiv's token exchange checks the HTTP status before parsing the body, so a
  429/5xx from the OAuth endpoint stays retryable instead of becoming a
  permanent Api/Json error (via the shared `pixiv_error_is_retryable`), and
  startup validation only disables pixiv for a rejected credential — one 503
  while the container came up used to turn every later pixiv link into
  "pixiv support is disabled".
2026-09-20 21:02:46 +08:00
2026-09-20 17:41:20 +08:00
2024-03-22 16:19:13 +08:00

TelegramXMediaBot

A Telegram bot that turns post links from X / Twitter, Pixiv, Bluesky, Misskey (misskey.io), and Bilibili dynamics into media messages (images, video, GIF) with the post's title, author, and tags.

Features

  • Sending a link in a private chat fetches and sends the images, videos and GIFs automatically; oversized media is split into batches (10 items per group)
  • Text-only posts report "no media"; unsupported links are silently ignored. Fetch failures name the reason (post gone / content withheld / source risk control / site not enabled)
  • Long posts (text ≥ CAPTION_QUOTE_TEXT_CHARS, default 200) show the text part of their caption inside a collapsible blockquote, with the link and author line left outside it
  • Inline queries (@bot <link>) — except Pixiv images and locally transcoded animations, which Telegram cannot fetch (no Referer) and would show broken, so they are skipped; a supported link posted in a group gets a one-line hint to use the private chat or inline mode (channels stay silent)
  • /start explains the supported sites and how to use it; /help lists the commands plus argument syntax, the caption placeholders and the private-chat rule; the bot's profile description texts are set at startup
  • /settings shows this chat's configuration (forward channel, edit-before-forward, per-site caption formats, saved templates); templates are added with /set_template and removed with /remove_template
  • Bind a forward channel for automatic forwarding; edit the caption before forwarding and apply custom templates (the prompt carries Confirm / Skip buttons, states its expiry, and is marked expired in place once it lapses)
  • Failed sends are retried automatically with persistence; the notice names which link failed, how long the retry waits, or the final cause
  • The chat action stays on screen for the whole fetch, so long jobs (ugoira transcode, large uploads) do not look stalled
  • Pixiv ugoira animations are transcoded to MP4; Bluesky videos are remuxed (HLS stream → MP4)
  • Photos exceeding Telegram's size/dimension limits are compressed automatically (original format kept, JPEG fallback only when needed)
  • Link-result cache: after a successful send the Telegram file ids and caption fields are cached locally, so a repeated link is re-sent from local state — no source-site request, no media file stored (expiry controlled by LINK_CACHE_TTL_SECONDS, default 7 days)

Quick start

# Required: BotFather token; optional: PIXIV_REFRESH_TOKEN (Pixiv is disabled without it)
export TELOXIDE_TOKEN=<token>
export PIXIV_REFRESH_TOKEN=<token>

cargo run -p xmedia-bot

Docker deployment (see docker-compose.yml.example):

docker build -t tgxmb .
docker run --rm -d --name tgxmb --env-file .env -v ./data:/app/data tgxmb

Environment variables: TELOXIDE_TOKEN (required), PIXIV_REFRESH_TOKEN, BOT_ADMIN, EDIT_MESSAGE_TTL_SECONDS, LINK_CACHE_TTL_SECONDS, RUST_LOG, TELOXIDE_PROXY, WEBHOOK*, TWITTER_AUTH_TOKEN (optional), BILIBILI_COOKIE (optional).

NSFW tweets: the public syndication endpoint does not return sensitive content. Setting TWITTER_AUTH_TOKEN (the auth_token cookie value of a logged-in x.com session) lets the bot fetch NSFW media in the logged-in state only when it hits a withheld tweet; without it the bot answers that the post's media is withheld and needs TWITTER_AUTH_TOKEN.

Bilibili dynamics are fetched anonymously by default (no login; the bot fetches bilibili's anonymous buvid3/buvid4 device cookies itself to raise the success rate). If the server's egress IP gets hard-flagged by bilibili (persistent risk control (-352) log lines or HTTP 412), set BILIBILI_COOKIE (the whole cookie string from a logged-in browser, e.g. SESSDATA=…; bili_jct=…) to restore access. Only a dynamic's images and animations are sent; an attached video degrades to its cover image.

Webhook deployment (needs a reverse proxy)

docker-compose.yml.example ships an nginx-proxy + acme-companion reverse-proxy orchestration. Pick one deployment shape:

With a domain

  1. Point a DNS A record at the server
  2. In compose set VIRTUAL_HOST and WEBHOOK_URL to the domain, and uncomment ACME_HOST (set it to the domain)
  3. acme-companion issues and renews certificates automatically — nothing manual

IP only Let's Encrypt can issue certificates for public IPs (available since 2026, validity ~7 days, requires the shortlived profile). Use acme.sh to issue and renew automatically, no manual certificates:

  1. Add an acme-ip service to compose (issue + daily auto-renewal check):

    acme-ip:
      image: neilpang/acme.sh
      container_name: acme-ip
      command: daemon
      restart: always
      volumes:
        - certs:/acme.sh
        - html:/usr/share/nginx/html
        - /var/run/docker.sock:/var/run/docker.sock:ro
      networks: [proxy]
    
  2. First issuance (replace <SERVER_IP> with the server's public IP; IPv6 works too, repeat -d for more):

    docker compose exec acme-ip acme.sh --issue --server letsencrypt \
      -d <SERVER_IP> --cert-profile shortlived --days 3 \
      --webroot /usr/share/nginx/html \
      --install-cert --cert-file /acme.sh/<SERVER_IP>.crt \
      --key-file /acme.sh/<SERVER_IP>.key \
      --reloadcmd "curl --unix-socket /var/run/docker.sock -X POST http://localhost/containers/nginx-proxy/kill?signal=HUP"
    
  3. In compose set VIRTUAL_HOST: '<SERVER_IP>' and WEBHOOK_URL: 'https://<SERVER_IP>/'; no WEBHOOK_CERT needed. Renewal is handled by the acme.sh daemon (--days 3 = renew every 3 days, buffer against the 7-day validity), and a successful renewal HUP-notifies nginx-proxy to load the new certificate.

    Limitations: certificate validity ~7 days; only http-01/tls-alpn-01 validation (port 80 must be publicly reachable); no DNS-01, private IPs or IP ranges; at most 5 certificates per 168 hours for the same IP set. It is recommended to trial-issue with --server letsencrypt_test first, then switch to the production server.

Telegram only accepts ports 443/80/88/8443.

Environment variables
Variable Description
TELOXIDE_TOKEN Bot token (required)
PIXIV_REFRESH_TOKEN Pixiv refresh token; Pixiv is disabled without it (a pixiv link then gets an explicit "site not enabled" reply instead of silence)
BILIBILI_COOKIE Optional bilibili cookie string (SESSDATA=…; bili_jct=…); only needed when the egress IP stays risk-controlled (device cookies are fetched automatically)
BOT_ADMIN Admin chat IDs, comma-separated; receives start/stop notifications
EDIT_MESSAGE_TTL_SECONDS Edit-before-forward record expiry in seconds, default 86400; once lapsed the prompt is rewritten in place to "expired — nothing was forwarded" (no extra message)
LINK_CACHE_TTL_SECONDS Link-result cache expiry in seconds, default 604800 (7 days)
CAPTION_QUOTE_TEXT_CHARS The text part of the caption (the joined {title} + {content}) is wrapped in a collapsible blockquote once it reaches this many characters, default 200; 0 disables
DATA_DIR Data directory (where the SQLite task_queue.db lives), default data (relative to the working directory, created automatically)
RUST_LOG Log level, default info,hyper_util=warn,reqwest=warn (an unset variable no longer silences the log). Recipes: info,xmedia_bot=debug,x_media=debug (app detail, no dependency noise) / debug,hyper_util=off (everything) / trace (also prints full links and message text — user data)
TELOXIDE_PROXY HTTP proxy (e.g. http://127.0.0.1:10808); applies to both the Telegram Bot API and site fetches — required on restricted networks (e.g. behind the GFW)
LOCAL_USER_ID UID the container runs as, default 9001
VIRTUAL_HOST Public domain or IP; nginx-proxy routes by this
VIRTUAL_PORT Port the bot listens on inside the container; nginx-proxy's forwarding target
ACME_HOST Domain deployment: when set to the domain, acme-companion issues/renews certificates automatically
DEFAULT_HOST nginx-proxy routes requests with unknown Host headers to this vhost (needed for IP access)
DEFAULT_EMAIL acme-companion certificate notification email
WEBHOOK true enables webhook mode (polling by default)
WEBHOOK_LISTEN / WEBHOOK_PORT Listen address/port inside the bot container
WEBHOOK_URL Public HTTPS URL (https://domain/ or https://IP/)
WEBHOOK_CERT Optional; self-signed certificate path, only used for Telegram-side validation (TLS is terminated by the reverse proxy)
WEBHOOK_SECRET_TOKEN Update validation token (X-Telegram-Bot-Api-Secret-Token)

Commands

Command Description
/start Welcome message
/help List all commands and usage (this command table)
/set_forward_channel <channel> Set the forward channel: @channel or channel ID; media messages are forwarded to it automatically afterwards
/remove_forward_channel Remove the forward channel
/edit_before_forward Toggle "edit before forward": when enabled, the bot posts a prompt after forwarding; replying to it edits the first forwarded message's caption (or tapping a template button applies one), then ↩️ Confirm forwards and 🛑 Skip drops this forward; the prompt states its expiry and is marked expired in place when it lapses (nothing is forwarded)
/set_template <name> Reply to a message containing [] to save it as a named template; [] is replaced by the original post link when forwarding (used with "edit before forward")
/remove_template <name> Remove a template (names are listed by /settings; the prompt's keyboard shows at most 60)
/settings Show this chat's configuration: forward channel, edit-before-forward, per-site caption formats, saved templates
/set_format <site> <format> Customize the caption format for one site. Sites: twitter / bsky / pixiv / misskey / bilibili. Placeholders: {url} {author} {author_url} {title} {content} {tags}; unknown placeholders are rejected with the list of valid ones, and - restores the site's built-in format (preview with /debug <link>)
/clear_cache [link] Clear the link cache (admin only); with a link only that entry, otherwise everything
/bot_dict Show the current chat state (debugging; admin only)
/test <link> Parse a link and send its media; no channel forward, no edit-before-forward prompt (send only)
/debug <link> Debug: parse a link and report the parse result only (site, title, author, tags, media list) — no media is sent

Link processing works only in private chats; commands work in any chat. A supported link posted in a group gets a one-line hint to use the private chat or inline mode; channels stay silent.

Notes

  • State is persisted in data/task_queue.db; compose deployments use the bind mount ./data (keep it a directory for easy backups)
  • The runtime needs ffmpeg (built into the Docker image)
  • Tests: cargo test --workspace
S
Description
No description provided
Readme MIT
1 MiB
Languages
Rust 99.2%
Dockerfile 0.6%
Shell 0.2%