mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-23 23:32:05 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10a672787a
|
||
|
|
9eb865bb02
|
||
|
|
74ffe66884
|
||
|
|
f8796913e5
|
||
|
|
d60f849864
|
||
|
|
a981256b11
|
||
|
|
8c085bea35
|
||
|
|
670351d436
|
||
|
|
64cf43dc01
|
||
|
|
62507d3a01
|
||
|
|
667f523c8b
|
||
|
|
507c8ac317
|
||
|
|
670a7bd02b
|
||
|
|
5ff921222a
|
||
|
|
0edaef56bd
|
||
|
|
994734b001
|
||
|
|
a501a17519
|
||
|
|
edf4dab26d
|
||
|
|
818a44d697
|
||
|
|
e31bf92df7
|
||
|
|
a1c452f19d
|
||
|
|
362eb9e729
|
||
|
|
35074bab67
|
||
|
|
1fb7837255
|
||
|
|
4e723e1657
|
||
|
|
cd8b5ac67b
|
||
|
|
bd5a6846e8
|
||
|
|
da8fde6a4e
|
||
|
|
3b946b1eab
|
||
|
|
d540fc31e9
|
||
|
|
024dfd50b3
|
||
|
|
3828d5b483
|
||
|
|
39dbd0f3a2
|
||
|
|
d3560dca52
|
||
|
|
24cbfc2f27
|
||
|
|
4cdf618c25
|
||
|
|
0a82ca5a42
|
||
|
|
4cf793cd7e
|
||
|
|
36e5e8afe6
|
||
|
|
3f9821d475
|
||
|
|
9e873131d4
|
||
|
|
5b77d14497
|
||
|
|
d4c36feb9a
|
||
|
|
5d0acdac01
|
||
|
|
d6707133cc
|
||
|
|
fb601f4d5d
|
||
|
|
d8dd4fa91e
|
||
|
|
af96caff40
|
||
|
|
52184ba6fb
|
||
|
|
0eb4e5c78d
|
||
|
|
5c51de217a
|
||
|
|
c1f5d3ca54
|
||
|
|
1bb6968108 | ||
|
|
14b444d109
|
||
|
|
de3105d4cd | ||
|
|
c46103a23f | ||
|
|
9529f64b41
|
||
|
|
d0de17329d | ||
|
|
9af37e92b4 | ||
|
|
e68a1dbd30 | ||
|
|
b9c6d16ff0
|
||
|
|
ec65c3ce74
|
||
|
|
893ab7a1e0
|
||
|
|
bd032e3d68
|
||
|
|
dbda6ec1c2
|
||
|
|
0a9ff58a69
|
||
|
|
c2d7c8406e
|
||
|
|
abdc27ed5e
|
||
|
|
3fb8421c3a
|
||
|
|
475cfd18f9
|
||
|
|
0a577600fd
|
||
|
|
32254fa807
|
||
|
|
11c04b66dc
|
||
|
|
2f741e5f4b
|
||
|
|
89c4642e1c
|
||
|
|
3f6a0f034a
|
||
|
|
90a011e978
|
||
|
|
12a065846c
|
||
|
|
dca1eff1c9
|
||
|
|
894a9ebf4a
|
||
|
|
c968891ff6
|
||
|
|
0087bd01ac
|
||
|
|
4cb40909c5
|
||
|
|
f260f41755
|
||
|
|
af901caddb
|
||
|
|
c0af42b1cc
|
||
|
|
d0810217b4
|
||
|
|
e6ba178983
|
||
|
|
ae69d72930
|
||
|
|
1e30815a10
|
||
|
|
50206a9056
|
||
|
|
c9e72fda70
|
||
|
|
f6845b1b5c
|
||
|
|
fae8dc6f2d
|
||
|
|
ac72e414c3
|
||
|
|
8b3b2a246b
|
||
|
|
6f6898c245
|
||
|
|
69698992d5
|
||
|
|
1e77bb0478
|
||
|
|
8f2b0a1dcb
|
||
|
|
b65fb967c4
|
||
|
|
a8fd685777
|
||
|
|
5679a8c172
|
||
|
|
bf4e6159b3
|
||
|
|
5e23916b40
|
||
|
|
7ca8fd1da2
|
||
|
|
96c11becb9
|
||
|
|
5830a3f013
|
||
|
|
183bb7e435
|
||
|
|
2a8433a8d2
|
||
|
|
6b3e61881d
|
||
|
|
47935dd7c6
|
||
|
|
6911e9146e
|
||
|
|
aa705aef90
|
||
|
|
39260a8817
|
||
|
|
6b9640aa48
|
||
|
|
ad59f518ff
|
||
|
|
e21643063e
|
||
|
|
246fc989f0
|
||
|
|
2e2d1b3506
|
||
|
|
1747d321d8
|
||
|
|
505990e49e
|
||
|
|
95b475ff08
|
||
|
|
2297fdc91c
|
||
|
|
4580b79d4f
|
||
|
|
edb32c23b4
|
||
|
|
4a467641aa
|
||
|
|
bd43a12dee
|
||
|
|
c496e41c55
|
||
|
|
68f026c990
|
||
|
|
62d80c8905
|
||
|
|
78c9c841c6
|
||
|
|
e49d500d23
|
||
|
|
6feabd723b
|
||
|
|
ea72516d5c
|
||
|
|
755330e585
|
||
|
|
c40b074b3c
|
||
|
|
9910da2914
|
||
|
|
ebc0122264
|
||
|
|
44cba8abe0
|
||
|
|
99009aae9a
|
||
|
|
72130b9023
|
||
|
|
734cfc2eb3
|
||
|
|
a8156697fa
|
||
|
|
c093dfe5ac
|
||
|
|
ee6f3e4a27
|
||
|
|
16ed53fead
|
||
|
|
1d9e3629c9
|
||
|
|
b3d87b4f7d
|
||
|
|
98c48b99c0
|
||
|
|
f40639c799
|
||
|
|
51cc079a85
|
||
|
|
aa3083792a
|
||
|
|
6849006ad7
|
||
|
|
042a04ab6e
|
||
|
|
9f28af4e6b
|
||
|
|
d61dba5096
|
||
|
|
f6df3e28cb
|
||
|
|
deb1ef2428
|
||
|
|
b5e5340edc
|
||
|
|
425d1505cf
|
||
|
|
6e40f55440
|
||
|
|
7998114dc3
|
||
|
|
9a96f78177
|
||
|
|
b50f794d52
|
||
|
|
d32fa969d6
|
||
|
|
020e2d01a3
|
||
|
|
3d6f8548c3
|
||
|
|
063e910473
|
||
|
|
b0ced34b4c
|
||
|
|
4060a88031
|
||
|
|
fb43441c56
|
||
|
|
bf628dc999
|
||
|
|
de22aa9b4d
|
||
|
|
65a9554173
|
||
|
|
e755785147
|
||
|
|
5d5b6d56e7
|
||
|
|
d0fdf1c5da
|
||
|
|
1db4ecfafa
|
||
|
|
47039bbe2d
|
||
|
|
bc954e6e0b
|
||
|
|
f7cb809e5a
|
||
|
|
51b40cdb42
|
||
|
|
ab2306002a
|
||
|
|
a92b12f633
|
||
|
|
74e3b7593c
|
||
|
|
2faccaac42
|
||
|
|
f4e60d8946
|
||
|
|
3006dcd98c
|
||
|
|
8e8acdd859
|
||
|
|
8b9dd963e1
|
||
|
|
e83d48f1f7
|
||
|
|
7c55b26731
|
||
|
|
950db48a13
|
||
|
|
32ea8ec6ca
|
||
|
|
62dc033452
|
||
|
|
2801aaa39c
|
||
|
|
93d47752cf
|
||
|
|
6fd4edb3c5
|
||
|
|
7c5afce0b4
|
||
|
|
caf41183a4
|
||
|
|
b6ae88e869
|
||
|
|
3dff9a2473
|
||
|
|
41f25052fe
|
||
|
|
fa95091c7c
|
||
|
|
84ab146069
|
||
|
|
e14363dd1d
|
||
|
|
0cffd5c297
|
||
|
|
b83bed5423
|
||
|
|
0f3131aec7
|
||
|
|
5483855de8
|
||
|
|
052163084b
|
||
|
|
cdee105c60
|
||
|
|
fae3dad43a
|
||
|
|
f4c53719d8
|
||
|
|
cf44c3ddd7
|
||
|
|
cbb15e6a33
|
||
|
|
afc31430e7
|
||
|
|
282cf46d58
|
||
|
|
6c53a1429d
|
||
|
|
ca9064a1b7
|
||
|
|
d58fd7cd46
|
||
|
|
9aa7aa1c36
|
||
|
|
9c2606160a
|
||
|
|
3fe8a1cf3b
|
||
|
|
62a2820af3
|
||
|
|
80b630d28d
|
||
|
|
4173972407
|
||
|
|
0d2648162c
|
||
|
|
0847ada3b7
|
||
|
|
7cad25125f
|
||
|
|
f0c287a148
|
||
|
|
3658bfd2aa
|
||
|
|
ae1b04dee8
|
||
|
|
641f7218a4
|
||
|
|
61dfa16011
|
||
|
|
2986e80076
|
||
|
|
f112ecdf63
|
||
|
|
79d50ae9a3
|
||
|
|
416331303d
|
||
|
|
55771e01fe
|
||
|
|
afca276f49
|
||
|
|
06d25854d2
|
||
|
|
515aa9711d
|
||
|
|
733adfc4a6
|
||
|
|
03066853de
|
||
|
|
566c17a855
|
||
|
|
87f0d16028
|
||
|
|
197993e522
|
||
|
|
02abcd899e
|
||
|
|
ebb48d2bb5
|
||
|
|
be77a33e9d
|
||
|
|
0439adefa0
|
||
|
|
81e5d2dbd1
|
||
|
|
e7ccd70e56
|
||
|
|
23128af96a
|
||
|
|
fe1724aca4
|
||
|
|
0433650563
|
||
|
|
a617a7e1a0
|
||
|
|
e35e3895ba
|
||
|
|
a4a9f2eaa0
|
||
|
|
1c779e98a8
|
||
|
|
4a5aa296ce
|
||
|
|
c1c1d42892
|
||
|
|
cade370538
|
||
|
|
a6325d5bc8
|
||
|
|
6c0e581135
|
||
|
|
494fc50446
|
||
|
|
a4e568667c
|
||
|
|
8231eeb20e
|
||
|
|
74f42a2df3
|
||
|
|
ae1f5a8c8c
|
||
|
|
38adbf0cb8
|
||
|
|
55ae73ee6b
|
||
|
|
d6589fec5b
|
||
|
|
91cad86325
|
||
|
|
cfdb05e476
|
||
|
|
6df6a950c8
|
||
|
|
78d185723b
|
||
|
|
1252ec8804
|
||
|
|
cde1091c85
|
||
|
|
8b2b6b49ab
|
||
|
|
86b4a972c9
|
||
|
|
483a3de649
|
||
|
|
48bc81898d
|
+10
-1
@@ -24,8 +24,17 @@
|
||||
**/secrets.dev.yaml
|
||||
**/values.dev.yaml
|
||||
*.db
|
||||
.python-version
|
||||
LICENSE
|
||||
README.md
|
||||
# Documentation and scratch files: the build only ever reads the manifests,
|
||||
# `crates/` and the entrypoint script.
|
||||
docs/
|
||||
*.md
|
||||
data/
|
||||
cert/
|
||||
nginx-certs/
|
||||
nginx-vhost.d/
|
||||
nginx-html/
|
||||
nginx-acme/
|
||||
**/target/
|
||||
.idea/
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Copy to `.env` (gitignored) and fill in:
|
||||
#
|
||||
# cp .env.example .env
|
||||
#
|
||||
# `docker compose` reads it for the `${VAR}` substitutions in
|
||||
# docker-compose.yml, and `cargo run` reads it through dotenv. Every variable is
|
||||
# described in README.md ("环境变量说明" / "Environment variables") — this file
|
||||
# only shows the shape, with the defaults the code would use anyway.
|
||||
|
||||
# --- required -------------------------------------------------------------
|
||||
# Token from @BotFather. Without it the bot exits at startup.
|
||||
TELOXIDE_TOKEN=
|
||||
|
||||
# --- sites (all optional) -------------------------------------------------
|
||||
# Pixiv: refresh token. Unset = pixiv links answer "support is disabled".
|
||||
PIXIV_REFRESH_TOKEN=
|
||||
# Twitter/X: the `auth_token` cookie of a logged-in session, used only for
|
||||
# NSFW tweets that the public syndication endpoint withholds.
|
||||
TWITTER_AUTH_TOKEN=
|
||||
# bilibili: the whole cookie string; only needed when the egress IP stays
|
||||
# risk-controlled (device cookies are fetched automatically).
|
||||
BILIBILI_COOKIE=
|
||||
|
||||
# --- bot behaviour --------------------------------------------------------
|
||||
# Admin chat IDs, comma-separated: start/stop notices, admin-only commands.
|
||||
BOT_ADMIN=
|
||||
# Log level. Leave the line commented out for the default
|
||||
# (`info,hyper_util=warn,reqwest=warn`); do not set it to an empty value.
|
||||
# RUST_LOG=info,xmedia_bot=debug,x_media=debug
|
||||
# Edit-before-forward record TTL (seconds).
|
||||
EDIT_MESSAGE_TTL_SECONDS=86400
|
||||
# Link-result cache TTL (seconds).
|
||||
LINK_CACHE_TTL_SECONDS=604800
|
||||
# Wrap a post's text in a collapsible blockquote from this many characters on;
|
||||
# 0 disables the wrap.
|
||||
CAPTION_QUOTE_TEXT_CHARS=200
|
||||
# State directory (local runs only — the container uses /app/data).
|
||||
DATA_DIR=data
|
||||
|
||||
# --- network --------------------------------------------------------------
|
||||
# HTTP proxy for the Bot API and site fetches. Two traps: teloxide panics on a
|
||||
# *blank* value, so comment the line out rather than leaving it empty; and
|
||||
# inside a container the proxy must be reachable from there (use
|
||||
# host.docker.internal, not 127.0.0.1 — that is the container itself).
|
||||
# docker-compose.yml does not pass this variable unless you add it to the bot
|
||||
# service's `environment:` block.
|
||||
# TELOXIDE_PROXY=http://127.0.0.1:10808
|
||||
|
||||
# --- webhook deployment (docker-compose.yml) ------------------------------
|
||||
# false = long polling (no public URL needed). true = webhook behind the
|
||||
# bundled nginx-proxy — and then WEBHOOK_LISTEN/PORT/URL are required.
|
||||
WEBHOOK=false
|
||||
# WEBHOOK_LISTEN=0.0.0.0
|
||||
# WEBHOOK_PORT=8443
|
||||
# WEBHOOK_URL=https://your.domain/
|
||||
# Validation token Telegram echoes back as X-Telegram-Bot-Api-Secret-Token.
|
||||
# WEBHOOK_SECRET_TOKEN=
|
||||
# Self-signed certificate path, used only for Telegram-side validation (TLS is
|
||||
# terminated by the reverse proxy); unneeded with acme-companion. Not passed by
|
||||
# docker-compose.yml — add the line there if this deployment needs it.
|
||||
# WEBHOOK_CERT=/app/cert/cert.pem
|
||||
|
||||
# --- reverse proxy (docker-compose.yml) -----------------------------------
|
||||
# Public domain or IP that nginx-proxy routes for; empty = do not route.
|
||||
VIRTUAL_HOST=
|
||||
# Port inside the bot container nginx-proxy forwards to.
|
||||
VIRTUAL_PORT=8443
|
||||
# Certificate notification address for acme-companion.
|
||||
DEFAULT_EMAIL=
|
||||
# UID the container runs as; it must be able to write ./data on the host.
|
||||
LOCAL_USER_ID=1000
|
||||
# Uncomment (here and the matching line in docker-compose.yml) to have
|
||||
# acme-companion issue the certificate for VIRTUAL_HOST.
|
||||
# ACME_HOST=
|
||||
# Send requests with an unknown Host to this vhost (needed for plain-IP access).
|
||||
# DEFAULT_HOST=
|
||||
@@ -0,0 +1,2 @@
|
||||
# Shell scripts must stay LF: CRLF breaks the shebang inside containers.
|
||||
*.sh text eol=lf
|
||||
@@ -0,0 +1,32 @@
|
||||
version: 2
|
||||
|
||||
# Pairs with the `actions-rust-lang/audit` gate in ci.yml: the gate reports
|
||||
# advisories in Cargo.lock, this is what actually moves the dependencies.
|
||||
# Patch bumps are batched into one PR; minor/major stay separate so they get
|
||||
# reviewed and tested individually.
|
||||
updates:
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
cargo-patch:
|
||||
applies-to: version-updates
|
||||
patterns: ['*']
|
||||
update-types: ['patch']
|
||||
|
||||
# The workflow actions are pinned to commit SHAs; that pin is what makes
|
||||
# bumping them a manual chore, so let the bot do it.
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# The Dockerfile's base images (rust:1-bookworm, debian:bookworm-slim).
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: monthly
|
||||
open-pull-requests-limit: 3
|
||||
@@ -0,0 +1,154 @@
|
||||
name: CI
|
||||
|
||||
# Test/lint gate (offline, no secrets) on every push/PR, plus a live-network
|
||||
# job that exercises the real source sites and the token-gated pixiv tests.
|
||||
#
|
||||
# Layering:
|
||||
# changes — decides whether anything but documentation changed; a docs-only
|
||||
# push/PR skips `test` (which then reports as skipped, not missing).
|
||||
# test — fmt + clippy + the full offline unit suite + a release-profile
|
||||
# build + cargo-audit dependency gate. Runs on every push and PR,
|
||||
# including forks (it needs no secrets).
|
||||
# live — the #[ignore]d live-network tests plus the pixiv tests that are
|
||||
# gated on PIXIV_REFRESH_TOKEN. Runs on schedule / manual dispatch
|
||||
# / tag pushes only, because pull requests from forks cannot read
|
||||
# repository secrets. continue-on-error keeps a flaky external site
|
||||
# from blocking, while the run still records the outcome.
|
||||
#
|
||||
# Every action is pinned to a commit SHA (Dependabot keeps the pins current);
|
||||
# `dtolnay/rust-toolchain` deliberately stays on its channel ref, because the
|
||||
# ref itself is what selects the toolchain (`@stable` = install stable).
|
||||
#
|
||||
# Test gating convention (keep in sync with AGENTS.md "Testing & QA"):
|
||||
# - pure unit tests: plain #[test] / #[tokio::test], always run.
|
||||
# - live-network tests: #[ignore = "live network: ..."], only run here.
|
||||
# - token-gated tests (pixiv): #[tokio::test] with an early return when
|
||||
# PIXIV_REFRESH_TOKEN is absent or empty (empty = unset CI secret).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
schedule:
|
||||
# Weekly probe of the live endpoints, so external API changes surface.
|
||||
- cron: '0 3 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to the same ref supersedes the older run; without this every
|
||||
# intermediate commit of a PR branch keeps a runner busy to completion.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# Panicking tests print their backtrace; free when nothing fails.
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
# Docs-only changes skip the heavy job: a README edit does not need a four
|
||||
# minute Rust build (and it cannot break one). A gate job rather than a
|
||||
# workflow-level `paths` filter — that leaves the run without a `test` check
|
||||
# at all, and a required status check then waits for something that will
|
||||
# never be reported, while a *skipped* job reports as neutral.
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
code: ${{ steps.diff.outputs.code }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0 # the diff below needs the pushed range
|
||||
- id: diff
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
zero=0000000000000000000000000000000000000000
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
base="origin/${{ github.base_ref }}"
|
||||
git fetch --quiet --no-tags origin "${{ github.base_ref }}"
|
||||
changed="$(git diff --name-only "$base...HEAD")"
|
||||
else
|
||||
before="${{ github.event.before }}"
|
||||
if [ -z "$before" ] || [ "$before" = "$zero" ]; then
|
||||
# New branch or force push: no usable base to compare against,
|
||||
# so the full suite runs. Same for schedule/dispatch, which have
|
||||
# no `before` at all.
|
||||
changed=""
|
||||
else
|
||||
changed="$(git diff --name-only "$before..${{ github.sha }}")"
|
||||
fi
|
||||
fi
|
||||
# Only a change that is *entirely* markdown may skip the job;
|
||||
# anything else — and an empty diff, i.e. a re-run of the same
|
||||
# commit — counts as code.
|
||||
code=true
|
||||
if [ -n "$changed" ] && ! grep -qvE '\.md$' <<<"$changed"; then
|
||||
code=false
|
||||
fi
|
||||
echo "changed: ${changed:-<no diff>}"
|
||||
echo "code=$code" >> "$GITHUB_OUTPUT"
|
||||
|
||||
test:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# Generous on purpose: the release-profile build below is cold on the very
|
||||
# first run (thin LTO + codegen-units = 1 across every dependency), and a
|
||||
# timeout there would kill the job *before* rust-cache saves its cache —
|
||||
# leaving every later run cold again.
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
# `--locked` on every cargo invocation: the version bump edits
|
||||
# Cargo.lock by hand (AGENTS.md), so a stale lock must fail here instead
|
||||
# of being silently re-resolved — otherwise CI tests a different
|
||||
# dependency set than the one committed, and than the one the released
|
||||
# image is built from.
|
||||
- name: Check formatting
|
||||
run: cargo fmt --check
|
||||
- name: Lint (deny warnings)
|
||||
run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
- name: Run offline tests
|
||||
run: cargo test --workspace --locked
|
||||
# The release profile (lto/strip/codegen-units=1, overflow checks off)
|
||||
# was otherwise only exercised by the Docker build on master/tag. Same
|
||||
# package the Dockerfile builds; the cache keeps it cheap after the
|
||||
# first run.
|
||||
- name: Build release profile
|
||||
run: cargo build --release --locked -p xmedia-bot
|
||||
# Dependency vulnerability gate: fails the build when a crate in
|
||||
# Cargo.lock has an unfixed security advisory. Unmaintained/unsound
|
||||
# *warnings* (dotenv, proc-macro-error2, anyhow transitive) do not fail
|
||||
# the build by default; the advisory DB is cached across runs.
|
||||
- name: Audit dependencies
|
||||
uses: actions-rust-lang/audit@72c09e02f132669d52284a3323acdb503cfc1a24 # v1
|
||||
|
||||
live:
|
||||
needs: test
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
continue-on-error: true
|
||||
env:
|
||||
PIXIV_REFRESH_TOKEN: ${{ secrets.PIXIV_REFRESH_TOKEN }}
|
||||
TWITTER_AUTH_TOKEN: ${{ secrets.TWITTER_AUTH_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
# Everything network- or secret-gated lives in x-media, and the bot
|
||||
# crate's suite (MockSender + tempdir stores, no network) already ran in
|
||||
# the `test` job — rebuilding it here bought nothing.
|
||||
- name: Run token-gated tests
|
||||
run: cargo test -p x-media --locked
|
||||
# The live-network tests, by the "live" name filter (all #[ignore]d).
|
||||
- name: Run live-network tests
|
||||
run: cargo test -p x-media --locked -- --ignored live
|
||||
+133
-10
@@ -1,23 +1,127 @@
|
||||
name: Build Docker Image
|
||||
|
||||
# Release builds (master / v* tags) plus a build-only check on pull requests
|
||||
# that touch anything the image depends on — the Dockerfile's stub-source
|
||||
# machinery, the ffmpeg download and the entrypoint are exactly the parts that
|
||||
# would otherwise break only at release time.
|
||||
#
|
||||
# Actions are pinned to commit SHAs (Dependabot keeps the pins current).
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
paths:
|
||||
- Dockerfile
|
||||
- docker-entrypoint.sh
|
||||
- .dockerignore
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- .github/workflows/docker.yml
|
||||
- 'crates/**/Cargo.toml'
|
||||
|
||||
env:
|
||||
APP_NAME: telegram-twitter-media-bot
|
||||
DOCKERHUB_REPO: yoursfunny/telegram-twitter-media-bot
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Serialize runs per ref. Never cancel in progress: a killed run would drop a
|
||||
# half-finished image push.
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
# 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). That check
|
||||
# can only see tags that already exist on the remote — see the check step's
|
||||
# re-fetch and the one-push release flow in AGENTS.md.
|
||||
should-build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
build: ${{ steps.check.outputs.build }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# A release tag is the version claim: the manifests are bumped by hand,
|
||||
# so `v1.5.1` with `Cargo.toml` still at 1.5.0 would publish an image
|
||||
# whose tag lies about what is inside it (the binary carries no version).
|
||||
- name: Verify the tag matches both crate versions
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME#v}"
|
||||
status=0
|
||||
for manifest in crates/x-media/Cargo.toml crates/xmedia-bot/Cargo.toml; do
|
||||
# tr -d '\r': a CRLF checkout (core.autocrlf on Windows) would
|
||||
# otherwise yield "1.5.0\r" and false-fail every tag.
|
||||
version="$(sed -n 's/^version = "\(.*\)"/\1/p' "$manifest" | head -1 | tr -d '\r')"
|
||||
if [ "$version" != "$tag" ]; then
|
||||
echo "::error file=$manifest::$manifest is at $version but the tag is v$tag"
|
||||
status=1
|
||||
else
|
||||
echo "$manifest: $version matches v$tag"
|
||||
fi
|
||||
done
|
||||
exit "$status"
|
||||
- id: check
|
||||
shell: bash
|
||||
run: |
|
||||
zero=0000000000000000000000000000000000000000
|
||||
if [ "$GITHUB_REF_TYPE" = "branch" ]; then
|
||||
# A branch run can start before the release tag for its commit
|
||||
# reaches the remote — pushing master first is the usual way to hit
|
||||
# it — and then `git tag --points-at` legitimately finds nothing
|
||||
# and this run builds the same commit the tag run is building: two
|
||||
# docker builds, one release. (Seen on v1.9.0 and v1.9.1: the
|
||||
# branch run's checkout had every tag *except* the one being
|
||||
# pushed.) Re-fetching here, immediately before the decision,
|
||||
# shrinks the window to "the tag was pushed after this step ran";
|
||||
# pushing the branch and the tag together
|
||||
# (`git push origin master vX.Y.Z`) removes it.
|
||||
git fetch --tags --force --quiet origin
|
||||
if git tag --points-at "$GITHUB_SHA" | grep -q .; then
|
||||
echo "commit already tagged; the tag run builds the image"
|
||||
echo "build=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Nothing the image is made of changed — a documentation or
|
||||
# workflow-only commit — so there is no new image to publish. The
|
||||
# PR trigger's path list plus the crate sources, which the image
|
||||
# compiles into the binary.
|
||||
before="${{ github.event.before }}"
|
||||
if [ -n "$before" ] && [ "$before" != "$zero" ] \
|
||||
&& ! git diff --name-only "$before..$GITHUB_SHA" \
|
||||
| grep -qE '^(Dockerfile|docker-entrypoint\.sh|\.dockerignore|Cargo\.toml|Cargo\.lock|\.github/workflows/docker\.yml|crates/)'; then
|
||||
echo "no build input changed; skipping the image build"
|
||||
echo "build=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo "build=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# No `actions/checkout` here on purpose: `docker/build-push-action` defaults
|
||||
# to the Git context (`https://github.com/<owner>/<repo>.git#<ref>`), so
|
||||
# BuildKit clones the repo itself and authenticates with the automatic
|
||||
# github.token. Adding `context: .` below without a checkout step would hand
|
||||
# BuildKit an empty workspace.
|
||||
docker:
|
||||
needs: should-build
|
||||
if: needs.should-build.outputs.build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
|
||||
with:
|
||||
images: ${{ env.DOCKERHUB_REPO }}
|
||||
tags: |
|
||||
@@ -26,24 +130,43 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||
# Pull requests build the image to prove the Dockerfile still works, but
|
||||
# must not read registry credentials (fork PRs have none).
|
||||
-
|
||||
name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
# Buildkit cache via the GitHub Actions cache backend (uses the
|
||||
# automatic GITHUB_TOKEN, no extra secrets). mode=max keeps every
|
||||
# stage's layers so the cargo-deps and ffmpeg layers are restored
|
||||
# instead of re-downloaded/recompiled. The scope must be pinned to a
|
||||
# fixed string: the gha backend defaults to the current git ref, which
|
||||
# would give every new tag a cold cache on release builds. PR runs only
|
||||
# read it (cache-to is empty) so they cannot evict the release cache.
|
||||
#
|
||||
# FFMPEG_URL/FFMPEG_SHA256 come from repository variables when set, so a
|
||||
# release can pin an exact ffmpeg build (the Dockerfile default follows
|
||||
# the project's `/redirect/latest/` URL, which has no sha256 sidecar).
|
||||
#
|
||||
# Single-arch (amd64) on purpose: adding arm64 means re-adding
|
||||
# `docker/setup-qemu-action`, `platforms: linux/amd64,linux/arm64`, and
|
||||
# parameterizing FFMPEG_URL by $TARGETARCH in the Dockerfile.
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||
with:
|
||||
push: true
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
build-args: |
|
||||
APP_NAME=${{ env.APP_NAME }}
|
||||
FFMPEG_URL=${{ vars.FFMPEG_URL || 'https://ffmpeg.martin-riedl.de/redirect/latest/linux/amd64/release/ffmpeg.zip' }}
|
||||
FFMPEG_SHA256=${{ vars.FFMPEG_SHA256 }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=tgxmb-build
|
||||
cache-to: ${{ github.event_name != 'pull_request' && 'type=gha,mode=max,scope=tgxmb-build' || '' }}
|
||||
|
||||
+10
-2
@@ -2,5 +2,13 @@
|
||||
__pycache__/
|
||||
cert/
|
||||
data/
|
||||
docker-compose.yml
|
||||
x.py
|
||||
nginx-certs/
|
||||
nginx-vhost.d/
|
||||
nginx-html/
|
||||
nginx-acme/
|
||||
.env
|
||||
|
||||
|
||||
# Added by cargo
|
||||
|
||||
/target
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Overview
|
||||
|
||||
Telegram bot (teloxide) that turns post links from X/Twitter, Pixiv, Bluesky, Misskey (misskey.io), and Bilibili dynamics into media messages (images, video, GIF) with the post's title, author, and tags. It supports batch media splitting, retry with persistence, inline queries, forward-channel rebinding with caption templates, and Pixiv ugoira→MP4 transcoding. README is in Chinese; user-facing bot strings are in English. The project is a Rust port of a Python predecessor (see `queue.rs` comments referencing `utils/task_queue.py`).
|
||||
|
||||
Two-crate Cargo workspace (both v1.9.1, edition 2024, resolver 3):
|
||||
|
||||
- **`crates/x-media`** — library that fetches and normalizes media from the four sites. Pure, no Telegram knowledge.
|
||||
- **`crates/xmedia-bot`** — the bot binary: teloxide dispatcher, SQLite-backed chat state, persistent task queue.
|
||||
|
||||
## Architecture & Data Flow
|
||||
|
||||
```
|
||||
Telegram update → Dispatcher (polling or axum webhook) → dptree branches
|
||||
├─ message → commands (any chat) / URL links (private chat only)
|
||||
├─ inline_query → InlineQueryResult Photo/Video/Mpeg4Gif
|
||||
└─ callback_query → "forward" (copy to channel) / "template|<name>" (apply caption template)
|
||||
```
|
||||
|
||||
Message flow: `message_handler` extracts URLs (from `url`/`text_link` entities, text + caption, deduped) → `x_media::site::fetch(url)` → `Fetched` → builds a `Task` → `send::send_media_sequence` (media groups ≤ 10, caption on first item) or `send::send_animation`. On Telegram URL-fetch failure or size error (`send_batch_via_upload`): download via `x_media::site::download_media` to a temp file (≤ 10 MiB), sniff magic bytes (`sniff_ext`), upload via multipart; oversized items fall back to `fallback_url`. On failure: `enqueue_retry` persists resume-state `Task` into the SQLite queue (it reports whether the row was really written, and only then does the user get the "retrying in Ns" notice — an enqueue that fails says so instead) → workers lease (120 s lock TTL) → retry with exponential backoff (≤ 30 s for the bot's own delays, `MAX_RETRIES = 2`) → dead-letter → `notify_failure`. Success → `post_send_actions`: edit-before-forward prompt with inline buttons, or `copy_messages` to the bound forward channel.
|
||||
|
||||
Debug command: `/debug <url>` runs the same `x_media::site::fetch` and replies with `debug_report` (`handlers/commands.rs`) — site id, normalized cache key, source URL, title/author/tags, sensitive flag, caption and the media list — nothing is sent, cached or forwarded; the report is capped at 4000 chars and sent with HTML parse mode: raw fields are escaped, and the caption is wrapped in a `<blockquote>` so it renders exactly like the sent media caption (escaped text and links included). The caption it shows is `preview_caption`'s: the chat's per-site format override plus the long-post quoting, i.e. exactly what the send paths produce — showing the raw built-in caption made `/set_format` look like a no-op, and the `/set_format` success reply points users at `/debug` to preview.
|
||||
|
||||
User-facing failure text is a function of the error class, never one generic sentence: `urls::fetch_error_message` maps `FetchError::NotFound` (post gone), `Sensitive` (withheld, needs `TWITTER_AUTH_TOKEN`), `Blocked` (source risk control), `Disabled { site }` (a registered site switched off — pixiv without a token, the one case `fetch` answers `Err` instead of `Ok(None)`) and `Transient`/`Http` (source down) apart. The same distinction drives the group hint: a supported link posted in a group (not a channel) gets one `GROUP_LINK_HINT` reply, because the link pipeline is private-chat only.
|
||||
|
||||
The `/test <url>` command runs the ordinary link pipeline (`urls::url_media`) with `PostSend::Suppressed`: the media is sent and cached like any other link, but the chat's `forward_channel_id`/`edit_before_forward` are ignored, so a test never forwards to the channel and never opens the edit prompt (retries and dead-letter notifications behave as usual). `/test`, `/debug`, `/set_format` and `/clear_cache` use the custom `parse_arg_remainder` parser (whole remainder, trimmed) because teloxide's built-in `split` parser takes exactly one space-separated token per field: `/set_format <site> <format>` never parsed with it (and `/clear_cache` without an argument did not either), and a command that fails to parse falls through to the URL flow in silence. `commands::tests::every_documented_invocation_parses` pins every documented form against exactly that.
|
||||
|
||||
The inline path (`handlers/inline.rs`) hands media URLs straight to Telegram, which fetches them itself and cannot send site-specific headers — so `x_media::site::needs_media_headers(url)` (true exactly where a site's `media_headers` is non-empty, i.e. pixiv's pximg.net) marks the media that must be skipped instead of shipped broken; locally produced media (ugoira MP4, bsky remux) fails `Url::parse` and is skipped the same way. Inline results are therefore URL-only by construction, and a query whose every item was skipped is answered *empty* (with a cache window) rather than left unanswered — an unanswered query keeps the client spinning and, through the debounce's release, re-runs the fetch on every keystroke.
|
||||
|
||||
`url_media` is a thin wrapper over `url_media_inner`: `run_with_chat_action` sends the chat action, then re-sends it every `ACTION_REFRESH` (4 s) while the pipeline future is pending, because Telegram drops an action after ~5 s and a fetch (ugoira encode, HLS remux) plus an upload routinely outlasts that. The pipeline flips the shared `ActionHint` from `Typing` to `UploadPhoto`/`UploadVideo` once the media kinds are known. The `select!` is `biased` on the pipeline branch so a finished pipeline never emits a stray action.
|
||||
|
||||
The `x-media` library: `site::fetch(url)` dispatches through the `SITES` registry (per-site `impl Site`, in order twitter → bsky → misskey → pixiv → bilibili) and returns `Ok(None)` for unmatched URLs (`Err(FetchError::Disabled { site })` when the URL matches a registered site whose `enabled()` is false — see `disabled_site`). `Fetched { source_url, caption, title, content, media: Vec<Media>, sensitive, site_id, … }` (title and content are split per platform: a pixiv artwork's title and description, a bilibili headline and body, and text-only posts whose text is all `content`); `caption_with(format)` substitutes `{url} {author} {author_url} {title} {content} {tags}`.
|
||||
|
||||
## Key Directories
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `crates/x-media/src/` | Fetch library. `site/mod.rs` = dispatcher + `Fetched`/`FetchError`/`download_media*` (the streaming `download_media_to_file` and the capped `download_media_limited`, which is where a download's size and its total time budget are enforced); `media.rs` = `Media` enum; `examples/fetch.rs` = end-to-end usage sample |
|
||||
| `crates/x-media/src/site/<twitter\|pixiv\|bsky\|misskey\|bilibili>/` | One directory per site: `mod.rs` (re-exports), `interface.rs` (PATTERN, `enabled()`, `fetch_from_url()`, `cache_key`/`is_retryable`/`media_headers`, unit struct `<Name>Site` implementing `site::Site`, `From<SiteStruct> for Fetched`), `model.rs` (serde DTOs). Pixiv adds `api.rs` (auth + transport); twitter adds `auth.rs` (logged-in GraphQL `TweetDetail` fallback for NSFW tweets, gated on `TWITTER_AUTH_TOKEN`; without the token a withheld tweet stays `FetchError::Sensitive` and the bot reports it as age-restricted instead of "no media"). Misskey targets misskey.io only (`POST /api/notes/show`, 400+`NO_SUCH_NOTE` → NotFound). Bilibili fetches dynamics (images/animated images only — an attached video degrades to its cover, and its title stands in for the post text, which AV dynamics do not have) from `/x/polymer/web-dynamic/v1/detail` sent with `features=itemOpusStyle` (without that flag the legacy serialization drops an image/text post's body and headline entirely — `desc` comes back `null`; the adapter still parses the legacy `major.draw`/`desc`/`archive` shapes as a fallback). No WBI signature is involved; device cookies `buvid3`/`buvid4` are fetched automatically from `/x/frontend/finger/spi` because bilibili's `-352` risk control starts rejecting plain requests, `BILIBILI_COOKIE` is the escalation when an IP stays blocked; `b23.tv` short links are deliberately unmatched. Twitter's `from_syndication_value` HTML-decodes the API text — syndication and GraphQL `full_text` both arrive pre-escaped (`>` `<` `&` `'`) — so the stored text is raw and the caption escap…
|
||||
| `crates/xmedia-bot/src/main.rs` | Entry point: env/log init, command registration (`register_commands` — `setMyCommands` plus the profile description texts), shared `send::BOT` force-init, startup sweep of this project's leftover temp files (`x_media::TEMP_FILE_PREFIX` + an age gate, since a killed process runs no destructors), startup repair of queued retries whose local media did not survive a restart (`handlers::repair_lost_local_media`, before any worker can lease: those rows are re-fetched from their `source_url`), queue worker start, site login validation (`site::validate_all`), `periodic_sweep` (`SWEEP_INTERVAL` 300 s): expired prompts are rewritten in place to `EDIT_PROMPT_EXPIRED_TEXT` with an empty keyboard — an edit, never a new message, so a background timer cannot wake a chat — plus the link-cache prune, the idle rate-limit buckets and the idle inline-query entries, and the queue backlog line (only when non-empty). Takes its collaborators rather than the statics so its loop is testable with a paused clock, dptree handler tree, webhook vs polling dispatch |
|
||||
| `crates/xmedia-bot/src/config.rs` | Manual env parsing into `Config` |
|
||||
| `crates/xmedia-bot/src/db.rs` | `DbPool`: one shared SQLite connection pool (`POOL_SIZE = 4`, WAL, busy_timeout) for all three tables over `$DATA_DIR/task_queue.db` (default `data/`) — the three stores share it; `open_store` creates file + schema and then applies the `PRAGMA user_version` migration chain (`MIGRATIONS` + `migrate` — append-only; `schema_init` is the version-0 baseline and must not gain columns an existing database would never receive — `db.rs`'s tests pin a pre-migration database upgrading intact, the shipped migration text frozen (appending is the only allowed change) and a fresh database landing at the latest version), `with_conn` runs all rusqlite I/O in `spawn_blocking` |
|
||||
| `crates/xmedia-bot/src/handlers/` | Handler modules: `mod.rs` (message entry point, `reply`, `log_key`, the group-only `GROUP_LINK_HINT` for a supported link posted outside a private chat), `commands.rs` (teloxide `BotCommands` enum + command executor, incl. `/test <url>` (send-only) / `/debug <url>` (parse-only) and the admin-only `/bot_dict` state dump; `/set_format` rejects unknown `{…}` placeholders and resets with `-`), `urls.rs` (URL extraction + bounded job channel (256) drained by `URL_WORKERS = 8` workers (`start_url_workers`) — backpressure instead of unbounded spawns; teloxide's per-chat workers are sequential — batch-forwards need concurrency; one *shared* in-flight fetch per cache key (`fetch_shared`: a second chat, a batch forward or a retry asking for the same post meanwhile waits for the first caller's result, the entry is dropped the moment the fetch settles so nothing is ever answered from an old fetch, and a waiter whose sharer was cancelled fetches for itself); plus the startup repair `repair_lost_local_media`, whose decision (`needs_refetch`) and rewrite (`apply_refresh`) are pure and tested while the fetch itself is a live test), `inline.rs`/`callback.rs` (inline queries / edit-before-forward buttons, incl. `skip`; a forward that fails retryably is both queued *and* settles the prompt — the queued row carries the message ids itself, and a prompt left live let a second Confirm copy the same messages twice and let Skip answer "nothing was forwarded" while the row still delivered), `statics.rs` (global statics) |
|
||||
| `crates/xmedia-bot/src/state.rs` | `ChatStore`: parking_lot `Mutex<HashMap>` cache + SQLite write-through (`chat_state` table); the 300 s sweep's `prune_expired` evicts any chat with no live edit-before-forward prompt, so the cache (and the per-chat lock map) stays bounded to active prompts — durable settings reload from the DB on next use |
|
||||
| `crates/xmedia-bot/src/link_cache.rs` | `LinkCache`: SQLite-backed cache (`link_cache` table) of successfully sent posts — raw caption fields + the source media URLs + Telegram `file_id`s; repeat links re-send locally (no fetch/upload), TTL + prune; a permanent send failure *degrades* the entry instead of dropping it (the file ids go, the URLs stay, so the next request re-sends from those without a fetch), and a degraded entry that fails again is removed |
|
||||
| `crates/xmedia-bot/src/queue.rs` | `PersistentTaskQueue`: SQLite-backed queue (`tasks` table), `QUEUE_WORKERS = 4` concurrent workers (lease via `BEGIN IMMEDIATE` + `locked_until` TTL), retry→dead-letter, a `lease_token` fence: `lease_next` stamps a random token and every write-back (heartbeat, `delete`, `reschedule`, `mark_done`) is guarded by it, so a lease that expired and was re-leased cannot be written by its former holder — a lost lease stops the attempt instead; a finished row's `DELETE`/reschedule retried and a failed delete falling back to a `done` tombstone (the lease query and the sweep only look at `pending`/`in_progress`, so a task that already ran cannot be resurrected and re-run), `runnable_rows`/`replace_payload` (the startup repair's read/rewrite path: it runs before the workers exist, which is why it needs no lease token), `notify_one` worker wakeup plus a separate `Notify` for the 30 s lease-expiry sweep (a shared one let the sweep steal the workers' wakeup permit; the sweep does notify the workers after it actually recovered a row, since a recovered task is due immediately while every worker may be parked on `notify` with no pending row to sleep on), `busy_timeout` on all connections |
|
||||
| `crates/xmedia-bot/src/ctx.rs` | `AppContext`: the injected collaborators (`sender` + `ChatStore`/`PersistentTaskQueue`/`LinkCache`/`Config`), `from_statics` for production and the `CONTEXT` static the worker closures hold. `test_support::TestStores` backs handler tests with a tempdir store set, and the module also carries the fixtures those tests share — the canonical cached post (`cached_photo`), the edit-before-forward prompt (`seed_prompt` with its `PROMPT_ID`/`FORWARDED_ID`) and a scripted API error (`api_error`) — so no two test modules keep their own copies |
|
||||
| `crates/xmedia-bot/src/send/` | `send/mod.rs`: `Task`/`MediaItemPayload` payloads, `SendError`/`Classification`, `send_media_sequence`/`send_animation`/`forward_messages`; `send/input_media.rs`: payload → `InputFile`/`InputMedia` + `build_media_group` (caption on the first item only); `send/upload.rs`: the download-and-reupload fallback (`prepare_upload_item`/`send_batch_via_upload`, photo downscale handoff); `send/post_send.rs`: link-cache write, `KEEP_ALIVE` registry, `settle_task`, `post_send_actions`, `handle_task`/`dead_letter_notify` |
|
||||
| `crates/xmedia-bot/src/media_sender.rs` | `MediaSender` trait: the user-flow surface (`send_media_group`/`send_animation`/`copy_messages`/`send_message`/`answer_callback_query`/`edit_message_text`/`edit_message_caption`/`delete_message`/`send_chat_action`) implemented by teloxide `Bot` (per-chat rate-limited) and by a recording `MockSender` in tests. Admin/setup APIs (`get_chat`, `set_my_commands`, …) stay on the concrete `Bot`. `test_support` holds the scripted `MockSender` and `fake_api` (the stand-in API the real-`Bot` tests drive) |
|
||||
| `crates/xmedia-bot/src/rate_limit.rs` | Two token buckets paced before sends reach the API so batch forwards don't trip flood control: one per chat (`CAPACITY = 20`, ~20 msg/min refill) and one bot-wide (`acquire_global`, 30/s — Telegram's per-bot ceiling, invisible to any per-chat bucket and only binding when a batch fans out over many chats). `prune_idle` drops the per-chat buckets that refilled while unheld |
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
export TELOXIDE_TOKEN=<token> # required; PIXIV_REFRESH_TOKEN optional (Pixiv disabled without it)
|
||||
cargo run -p xmedia-bot # run the bot (polling by default)
|
||||
cargo run -p x-media --example fetch -- <url> # test a link through the fetch library
|
||||
cargo test --workspace # full test suite (no CI test step exists — run locally)
|
||||
cargo build --release -p xmedia-bot # release build (Dockerfile does this)
|
||||
cargo clippy --workspace --all-targets # lint (Clippy is the configured IDE linter)
|
||||
cargo fmt --check # formatting
|
||||
```
|
||||
|
||||
Docker: `docker build -t tgxmb .` then `docker run --rm -d --name tgxmb --env-file .env -v ./data:/app/data tgxmb`. Runtime requires **ffmpeg** (built into the image). The builder fetches crates.io + ffmpeg; on restricted networks pass proxy build args, e.g. `--build-arg HTTP_PROXY=http://host.docker.internal:10808 --build-arg HTTPS_PROXY=…` (Docker Desktop builds can't reach the host loopback — use `host.docker.internal`).
|
||||
|
||||
## Code Conventions & Common Patterns
|
||||
|
||||
- **Errors via `thiserror` derive** (no anyhow): the public, stringified errors — `FetchError` (`Http`/`Json`/`Pixiv`/`Site`/`NotFound`/`Blocked`/`Disabled`/`Sensitive`/`TooLarge`/`MediaPrep`/`Transient`/`Io`) and `PixivError` — derive `thiserror::Error` with `#[from]` conversions; `Display`/`source()` come from the derive. The internal control-flow enums — `QueueError` (`Retryable { delay_seconds, payload }` / `Permanent`), `SendError` (Retryable/Permanent), `Classification`, `FallbackError` — carry no `Display` and are handled by direct variant matching. New errors should follow the same split: stringified/public errors derive `thiserror`, internal flow enums stay plain.
|
||||
- **Global state via `std::sync::LazyLock` statics**, not DI: `CONFIG`, `CHAT_STORE`, `TASK_QUEUE` in `handlers/statics.rs`; shared reqwest `CLIENT` in `x-media/src/site/mod.rs`. `Bot` is passed/cloned into handlers; queue workers share the process-wide `send::BOT` (`LazyLock<Bot>`, force-initialized in `main` so a missing token fails at startup).
|
||||
- **Async**: tokio multi-thread runtime (`#[tokio::main]` default). All rusqlite I/O inside `tokio::task::spawn_blocking`. Long loops use `tokio::select!` with `tokio::sync::{watch, Notify}` stop/wake channels. No streams.
|
||||
- **Blocking sync primitives**: `parking_lot::Mutex` for hot caches, `tokio::sync::Mutex` for async-shared state (pixiv token cache), `AtomicBool` for feature gates.
|
||||
- **Site adapter convention**: each site module exports `PATTERN: LazyLock<Regex>`, `enabled() -> bool`, `fetch_from_url(url) -> Result<Fetched, FetchError>`, plus `cache_key`/`is_retryable`/`media_headers`, and a unit struct `<Name>Site` implementing `site::Site`; the central dispatcher (`site/mod.rs`) only iterates the `SITES` registry. Adding a site = new `site/<name>/{mod.rs,interface.rs,model.rs}` + one `Box::new(...)` entry in `SITES` — the bot crate never lists sites (SetFormat whitelist, cache-key site lookup and startup validation all derive from the registry). Async trait methods return `SiteFuture` (a boxed `Pin<Box<dyn Future + Send>>`) because `async fn` in traits is not dyn-compatible.
|
||||
- **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); `site::fetch_once` is the same code path with a single attempt, used by inline queries whose answer window is shorter than the backoff. A status a site answers with is classified by what a *retry* can change: 404/410 are `NotFound` and 401/403 are `Blocked` (permanent, reported at once), 429/5xx are `Transient` and retried. Queue retries are explicit `QueueError::Retryable` with computed delay (`retry_delay_seconds`), scaled per attempt by `scaled_retry_delay` — which only ever scales **up**, so a delay the server asked for (Telegram `retry_after`) is never shortened. `send::classify_request_error` is the send-side counterpart: `RetryAfter` and `Network` are retryable, and so is a 5xx — teloxide sleeps 10 s on a server error and then parses the body, so by then the HTTP status is gone and the condition is recognised by shape instead (a JSON server-error description, or an `InvalidJson` whose raw body is not JSON, i.e. a proxy/error page).
|
||||
- Logging via `log` macros (`pretty_env_logger`, level from `RUST_LOG`). `main.rs` initializes the **timed** builder with a default filter of `info,hyper_util=warn,reqwest=warn` when `RUST_LOG` is unset: the plain `init` had no timestamps and fell back to `error`, so a deployment that forgot the variable logged nothing at all, and at `debug` the HTTP client's own lines outnumbered the bot's two to one. An explicit `RUST_LOG` overrides the default wholesale. Level convention: `info` = lifecycle + per-post business results (`sent`/`forwarded`/`copied`, with `chat=` and the total `ms`), admin/operator actions and anomalies (fallback, retry enqueue, dead-letter is `error`); `debug` = per-request detail (URL extraction, `fetching`/`fetched` with the fetch duration, batch sends, queue processing with the row's `chat=`/`key=` and per-attempt `ms`, photo processing, inline queries); `trace` = user data (the full URL, the message text, the inline query). At `debug` and above links are printed via the normalized cache key (`handlers::log_key`, e.g. `[key=twitter:123...]`), so a `debug` log can be shared without echoing what users pasted, and degradations that leave the user served (a failed cache read/write, a failed chat action) are `warn`, not `error`. The only queue/sweep aggregate is the 300 s sweep's queue line, and it speaks only when the queue is non-empty.
|
||||
|
||||
## Important Files
|
||||
|
||||
| File | Why it matters |
|
||||
|---|---|
|
||||
| `crates/xmedia-bot/src/main.rs` | Startup sequence, webhook vs polling, graceful shutdown (SIGINT via teloxide ctrlc / SIGTERM via `stop_token` for docker, → sweep stop → admin msg → queue stop) |
|
||||
| `crates/xmedia-bot/src/handlers/` | `statics.rs` = `CHAT_STORE`/`TASK_QUEUE`/`CONFIG` singletons (open `$DATA_DIR/task_queue.db`, default `data/` **relative to CWD**, dir auto-created); `mod.rs` also holds `apply_caption_edit`, the one place a caption edit is applied and its failure classified: a short retryable delay is retried once, anything else is reported to the user instead of being swallowed (`callback.rs`'s template button answers its toast with the failure and leaves the record alone); `commands.rs` = command dispatch (incl. `/test <url>` send-only, `/debug <url>` parse-only, the read-only `/settings` every chat member can read — unlike the admin-only `/bot_dict` raw dump — and template removal; `/start`/`/help` carry the guidance teloxide's `descriptions()` cannot render, and `/set_format` rejects unknown `{…}` placeholders, resetting with `-`); `urls.rs` = URL extraction + the per-URL pipeline (`url_media` takes a `PostSend` mode: chat settings vs `/test`'s suppressed actions); `inline.rs` = debounced inline queries (hotlink-protected and local media skipped); `callback.rs` = edit-before-forward buttons (dptree entry + testable `handle_callback` core, incl. `skip`) |
|
||||
| `crates/xmedia-bot/src/send/` | `mod.rs`: constants `MAX_MEDIA_GROUP = 10`; `classify_request_error` (5xx/non-JSON bodies retry, see the Retries bullet) and the media-fetch markers that route a URL send into the reupload fallback — including `failed to get HTTP url content`, the description single-media URL sends answer with; the senders. `upload.rs`: download-and-reupload fallback triggered only by Telegram API errors (`is_media_fetch_failure` / `is_size_error`), with a download's class from `classify_download_error` (transport/429/5xx retry; 4xx is permanent — the media itself is gone or refused — and a temp-file *write* failure retries, being resource exhaustion far more often than a broken temp dir). Item preparation is bounded **process-wide** (`PREP_SLOTS` in `upload.rs`: URL workers and queue workers can each be inside a batch, so a per-batch bound is not a memory bound), and the check that routes an oversized item to `fallback_url` is the download's own declared-Content-Length abort (`FetchError::TooLarge` → `MediaTooLarge`) — there is no separate size probe, which used to cost a second request per item. `post_send.rs`: settlement (`settle_task`), cache write, post-send actions (dead-letter text via `failure_text`: post key + cause, since the raw error alone does not say which link died), queue handlers. `input_media.rs`: payload → `InputMedia` |
|
||||
| `crates/xmedia-bot/src/photo.rs` | Pure-Rust photo processing (no ffmpeg): `png` (image-png) decode/encode + `zune-jpeg` decode + `fast_image_resize` Lanczos3 downscale + `jpeg-encoder`. Photos over Telegram's limits (width + height > 10000 px → `PHOTO_INVALID_DIMENSIONS`; bytes > 10 MiB) are decoded, downscaled keeping the format, PNG bit depth > 24 (RGBA 32-bit / 16-bit per channel) reduced to 24-bit RGB with alpha flattened white (≤24-bit untouched, never upconverted), and transcoded to JPEG only if still over the cap; memory budget guarded, otherwise the item's smaller fallback URL. Two budgets, not one: `MAX_PHOTO_DOWNLOAD_BYTES` (32 MiB) caps the *download* in the send fallback — the whole body is buffered, once per prep slot — while `MAX_DECODE_BYTES` (512 MiB) stays the pre-allocation guard that decides whether a decoded photo can be processed at all; over either one the item degrades to its smaller URL |
|
||||
| `crates/x-media/src/site/mod.rs` | Dispatcher, `Fetched`/`FetchError`, shared `CLIENT`, `download_media` (adds `Referer: https://www.pixiv.net/` for `pximg.net` hotlink protection), `needs_media_headers` (the same per-site rule, asked by the inline path to skip what Telegram cannot fetch) |
|
||||
| `crates/x-media/src/site/pixiv/api.rs` | OAuth token exchange (hardcoded app client id/secret), access-token cache, ugoira zip→MP4 via ffmpeg in `spawn_blocking` |
|
||||
| `Dockerfile` | Multi-stage: cached dep layer via stub sources + `touch *.rs` mtime bump (cargo's freshness is mtime-based and `cargo clean -p` removes 0 files — the touch is what forces the real sources to rebuild while deps stay cached), static ffmpeg from ffmpeg.martin-riedl.de (`FFMPEG_URL` arg, optional `FFMPEG_SHA256` checksum, `unzip -t` integrity check), `debian:bookworm-slim` runtime, entrypoint. Runtime ships **no libssl/libcrypto/CA bundle** — rustls webpki-roots handles all TLS, and the static ffmpeg only processes local files (downloads go through reqwest) |
|
||||
| `docker-entrypoint.sh` | Privilege drop: `useradd` with `LOCAL_USER_ID` (default 9001) + `setpriv` (no gosu on bookworm-slim) |
|
||||
| `docker-compose.yml` | The deployment composition, committed as-is: every instance value (token, admins, site credentials, domain) is a `${VAR}` substitution read from the gitignored `.env` beside it, so the file needs no per-deployment edit — and a variable not listed in a service's `environment:` never reaches that container. Ships nginx-proxy + acme-companion: webhook mode needs TLS termination in front (teloxide's axum listener is HTTP-only; `WEBHOOK_CERT` only feeds `set_webhook`), bot exposes `VIRTUAL_HOST`/`VIRTUAL_PORT` on the shared `proxy` network, no host port; container names `nginx-proxy`/`acme-companion`/`tgxmb`, start order via `depends_on` (proxy → acme → bot) |
|
||||
| `.github/workflows/docker.yml` | CI: build+push to Docker Hub on tag `v*`/master, plus a build-only check on PRs touching the build inputs; **no test step**; verifies a release tag matches both crate versions; buildx gha cache (`cache-from` always, `cache-to` except on PRs, scope `tgxmb-build`, `mode=max`) so cargo deps + ffmpeg layers are restored across runs; `FFMPEG_URL`/`FFMPEG_SHA256` come from repo variables when set |
|
||||
| `README.md` | Feature docs + command table (Chinese) |
|
||||
|
||||
## Runtime/Tooling Preferences
|
||||
|
||||
- **Rust, stable, edition 2024**, workspace resolver 3. No `rust-version`/MSRV pin, no `rust-toolchain.toml` — recent stable is assumed. No nightly features.
|
||||
- Package manager: **Cargo** (workspace with path dep `x-media` ← `xmedia-bot`). No `[workspace.package]`/shared deps — each crate lists deps independently.
|
||||
- **TLS is rustls end-to-end** (no native-tls/openssl in the tree, no libssl in the Docker runtime image): `teloxide` is declared `default-features = false` with `["webhooks-axum", "macros", "rustls", "ctrlc_handler"]` (the removed `default` also carried `native-tls` and `ctrlc_handler` — the latter must stay); x-media's reqwest is `default-features = false` with `["json", "rustls-tls", "gzip", "http2"]` (webpki-roots baked in, so the image ships no CA bundle; `gzip` because the site APIs answer their JSON compressed — twitter's syndication body is 4469 bytes identity vs 1066 gzipped — and `http2` because every site CDN here negotiates h2). One reqwest 0.12.28 in the lock.
|
||||
- **Versioning**: bump the version in all three places (`crates/x-media/Cargo.toml`, `crates/xmedia-bot/Cargo.toml`, `Cargo.lock`) and **keep `README.md`, `README.en.md` and `AGENTS.md` in sync with the code on every bump**, then commit (`chore: bump version to X.Y.Z`), create an annotated tag `vX.Y.Z`, and push branch + tag **in one push** (`git push origin master vX.Y.Z`; the tag push triggers the Docker Hub build). Pushing them separately with the branch first makes the master run of `docker.yml` build the same commit as the tag run — its duplicate check can only see the tags that already exist on the remote. The tag must equal both crate versions: `.github/workflows/docker.yml` verifies that before building, and `--locked` verifies the lock file.
|
||||
- Config is **environment-variable driven** (dotenv loads `.env`, which is gitignored; `.env.example` is the tracked template — `cp .env.example .env` — and is also the file `docker compose` substitutes `${VAR}` from, so every variable the compose passes must be documented there). Key vars: `TELOXIDE_TOKEN` (required), `PIXIV_REFRESH_TOKEN`, `TWITTER_AUTH_TOKEN` (optional; x.com `auth_token` cookie — enables the logged-in GraphQL fallback that fetches NSFW tweets syndication withholds), `BILIBILI_COOKIE` (optional; whole bilibili cookie string — bilibili dynamics fetch anonymously and add their own device cookies, this only rescues an egress IP that bilibili has hard-flagged with `-352`/412), `BOT_ADMIN` (comma-separated ids), `EDIT_MESSAGE_TTL_SECONDS` (default 86400), `LINK_CACHE_TTL_SECONDS` (default 604800), `CAPTION_QUOTE_TEXT_CHARS` (default 200; a post whose text — the `title` plus `content` joined, see `site::compose_text` — reaches this length gets that text wrapped in an expandable blockquote inside its caption, the URL and author line staying outside; `0` disables it. Applied at the send boundary in `send::quote_long_caption`, which locates the text as what follows the author link, so a `/set_format` that moves `{title}`/`{content}` elsewhere and pixiv's title-inside-a-link layout opt out; `copy_messages` forwards and queued retries inherit the wrap, while the edit-before-forward rewrite stays unquoted by design), `DATA_DIR` (default `data`, CWD-relative; the SQLite dir, auto-created), `WEBHOOK`/`WEBHOOK_URL`/`WEBHOOK_LISTEN`/`WEBHOOK_PORT`/`WEBHOOK_CERT`/`WEBHOOK_SECRET_TOKEN` (webhook mode requires URL/listen/port, `.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_DIR/task_queue.db` (default `data/task_queue.db`, CWD-relative — run from the workspace root, or `/app` in Docker; set `DATA_DIR` to pin state anywhere). Mount `./data` and `./cert` volumes.
|
||||
- `.gitattributes` enforces LF for `*.sh` (CRLF breaks shebangs in containers). `.gitignore`: `.env`, `data/`, `cert/`, `nginx-*` (proxy state), `/target`, `.idea/` (the compose file is tracked; only `.env` carries the deployment's own values).
|
||||
- Docs are in Chinese (README, AGENTS.md); user-facing bot strings are in English. Keep that split when editing user-facing strings and docs.
|
||||
|
||||
## Testing & QA
|
||||
|
||||
- **~180 tests, all inline `#[cfg(test)] mod tests`** — 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. Tests that must go through a **real `Bot`** (its URL/multipart building, the per-chat limiter and the bot-wide budget) talk to a stand-in API instead (`media_sender::test_support::fake_api::FakeApi`, a `tokio` TCP listener that records every call and answers the smallest result each method needs — teloxide keys methods by payload type, so the recorded name is `SendMediaGroup`, not `sendMediaGroup`): a media group, the edit-before-forward prompt through the real callback path, and `handlers::handle_message` (the context-taking body of `message_handler`, split out for exactly this).
|
||||
- Live-network tests exist in `site/twitter/interface.rs` (5), `site/bsky/interface.rs` (1), `site/misskey/interface.rs` (1), `site/bilibili/interface.rs` (5), `site/pixiv/api.rs` (1); `photo.rs` adds one `#[ignore = "heavy: …"]` test. `site/mod.rs` also has a **token-gated but not `#[ignore]`d** pixiv download test (`download_media_pixiv_original_with_referer`): it hits `i.pximg.net` whenever `PIXIV_REFRESH_TOKEN` is set, so a local `cargo test --workspace` is not fully offline and can flake on a pixiv CDN body timeout. `disabled_site_is_reported_not_ignored` (same file) is gated the other way round: it asserts `fetch` answers `FetchError::Disabled { site: "pixiv" }` for a pixiv link and early-returns when `PIXIV_REFRESH_TOKEN` **is** set (the site is then enabled). Test gating convention (enforced by `.github/workflows/ci.yml`): pure unit tests always run; live-network tests carry `#[ignore = "live network: ..."]` (run via `cargo test --workspace -- --ignored live`); token-gated pixiv tests early-return when `PIXIV_REFRESH_TOKEN` is absent **or empty** (an unset GitHub secret arrives as `""` — `is_err()` alone would run them tokenless and fail), and the bilibili live tests early-return when the API answers risk control (`-352`, which bilibili applies per IP by request volume). Run the full offline suite with `cargo test --workspace`.
|
||||
- Fixtures are inline `serde_json::json!` builder fns (`fixture()`, `thread_json()`, `illust_json()`), not files. The shared `CLIENT` sets `pool_max_idle_per_host(0)` under `#[cfg(test)]` to avoid cross-runtime `DispatchGone`.
|
||||
- **CI** — `.github/workflows/ci.yml` (actions pinned to commit SHAs, `--locked` on every cargo invocation, `concurrency` cancels superseded runs, `RUST_BACKTRACE=1`) runs (behind a `changes` gate job, so a push/PR whose entire diff is markdown skips it instead of burning four minutes on nothing) `cargo fmt --check` + `cargo clippy --workspace --all-targets --locked -- -D warnings` + `cargo test --workspace --locked` + a release-profile `cargo build --release --locked` + an `actions-rust-lang/audit` dependency-vulnerability gate (offline, no secrets, on every push/PR) and a `live` job (schedule/manual/tag only, `-p x-media` since every network/secret-gated test lives there, `continue-on-error`) for the `#[ignore]`d live + token tests. `.github/workflows/docker.yml` builds and pushes the image on master/tag and runs a **build-only check on pull requests touching the build inputs**; its `should-build` gate skips a branch push that is already tagged (`git tag --points-at` — the tag run builds it, so push both refs together) or that touched no build input at all, while a tag push always builds (`Dockerfile`, entrypoint, manifests, `.dockerignore`); a release tag must match both crate versions or the build stops, and `FFMPEG_URL`/`FFMPEG_SHA256` are taken from repository variables when set (a release can pin an exact ffmpeg build). `.github/dependabot.yml` keeps crates, the pinned actions and the Docker base images current.
|
||||
- Untested and hard to test without a mock seam: `config.rs`, `handlers/statics.rs`; `db.rs` is covered for the migration chain but not for pool behaviour under contention; `main.rs` is covered where it was split out (`periodic_sweep`, `sweep_temp_dir`) but not for startup/shutdown or its `dptree` branch tree (the handlers themselves are, through the stand-in API); in `x-media`: `media.rs`, `lib.rs`, all `model.rs`. The `commands.rs` *executor* needs a real `Bot` (only its pure report builder is tested). Everything else — `handlers/{mod,callback,inline,urls}.rs`, `send/*`, `ctx.rs`, `state.rs`, `queue.rs`, `link_cache.rs`, `rate_limit.rs` — is driven through `TestStores`/`ctx::test_support` and the scripted `MockSender`.
|
||||
- No coverage tracking.
|
||||
Generated
+3120
File diff suppressed because it is too large
Load Diff
+12
@@ -0,0 +1,12 @@
|
||||
[workspace]
|
||||
members = ["crates/x-media", "crates/xmedia-bot"]
|
||||
resolver = "3"
|
||||
|
||||
# Smaller/faster production binary: strip debug symbols, link-time
|
||||
# optimization across crates, and one codegen unit per crate (bigger LTO
|
||||
# wins). panic=abort is intentionally NOT set: queue workers and db
|
||||
# closures rely on JoinHandle catching panics, which abort would defeat.
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
+76
-19
@@ -1,26 +1,83 @@
|
||||
FROM python:3.12-slim-bullseye
|
||||
# ---------- build stage ----------
|
||||
# rust:1-bookworm (full, not slim) ships the C toolchain needed by
|
||||
# rusqlite's bundled SQLite, plus wget/unzip for the ffmpeg download.
|
||||
FROM rust:1-bookworm AS builder
|
||||
|
||||
ARG APP_NAME=telegram-twitter-media-bot
|
||||
# Prebuilt static ffmpeg (glibc-linked, includes libx264) for ugoira MP4
|
||||
# encoding. Served from https://ffmpeg.martin-riedl.de (Cloudflare CDN,
|
||||
# built on Debian 12 — glibc-compatible with the bookworm-slim runtime).
|
||||
# johnvansickle.com throttles datacenter IPs and served garbage from GitHub
|
||||
# runners. `/redirect/latest/` floats to the newest release build; each build
|
||||
# also ships a .sha256. Swap `amd64` for `arm64` when building arm64 images.
|
||||
ARG FFMPEG_URL=https://ffmpeg.martin-riedl.de/redirect/latest/linux/amd64/release/ffmpeg.zip
|
||||
# Arm64 images need this URL swapped for the `linux/arm64` build (currently
|
||||
# hardcoded amd64; the workflow builds amd64 only — see docker.yml).
|
||||
# Optional sha256 of ffmpeg.zip (pinned releases only): set to verify the
|
||||
# download. The mirror publishes .sha256 sidecars next to pinned builds, e.g.
|
||||
# https://ffmpeg.martin-riedl.de/download/linux/amd64/<id>_9.0/ffmpeg.zip.sha256
|
||||
# (the /redirect/latest/ URL itself has no sidecar — pin the effective URL).
|
||||
ARG FFMPEG_SHA256=
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# 1. Rust dependencies first: only the manifests plus stub sources, so the
|
||||
# expensive dependency fetch + compile lives in a layer invalidated only by
|
||||
# manifest/lock changes.
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/x-media/Cargo.toml crates/x-media/Cargo.toml
|
||||
COPY crates/xmedia-bot/Cargo.toml crates/xmedia-bot/Cargo.toml
|
||||
RUN mkdir -p crates/x-media/src crates/xmedia-bot/src \
|
||||
&& printf 'fn main() {}\n' > crates/xmedia-bot/src/main.rs \
|
||||
&& : > crates/x-media/src/lib.rs \
|
||||
&& cargo build --release --locked -p xmedia-bot
|
||||
|
||||
# 2. Static ffmpeg next (cached unless FFMPEG_URL changes), so source edits
|
||||
# never re-download it. The zip contains a single `ffmpeg` binary at the
|
||||
# root. `unzip -t` verifies the archive before extraction so a bad
|
||||
# download fails loudly here instead of a cryptic later error.
|
||||
RUN wget -q -O /tmp/ffmpeg.zip "$FFMPEG_URL" \
|
||||
&& if [ -n "$FFMPEG_SHA256" ]; then echo "$FFMPEG_SHA256 /tmp/ffmpeg.zip" | sha256sum -c -; fi \
|
||||
&& unzip -tq /tmp/ffmpeg.zip \
|
||||
&& unzip -q /tmp/ffmpeg.zip -d /usr/local/bin \
|
||||
&& chmod +x /usr/local/bin/ffmpeg \
|
||||
&& rm /tmp/ffmpeg.zip \
|
||||
&& /usr/local/bin/ffmpeg -version >/dev/null
|
||||
|
||||
# 3. Real sources last: only our crates recompile on source changes. Cargo's
|
||||
# freshness check is mtime-based; the COPY'd host files usually predate the
|
||||
# step-1 stub build, so cargo would consider the stub up to date and never
|
||||
# compile the real sources. `touch` makes every .rs newer than the stub
|
||||
# artifacts, forcing a rebuild of just the two crates while the compiled
|
||||
# dependency layer stays cached. (`cargo clean -p` does NOT work here — it
|
||||
# removes 0 files and the stub binary silently ships.)
|
||||
COPY crates/ ./crates/
|
||||
RUN find crates -type f -name '*.rs' -exec touch {} + \
|
||||
&& cargo build --release --locked -p xmedia-bot
|
||||
|
||||
# ---------- runtime stage ----------
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
# ARG scope is per-stage: re-declare for the label below.
|
||||
ARG APP_NAME=telegram-twitter-media-bot
|
||||
|
||||
LABEL maintainer="admin@yoursfunny.top"
|
||||
LABEL org.opencontainers.image.title="${APP_NAME}"
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y gosu; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
# verify that the binary works
|
||||
gosu nobody true
|
||||
# Everything is copied in — no apt in the runtime stage. Privilege dropping is
|
||||
# done by docker-entrypoint.sh with setpriv (util-linux, already in
|
||||
# bookworm-slim), so no gosu needed. TLS is rustls (webpki-roots baked in,
|
||||
# see Cargo.toml feature `rustls`/`rustls-tls`), so no system CA bundle or
|
||||
# libssl are needed; the static ffmpeg only processes local files (all
|
||||
# downloads go through reqwest).
|
||||
COPY --from=builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /build/target/release/xmedia-bot /usr/local/bin/xmedia-bot
|
||||
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
|
||||
RUN chmod a+x /app/docker-entrypoint.sh
|
||||
|
||||
COPY requirements.txt /app
|
||||
RUN python -m pip install --no-cache-dir --upgrade -r requirements.txt
|
||||
|
||||
COPY . /app
|
||||
|
||||
RUN chmod a+x docker-entrypoint.sh
|
||||
|
||||
# State lives in /app/data (SQLite task queue + chat state); mount a volume
|
||||
# there to keep it across restarts.
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
CMD ["xmedia-bot"]
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
# 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 (such a query answers empty rather than spinning or re-fetching); 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
|
||||
|
||||
```bash
|
||||
# Required: BotFather token; optional: PIXIV_REFRESH_TOKEN (Pixiv is disabled without it)
|
||||
export TELOXIDE_TOKEN=<token>
|
||||
export PIXIV_REFRESH_TOKEN=<token>
|
||||
|
||||
cargo run -p xmedia-bot
|
||||
```
|
||||
|
||||
Docker deployment (`docker-compose.yml` in this repo is the orchestration; instance values live in the `.env` next to it, and compose substitutes every `${VAR}` from there):
|
||||
|
||||
```bash
|
||||
cp .env.example .env # fill in TELOXIDE_TOKEN and the rest; every line is commented
|
||||
docker build -t tgxmb .
|
||||
docker run --rm -d --name tgxmb --env-file .env -v ./data:/app/data tgxmb
|
||||
# or use the bundled orchestration (nginx-proxy + acme-companion):
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
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` ships an [nginx-proxy](https://github.com/nginx-proxy/nginx-proxy) + [acme-companion](https://github.com/nginx-proxy/acme-companion) reverse-proxy orchestration. The committed file needs **no editing**: domain, tokens and admins are instance values and live in the `.env` beside it (compose reads and substitutes `${VAR}` at startup). Pick one deployment shape:
|
||||
|
||||
**With a domain**
|
||||
1. Point a DNS A record at the server
|
||||
2. In `.env` set `VIRTUAL_HOST` and `WEBHOOK_URL` to the domain; to have acme-companion issue the certificate, also uncomment the `ACME_HOST` line in `docker-compose.yml` and set `ACME_HOST` in `.env`
|
||||
3. acme-companion issues and renews certificates automatically — nothing manual
|
||||
|
||||
**IP only**
|
||||
Let's Encrypt can issue certificates for public IPs (available since 2026, validity ~7 days, requires the `shortlived` profile). Use [acme.sh](https://github.com/acmesh-official/acme.sh) to issue and renew automatically, no manual certificates:
|
||||
|
||||
1. Add an acme-ip service to compose (issue + daily auto-renewal check):
|
||||
```yaml
|
||||
acme-ip:
|
||||
image: neilpang/acme.sh
|
||||
container_name: acme-ip
|
||||
command: daemon
|
||||
restart: always
|
||||
volumes:
|
||||
- certs:/acme.sh
|
||||
- html:/usr/share/nginx/html
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks: [proxy]
|
||||
```
|
||||
2. First issuance (replace `<SERVER_IP>` with the server's public IP; IPv6 works too, repeat `-d` for more):
|
||||
```bash
|
||||
docker compose exec acme-ip acme.sh --issue --server letsencrypt \
|
||||
-d <SERVER_IP> --cert-profile shortlived --days 3 \
|
||||
--webroot /usr/share/nginx/html \
|
||||
--install-cert --cert-file /acme.sh/<SERVER_IP>.crt \
|
||||
--key-file /acme.sh/<SERVER_IP>.key \
|
||||
--reloadcmd "curl --unix-socket /var/run/docker.sock -X POST http://localhost/containers/nginx-proxy/kill?signal=HUP"
|
||||
```
|
||||
3. In `.env` set `VIRTUAL_HOST=<SERVER_IP>` and `WEBHOOK_URL=https://<SERVER_IP>/`; no `WEBHOOK_CERT` needed. Renewal is handled by the acme.sh daemon (`--days 3` = renew every 3 days, buffer against the 7-day validity), and a successful renewal HUP-notifies nginx-proxy to load the new certificate.
|
||||
|
||||
Limitations: certificate validity ~7 days; only http-01/tls-alpn-01 validation (port 80 must be publicly reachable); no DNS-01, private IPs or IP ranges; at most 5 certificates per 168 hours for the same IP set. It is recommended to trial-issue with `--server letsencrypt_test` first, then switch to the production server.
|
||||
|
||||
Telegram only accepts ports 443/80/88/8443.
|
||||
|
||||
<details>
|
||||
<summary>Environment variables</summary>
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `TELOXIDE_TOKEN` | Bot token (required) |
|
||||
| `PIXIV_REFRESH_TOKEN` | Pixiv refresh token; Pixiv is disabled without it (a pixiv link then gets an explicit "site not enabled" reply instead of silence) |
|
||||
| `TWITTER_AUTH_TOKEN` | Optional; the `auth_token` cookie of a logged-in x.com session, used only to fetch NSFW tweets' media |
|
||||
| `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). **Never leave it blank** (`TELOXIDE_PROXY=`) — teloxide panics on a value it cannot parse; omit the line when unused. `docker-compose.yml` deliberately does not pass it to the container (a `127.0.0.1` proxy there is the container itself): add the line and use `host.docker.internal:<port>` when a deployment needs one |
|
||||
| `LOCAL_USER_ID` | UID the container runs as, default 9001 |
|
||||
| `VIRTUAL_HOST` | Public domain or IP; nginx-proxy routes by this (set it in `.env`, which compose reads) |
|
||||
| `VIRTUAL_PORT` | Port the bot listens on inside the container; nginx-proxy's forwarding target |
|
||||
| `ACME_HOST` | Domain deployment: when set to the domain, acme-companion issues/renews certificates automatically |
|
||||
| `DEFAULT_HOST` | nginx-proxy routes requests with unknown Host headers to this vhost (needed for IP access) |
|
||||
| `DEFAULT_EMAIL` | acme-companion certificate notification email |
|
||||
| `WEBHOOK` | `true` enables webhook mode (polling by default) |
|
||||
| `WEBHOOK_LISTEN` / `WEBHOOK_PORT` | Listen address/port inside the bot container |
|
||||
| `WEBHOOK_URL` | Public HTTPS URL (`https://domain/` or `https://IP/`) |
|
||||
| `WEBHOOK_CERT` | Optional; self-signed certificate path, only used for Telegram-side validation (TLS is terminated by the reverse proxy) |
|
||||
| `WEBHOOK_SECRET_TOKEN` | Update validation token (`X-Telegram-Bot-Api-Secret-Token`) |
|
||||
|
||||
</details>
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `/start` | Welcome message |
|
||||
| `/help` | List all commands and usage (this command table) |
|
||||
| `/set_forward_channel <channel>` | Set the forward channel: `@channel` or channel ID; media messages are forwarded to it automatically afterwards |
|
||||
| `/remove_forward_channel` | Remove the forward channel |
|
||||
| `/edit_before_forward` | Toggle "edit before forward": when enabled, the bot posts a prompt after forwarding; replying to it edits the first forwarded message's caption (or 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`
|
||||
@@ -0,0 +1,140 @@
|
||||
# TelegramXMediaBot
|
||||
|
||||
Telegram 机器人,将 X / Twitter、Pixiv、Bluesky、Misskey (misskey.io)、Bilibili 动态的帖子链接转换为媒体消息发送,附带帖子标题、作者与标签。
|
||||
|
||||
## 功能
|
||||
|
||||
- 私聊发送链接后自动抓取并发送图片、视频与 GIF,超量图片自动分批(每批 10 张)
|
||||
- 纯文字帖提示无媒体;不支持的链接静默忽略。抓取失败会按原因分别提示(帖子已删除 / 内容受限 / 源站风控 / 站点未启用)
|
||||
- 长帖(正文 ≥ `CAPTION_QUOTE_TEXT_CHARS`,默认 200)的**正文部分**用可折叠引用块展示,链接与作者行留在引用块外
|
||||
- 支持内联查询(`@机器人 <链接>`;Pixiv 图片与本地转码的动图不支持内联 —— Telegram 取图时无法携带 Referer,会显示破图,因此跳过;这类查询直接返回空结果,不会一直转圈或反复请求);在群聊里发链接会提示改用私聊或内联查询(频道内保持静默)
|
||||
- `/start` 说明支持的站点与用法,`/help` 列出命令、参数格式、caption 占位符与私聊限制;bot 资料页(description / short description)启动时一并设置
|
||||
- `/settings` 查看本聊天配置(转发频道、转发前编辑开关、各站点 caption 格式、模板列表);模板可用 `/set_template` 增、`/remove_template` 删
|
||||
- 可绑定转发频道自动转发;支持转发前编辑 caption 与自定义模板(提示消息带 Confirm / Skip 按钮并写明过期时间,过期后就地标记为已过期)
|
||||
- 发送失败自动重试并持久化,重试耗尽后通知用户;提示会写明是哪条链接、重试等待多久、或最终失败的原因
|
||||
- 抓取期间持续显示"正在输入 / 正在发送"状态,长任务(ugoira 转码、大图上传)不会看起来卡死
|
||||
- Pixiv ugoira 动图自动转码为 MP4;Bluesky 视频自动转码(HLS 流 → MP4)
|
||||
- 超过 Telegram 尺寸/大小限制的图片自动压缩(保持原格式,必要时转 JPEG)
|
||||
- 链接结果本地缓存:成功发送后缓存 Telegram file id 与 caption 等,再次收到相同链接直接本地重发,不再请求源站、不保存媒体文件(`LINK_CACHE_TTL_SECONDS` 控制过期,默认 7 天)
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 必填:BotFather 的 token;可选:PIXIV_REFRESH_TOKEN(未设置则禁用 Pixiv)
|
||||
export TELOXIDE_TOKEN=<token>
|
||||
export PIXIV_REFRESH_TOKEN=<token>
|
||||
|
||||
cargo run -p xmedia-bot
|
||||
```
|
||||
|
||||
Docker 部署(编排见仓库里的 `docker-compose.yml`,实例相关的值写在同目录的 `.env`,compose 会自动替换其中的 `${VAR}`):
|
||||
|
||||
```bash
|
||||
cp .env.example .env # 填 TELOXIDE_TOKEN 等,逐项都有注释
|
||||
docker build -t tgxmb .
|
||||
docker run --rm -d --name tgxmb --env-file .env -v ./data:/app/data tgxmb
|
||||
# 或者用仓库里的编排(含 nginx-proxy + acme-companion):
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
环境变量:`TELOXIDE_TOKEN`(必填)、`PIXIV_REFRESH_TOKEN`、`BOT_ADMIN`、`EDIT_MESSAGE_TTL_SECONDS`、`LINK_CACHE_TTL_SECONDS`、`RUST_LOG`、`TELOXIDE_PROXY`、`WEBHOOK*`、`TWITTER_AUTH_TOKEN`(可选)、`BILIBILI_COOKIE`(可选)。
|
||||
|
||||
NSFW 推文:公开的 syndication 接口不返回敏感内容。设置 `TWITTER_AUTH_TOKEN`(登录 x.com 后浏览器 Cookie 里的 `auth_token` 值)后,bot 会仅在遇到 NSFW 推文时以登录态获取媒体;未设置则回复该推文内容受限(需要配置 `TWITTER_AUTH_TOKEN`)。
|
||||
|
||||
Bilibili 动态默认匿名抓取(无需登录,bot 会自动从 B 站的匿名指纹接口取 `buvid3`/`buvid4` 设备 cookie 以提高成功率)。若服务器出口 IP 被 B 站重度风控(日志里的 `risk control (-352)` 或 HTTP 412,且持续出现),设置 `BILIBILI_COOKIE`(登录后浏览器里整条 Cookie 串,如 `SESSDATA=…; bili_jct=…`)可恢复访问。当前只发送动态里的图片与动图,动态内嵌视频发送其封面。
|
||||
|
||||
### Webhook 部署(需要反向代理)
|
||||
|
||||
`docker-compose.yml` 内置了 [nginx-proxy](https://github.com/nginx-proxy/nginx-proxy) + [acme-companion](https://github.com/nginx-proxy/acme-companion) 反向代理编排,仓库里的这份文件**不需要改动**:域名、令牌、管理员等实例相关的值都写在同目录的 `.env` 里(compose 启动时自动读取并替换 `${VAR}`)。按部署环境二选一:
|
||||
|
||||
**有域名**
|
||||
1. DNS A 记录指向服务器
|
||||
2. `.env` 里设 `VIRTUAL_HOST`、`WEBHOOK_URL` 为域名;要由 acme-companion 自动签发证书时,再取消 `docker-compose.yml` 里 `ACME_HOST` 那行的注释,并在 `.env` 里把 `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. `.env` 里设 `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(此时收到 pixiv 链接会明确回复「站点未启用」,不会静默忽略) |
|
||||
| `TWITTER_AUTH_TOKEN` | 可选;登录 x.com 后浏览器 Cookie 里的 `auth_token`,仅在遇到 NSFW 推文时以登录态获取媒体 |
|
||||
| `BILIBILI_COOKIE` | 可选的 B 站 Cookie 串(`SESSDATA=…; bili_jct=…`),仅在出口 IP 被持续风控时才需要(设备 cookie 由 bot 自动获取) |
|
||||
| `BOT_ADMIN` | 管理员聊天 ID,逗号分隔;接收启动/停止通知 |
|
||||
| `EDIT_MESSAGE_TTL_SECONDS` | 转发前编辑记录过期秒数,默认 86400;过期后提示消息会被就地改写为「已过期,未转发」(不额外发消息打扰) |
|
||||
| `LINK_CACHE_TTL_SECONDS` | 链接结果缓存过期秒数,默认 604800(7 天) |
|
||||
| `CAPTION_QUOTE_TEXT_CHARS` | 正文(`{title}` + `{content}` 合计)达到该长度(字符)时,caption 的**正文部分**用可折叠引用块包裹,默认 200;`0` 关闭 |
|
||||
| `DATA_DIR` | 数据目录(SQLite 数据库 `task_queue.db` 所在目录),默认 `data`(相对工作目录,会自动创建) |
|
||||
| `RUST_LOG` | 日志级别,默认 `info,hyper_util=warn,reqwest=warn`(未设置也**不会**哑掉)。排障配方:`info,xmedia_bot=debug,x_media=debug`(应用细节,无依赖噪音)/ `debug,hyper_util=off`(全量)/ `trace`(额外打印完整链接与消息原文,**含用户数据**) |
|
||||
| `TELOXIDE_PROXY` | HTTP 代理(如 `http://127.0.0.1:10808`);同时作用于 Telegram Bot API 与站点抓取请求,网络受限环境(如 GFW)必需。**不要留空值**(`TELOXIDE_PROXY=`)——teloxide 对无法解析的值会直接 panic;不用代理就别写这一行。容器里要用代理时,`docker-compose.yml` 的 `environment` 里默认没有它(容器内的 `127.0.0.1` 是容器自己),需要时手动加上并把地址换成 `host.docker.internal:<port>` |
|
||||
| `LOCAL_USER_ID` | 容器内运行用户 UID,默认 9001 |
|
||||
| `VIRTUAL_HOST` | 对外域名或 IP,nginx-proxy 按此路由(写在 `.env`,compose 读取) |
|
||||
| `VIRTUAL_PORT` | bot 容器内监听端口,nginx-proxy 的转发目标 |
|
||||
| `ACME_HOST` | 域名部署:设为域名时由 acme-companion 自动签发/续期证书 |
|
||||
| `DEFAULT_HOST` | nginx-proxy 将未知 Host 的请求路由到该 vhost(IP 访问时需要) |
|
||||
| `DEFAULT_EMAIL` | acme-companion 证书通知邮箱 |
|
||||
| `WEBHOOK` | `true` 启用 webhook 模式(默认轮询) |
|
||||
| `WEBHOOK_LISTEN` / `WEBHOOK_PORT` | bot 容器内监听地址/端口 |
|
||||
| `WEBHOOK_URL` | 对外公网 HTTPS 地址(`https://域名/` 或 `https://IP/`) |
|
||||
| `WEBHOOK_CERT` | 可选;自签名证书路径,仅用于 Telegram 侧验证(TLS 由反向代理终止) |
|
||||
| `WEBHOOK_SECRET_TOKEN` | 更新校验令牌(`X-Telegram-Bot-Api-Secret-Token`) |
|
||||
|
||||
</details>
|
||||
|
||||
## 命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
|---|---|
|
||||
| `/start` | 欢迎语 |
|
||||
| `/help` | 查看全部命令及用法(即本文档的命令表) |
|
||||
| `/set_forward_channel <频道>` | 设置转发频道,参数为 `@频道名` 或频道 ID;设置后发送的媒体消息会自动转发到该频道 |
|
||||
| `/remove_forward_channel` | 取消转发频道 |
|
||||
| `/edit_before_forward` | 开关「转发前编辑」:开启后,转发成功后 bot 会发一条提示消息,回复它可修改第一条转发消息的 caption(或点击模板按钮套用模板),再点 `↩️ Confirm` 才会真正转发,`🛑 Skip` 放弃本次转发;提示消息写明过期时间,过期后原地标记为已过期且不会转发 |
|
||||
| `/set_template <名称>` | 回复一条含 `[]` 的消息,将其保存为命名模板;转发时 `[]` 会被替换为原帖链接(配合「转发前编辑」使用) |
|
||||
| `/remove_template <名称>` | 删除某个模板(名称见 `/settings`;提示消息的模板按钮最多显示 60 个) |
|
||||
| `/settings` | 查看本聊天配置:转发频道、转发前编辑开关、各站点 caption 格式、模板列表 |
|
||||
| `/set_format <站点> <格式>` | 自定义某站点的 caption 格式。站点:`twitter` / `bsky` / `pixiv` / `misskey` / `bilibili`。占位符:`{url}` `{author}` `{author_url}` `{title}` `{content}` `{tags}`;未识别的占位符会被拒绝并列出可用项,格式填 `-` 恢复站点默认格式(可用 `/debug <链接>` 预览效果) |
|
||||
| `/clear_cache [链接]` | 清空链接缓存(仅管理员);带链接只清该条,否则清空全部 |
|
||||
| `/bot_dict` | 查看当前聊天状态(调试用;仅管理员) |
|
||||
| `/test <链接>` | 解析链接并发送媒体;不转发到频道、不弹转发前编辑提示(仅发送) |
|
||||
| `/debug <链接>` | 调试:只解析链接并返回解析结果(站点、标题、作者、标签、媒体列表),不发送任何媒体 |
|
||||
|
||||
链接处理仅限私聊;命令在任意聊天可用。在群聊里发受支持的链接会回复一条提示(改用私聊或内联查询),频道内保持静默。
|
||||
|
||||
## 备注
|
||||
|
||||
- 数据持久化于 `data/task_queue.db`,compose 部署使用 bind mount `./data`(保持目录形式便于备份)
|
||||
- 运行环境需安装 ffmpeg(Docker 镜像已内置)
|
||||
- 测试:`cargo test --workspace`
|
||||
@@ -1,26 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
BOT_TOKEN = os.getenv("BOT_TOKEN")
|
||||
ADMIN = [int(i) for i in os.getenv("BOT_ADMIN").split(",")]
|
||||
|
||||
WEBHOOK = os.getenv("WEBHOOK", False)
|
||||
if WEBHOOK:
|
||||
WEBHOOK_LISTEN = os.getenv("WEBHOOK_LISTEN", "0.0.0.0")
|
||||
WEBHOOK_PORT = int(os.getenv("WEBHOOK_PORT", 8443))
|
||||
WEBHOOK_URL = os.getenv("WEBHOOK_URL")
|
||||
WEBHOOK_KEY = os.getenv("WEBHOOK_KEY", "cert/private.key")
|
||||
WEBHOOK_CERT = os.getenv("WEBHOOK_CERT", "cert/cert.pem")
|
||||
WEBHOOK_SECRET_TOKEN = os.getenv("WEBHOOK_SECRET_TOKEN")
|
||||
|
||||
x_url_regex = re.compile(r"^(?:https?://)(?:www\.|mobile\.|)(?:x|twitter)\.com/(.+)/status/(\d+)")
|
||||
x_media_regex = re.compile(r"^(?:https?://)(pbs|video)\.twimg\.com/(.*)")
|
||||
x_tco_regex = re.compile(r"(?:https?://)t\.co/.+$", re.M)
|
||||
message_url_regex = re.compile(r"\[.+]", re.S)
|
||||
|
||||
logging.basicConfig(
|
||||
level=os.getenv("LOG_LEVEL", "WARNING"),
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "x-media"
|
||||
version = "1.9.1"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip", "http2"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
regex = "1.12"
|
||||
html-escape = "0.2"
|
||||
url = "2.5.2"
|
||||
bytes = "1"
|
||||
zip = "8"
|
||||
tempfile = "3"
|
||||
thiserror = "2"
|
||||
rand = "0.10"
|
||||
log = "0.4"
|
||||
tokio = { version = "1.40", features = ["time", "rt", "fs"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.40", features = ["macros", "rt-multi-thread"] }
|
||||
dotenv = "0.15"
|
||||
@@ -0,0 +1,10 @@
|
||||
use x_media::site;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let url = std::env::args()
|
||||
.nth(1)
|
||||
.expect("usage: cargo run -p x-media --example fetch -- <url>");
|
||||
let result = site::fetch(&url).await;
|
||||
println!("{result:#?}");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod media;
|
||||
pub mod site;
|
||||
|
||||
/// Prefix every temp file and temp dir this project creates, so a startup
|
||||
/// sweep can recognise its own leftovers: a killed process leaves them behind
|
||||
/// (`TempDir`/`NamedTempFile` clean up on drop, and a killed process runs no
|
||||
/// destructors), and without a marker the only safe assumption about the OS
|
||||
/// temp directory is "not mine".
|
||||
pub const TEMP_FILE_PREFIX: &str = "tgxmb-";
|
||||
@@ -0,0 +1,55 @@
|
||||
impl Media {
|
||||
pub fn url(&self) -> &str {
|
||||
match self {
|
||||
Media::Illustration { url, .. } => url,
|
||||
Media::Video { url, .. } => url,
|
||||
Media::Animated { url, .. } => url,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn thumbnail_url(&self) -> Option<&str> {
|
||||
match self {
|
||||
Media::Illustration { thumbnail_url, .. } => thumbnail_url.as_deref(),
|
||||
Media::Video { thumbnail_url, .. } => Some(thumbnail_url),
|
||||
Media::Animated { thumbnail_url, .. } => Some(thumbnail_url),
|
||||
}
|
||||
}
|
||||
|
||||
/// A smaller variant of this media's file (used as the fallback when the
|
||||
/// primary URL or upload exceeds Telegram's size limits). None when no
|
||||
/// smaller variant exists (videos, animated gifs).
|
||||
pub fn smaller_url(&self) -> Option<&str> {
|
||||
match self {
|
||||
Media::Illustration {
|
||||
url,
|
||||
fallback_url,
|
||||
thumbnail_url,
|
||||
..
|
||||
} => fallback_url
|
||||
.as_deref()
|
||||
.or(thumbnail_url.as_deref())
|
||||
.filter(|smaller| *smaller != url),
|
||||
Media::Video { .. } | Media::Animated { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Media {
|
||||
Illustration {
|
||||
title: Option<String>,
|
||||
url: String,
|
||||
thumbnail_url: Option<String>,
|
||||
fallback_url: Option<String>,
|
||||
},
|
||||
Video {
|
||||
title: Option<String>,
|
||||
url: String,
|
||||
thumbnail_url: String,
|
||||
},
|
||||
Animated {
|
||||
title: Option<String>,
|
||||
url: String,
|
||||
thumbnail_url: String,
|
||||
},
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
mod interface;
|
||||
mod model;
|
||||
|
||||
pub use interface::{
|
||||
BilibiliSite, PATTERN, cache_key, enabled, fetch_from_url, is_retryable, media_headers,
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Serde DTOs for the Bilibili dynamic detail endpoint
|
||||
//! (`/x/polymer/web-dynamic/v1/detail`), mirroring live responses
|
||||
//! (field paths verified 2026-09-17). Every field is optional so an API
|
||||
//! shape change degrades to "no media" instead of a parse failure.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Detail {
|
||||
/// Business code: `0` = OK, `-352`/`-412` = risk control, `500`/`4101147`
|
||||
/// = gone.
|
||||
pub(crate) code: i64,
|
||||
#[serde(default)]
|
||||
pub(crate) message: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) data: Option<Data>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Data {
|
||||
#[serde(default)]
|
||||
pub(crate) item: Option<Box<Item>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Item {
|
||||
/// The dynamic id, same numeric id as in the URL.
|
||||
#[serde(default)]
|
||||
pub(crate) id_str: String,
|
||||
#[serde(default)]
|
||||
pub(crate) modules: Option<Modules>,
|
||||
/// The quoted dynamic when this item is a forward. A forward shell often
|
||||
/// carries no media of its own — the original holds it.
|
||||
#[serde(default)]
|
||||
pub(crate) orig: Option<Box<Item>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Modules {
|
||||
#[serde(default)]
|
||||
pub(crate) module_author: Option<Author>,
|
||||
#[serde(default)]
|
||||
pub(crate) module_dynamic: Option<Dynamic>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Author {
|
||||
#[serde(default)]
|
||||
pub(crate) name: String,
|
||||
#[serde(default)]
|
||||
pub(crate) mid: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Dynamic {
|
||||
#[serde(default)]
|
||||
pub(crate) desc: Option<Desc>,
|
||||
#[serde(default)]
|
||||
pub(crate) major: Option<Major>,
|
||||
/// A single topic (`{"id":…,"name":…}`), the dynamic's only tag source.
|
||||
#[serde(default)]
|
||||
pub(crate) topic: Option<Topic>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Desc {
|
||||
#[serde(default)]
|
||||
pub(crate) text: String,
|
||||
}
|
||||
|
||||
/// `major` is a tagged union: `type` (`MAJOR_TYPE_DRAW` / `_OPUS` /
|
||||
/// `_ARCHIVE` / …) plus one payload object per type. Only the three payloads
|
||||
/// this adapter reads are modeled; an unknown major simply yields no media.
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Major {
|
||||
#[serde(default)]
|
||||
pub(crate) draw: Option<Draw>,
|
||||
#[serde(default)]
|
||||
pub(crate) opus: Option<Opus>,
|
||||
#[serde(default)]
|
||||
pub(crate) archive: Option<Archive>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Draw {
|
||||
#[serde(default)]
|
||||
pub(crate) items: Vec<Pic>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Pic {
|
||||
/// `major.draw` image URL.
|
||||
#[serde(default)]
|
||||
pub(crate) src: Option<String>,
|
||||
/// `major.opus.pics` image URL — the opus shape names the field
|
||||
/// differently while carrying the same image.
|
||||
#[serde(default)]
|
||||
pub(crate) url: Option<String>,
|
||||
}
|
||||
|
||||
impl Pic {
|
||||
/// The image URL, whichever key this serialization put it under.
|
||||
pub(crate) fn url(&self) -> Option<&str> {
|
||||
self.src.as_deref().or(self.url.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// `major.opus`: the serialization of an image/text post the web client asks
|
||||
/// for (`features=itemOpusStyle`). It carries the parts the legacy shape drops
|
||||
/// entirely — the document title and body of an opus post, whose
|
||||
/// `module_dynamic.desc` comes back `null`.
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Opus {
|
||||
/// Document headline; often absent.
|
||||
#[serde(default)]
|
||||
pub(crate) title: Option<String>,
|
||||
/// Document body (untruncated: a 307-char sample came back whole).
|
||||
#[serde(default)]
|
||||
pub(crate) summary: Option<Desc>,
|
||||
#[serde(default)]
|
||||
pub(crate) pics: Vec<Pic>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Archive {
|
||||
/// The attached video's cover — the only image an AV dynamic has (the
|
||||
/// video itself is deliberately not resolved, see the module docs).
|
||||
#[serde(default)]
|
||||
pub(crate) cover: Option<String>,
|
||||
/// The video's title. An AV dynamic has no body of its own (`desc` comes
|
||||
/// back `null`), so this card title is the post's content.
|
||||
#[serde(default)]
|
||||
pub(crate) title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Topic {
|
||||
#[serde(default)]
|
||||
pub(crate) name: String,
|
||||
}
|
||||
|
||||
/// Response of the anonymous fingerprint endpoint (`/x/frontend/finger/spi`),
|
||||
/// the source of the adapter's device cookies.
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Fingerprint {
|
||||
#[serde(default)]
|
||||
pub(crate) data: Option<FingerprintData>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct FingerprintData {
|
||||
/// Sent as the `buvid3` cookie.
|
||||
#[serde(default, rename = "b_3")]
|
||||
pub(crate) buvid3: String,
|
||||
/// Sent as the `buvid4` cookie.
|
||||
#[serde(default, rename = "b_4")]
|
||||
pub(crate) buvid4: String,
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
use super::model;
|
||||
use crate::media::Media;
|
||||
use crate::site::{FetchError, Fetched, Site, SiteFuture};
|
||||
use html_escape::{encode_double_quoted_attribute, encode_text};
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// Registry entry for the bluesky adapter (see [`crate::site::Site`]).
|
||||
pub struct BskySite;
|
||||
|
||||
impl Site for BskySite {
|
||||
fn id(&self) -> &'static str {
|
||||
"bsky"
|
||||
}
|
||||
|
||||
fn pattern(&self) -> &'static Regex {
|
||||
&PATTERN
|
||||
}
|
||||
|
||||
fn cache_key(&self, url: &str) -> Option<String> {
|
||||
cache_key(url)
|
||||
}
|
||||
|
||||
fn fetch_from_url<'a>(&'a self, url: &'a str) -> SiteFuture<'a, Fetched> {
|
||||
Box::pin(async move { fetch_from_url(url).await })
|
||||
}
|
||||
}
|
||||
|
||||
pub static PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"^(?:https?://)?bsky\.app/profile/([\w.\-:]+)/post/([\w.\-~]+)").unwrap()
|
||||
});
|
||||
|
||||
pub fn enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
|
||||
let caps = PATTERN.captures(url).ok_or(FetchError::NotFound)?;
|
||||
let handle = caps
|
||||
.get(1)
|
||||
.map(|m| m.as_str())
|
||||
.ok_or(FetchError::NotFound)?;
|
||||
let rkey = caps
|
||||
.get(2)
|
||||
.map(|m| m.as_str())
|
||||
.ok_or(FetchError::NotFound)?;
|
||||
let post = fetch(handle, rkey).await?;
|
||||
let mut fetched: Fetched = post.into();
|
||||
// bsky video embeds expose only an HLS playlist URL, which Telegram
|
||||
// cannot fetch; remux it to a single MP4 (mirrors the pixiv ugoira
|
||||
// encode path — the temp file stays alive via `_keep_alive`). On any
|
||||
// failure the video item is dropped and the post degrades to its text.
|
||||
let mut media = Vec::with_capacity(fetched.media.len());
|
||||
// The remux warnings below name the post, not the CDN URL they were
|
||||
// working on: the media URL is derived from what the user pasted, and
|
||||
// `warn` is a level operators share.
|
||||
let key = cache_key(url).unwrap_or_else(|| "?".into());
|
||||
// A failed remux is remembered: if it leaves the post with no media at
|
||||
// all, returning `Ok` would read as "this post has no media". It is
|
||||
// reported as `FetchError::MediaPrep` rather than a transient failure —
|
||||
// the download legs already got their own retry in place ([`fetch_hls`]),
|
||||
// and the fetch loop's retry would only download every segment again to
|
||||
// fail the same way.
|
||||
let mut remux_failure: Option<String> = None;
|
||||
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(std::sync::Arc::new(keep_alive));
|
||||
}
|
||||
// No ffmpeg: a deployment gap, not a bad moment — retrying it
|
||||
// would only waste the fetch budget, so the post degrades (and an
|
||||
// all-video post reports the media type as unsupported).
|
||||
Ok(None) => log::warn!("bsky video remux unavailable for [key={key}]"),
|
||||
Err(e) => {
|
||||
log::warn!("bsky video remux failed for [key={key}]: {e}");
|
||||
remux_failure = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if media.is_empty()
|
||||
&& let Some(reason) = remux_failure
|
||||
{
|
||||
return Err(FetchError::MediaPrep(format!(
|
||||
"bsky video remux failed: {reason}"
|
||||
)));
|
||||
}
|
||||
fetched.media = media;
|
||||
Ok(fetched)
|
||||
}
|
||||
|
||||
/// Cache key for a bsky URL: `"bsky:<handle>/<rkey>"`. The prefix is the
|
||||
/// site id used for caption-format lookup and link-cache keys.
|
||||
pub fn cache_key(url: &str) -> Option<String> {
|
||||
PATTERN
|
||||
.captures(url)
|
||||
.map(|caps| format!("bsky:{}/{}", &caps[1], &caps[2]))
|
||||
}
|
||||
|
||||
/// Bluesky's fetch-retry policy: transient classes only. Not-found, blocked
|
||||
/// and parse failures are permanent.
|
||||
pub fn is_retryable(err: &FetchError) -> bool {
|
||||
matches!(err, FetchError::Http(_) | FetchError::Transient(_))
|
||||
}
|
||||
|
||||
/// bsky media (cdn.bsky.app) needs no extra headers.
|
||||
pub fn media_headers(_url: &str) -> Option<Vec<(&'static str, String)>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Segments fetched (and written) at once while remuxing an HLS video. Small
|
||||
/// on purpose: a segment can be up to 20 MiB and the whole playlist is capped
|
||||
/// at 256 MiB, so this is also what bounds the remux's peak memory.
|
||||
const SEGMENT_CONCURRENCY: usize = 4;
|
||||
|
||||
/// The ffmpeg concat list for the downloaded segments, **in segment order**.
|
||||
/// The downloads complete in completion order (`JoinSet`), and ffmpeg would
|
||||
/// happily concatenate them in whatever order the list holds: an out-of-order
|
||||
/// list produces a silently scrambled video, not an error.
|
||||
fn concat_list(files: &mut [(usize, std::path::PathBuf)]) -> String {
|
||||
files.sort_by_key(|(i, _)| *i);
|
||||
files
|
||||
.iter()
|
||||
.map(|(_, path)| format!("file '{}'\n", path.to_string_lossy()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One HLS fetch (a playlist or a segment) with an in-place retry for a
|
||||
/// retryable class (transport, 429/5xx). These used to get their retry from the
|
||||
/// outer fetch loop, which pays for it by replaying the whole post: master
|
||||
/// playlist, variant playlist and every segment again. A segment failing near
|
||||
/// the end of a 500-segment video meant downloading the entire thing twice
|
||||
/// more, so the second attempt belongs on the request that actually failed.
|
||||
async fn fetch_hls(url: &str, cap: u64) -> Result<bytes::Bytes, String> {
|
||||
match crate::site::download_media_limited(url, cap).await {
|
||||
Err(FetchError::Http(_) | FetchError::Transient(_)) => {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
crate::site::download_media_limited(url, cap)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
other => other.map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = fetch_hls(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 = fetch_hls(&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::Builder::new()
|
||||
.prefix(crate::TEMP_FILE_PREFIX)
|
||||
.tempdir()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let out_dir = tempfile::Builder::new()
|
||||
.prefix(crate::TEMP_FILE_PREFIX)
|
||||
.tempdir()
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Segments are fetched concurrently under a small bound, and written with
|
||||
// `tokio::fs` (a multi-megabyte `std::fs::write` blocks the executor
|
||||
// thread). Serially, a several-hundred-segment video made the user wait
|
||||
// for every round trip in turn — the dominant cost of a remux.
|
||||
let mut total: u64 = 0;
|
||||
let mut written: Vec<(usize, std::path::PathBuf)> = Vec::with_capacity(segments.len());
|
||||
let mut next = 0;
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
loop {
|
||||
while set.len() < SEGMENT_CONCURRENCY && next < segments.len() {
|
||||
let i = next;
|
||||
next += 1;
|
||||
let seg = segments[i].clone();
|
||||
let path = frames_dir.path().join(format!("seg_{i:04}.ts"));
|
||||
set.spawn(async move {
|
||||
let bytes = fetch_hls(&seg, 20 * 1024 * 1024)
|
||||
.await
|
||||
.map_err(|e| format!("bsky segment {i}: {e}"))?;
|
||||
tokio::fs::write(&path, &bytes)
|
||||
.await
|
||||
.map_err(|e| format!("bsky segment {i}: {e}"))?;
|
||||
Ok::<_, String>((i, bytes.len() as u64, path))
|
||||
});
|
||||
}
|
||||
let Some(joined) = set.join_next().await else {
|
||||
break;
|
||||
};
|
||||
let (i, len, path) = joined.map_err(|e| format!("bsky segment task panicked: {e}"))??;
|
||||
total += len;
|
||||
if total > 256 * 1024 * 1024 {
|
||||
return Err("bsky video exceeds total size cap".to_string());
|
||||
}
|
||||
written.push((i, path));
|
||||
}
|
||||
let list = concat_list(&mut written);
|
||||
let list_path = frames_dir.path().join("list.txt");
|
||||
tokio::fs::write(&list_path, &list)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let output = out_dir.path().join("video.mp4");
|
||||
let list_str = list_path.to_string_lossy().into_owned();
|
||||
let output_str = output.to_string_lossy().into_owned();
|
||||
let status = tokio::task::spawn_blocking(move || {
|
||||
std::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
&list_str,
|
||||
"-c",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
&output_str,
|
||||
])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("bsky remux worker panicked: {e}"))?;
|
||||
match status {
|
||||
Ok(s) if s.success() => Ok(Some((output, out_dir))),
|
||||
Ok(s) => Err(format!("ffmpeg exited with {s}")),
|
||||
Err(e) => Err(format!("ffmpeg spawn failed: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches a post thread by handle or DID (`at://` URIs work for both).
|
||||
pub async fn fetch(handle: &str, rkey: &str) -> Result<Post, FetchError> {
|
||||
let response = crate::site::CLIENT
|
||||
.get(API_URL)
|
||||
.query(&[
|
||||
("uri", format!("at://{handle}/app.bsky.feed.post/{rkey}")),
|
||||
("depth", "0".to_string()),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
// 404/410 = gone (permanent); 429/5xx = transient and retried by fetch.
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return match status.as_u16() {
|
||||
404 | 410 => Err(FetchError::NotFound),
|
||||
// A refusal or an auth demand is not a bad moment: retrying it
|
||||
// three times only delays an error the user has to see.
|
||||
401 | 403 => Err(FetchError::Blocked),
|
||||
_ => Err(FetchError::Transient(format!("bsky status {status}"))),
|
||||
};
|
||||
}
|
||||
let text = response.text().await?;
|
||||
Post::from_json(&text, rkey.to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Post {
|
||||
id: String,
|
||||
author: String,
|
||||
author_id: String,
|
||||
text: String,
|
||||
media: Vec<Media>,
|
||||
sensitive: bool,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
fn url(&self) -> String {
|
||||
format!("{}/post/{}", self.author_url(), self.id)
|
||||
}
|
||||
|
||||
fn author_url(&self) -> String {
|
||||
format!("https://bsky.app/profile/{}", self.author_id)
|
||||
}
|
||||
|
||||
pub fn caption(&self) -> String {
|
||||
format!(
|
||||
"{url}\n<a href=\"{author_url}\">{author}</a>: {text}",
|
||||
url = encode_double_quoted_attribute(&self.url()),
|
||||
author_url = encode_double_quoted_attribute(&self.author_url()),
|
||||
author = encode_text(&self.author),
|
||||
text = encode_text(&self.text),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn from_json(raw_json: &str, id: String) -> Result<Self, FetchError> {
|
||||
let json: serde_json::Value = serde_json::from_str(raw_json).map_err(FetchError::Json)?;
|
||||
let json: model::Info = serde_json::from_value(json).map_err(FetchError::Json)?;
|
||||
match json.thread {
|
||||
model::Thread::Post { post } => {
|
||||
let text = post.record.text;
|
||||
let author = post.author.display_name.unwrap_or_default();
|
||||
let author_id = post.author.handle;
|
||||
let mut media = vec![];
|
||||
if let Some(embed) = post.embed {
|
||||
match embed {
|
||||
model::Media::Images { images } => {
|
||||
media.extend(images.into_iter().map(|image| Media::Illustration {
|
||||
title: None,
|
||||
url: image.fullsize,
|
||||
thumbnail_url: Some(image.thumb),
|
||||
fallback_url: None,
|
||||
}));
|
||||
}
|
||||
model::Media::Video {
|
||||
playlist,
|
||||
thumbnail,
|
||||
} => {
|
||||
media.push(Media::Video {
|
||||
title: None,
|
||||
url: playlist,
|
||||
thumbnail_url: thumbnail,
|
||||
});
|
||||
}
|
||||
model::Media::External => {}
|
||||
}
|
||||
}
|
||||
let sensitive = post
|
||||
.labels
|
||||
.iter()
|
||||
.any(|label| SENSITIVE_LABEL.contains(&label.val.as_str()));
|
||||
Ok(Post {
|
||||
id,
|
||||
author,
|
||||
author_id,
|
||||
text,
|
||||
media,
|
||||
sensitive,
|
||||
})
|
||||
}
|
||||
model::Thread::NotFound => Err(FetchError::NotFound),
|
||||
model::Thread::Blocked => Err(FetchError::Blocked),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Post> for Fetched {
|
||||
fn from(post: Post) -> Self {
|
||||
let url = post.url();
|
||||
let author_url = post.author_url();
|
||||
let render_data = Some(crate::site::RenderData {
|
||||
url: url.clone(),
|
||||
author: encode_text(&post.author).into_owned(),
|
||||
author_url: author_url.clone(),
|
||||
// A post has no title: its text is all content.
|
||||
title: String::new(),
|
||||
content: encode_text(&post.text).into_owned(),
|
||||
tags: String::new(),
|
||||
});
|
||||
Fetched {
|
||||
source_url: url,
|
||||
caption: post.caption(),
|
||||
title: String::new(),
|
||||
content: post.text.clone(),
|
||||
media: post.media,
|
||||
sensitive: post.sensitive,
|
||||
site_id: "bsky",
|
||||
render_data,
|
||||
_keep_alive: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const API_URL: &str = "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread";
|
||||
const SENSITIVE_LABEL: [&str; 4] = ["sexual", "nudity", "porn", "graphic-media"];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn thread_json(post_json: serde_json::Value) -> serde_json::Value {
|
||||
serde_json::json!({ "thread": post_json })
|
||||
}
|
||||
|
||||
/// The downloads finish in completion order; ffmpeg concatenates whatever
|
||||
/// order `list.txt` holds, so an unsorted list is a scrambled video rather
|
||||
/// than an error.
|
||||
#[test]
|
||||
fn concat_list_is_in_segment_order() {
|
||||
let mut files = vec![
|
||||
(2, std::path::PathBuf::from("/t/seg_0002.ts")),
|
||||
(0, std::path::PathBuf::from("/t/seg_0000.ts")),
|
||||
(1, std::path::PathBuf::from("/t/seg_0001.ts")),
|
||||
];
|
||||
assert_eq!(
|
||||
concat_list(&mut files),
|
||||
"file '/t/seg_0000.ts'\nfile '/t/seg_0001.ts'\nfile '/t/seg_0002.ts'\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_matches_handle_and_did() {
|
||||
let cases = [
|
||||
(
|
||||
"https://bsky.app/profile/user.bsky.social/post/3laoveufjv224",
|
||||
"user.bsky.social",
|
||||
"3laoveufjv224",
|
||||
),
|
||||
(
|
||||
"https://bsky.app/profile/did:plc:abc123def/post/3xxxx",
|
||||
"did:plc:abc123def",
|
||||
"3xxxx",
|
||||
),
|
||||
];
|
||||
for (url, handle, rkey) in cases {
|
||||
let caps = PATTERN.captures(url).unwrap_or_else(|| panic!("{url}"));
|
||||
assert_eq!(caps.get(1).unwrap().as_str(), handle);
|
||||
assert_eq!(caps.get(2).unwrap().as_str(), rkey);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_rejects_non_post_urls() {
|
||||
for url in [
|
||||
"https://bsky.app/profile/user.bsky.social",
|
||||
"https://bsky.app/profile/user.bsky.social/posts",
|
||||
"https://x.com/user/status/123",
|
||||
] {
|
||||
assert!(!PATTERN.is_match(url), "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A remux failure is a `MediaPrep`, which the fetch loop does not retry:
|
||||
/// replaying the post means downloading every HLS segment again, when the
|
||||
/// request that failed already got its second attempt in place
|
||||
/// ([`fetch_hls`]). The classes below are the ones still retried there.
|
||||
#[test]
|
||||
fn media_prep_failure_is_not_retried() {
|
||||
assert!(!is_retryable(&FetchError::MediaPrep(
|
||||
"bsky video remux failed: segment 400: 503".into()
|
||||
)));
|
||||
assert!(is_retryable(&FetchError::Transient("429".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_json_images_with_missing_defaults() {
|
||||
let raw = thread_json(serde_json::json!({
|
||||
"$type": "app.bsky.feed.defs#threadViewPost",
|
||||
"post": {
|
||||
"author": { "handle": "user.bsky.social" },
|
||||
"record": { "$type": "app.bsky.feed.post", "text": "hello <world>" },
|
||||
"embed": {
|
||||
"$type": "app.bsky.embed.images#view",
|
||||
"images": [
|
||||
{ "thumb": "https://cdn.bsky.app/img/thumb", "fullsize": "https://cdn.bsky.app/img/full", "alt": "" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}));
|
||||
let post = Post::from_json(&raw.to_string(), "3xxxx".into()).unwrap();
|
||||
let fetched: Fetched = post.into();
|
||||
assert_eq!(
|
||||
fetched.source_url,
|
||||
"https://bsky.app/profile/user.bsky.social/post/3xxxx"
|
||||
);
|
||||
assert_eq!(fetched.title, "");
|
||||
assert_eq!(fetched.content, "hello <world>");
|
||||
assert_eq!(fetched.media.len(), 1);
|
||||
assert!(!fetched.sensitive);
|
||||
// display_name absent -> empty fallback
|
||||
assert!(
|
||||
fetched.caption.contains("</a>: hello <world>"),
|
||||
"caption: {}",
|
||||
fetched.caption
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_json_sensitive_labels() {
|
||||
let raw = thread_json(serde_json::json!({
|
||||
"$type": "app.bsky.feed.defs#threadViewPost",
|
||||
"post": {
|
||||
"author": { "handle": "u.bsky.social", "displayName": "U" },
|
||||
"record": { "$type": "app.bsky.feed.post", "text": "x" },
|
||||
"labels": [{ "val": "porn" }]
|
||||
}
|
||||
}));
|
||||
let post = Post::from_json(&raw.to_string(), "3xxxx".into()).unwrap();
|
||||
assert!(post.sensitive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_json_blocked_and_not_found() {
|
||||
let blocked = thread_json(serde_json::json!({
|
||||
"$type": "app.bsky.feed.defs#blockedPost",
|
||||
"blocked": true
|
||||
}));
|
||||
assert!(matches!(
|
||||
Post::from_json(&blocked.to_string(), "3xxxx".into()),
|
||||
Err(FetchError::Blocked)
|
||||
));
|
||||
|
||||
let not_found = thread_json(serde_json::json!({
|
||||
"$type": "app.bsky.feed.defs#notFoundPost",
|
||||
"notFound": true
|
||||
}));
|
||||
assert!(matches!(
|
||||
Post::from_json(¬_found.to_string(), "3xxxx".into()),
|
||||
Err(FetchError::NotFound)
|
||||
));
|
||||
}
|
||||
|
||||
/// The one live bsky check: a labelled post with photos — source URL,
|
||||
/// caption, media and the sensitive label all survive the parse. This
|
||||
/// replaced a second byte-identical live test whose URL is a *text-only*
|
||||
/// post, so neither copy pinned any media.
|
||||
#[tokio::test]
|
||||
#[ignore = "live network: requires outbound HTTPS to public.api.bsky.app"]
|
||||
async fn live_fetch_with_photos() {
|
||||
let url = "https://bsky.app/profile/fu-futa.bsky.social/post/3laoveufjv224";
|
||||
let fetched = fetch_from_url(url).await.unwrap();
|
||||
assert_eq!(fetched.source_url, url);
|
||||
assert!(!fetched.caption.is_empty());
|
||||
assert!(!fetched.media.is_empty(), "expected photos in {url}");
|
||||
assert!(fetched.sensitive, "expected a label on {url}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod interface;
|
||||
mod model;
|
||||
|
||||
pub use interface::{
|
||||
BskySite, PATTERN, Post, cache_key, enabled, fetch_from_url, is_retryable, media_headers,
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Info {
|
||||
pub(crate) thread: Thread,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(tag = "$type")]
|
||||
pub(crate) enum Thread {
|
||||
#[serde(rename = "app.bsky.feed.defs#threadViewPost")]
|
||||
Post { post: Post },
|
||||
#[serde(rename = "app.bsky.feed.defs#notFoundPost")]
|
||||
NotFound,
|
||||
#[serde(rename = "app.bsky.feed.defs#blockedPost")]
|
||||
Blocked,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Post {
|
||||
pub(crate) author: Author,
|
||||
pub(crate) record: PostRecord,
|
||||
pub(crate) embed: Option<Media>,
|
||||
#[serde(default)]
|
||||
pub(crate) labels: Vec<Label>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Author {
|
||||
pub(crate) handle: String,
|
||||
#[serde(rename = "displayName", default)]
|
||||
pub(crate) display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct PostRecord {
|
||||
pub(crate) text: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(tag = "$type")]
|
||||
pub(crate) enum Media {
|
||||
#[serde(rename = "app.bsky.embed.images#view")]
|
||||
Images { images: Vec<Image> },
|
||||
#[serde(rename = "app.bsky.embed.video#view")]
|
||||
Video { playlist: String, thumbnail: String },
|
||||
#[serde(rename = "app.bsky.embed.external#view")]
|
||||
External,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Image {
|
||||
pub(crate) thumb: String,
|
||||
pub(crate) fullsize: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Label {
|
||||
pub(crate) val: String,
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
//! Site adapter for misskey.io notes: URL pattern, API fetch and
|
||||
//! normalization into [`Fetched`] (see [`crate::site::Site`]).
|
||||
|
||||
use super::model;
|
||||
use crate::media::Media;
|
||||
use crate::site::{FetchError, Fetched, RenderData, Site, SiteFuture};
|
||||
use html_escape::{encode_double_quoted_attribute, encode_text};
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
const API_URL: &str = "https://misskey.io/api/notes/show";
|
||||
|
||||
/// Registry entry for the misskey.io adapter (see [`crate::site::Site`]).
|
||||
pub struct MisskeySite;
|
||||
|
||||
impl Site for MisskeySite {
|
||||
fn id(&self) -> &'static str {
|
||||
"misskey"
|
||||
}
|
||||
|
||||
fn pattern(&self) -> &'static Regex {
|
||||
&PATTERN
|
||||
}
|
||||
|
||||
fn cache_key(&self, url: &str) -> Option<String> {
|
||||
cache_key(url)
|
||||
}
|
||||
|
||||
fn fetch_from_url<'a>(&'a self, url: &'a str) -> SiteFuture<'a, Fetched> {
|
||||
Box::pin(async move { fetch_from_url(url).await })
|
||||
}
|
||||
}
|
||||
|
||||
pub static PATTERN: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^(?:https?://)?misskey\.io/notes/([\w.\-~]+)").unwrap());
|
||||
|
||||
pub fn enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
|
||||
let caps = PATTERN.captures(url).ok_or(FetchError::NotFound)?;
|
||||
let note_id = caps.get(1).ok_or(FetchError::NotFound)?.as_str();
|
||||
let note = fetch(note_id).await?;
|
||||
Ok(note.into())
|
||||
}
|
||||
|
||||
/// Cache key for a misskey URL: `"misskey:<note id>"`. The prefix is the
|
||||
/// site id used for caption-format lookup and link-cache keys.
|
||||
pub fn cache_key(url: &str) -> Option<String> {
|
||||
PATTERN
|
||||
.captures(url)
|
||||
.map(|caps| format!("misskey:{}", &caps[1]))
|
||||
}
|
||||
|
||||
/// Misskey's fetch-retry policy: transient classes only. Not-found, blocked
|
||||
/// and parse failures are permanent.
|
||||
pub fn is_retryable(err: &FetchError) -> bool {
|
||||
matches!(err, FetchError::Http(_) | FetchError::Transient(_))
|
||||
}
|
||||
|
||||
/// misskey.io media hosts need no extra headers (verified: direct GET works).
|
||||
pub fn media_headers(_url: &str) -> Option<Vec<(&'static str, String)>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Fetches a note from misskey.io by id. The API answers client failures
|
||||
/// with HTTP 400 + `{"error":{"code":...}}` (NO_SUCH_NOTE → NotFound);
|
||||
/// everything else non-success is transient and retried by [`crate::site::fetch`].
|
||||
pub async fn fetch(note_id: &str) -> Result<model::Note, FetchError> {
|
||||
let response = crate::site::CLIENT
|
||||
.post(API_URL)
|
||||
.json(&serde_json::json!({ "noteId": note_id }))
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(match status.as_u16() {
|
||||
400 => not_found_or_invalid(response).await,
|
||||
// A refusal or an auth demand is not a bad moment.
|
||||
401 | 403 => FetchError::Blocked,
|
||||
_ => FetchError::Transient(format!("misskey status {status}")),
|
||||
});
|
||||
}
|
||||
response.json().await.map_err(|e| FetchError::Site {
|
||||
site: "misskey",
|
||||
error: Box::new(e),
|
||||
})
|
||||
}
|
||||
|
||||
/// Maps a 400 response: NO_SUCH_NOTE is permanent NotFound, any other 400 is
|
||||
/// a site error (permanent — retrying a rejected request cannot succeed).
|
||||
async fn not_found_or_invalid(response: reqwest::Response) -> FetchError {
|
||||
match response.json::<serde_json::Value>().await {
|
||||
Ok(v) if v["error"]["code"] == "NO_SUCH_NOTE" => FetchError::NotFound,
|
||||
_ => FetchError::Site {
|
||||
site: "misskey",
|
||||
error: "note rejected (invalid param or private note)".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The note whose content matters: a renote shell has no text/files of its
|
||||
/// own — the embedded renote carries them.
|
||||
fn effective(note: &model::Note) -> &model::Note {
|
||||
match ¬e.renote {
|
||||
Some(renote) if note.files.is_empty() => renote,
|
||||
_ => note,
|
||||
}
|
||||
}
|
||||
|
||||
impl From<model::Note> for Fetched {
|
||||
fn from(note: model::Note) -> Self {
|
||||
let note = ¬e;
|
||||
let content = effective(note);
|
||||
let url = format!("https://misskey.io/notes/{}", note.id);
|
||||
let author = content
|
||||
.user
|
||||
.name
|
||||
.as_deref()
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or(&content.user.username)
|
||||
.to_string();
|
||||
let author_url = format!("https://misskey.io/@{}", content.user.username);
|
||||
let cw = content.cw.as_deref().unwrap_or_default();
|
||||
// Notes carry hashtags inline in the text (no structured tags array);
|
||||
// a CW note gets the marker prefixed so recipients see the spoiler.
|
||||
let mut text = cw.to_string();
|
||||
if !cw.is_empty() && !text.ends_with(' ') {
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(content.text.as_deref().unwrap_or_default().trim());
|
||||
let text = text.trim().to_string();
|
||||
|
||||
let caption = caption(&url, &author_url, &author, &text);
|
||||
let sensitive = content.cw.is_some() || content.files.iter().any(|f| f.is_sensitive);
|
||||
let media: Vec<Media> = content.files.iter().filter_map(media_from_file).collect();
|
||||
|
||||
Fetched {
|
||||
source_url: url.clone(),
|
||||
caption,
|
||||
// A note has no title: its text (CW marker included) is content.
|
||||
title: String::new(),
|
||||
content: text.clone(),
|
||||
media,
|
||||
sensitive,
|
||||
site_id: "misskey",
|
||||
render_data: Some(RenderData {
|
||||
url,
|
||||
author: encode_text(&author).into_owned(),
|
||||
author_url: author_url.clone(),
|
||||
title: String::new(),
|
||||
content: encode_text(&text).into_owned(),
|
||||
tags: String::new(),
|
||||
}),
|
||||
_keep_alive: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn caption(url: &str, author_url: &str, author: &str, text: &str) -> String {
|
||||
let url = encode_double_quoted_attribute(url);
|
||||
let author_url = encode_double_quoted_attribute(author_url);
|
||||
let author = encode_text(author);
|
||||
if text.is_empty() {
|
||||
return format!("{url}\n<a href=\"{author_url}\">{author}</a>");
|
||||
}
|
||||
format!(
|
||||
"{url}\n<a href=\"{author_url}\">{author}</a>: {text}",
|
||||
text = encode_text(text),
|
||||
)
|
||||
}
|
||||
|
||||
/// Maps a Misskey DriveFile to a [`Media`] item; unknown/audio/other types
|
||||
/// are skipped (twitter's `_ => {}` precedent). GIF must be matched before
|
||||
/// the generic image arm.
|
||||
fn media_from_file(file: &model::DriveFile) -> Option<Media> {
|
||||
let title = file.name.clone();
|
||||
match file.mime_type.as_str() {
|
||||
"image/gif" => Some(Media::Animated {
|
||||
title,
|
||||
url: file.url.clone(),
|
||||
thumbnail_url: file.thumbnail_url.clone().unwrap_or_default(),
|
||||
}),
|
||||
mime if mime.starts_with("image/") => Some(Media::Illustration {
|
||||
title,
|
||||
url: file.url.clone(),
|
||||
thumbnail_url: file.thumbnail_url.clone(),
|
||||
fallback_url: None,
|
||||
}),
|
||||
mime if mime.starts_with("video/") => Some(Media::Video {
|
||||
title,
|
||||
url: file.url.clone(),
|
||||
thumbnail_url: file.thumbnail_url.clone().unwrap_or_default(),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn note_json(json: serde_json::Value) -> model::Note {
|
||||
serde_json::from_value(json).unwrap()
|
||||
}
|
||||
|
||||
fn base_note() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id": "aotihl10lqrs015s",
|
||||
"text": "hello",
|
||||
"user": { "name": "ミロン", "username": "donyan47897", "host": null },
|
||||
"files": []
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_matches_misskey_note_urls() {
|
||||
for url in [
|
||||
"https://misskey.io/notes/aotihl10lqrs015s",
|
||||
"http://misskey.io/notes/aotihl10lqrs015s",
|
||||
"misskey.io/notes/aotihl10lqrs015s",
|
||||
] {
|
||||
assert!(PATTERN.is_match(url), "{url}");
|
||||
}
|
||||
for url in [
|
||||
"https://misskey.io/",
|
||||
"https://misskey.io/@user",
|
||||
"https://misskey.io/notes/",
|
||||
"https://x.com/user/status/123",
|
||||
] {
|
||||
assert!(!PATTERN.is_match(url), "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_key_normalizes_variants() {
|
||||
assert_eq!(
|
||||
cache_key("https://misskey.io/notes/aotihl10lqrs015s"),
|
||||
Some("misskey:aotihl10lqrs015s".to_string())
|
||||
);
|
||||
assert_eq!(x_media_site_id("misskey:abc"), "misskey");
|
||||
}
|
||||
|
||||
fn x_media_site_id(key: &str) -> &'static str {
|
||||
crate::site::site_id_from_key(key)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_json_image_file() {
|
||||
let mut note = base_note();
|
||||
note["files"] = serde_json::json!([{
|
||||
"type": "image/webp",
|
||||
"url": "https://media.misskeyusercontent.jp/io/a.webp",
|
||||
"thumbnailUrl": "https://media.misskeyusercontent.jp/io/t.webp",
|
||||
"isSensitive": true,
|
||||
"name": "pic.webp"
|
||||
}]);
|
||||
let fetched: Fetched = note_json(note).into();
|
||||
assert_eq!(
|
||||
fetched.source_url,
|
||||
"https://misskey.io/notes/aotihl10lqrs015s"
|
||||
);
|
||||
assert_eq!(fetched.site_id, "misskey");
|
||||
assert_eq!(fetched.title, "");
|
||||
assert_eq!(fetched.content, "hello");
|
||||
assert!(fetched.sensitive);
|
||||
assert_eq!(fetched.media.len(), 1);
|
||||
match &fetched.media[0] {
|
||||
Media::Illustration {
|
||||
title,
|
||||
url,
|
||||
thumbnail_url,
|
||||
fallback_url,
|
||||
} => {
|
||||
assert_eq!(title.as_deref(), Some("pic.webp"));
|
||||
assert_eq!(url, "https://media.misskeyusercontent.jp/io/a.webp");
|
||||
assert_eq!(
|
||||
thumbnail_url.as_deref(),
|
||||
Some("https://media.misskeyusercontent.jp/io/t.webp")
|
||||
);
|
||||
assert!(fallback_url.is_none());
|
||||
}
|
||||
other => panic!("expected illustration, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_json_gif_video_and_skip_audio() {
|
||||
let mut note = base_note();
|
||||
note["files"] = serde_json::json!([
|
||||
{ "type": "audio/mpeg", "url": "https://m/a.mp3", "isSensitive": false },
|
||||
{ "type": "image/gif", "url": "https://m/a.gif", "isSensitive": false },
|
||||
{ "type": "video/webm", "url": "https://m/a.webm", "isSensitive": false }
|
||||
]);
|
||||
let fetched: Fetched = note_json(note).into();
|
||||
assert_eq!(fetched.media.len(), 2);
|
||||
assert!(
|
||||
matches!(&fetched.media[0], Media::Animated { url, .. } if url == "https://m/a.gif")
|
||||
);
|
||||
assert!(matches!(&fetched.media[1], Media::Video { url, .. } if url == "https://m/a.webm"));
|
||||
// No thumbnailUrl → empty string, not a broken URL.
|
||||
match &fetched.media[1] {
|
||||
Media::Video { thumbnail_url, .. } => assert_eq!(thumbnail_url, ""),
|
||||
other => panic!("expected video, got {other:?}"),
|
||||
}
|
||||
assert!(!fetched.sensitive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_json_cw_marks_sensitive_and_prefixes_title() {
|
||||
let mut note = base_note();
|
||||
note["cw"] = serde_json::json!("spoiler");
|
||||
note["text"] = serde_json::json!("body");
|
||||
let fetched: Fetched = note_json(note).into();
|
||||
assert!(fetched.sensitive);
|
||||
assert_eq!(fetched.title, "");
|
||||
assert_eq!(fetched.content, "spoiler body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_json_author_falls_back_to_username() {
|
||||
let mut note = base_note();
|
||||
note["user"] = serde_json::json!({ "name": null, "username": "donyan47897", "host": null });
|
||||
let fetched: Fetched = note_json(note).into();
|
||||
assert!(
|
||||
fetched.caption.contains("donyan47897"),
|
||||
"{}",
|
||||
fetched.caption
|
||||
);
|
||||
assert!(fetched.caption.contains("https://misskey.io/@donyan47897"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_json_renote_uses_embedded_content() {
|
||||
let note = serde_json::json!({
|
||||
"id": "shell0000000000",
|
||||
"text": null,
|
||||
"user": { "name": "shell", "username": "shelluser", "host": null },
|
||||
"files": [],
|
||||
"renote": {
|
||||
"id": "inner000000000",
|
||||
"text": "inner text",
|
||||
"user": { "name": "inner", "username": "inneruser", "host": null },
|
||||
"files": [
|
||||
{ "type": "image/png", "url": "https://m/i.png", "isSensitive": false }
|
||||
]
|
||||
}
|
||||
});
|
||||
let fetched: Fetched = note_json(note).into();
|
||||
assert_eq!(fetched.title, "");
|
||||
assert_eq!(fetched.content, "inner text");
|
||||
assert_eq!(fetched.media.len(), 1);
|
||||
// The source URL still points at the renote shell the user posted.
|
||||
assert_eq!(
|
||||
fetched.source_url,
|
||||
"https://misskey.io/notes/shell0000000000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caption_layout_matches_bsky() {
|
||||
let fetched: Fetched = note_json(base_note()).into();
|
||||
assert_eq!(
|
||||
fetched.caption,
|
||||
"https://misskey.io/notes/aotihl10lqrs015s\n<a href=\"https://misskey.io/@donyan47897\">ミロン</a>: hello"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caption_without_text_has_no_dangling_colon() {
|
||||
let mut note = base_note();
|
||||
note["text"] = serde_json::json!(null);
|
||||
let fetched: Fetched = note_json(note).into();
|
||||
assert_eq!(
|
||||
fetched.caption,
|
||||
"https://misskey.io/notes/aotihl10lqrs015s\n<a href=\"https://misskey.io/@donyan47897\">ミロン</a>"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "live network: requires outbound HTTPS to misskey.io"]
|
||||
async fn live_fetch_reference_note() {
|
||||
let fetched = fetch_from_url("https://misskey.io/notes/aotihl10lqrs015s")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(fetched.site_id, "misskey");
|
||||
assert_eq!(fetched.media.len(), 1);
|
||||
assert!(fetched.sensitive);
|
||||
assert!(!fetched.caption.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod interface;
|
||||
mod model;
|
||||
|
||||
pub use interface::{
|
||||
MisskeySite, PATTERN, cache_key, enabled, fetch_from_url, is_retryable, media_headers,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Note {
|
||||
pub(crate) id: String,
|
||||
pub(crate) text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) cw: Option<String>,
|
||||
pub(crate) user: User,
|
||||
#[serde(default)]
|
||||
pub(crate) files: Vec<DriveFile>,
|
||||
/// Embedded original note when this note is a renote; the shell's own
|
||||
/// text/files are usually empty and the content lives here.
|
||||
#[serde(default)]
|
||||
pub(crate) renote: Option<Box<Note>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct User {
|
||||
pub(crate) name: Option<String>,
|
||||
pub(crate) username: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct DriveFile {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) mime_type: String,
|
||||
pub(crate) url: String,
|
||||
#[serde(default, rename = "thumbnailUrl")]
|
||||
pub(crate) thumbnail_url: Option<String>,
|
||||
#[serde(default, rename = "isSensitive")]
|
||||
pub(crate) is_sensitive: bool,
|
||||
#[serde(default)]
|
||||
pub(crate) name: Option<String>,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,417 @@
|
||||
//! Native pixiv app-API client (replaces pixiv3-rs).
|
||||
//!
|
||||
//! Token exchange against `oauth.secure.pixiv.net` and illust detail against
|
||||
//! `app-api.pixiv.net`, deserialized with the kept `model.rs` types.
|
||||
|
||||
use super::interface::Illustration;
|
||||
use super::model::{IllustrationModel, TypeModel, UgoiraMetadataModel};
|
||||
use crate::media::Media;
|
||||
use crate::site::FetchError;
|
||||
use std::env;
|
||||
use std::io::Read;
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use thiserror::Error;
|
||||
|
||||
const AUTH_TOKEN_URL: &str = "https://oauth.secure.pixiv.net/auth/token";
|
||||
const APP_API_URL: &str = "https://app-api.pixiv.net";
|
||||
const CLIENT_ID: &str = "MOBrBDS8blbauoSck0ZfDbtuzpyT";
|
||||
const CLIENT_SECRET: &str = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj";
|
||||
const AUTH_USER_AGENT: &str = "PixivAndroidApp/5.0.234 (Android 11; Pixel 5)";
|
||||
const APP_USER_AGENT: &str = "PixivIOSApp/7.13.3 (iOS 14.6; iPhone13,2)";
|
||||
/// Token refresh safe margin (seconds).
|
||||
const TOKEN_REFRESH_SAFE_MARGIN: u64 = 300;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PixivError {
|
||||
/// No refresh token available (PIXIV_REFRESH_TOKEN unset).
|
||||
#[error("pixiv: no authentication")]
|
||||
NoAuth,
|
||||
#[error("pixiv http error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error("pixiv json error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
/// Non-2xx HTTP status from the app API. The code lets [`crate::site::fetch`]
|
||||
/// retry only transient classes (429 / 5xx) instead of burning attempts on
|
||||
/// permanent 4xx (bad token, forbidden, not found).
|
||||
#[error("pixiv status {0}")]
|
||||
Status(u16),
|
||||
#[error("pixiv api error: {0}")]
|
||||
Api(String),
|
||||
}
|
||||
|
||||
/// Native pixiv app-API client.
|
||||
pub struct PixivAPI {
|
||||
refresh_token: String,
|
||||
access_token: tokio::sync::Mutex<Option<(String, SystemTime)>>,
|
||||
}
|
||||
|
||||
impl PixivAPI {
|
||||
pub fn new(refresh_token: String) -> Self {
|
||||
Self {
|
||||
refresh_token,
|
||||
access_token: tokio::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a valid access token, exchanging the refresh token when none
|
||||
/// is cached or it has expired.
|
||||
pub async fn get_access_token(&self) -> Result<String, PixivError> {
|
||||
let mut guard = self.access_token.lock().await;
|
||||
if let Some((token, expires_at)) = guard.as_ref()
|
||||
&& *expires_at > SystemTime::now()
|
||||
{
|
||||
return Ok(token.clone());
|
||||
}
|
||||
let response = crate::site::CLIENT
|
||||
.post(AUTH_TOKEN_URL)
|
||||
.form(&[
|
||||
("client_id", CLIENT_ID),
|
||||
("client_secret", CLIENT_SECRET),
|
||||
("grant_type", "refresh_token"),
|
||||
("include_policy", "true"),
|
||||
("refresh_token", &self.refresh_token),
|
||||
])
|
||||
.header("User-Agent", AUTH_USER_AGENT)
|
||||
.send()
|
||||
.await?;
|
||||
// Check the status *before* reading the body: a 429/5xx from the
|
||||
// token endpoint is worth retrying (the class comes from
|
||||
// `is_retryable`), while parsing a maintenance page as JSON turned it
|
||||
// into a permanent `Api`/`Json` error with no retry at all.
|
||||
if !response.status().is_success() {
|
||||
return Err(PixivError::Status(response.status().as_u16()));
|
||||
}
|
||||
let json: serde_json::Value = serde_json::from_str(&response.text().await?)?;
|
||||
let access_token = json
|
||||
.get("access_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
let message = json
|
||||
.get("error")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("invalid token response");
|
||||
PixivError::Api(message.to_string())
|
||||
})?
|
||||
.to_string();
|
||||
let expires_in = json
|
||||
.get("expires_in")
|
||||
.and_then(|v| v.as_u64())
|
||||
.filter(|&sec| sec > 0)
|
||||
.unwrap_or(3600);
|
||||
let expires_at = SystemTime::now()
|
||||
+ Duration::from_secs(expires_in.saturating_sub(TOKEN_REFRESH_SAFE_MARGIN));
|
||||
*guard = Some((access_token.clone(), expires_at));
|
||||
Ok(access_token)
|
||||
}
|
||||
|
||||
/// Fetches illust detail from the app API.
|
||||
pub async fn illust_detail(&self, illust_id: u64) -> Result<IllustrationModel, PixivError> {
|
||||
let access_token = self.get_access_token().await?;
|
||||
let response = crate::site::CLIENT
|
||||
.get(format!(
|
||||
"{APP_API_URL}/v1/illust/detail?illust_id={illust_id}"
|
||||
))
|
||||
.header("app-os", "ios")
|
||||
.header("app-os-version", "14.6")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(PixivError::Status(response.status().as_u16()));
|
||||
}
|
||||
let json: serde_json::Value = serde_json::from_str(&response.text().await?)?;
|
||||
if json.get("error").is_some() {
|
||||
let message = json
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("illust detail failed");
|
||||
return Err(PixivError::Api(message.to_string()));
|
||||
}
|
||||
let illust = json
|
||||
.get("illust")
|
||||
.ok_or_else(|| PixivError::Api("missing illust in response".to_string()))?;
|
||||
Ok(serde_json::from_value(illust.clone())?)
|
||||
}
|
||||
|
||||
pub async fn fetch(&self, illust_id: u64) -> Result<Illustration, FetchError> {
|
||||
let model = self.illust_detail(illust_id).await?;
|
||||
let mut illustration = Illustration::from_model(&model);
|
||||
if matches!(&model.r#type, TypeModel::Ugoira) {
|
||||
// Real ugoira support: download the frame zip and encode an MP4.
|
||||
// Without ffmpeg the post stays unsupported (empty media, like
|
||||
// Python) — but a *failed* download/encode is reported instead:
|
||||
// a ugoira post has no static image to fall back to, so
|
||||
// swallowing it would present a transient zip-download error as
|
||||
// "this post has no media", with the retries skipped.
|
||||
match self.ugoira_video(illust_id).await {
|
||||
Ok(Some((mp4_path, _keep_alive))) => {
|
||||
illustration.media.push(Media::Video {
|
||||
title: None,
|
||||
url: mp4_path,
|
||||
thumbnail_url: model.image_urls.medium.clone(),
|
||||
});
|
||||
illustration._keep_alive = Some(std::sync::Arc::new(_keep_alive));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
log::error!("ugoira encode failed for {illust_id}: {e}");
|
||||
return Err(FetchError::Pixiv(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(illustration)
|
||||
}
|
||||
|
||||
/// Fetches ugoira metadata (frame zip + frame delays) from the app API.
|
||||
pub async fn ugoira_metadata(&self, illust_id: u64) -> Result<UgoiraMetadataModel, PixivError> {
|
||||
let access_token = self.get_access_token().await?;
|
||||
let response = crate::site::CLIENT
|
||||
.get(format!(
|
||||
"{APP_API_URL}/v1/ugoira/metadata?illust_id={illust_id}"
|
||||
))
|
||||
.header("app-os", "ios")
|
||||
.header("app-os-version", "14.6")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(PixivError::Status(response.status().as_u16()));
|
||||
}
|
||||
let json: serde_json::Value = serde_json::from_str(&response.text().await?)?;
|
||||
if json.get("error").is_some() {
|
||||
let message = json
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("ugoira metadata failed");
|
||||
return Err(PixivError::Api(message.to_string()));
|
||||
}
|
||||
let metadata = json
|
||||
.get("ugoira_metadata")
|
||||
.ok_or_else(|| PixivError::Api("missing ugoira_metadata".to_string()))?;
|
||||
Ok(serde_json::from_value(metadata.clone())?)
|
||||
}
|
||||
|
||||
/// Downloads the frame zip and encodes one MP4 via ffmpeg. Returns the
|
||||
/// MP4 path plus the temp directory that must stay alive until the file
|
||||
/// is uploaded.
|
||||
async fn ugoira_video(
|
||||
&self,
|
||||
illust_id: u64,
|
||||
) -> Result<Option<(String, tempfile::TempDir)>, PixivError> {
|
||||
if !crate::site::ffmpeg_available() {
|
||||
crate::site::log_once_ffmpeg_missing();
|
||||
return Ok(None);
|
||||
}
|
||||
let metadata = self.ugoira_metadata(illust_id).await?;
|
||||
if metadata.frames.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let zip_url = metadata
|
||||
.zip_url
|
||||
.clone()
|
||||
.or_else(|| metadata.zip_urls.as_ref().map(|z| z.medium.clone()));
|
||||
let Some(zip_url) = zip_url else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Stream the frame zip to a temp file instead of buffering it in
|
||||
// memory: ugoira zips can be hundreds of MB, and the old
|
||||
// download_media_limited path spiked RAM up to the size cap.
|
||||
let mut zip_file = tempfile::Builder::new()
|
||||
.prefix(crate::TEMP_FILE_PREFIX)
|
||||
.suffix(".zip")
|
||||
.tempfile()
|
||||
.map_err(|e| PixivError::Api(format!("temp zip failed: {e}")))?;
|
||||
crate::site::download_media_to_file(&zip_url, 512 * 1024 * 1024, zip_file.as_file_mut())
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
FetchError::Http(e) => PixivError::Http(e),
|
||||
other => PixivError::Api(format!("frame zip download failed: {other}")),
|
||||
})?;
|
||||
let frame_delays = metadata.frames.iter().map(|f| f.delay).collect::<Vec<_>>();
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || -> Result<(String, tempfile::TempDir), String> {
|
||||
let frames_dir = tempfile::Builder::new()
|
||||
.prefix(crate::TEMP_FILE_PREFIX)
|
||||
.tempdir()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let out_dir = tempfile::Builder::new()
|
||||
.prefix(crate::TEMP_FILE_PREFIX)
|
||||
.tempdir()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Extract frames to canonical zero-padded names; pixiv ugoira
|
||||
// frames are uniformly jpg or png per artwork. The zip is read
|
||||
// from disk; `zip_file` stays alive for the whole extraction.
|
||||
let mut archive = zip::ZipArchive::new(
|
||||
std::fs::File::open(zip_file.path()).map_err(|e| e.to_string())?,
|
||||
)
|
||||
.map_err(|e| format!("unzip: {e}"))?;
|
||||
if archive.is_empty() {
|
||||
return Err("empty frame zip".to_string());
|
||||
}
|
||||
// Uniform jpg or png per artwork; sniff the first entry's
|
||||
// magic bytes instead of trusting its filename.
|
||||
let first = archive.by_index(0).map_err(|e| e.to_string())?;
|
||||
let mut first_bytes = Vec::new();
|
||||
first
|
||||
.take(64 * 1024 * 1024 + 1)
|
||||
.read_to_end(&mut first_bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if first_bytes.len() > 64 * 1024 * 1024 {
|
||||
return Err("frame exceeds size cap".to_string());
|
||||
}
|
||||
let extension = if first_bytes.starts_with(&[0xFF, 0xD8]) {
|
||||
"jpg"
|
||||
} else if first_bytes.starts_with(b"\x89PNG") {
|
||||
"png"
|
||||
} else {
|
||||
"jpg"
|
||||
};
|
||||
let mut count = 0usize;
|
||||
{
|
||||
let path = frames_dir
|
||||
.path()
|
||||
.join(format!("img_{count:05}.{extension}"));
|
||||
std::fs::write(&path, &first_bytes).map_err(|e| e.to_string())?;
|
||||
count += 1;
|
||||
}
|
||||
for i in 1..archive.len() {
|
||||
let entry = archive.by_index(i).map_err(|e| e.to_string())?;
|
||||
if entry.size() > 64 * 1024 * 1024 {
|
||||
return Err(format!("frame {i} exceeds size cap"));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
entry
|
||||
.take(64 * 1024 * 1024 + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if bytes.len() > 64 * 1024 * 1024 {
|
||||
return Err(format!("frame {i} exceeds size cap"));
|
||||
}
|
||||
let path = frames_dir
|
||||
.path()
|
||||
.join(format!("img_{count:05}.{extension}"));
|
||||
std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
|
||||
count += 1;
|
||||
}
|
||||
if count == 0 {
|
||||
return Err("empty frame zip".to_string());
|
||||
}
|
||||
|
||||
// Constant rate from the median frame delay (ms).
|
||||
let mut delays = frame_delays;
|
||||
delays.sort_unstable();
|
||||
let median = delays[delays.len() / 2].max(1);
|
||||
let framerate = 1000.0 / median as f64;
|
||||
|
||||
let output = out_dir.path().join("ugoira.mp4");
|
||||
let status = std::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-y",
|
||||
"-framerate",
|
||||
&framerate.to_string(),
|
||||
"-i",
|
||||
&frames_dir
|
||||
.path()
|
||||
.join(format!("img_%05d.{extension}"))
|
||||
.to_string_lossy(),
|
||||
// libx264 needs even dimensions; pixiv ugoira frames can
|
||||
// be odd-sized (e.g. 277x405).
|
||||
"-vf",
|
||||
"scale=trunc(iw/2)*2:trunc(ih/2)*2",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
&output.to_string_lossy(),
|
||||
])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map_err(|e| format!("ffmpeg spawn failed: {e}"))?;
|
||||
if !status.success() {
|
||||
return Err(format!("ffmpeg exited with {status}"));
|
||||
}
|
||||
Ok((output.to_string_lossy().into_owned(), out_dir))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("ugoira encode worker panicked for {illust_id}: {e}");
|
||||
PixivError::Api(format!("ugoira worker failed: {e}"))
|
||||
})?;
|
||||
match result {
|
||||
Ok(pair) => Ok(Some(pair)),
|
||||
Err(message) => {
|
||||
log::error!("ugoira encode failed for {illust_id}: {message}");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// pixiv3-rs replacement: `None` when `PIXIV_REFRESH_TOKEN` is unset.
|
||||
static PIXIV_CLIENT: LazyLock<Option<PixivAPI>> =
|
||||
LazyLock::new(|| env::var("PIXIV_REFRESH_TOKEN").ok().map(PixivAPI::new));
|
||||
|
||||
/// Set at startup when the login validation fails; pixiv stays disabled until
|
||||
/// the next process start.
|
||||
static DISABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn enabled() -> bool {
|
||||
!DISABLED.load(Ordering::Relaxed) && env::var("PIXIV_REFRESH_TOKEN").is_ok()
|
||||
}
|
||||
|
||||
/// Permanently disables pixiv until the next process start.
|
||||
pub fn disable() {
|
||||
DISABLED.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Forces the refresh-token → access-token exchange now, surfacing invalid
|
||||
/// tokens and network errors. Called once at bot startup; on failure the bot
|
||||
/// calls [`disable`].
|
||||
pub async fn validate() -> Result<(), PixivError> {
|
||||
match PIXIV_CLIENT.as_ref() {
|
||||
None => Err(PixivError::NoAuth),
|
||||
Some(client) => {
|
||||
client.get_access_token().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch(illust_id: u64) -> Result<Illustration, FetchError> {
|
||||
let client = PIXIV_CLIENT
|
||||
.as_ref()
|
||||
.filter(|_| enabled())
|
||||
.ok_or(FetchError::Pixiv(PixivError::NoAuth))?;
|
||||
client.fetch(illust_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dotenv::dotenv;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "live network: requires outbound HTTPS to oauth.secure.pixiv.net"]
|
||||
async fn live_validate_with_bogus_token_fails() {
|
||||
dotenv().ok();
|
||||
// A rejected credential must surface as a permanent status, not a panic
|
||||
// and not a retryable class: the exchange answers 4xx and the status is
|
||||
// checked before the body is read (api.rs, `get_access_token`). This
|
||||
// used to assert `Api`, which that check made unreachable — `Api` is
|
||||
// only reached from a 2xx body without an `access_token`.
|
||||
let client = PixivAPI::new("bogus_token_for_testing".to_string());
|
||||
let result = client.get_access_token().await;
|
||||
assert!(
|
||||
matches!(result, Err(PixivError::Status(code)) if (400..500).contains(&code)),
|
||||
"got {result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
use super::model::{IllustrationModel, TypeModel};
|
||||
use crate::media::Media;
|
||||
use crate::site::{FetchError, Fetched, PixivError, Site, SiteFuture};
|
||||
use html_escape::{encode_double_quoted_attribute, encode_text};
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
pub static PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"^(?:https?://)?(?:www\.)?pixiv\.net/(?:en/)?(?:(?:i|artworks)/|member_illust\.php\?(?:mode=[a-z_]*&)?illust_id=)(\d+)").unwrap()
|
||||
});
|
||||
|
||||
pub fn enabled() -> bool {
|
||||
super::api::enabled()
|
||||
}
|
||||
|
||||
/// Registry entry for the pixiv adapter (see [`crate::site::Site`]).
|
||||
pub struct PixivSite;
|
||||
|
||||
impl Site for PixivSite {
|
||||
fn id(&self) -> &'static str {
|
||||
"pixiv"
|
||||
}
|
||||
|
||||
fn pattern(&self) -> &'static Regex {
|
||||
&PATTERN
|
||||
}
|
||||
|
||||
fn enabled(&self) -> bool {
|
||||
enabled()
|
||||
}
|
||||
|
||||
fn cache_key(&self, url: &str) -> Option<String> {
|
||||
cache_key(url)
|
||||
}
|
||||
|
||||
fn fetch_from_url<'a>(&'a self, url: &'a str) -> SiteFuture<'a, Fetched> {
|
||||
Box::pin(async move { fetch_from_url(url).await })
|
||||
}
|
||||
|
||||
fn is_retryable(&self, err: &FetchError) -> bool {
|
||||
is_retryable(err)
|
||||
}
|
||||
|
||||
fn media_headers(&self, url: &str) -> Option<Vec<(&'static str, String)>> {
|
||||
media_headers(url)
|
||||
}
|
||||
|
||||
fn validate(&self) -> SiteFuture<'static, (), String> {
|
||||
Box::pin(async { startup_validation(super::api::validate().await) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns the startup token exchange's outcome into what the bot reports, and
|
||||
/// disables pixiv only for a rejected credential. A bad *moment* — a 5xx or a
|
||||
/// network error while the container comes up — must not disable it: disabling
|
||||
/// on any error turned every later pixiv link into "support is disabled".
|
||||
/// Separate from the network call so the decision is testable.
|
||||
fn startup_validation(result: Result<(), PixivError>) -> Result<(), String> {
|
||||
match result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if pixiv_error_is_retryable(&e) => {
|
||||
Err(format!("{e} (transient — pixiv stays enabled)"))
|
||||
}
|
||||
Err(e) => {
|
||||
super::api::disable();
|
||||
Err(format!("{e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
|
||||
let id = PATTERN
|
||||
.captures(url)
|
||||
.and_then(|caps| caps.get(1))
|
||||
.map(|m| m.as_str())
|
||||
.ok_or(FetchError::NotFound)?;
|
||||
let id = id.parse::<u64>().map_err(|_| FetchError::NotFound)?;
|
||||
Ok(super::api::fetch(id).await?.into())
|
||||
}
|
||||
|
||||
/// Cache key for a pixiv URL: `"pixiv:<id>"`. The prefix is the site id used
|
||||
/// for caption-format lookup and link-cache keys.
|
||||
pub fn cache_key(url: &str) -> Option<String> {
|
||||
PATTERN
|
||||
.captures(url)
|
||||
.map(|caps| format!("pixiv:{}", &caps[1]))
|
||||
}
|
||||
|
||||
/// Pixiv's fetch-retry policy: transient classes only — network errors and
|
||||
/// HTTP 429/5xx. Permanent 4xx (bad/expired token, forbidden, not found),
|
||||
/// API/auth errors, unparseable bodies and missing auth are not retried.
|
||||
pub fn is_retryable(err: &FetchError) -> bool {
|
||||
match err {
|
||||
FetchError::Http(_) | FetchError::Transient(_) => true,
|
||||
FetchError::Pixiv(e) => pixiv_error_is_retryable(e),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The pixiv-specific half of the retry policy, shared with startup
|
||||
/// validation: a bad moment (429/5xx, a network error) is retryable, a
|
||||
/// rejected credential is not.
|
||||
fn pixiv_error_is_retryable(err: &PixivError) -> bool {
|
||||
match err {
|
||||
PixivError::Http(_) => true,
|
||||
PixivError::Status(code) if *code == 429 || *code >= 500 => true,
|
||||
PixivError::Status(_) | PixivError::Api(_) | PixivError::Json(_) | PixivError::NoAuth => {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// pximg.net is hotlink-protected: downloads must carry the pixiv Referer.
|
||||
/// The match is on the media host, not the site PATTERN — pixiv's PATTERN
|
||||
/// only matches `pixiv.net/artworks/...`, never `i.pximg.net`.
|
||||
pub fn media_headers(url: &str) -> Option<Vec<(&'static str, String)>> {
|
||||
if url.to_ascii_lowercase().contains("pximg.net") {
|
||||
Some(vec![("Referer", "https://www.pixiv.net/".to_string())])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Flattens the app API's HTML description into plain text: `<br>` (and `<p>`)
|
||||
/// become line breaks, other tags are dropped, entities decoded, the ends
|
||||
/// trimmed. A caption shows text, not markup, so the author's `<a href>` links
|
||||
/// contribute their link text only.
|
||||
fn flatten_html(raw: &str) -> String {
|
||||
let mut out = String::with_capacity(raw.len());
|
||||
let mut chars = raw.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
// Only `<` followed by `/` or a letter opens a tag — a bare `<` in
|
||||
// prose ("2 < 3") is text.
|
||||
let opens_tag = c == '<'
|
||||
&& chars
|
||||
.peek()
|
||||
.is_some_and(|next| *next == '/' || next.is_ascii_alphabetic());
|
||||
if !opens_tag {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
let mut tag = String::new();
|
||||
let mut closed = false;
|
||||
for c in chars.by_ref() {
|
||||
if c == '>' {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
tag.push(c);
|
||||
}
|
||||
if !closed {
|
||||
// Unclosed `<…`: keep it as text rather than dropping the tail.
|
||||
out.push('<');
|
||||
out.push_str(&tag);
|
||||
break;
|
||||
}
|
||||
// `<br>`, `<br/>`, `<br />` with or without attributes, and both
|
||||
// halves of a paragraph break the line; everything else is dropped.
|
||||
let tag = tag
|
||||
.trim()
|
||||
.trim_start_matches('/')
|
||||
.trim_end_matches('/')
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if tag == "p" || tag.starts_with("br") {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
html_escape::decode_html_entities(&out).trim().to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Illustration {
|
||||
id: String,
|
||||
title: String,
|
||||
/// The artwork's description, HTML flattened to plain text.
|
||||
content: String,
|
||||
author: String,
|
||||
author_id: String,
|
||||
tags: Vec<String>,
|
||||
pub(crate) media: Vec<Media>,
|
||||
nsfw: bool,
|
||||
/// Keeps a temp dir (ugoira MP4) alive until the send completes.
|
||||
pub(crate) _keep_alive: Option<std::sync::Arc<tempfile::TempDir>>,
|
||||
}
|
||||
|
||||
impl Illustration {
|
||||
fn url(&self) -> String {
|
||||
format!("https://www.pixiv.net/artworks/{}", self.id)
|
||||
}
|
||||
|
||||
fn author_url(&self) -> String {
|
||||
format!("https://www.pixiv.net/users/{}", self.author_id)
|
||||
}
|
||||
|
||||
pub fn caption(&self) -> String {
|
||||
format!(
|
||||
"<a href=\"{url}\">{title}</a> / <a href=\"{author_url}\">{author}</a>\n{tags}",
|
||||
url = encode_double_quoted_attribute(&self.url()),
|
||||
title = encode_text(&self.title),
|
||||
author_url = encode_double_quoted_attribute(&self.author_url()),
|
||||
author = encode_text(&self.author),
|
||||
tags = encode_text(
|
||||
&self
|
||||
.tags
|
||||
.iter()
|
||||
.map(|tag| format!("#{tag}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn from_model(model: &IllustrationModel) -> Self {
|
||||
let id = model.id.to_string();
|
||||
let title = model.title.clone();
|
||||
let content = flatten_html(&model.caption);
|
||||
let author = model.user.name.clone();
|
||||
let author_id = model.user.id.to_string();
|
||||
let mut tags: Vec<String> = model.tags.iter().map(|tag| tag.name.clone()).collect();
|
||||
// illust_ai_type: 0 = undefined, 1 = not AI, 2 = AI-generated.
|
||||
// Mark AI works with a leading #AI tag (rendered via the `#{tag}`
|
||||
// caption format).
|
||||
if model.illust_ai_type == 2 {
|
||||
tags.insert(0, "AI".to_string());
|
||||
}
|
||||
let mut media = vec![];
|
||||
if matches!(&model.r#type, TypeModel::Ugoira) {
|
||||
// No static images for ugoira; the fetch path encodes an MP4 via
|
||||
// ffmpeg and appends it as a Video item (api.rs). This fallback
|
||||
// keeps media empty when encoding fails or ffmpeg is missing.
|
||||
} else if model.page_count > 1 {
|
||||
media.extend(model.meta_pages.iter().filter_map(|page| {
|
||||
page.image_urls
|
||||
.original
|
||||
.clone()
|
||||
.map(|original| Media::Illustration {
|
||||
title: None,
|
||||
url: original,
|
||||
thumbnail_url: Some(page.image_urls.medium.clone()),
|
||||
fallback_url: Some(page.image_urls.large.clone()),
|
||||
})
|
||||
}));
|
||||
} else if let Some(original) = model
|
||||
.meta_single_page
|
||||
.original_image_url
|
||||
.clone()
|
||||
.or(model.image_urls.original.clone())
|
||||
{
|
||||
media.push(Media::Illustration {
|
||||
title: None,
|
||||
url: original,
|
||||
thumbnail_url: Some(model.image_urls.medium.clone()),
|
||||
fallback_url: Some(model.image_urls.large.clone()),
|
||||
});
|
||||
}
|
||||
let nsfw = model.sanity_level > 5;
|
||||
Self {
|
||||
id,
|
||||
title,
|
||||
content,
|
||||
author,
|
||||
author_id,
|
||||
tags,
|
||||
media,
|
||||
nsfw,
|
||||
_keep_alive: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Illustration> for Fetched {
|
||||
fn from(illustration: Illustration) -> Self {
|
||||
let url = illustration.url();
|
||||
let author_url = illustration.author_url();
|
||||
let tags = illustration
|
||||
.tags
|
||||
.iter()
|
||||
.map(|tag| format!("#{tag}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let render_data = Some(crate::site::RenderData {
|
||||
url: url.clone(),
|
||||
author: encode_text(&illustration.author).into_owned(),
|
||||
author_url: author_url.clone(),
|
||||
title: encode_text(&illustration.title).into_owned(),
|
||||
content: encode_text(&illustration.content).into_owned(),
|
||||
tags: encode_text(&tags).into_owned(),
|
||||
});
|
||||
Fetched {
|
||||
source_url: url,
|
||||
caption: illustration.caption(),
|
||||
title: illustration.title.clone(),
|
||||
content: illustration.content.clone(),
|
||||
media: illustration.media,
|
||||
sensitive: illustration.nsfw,
|
||||
site_id: "pixiv",
|
||||
render_data,
|
||||
_keep_alive: illustration._keep_alive,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::model::IllustrationModel;
|
||||
use super::*;
|
||||
|
||||
fn illust_json(
|
||||
type_: &str,
|
||||
page_count: u8,
|
||||
single_original: Option<&str>,
|
||||
image_urls_original: Option<&str>,
|
||||
pages: Vec<(Option<&str>, &str, &str)>,
|
||||
ai_type: i32,
|
||||
) -> serde_json::Value {
|
||||
let meta_pages: Vec<serde_json::Value> = pages
|
||||
.into_iter()
|
||||
.map(|(original, medium, large)| {
|
||||
serde_json::json!({
|
||||
"image_urls": {
|
||||
"medium": medium,
|
||||
"large": large,
|
||||
"original": original
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({
|
||||
"illust": {
|
||||
"id": 123,
|
||||
"title": "Art <title>",
|
||||
"caption": "一行说明<br />二行 <a href=\"https://x.example/\">链接</a> & 结尾",
|
||||
"type": type_,
|
||||
"image_urls": {
|
||||
"medium": "medium.jpg",
|
||||
"large": "large.jpg",
|
||||
"original": image_urls_original
|
||||
},
|
||||
"user": { "id": 456, "name": "Artist" },
|
||||
"tags": [{ "name": "tag1" }, { "name": "tag2" }],
|
||||
"page_count": page_count,
|
||||
"sanity_level": 6,
|
||||
"illust_ai_type": ai_type,
|
||||
"meta_single_page": { "original_image_url": single_original },
|
||||
"meta_pages": meta_pages
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn parse(v: serde_json::Value) -> Illustration {
|
||||
let model: IllustrationModel = serde_json::from_value(v["illust"].clone()).unwrap();
|
||||
Illustration::from_model(&model)
|
||||
}
|
||||
|
||||
/// The description arrives as HTML and becomes plain-text content: breaks
|
||||
/// kept, tags dropped (links keep their text), entities decoded.
|
||||
#[test]
|
||||
fn from_json_maps_description_to_content() {
|
||||
let v = illust_json("illust", 1, None, Some("o.jpg"), vec![], 0);
|
||||
let illustration = parse(v);
|
||||
assert_eq!(illustration.content, "一行说明\n二行 链接 & 结尾");
|
||||
|
||||
let fetched: Fetched = illustration.into();
|
||||
assert_eq!(fetched.title, "Art <title>");
|
||||
assert_eq!(fetched.content, "一行说明\n二行 链接 & 结尾");
|
||||
// The built-in caption keeps its layout: the description stays out of
|
||||
// it and is available through `{content}`.
|
||||
assert!(!fetched.caption.contains("一行说明"), "{}", fetched.caption);
|
||||
assert_eq!(
|
||||
fetched.render_fields().unwrap().3,
|
||||
"一行说明\n二行 链接 & 结尾"
|
||||
);
|
||||
assert!(
|
||||
fetched
|
||||
.caption_with("{title}: {content}")
|
||||
.ends_with("一行说明\n二行 链接 & 结尾")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatten_html_handles_common_markup() {
|
||||
assert_eq!(flatten_html(""), "");
|
||||
assert_eq!(flatten_html("plain"), "plain");
|
||||
assert_eq!(flatten_html("a<br />b<br/>c<br>d"), "a\nb\nc\nd");
|
||||
// A paragraph break is a blank line, exactly like `<br /><br />` —
|
||||
// writing it as one newline would flatten the author's paragraphs.
|
||||
assert_eq!(flatten_html("<p>one</p><p>two</p>"), "one\n\ntwo");
|
||||
assert_eq!(flatten_html("a & b <c>"), "a & b <c>");
|
||||
// Nothing to strip: angle brackets that are not a tag survive.
|
||||
assert_eq!(flatten_html("2 < 3"), "2 < 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_matches_all_forms() {
|
||||
let cases = [
|
||||
("https://www.pixiv.net/artworks/123456", "123456"),
|
||||
("https://pixiv.net/artworks/123456", "123456"),
|
||||
("https://www.pixiv.net/en/artworks/123456", "123456"),
|
||||
("https://www.pixiv.net/i/123456", "123456"),
|
||||
(
|
||||
"https://www.pixiv.net/member_illust.php?mode=medium&illust_id=123456",
|
||||
"123456",
|
||||
),
|
||||
(
|
||||
"https://www.pixiv.net/en/member_illust.php?illust_id=123456",
|
||||
"123456",
|
||||
),
|
||||
];
|
||||
for (url, id) in cases {
|
||||
let caps = PATTERN.captures(url).unwrap_or_else(|| panic!("{url}"));
|
||||
assert_eq!(caps.get(1).unwrap().as_str(), id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_rejects_non_artwork_urls() {
|
||||
for url in [
|
||||
"https://www.pixiv.net/users/123",
|
||||
"https://x.com/user/status/123",
|
||||
"https://bsky.app/profile/u/post/3xxxx",
|
||||
] {
|
||||
assert!(!PATTERN.is_match(url), "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_validation_keeps_the_site_enabled_on_a_bad_moment() {
|
||||
use super::super::api;
|
||||
|
||||
// The startup decision, not the retry policy: a 5xx/429 while the
|
||||
// container comes up must leave pixiv enabled and say so in the message
|
||||
// the admin gets. The rejected-credential half is not exercised here —
|
||||
// it calls `disable()`, a process-wide flag with no reset, so a test
|
||||
// touching it would order-couple every other pixiv test (the predicate
|
||||
// it keys on is covered by the table below).
|
||||
for err in [PixivError::Status(429), PixivError::Status(503)] {
|
||||
let enabled_before = api::enabled();
|
||||
let message = startup_validation(Err(err)).unwrap_err();
|
||||
assert!(message.contains("stays enabled"), "{message}");
|
||||
assert_eq!(
|
||||
api::enabled(),
|
||||
enabled_before,
|
||||
"a bad moment must not disable the site"
|
||||
);
|
||||
}
|
||||
assert!(startup_validation(Ok(())).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_retryable_classifies_transient_and_permanent() {
|
||||
// Transient: network errors, explicit transient, pixiv 429/5xx.
|
||||
assert!(is_retryable(&FetchError::Transient("429".into())));
|
||||
assert!(is_retryable(&FetchError::Pixiv(PixivError::Status(429))));
|
||||
assert!(is_retryable(&FetchError::Pixiv(PixivError::Status(500))));
|
||||
assert!(is_retryable(&FetchError::Pixiv(PixivError::Status(503))));
|
||||
// Permanent: pixiv 4xx (bad/expired token, forbidden, not found),
|
||||
// api/auth errors, unparseable bodies, not-found/blocked/sensitive.
|
||||
assert!(!is_retryable(&FetchError::Pixiv(PixivError::Status(400))));
|
||||
assert!(!is_retryable(&FetchError::Pixiv(PixivError::Status(401))));
|
||||
assert!(!is_retryable(&FetchError::Pixiv(PixivError::Status(403))));
|
||||
assert!(!is_retryable(&FetchError::Pixiv(PixivError::Status(404))));
|
||||
assert!(!is_retryable(&FetchError::Pixiv(PixivError::Api(
|
||||
"invalid_grant".into()
|
||||
))));
|
||||
assert!(!is_retryable(&FetchError::Pixiv(PixivError::NoAuth)));
|
||||
let json_err = serde_json::from_str::<serde_json::Value>("x").unwrap_err();
|
||||
assert!(!is_retryable(&FetchError::Pixiv(PixivError::Json(
|
||||
json_err
|
||||
))));
|
||||
assert!(!is_retryable(&FetchError::NotFound));
|
||||
assert!(!is_retryable(&FetchError::Blocked));
|
||||
assert!(!is_retryable(&FetchError::Sensitive));
|
||||
assert!(!is_retryable(&FetchError::TooLarge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_headers_adds_referer_only_for_pximg() {
|
||||
assert_eq!(
|
||||
media_headers("https://i.pximg.net/img-original/img/1.png"),
|
||||
Some(vec![("Referer", "https://www.pixiv.net/".to_string())])
|
||||
);
|
||||
assert_eq!(media_headers("https://www.pixiv.net/artworks/1"), None);
|
||||
assert_eq!(media_headers("https://x.com/u/status/1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ugoira_yields_empty_media() {
|
||||
let v = illust_json(
|
||||
"ugoira",
|
||||
1,
|
||||
Some("https://i.pximg.net/orig.jpg"),
|
||||
None,
|
||||
vec![],
|
||||
0,
|
||||
);
|
||||
let illustration = parse(v);
|
||||
let fetched: Fetched = illustration.into();
|
||||
assert!(fetched.media.is_empty());
|
||||
assert!(fetched.sensitive, "sanity_level 6 > 5");
|
||||
assert_eq!(fetched.title, "Art <title>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_page_with_single_original() {
|
||||
let v = illust_json(
|
||||
"illust",
|
||||
1,
|
||||
Some("https://i.pximg.net/single.jpg"),
|
||||
None,
|
||||
vec![],
|
||||
0,
|
||||
);
|
||||
let fetched: Fetched = parse(v).into();
|
||||
assert_eq!(fetched.media.len(), 1);
|
||||
match &fetched.media[0] {
|
||||
Media::Illustration { url, .. } => {
|
||||
assert_eq!(url, "https://i.pximg.net/single.jpg")
|
||||
}
|
||||
other => panic!("expected Illustration, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_page_falls_back_to_image_urls_original() {
|
||||
let v = illust_json(
|
||||
"illust",
|
||||
1,
|
||||
None,
|
||||
Some("https://i.pximg.net/fallback.jpg"),
|
||||
vec![],
|
||||
0,
|
||||
);
|
||||
let fetched: Fetched = parse(v).into();
|
||||
assert_eq!(fetched.media.len(), 1);
|
||||
match &fetched.media[0] {
|
||||
Media::Illustration { url, .. } => {
|
||||
assert_eq!(url, "https://i.pximg.net/fallback.jpg")
|
||||
}
|
||||
other => panic!("expected Illustration, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_page_without_any_original_is_empty() {
|
||||
let v = illust_json("illust", 1, None, None, vec![], 0);
|
||||
let fetched: Fetched = parse(v).into();
|
||||
assert!(fetched.media.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_page_skips_pages_without_original() {
|
||||
let v = illust_json(
|
||||
"illust",
|
||||
2,
|
||||
None,
|
||||
None,
|
||||
vec![
|
||||
(None, "m1.jpg", "l1.jpg"),
|
||||
(Some("https://i.pximg.net/p2.jpg"), "m2.jpg", "l2.jpg"),
|
||||
],
|
||||
0,
|
||||
);
|
||||
let fetched: Fetched = parse(v).into();
|
||||
assert_eq!(fetched.media.len(), 1);
|
||||
match &fetched.media[0] {
|
||||
Media::Illustration {
|
||||
url,
|
||||
thumbnail_url,
|
||||
fallback_url,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(url, "https://i.pximg.net/p2.jpg");
|
||||
assert_eq!(thumbnail_url.as_deref(), Some("m2.jpg"));
|
||||
assert_eq!(fallback_url.as_deref(), Some("l2.jpg"));
|
||||
}
|
||||
other => panic!("expected Illustration, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caption_with_escapes_format_and_substitutes() {
|
||||
let v = illust_json(
|
||||
"illust",
|
||||
1,
|
||||
Some("https://i.pximg.net/o.jpg"),
|
||||
None,
|
||||
vec![],
|
||||
0,
|
||||
);
|
||||
let fetched: Fetched = parse(v).into();
|
||||
// Format string is escaped in full, then placeholders substituted.
|
||||
let out = fetched.caption_with("{title} by {author} <script> {tags}");
|
||||
assert!(
|
||||
out.contains("Art <title> by Artist <script> #tag1 #tag2"),
|
||||
"got: {out}"
|
||||
);
|
||||
assert!(!out.contains("<script>"), "no raw HTML injection: {out}");
|
||||
// {url} and {author_url} carry the site's own URLs.
|
||||
let out = fetched.caption_with("{url} {author_url}");
|
||||
assert_eq!(
|
||||
out,
|
||||
"https://www.pixiv.net/artworks/123 https://www.pixiv.net/users/456"
|
||||
);
|
||||
// Empty format falls back to the built-in caption.
|
||||
assert_eq!(fetched.caption_with(""), fetched.caption);
|
||||
assert_eq!(fetched.site_name(), "pixiv");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_work_gets_leading_ai_tag() {
|
||||
// illust_ai_type == 2 is the only AI marker.
|
||||
let v = illust_json(
|
||||
"illust",
|
||||
1,
|
||||
Some("https://i.pximg.net/o.jpg"),
|
||||
None,
|
||||
vec![],
|
||||
2,
|
||||
);
|
||||
let fetched: Fetched = parse(v).into();
|
||||
assert!(
|
||||
fetched.caption.contains("#AI #tag1 #tag2"),
|
||||
"caption: {}",
|
||||
fetched.caption
|
||||
);
|
||||
// The {tags} placeholder reflects the tag array too.
|
||||
assert!(
|
||||
fetched.caption_with("{tags}").starts_with("#AI "),
|
||||
"got: {}",
|
||||
fetched.caption_with("{tags}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ai_work_has_no_ai_tag() {
|
||||
// 1 = explicitly not AI, 0 = undefined: neither gets the #AI tag.
|
||||
for ai_type in [0, 1] {
|
||||
let v = illust_json(
|
||||
"illust",
|
||||
1,
|
||||
Some("https://i.pximg.net/o.jpg"),
|
||||
None,
|
||||
vec![],
|
||||
ai_type,
|
||||
);
|
||||
let fetched: Fetched = parse(v).into();
|
||||
assert!(
|
||||
!fetched.caption.contains("#AI"),
|
||||
"ai_type={ai_type} got: {}",
|
||||
fetched.caption
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caption_escapes_and_links() {
|
||||
let v = illust_json(
|
||||
"illust",
|
||||
1,
|
||||
Some("https://i.pximg.net/o.jpg"),
|
||||
None,
|
||||
vec![],
|
||||
0,
|
||||
);
|
||||
let fetched: Fetched = parse(v).into();
|
||||
assert!(
|
||||
fetched
|
||||
.caption
|
||||
.contains("<a href=\"https://www.pixiv.net/artworks/123\">Art <title></a>"),
|
||||
"caption: {}",
|
||||
fetched.caption
|
||||
);
|
||||
assert!(fetched.caption.contains("#tag1 #tag2"));
|
||||
assert_eq!(fetched.source_url, "https://www.pixiv.net/artworks/123");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mod api;
|
||||
mod interface;
|
||||
mod model;
|
||||
|
||||
pub use api::{PixivAPI, PixivError, disable, fetch, validate};
|
||||
pub use interface::{
|
||||
Illustration, PATTERN, PixivSite, cache_key, enabled, fetch_from_url, is_retryable,
|
||||
media_headers,
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
// Model set for the native pixiv app-API client (app-api.pixiv.net).
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct IllustrationModel {
|
||||
pub id: u64,
|
||||
pub title: String,
|
||||
/// The artwork's description as the app API returns it — HTML in most
|
||||
/// works (`<br />`, `<a href>`, sometimes `<p>`), empty for many.
|
||||
#[serde(default)]
|
||||
pub caption: String,
|
||||
pub r#type: TypeModel,
|
||||
pub image_urls: ImageUrlsModel,
|
||||
pub user: UserInfoModel,
|
||||
pub tags: Vec<IllustrationTagModel>,
|
||||
pub page_count: u8,
|
||||
pub sanity_level: u8,
|
||||
/// 0 = undefined (unlabeled), 1 = not AI, 2 = AI-generated.
|
||||
pub illust_ai_type: i32,
|
||||
pub meta_single_page: MetaSinglePageModel,
|
||||
pub meta_pages: Vec<MetaPageModel>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub enum TypeModel {
|
||||
#[serde(rename = "illust")]
|
||||
Illust,
|
||||
#[serde(rename = "manga")]
|
||||
Manga,
|
||||
#[serde(rename = "ugoira")]
|
||||
Ugoira,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct UserInfoModel {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ImageUrlsModel {
|
||||
pub medium: String,
|
||||
pub large: String,
|
||||
#[serde(default)]
|
||||
pub original: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct IllustrationTagModel {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct MetaSinglePageModel {
|
||||
#[serde(default)]
|
||||
pub original_image_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct MetaPageModel {
|
||||
pub image_urls: ImageUrlsModel,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct UgoiraMetadataModel {
|
||||
/// Older API shape (`zip_url`); newer responses use `zip_urls.medium`.
|
||||
#[serde(default)]
|
||||
pub zip_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub zip_urls: Option<UgoiraZipUrlsModel>,
|
||||
pub frames: Vec<UgoiraFrameModel>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct UgoiraZipUrlsModel {
|
||||
pub medium: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct UgoiraFrameModel {
|
||||
pub delay: u32,
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
//! 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),
|
||||
// A stale/refused `auth_token` is not a bad moment: retrying it
|
||||
// three times only delays the report.
|
||||
401 | 403 => Err(FetchError::Blocked),
|
||||
_ => 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_value(syndication_shape).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_value`] 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_value(shape).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, "");
|
||||
assert_eq!(fetched.content, "nsfw content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwraps_retweet_to_original() {
|
||||
let original = tweet_result();
|
||||
let mut rt = tweet_result();
|
||||
rt["legacy"]["retweeted_status_result"] = json!({ "result": original });
|
||||
let json = conversation(rt);
|
||||
let result = parse_tweet_result(&json, "2083868672721039569").unwrap();
|
||||
assert!(result.pointer("/legacy/retweeted_status_result").is_none());
|
||||
assert_eq!(
|
||||
result.pointer("/legacy/id_str").unwrap(),
|
||||
"2083868672721039569"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_response_maps_to_not_found() {
|
||||
let json = json!({ "errors": [{ "message": "NsfwLoggedOut" }] });
|
||||
assert!(matches!(
|
||||
parse_tweet_result(&json, "1"),
|
||||
Err(FetchError::NotFound)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_entry_maps_to_not_found() {
|
||||
let json = conversation(json!({ "__typename": "Tweet" }));
|
||||
assert!(matches!(
|
||||
parse_tweet_result(&json, "999"),
|
||||
Err(FetchError::NotFound)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tombstone_maps_to_not_found() {
|
||||
let tombstone = json!({
|
||||
"__typename": "TweetTombstone",
|
||||
"tombstone": { "text": { "text": "Age-restricted adult content" } }
|
||||
});
|
||||
let json = conversation(tombstone);
|
||||
assert!(matches!(
|
||||
parse_tweet_result(&json, "2083868672721039569"),
|
||||
Err(FetchError::NotFound)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_wrapper_unwraps() {
|
||||
let inner = tweet_result();
|
||||
let wrapped = json!({ "__typename": "TweetWithVisibilityResults", "tweet": inner });
|
||||
let json = conversation(wrapped);
|
||||
let result = parse_tweet_result(&json, "2083868672721039569").unwrap();
|
||||
assert_eq!(result.get("__typename").unwrap(), "Tweet");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
use super::model;
|
||||
use crate::media::Media;
|
||||
use crate::site::{FetchError, Fetched, Site, SiteFuture};
|
||||
use html_escape::{decode_html_entities, encode_double_quoted_attribute, encode_text};
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// Registry entry for the twitter adapter (see [`crate::site::Site`]).
|
||||
pub struct TwitterSite;
|
||||
|
||||
impl Site for TwitterSite {
|
||||
fn id(&self) -> &'static str {
|
||||
"twitter"
|
||||
}
|
||||
|
||||
fn pattern(&self) -> &'static Regex {
|
||||
&PATTERN
|
||||
}
|
||||
|
||||
fn cache_key(&self, url: &str) -> Option<String> {
|
||||
cache_key(url)
|
||||
}
|
||||
|
||||
fn fetch_from_url<'a>(&'a self, url: &'a str) -> SiteFuture<'a, Fetched> {
|
||||
Box::pin(async move { fetch_from_url(url).await })
|
||||
}
|
||||
}
|
||||
|
||||
pub static PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"^(?:https?://)?(?:www\.|mobile\.)?(?:x|twitter|fixvx|vxtwitter|fixupx|fxtwitter)\.com/[^.]+/status/(\d+)").unwrap()
|
||||
});
|
||||
|
||||
pub fn enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
|
||||
let id = PATTERN
|
||||
.captures(url)
|
||||
.and_then(|caps| caps.get(1))
|
||||
.map(|m| m.as_str())
|
||||
.ok_or(FetchError::NotFound)?;
|
||||
match fetch(id).await {
|
||||
Ok(tweet) => Ok(tweet.into()),
|
||||
// Syndication withholds NSFW/age-restricted tweets (empty `{}`).
|
||||
// Retry as the logged-in user when TWITTER_AUTH_TOKEN is set; without
|
||||
// the token the withholding is reported as `Sensitive`, so the bot can
|
||||
// answer "age-restricted / needs TWITTER_AUTH_TOKEN" instead of the
|
||||
// misleading "No media found".
|
||||
Err(FetchError::Sensitive) => {
|
||||
if super::auth::enabled() {
|
||||
match super::auth::fetch(id).await {
|
||||
Ok(tweet) => Ok(tweet.into()),
|
||||
// Deleted/suspended (tombstoned) and unexpected fallback
|
||||
// failures keep their own class: the bot reports what
|
||||
// actually happened rather than "No media found".
|
||||
Err(e) => {
|
||||
log::warn!("twitter auth fallback failed for {id}: {e}");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::debug!("tweet {id} is sensitive; set TWITTER_AUTH_TOKEN to fetch NSFW media");
|
||||
Err(FetchError::Sensitive)
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache key for a twitter URL: `"twitter:<id>"`. The prefix is the site id
|
||||
/// used for caption-format lookup and link-cache keys.
|
||||
pub fn cache_key(url: &str) -> Option<String> {
|
||||
PATTERN
|
||||
.captures(url)
|
||||
.map(|caps| format!("twitter:{}", &caps[1]))
|
||||
}
|
||||
|
||||
/// Twitter's fetch-retry policy: transient classes only. Not-found, blocked,
|
||||
/// sensitive (NSFW withholding) and parse failures are permanent — retrying
|
||||
/// them only wastes attempts against the syndication endpoint.
|
||||
pub fn is_retryable(err: &FetchError) -> bool {
|
||||
matches!(err, FetchError::Http(_) | FetchError::Transient(_))
|
||||
}
|
||||
|
||||
/// twimg URLs need no extra headers (no hotlink protection).
|
||||
pub fn media_headers(_url: &str) -> Option<Vec<(&'static str, String)>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Fetches a tweet from the syndication endpoint. Deleted/blocked tweets
|
||||
/// surface as `FetchError::NotFound`; withheld content (empty tombstone,
|
||||
/// age-restricted) as `FetchError::Sensitive`.
|
||||
pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
|
||||
let id_num = id.parse::<u64>().map_err(|_| FetchError::NotFound)?;
|
||||
let response = crate::site::CLIENT
|
||||
.get(format!(
|
||||
"https://cdn.syndication.twimg.com/tweet-result?id={id}&lang=en&token={}",
|
||||
syndication_token(id_num)
|
||||
))
|
||||
.send()
|
||||
.await?;
|
||||
// 404/410 = gone (permanent); 429/5xx = transient and retried by fetch.
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return match status.as_u16() {
|
||||
404 | 410 => Err(FetchError::NotFound),
|
||||
// A refusal or an auth demand is not a bad moment: retrying it
|
||||
// three times only delays an error the user has to see.
|
||||
401 | 403 => Err(FetchError::Blocked),
|
||||
_ => Err(FetchError::Transient(format!("twitter status {status}"))),
|
||||
};
|
||||
}
|
||||
let text = response.text().await?;
|
||||
// Classify before building the tweet (see [`parse_syndication_body`]), and
|
||||
// build it from the value that classification already parsed: this used to
|
||||
// scan and allocate the whole body twice.
|
||||
let body = parse_syndication_body(&text)?;
|
||||
Tweet::from_syndication_value(body).map_err(FetchError::Json)
|
||||
}
|
||||
|
||||
/// Parses and classifies a syndication response body. `Ok` carries the parsed
|
||||
/// body on for the caller to build the tweet from — the same value, so the
|
||||
/// text is never parsed twice; `Err` carries the permanent error class:
|
||||
/// - `NotFound`: an `errors` array (deleted/blocked) or a `TweetTombstone`
|
||||
/// **with a reason** — "This Post was deleted by the Post author." /
|
||||
/// "This Post is from a suspended account." (the tweet is gone).
|
||||
/// - `Sensitive`: content withheld **without a deletion reason** — the empty
|
||||
/// `{}` shape or an *empty* `TweetTombstone` (`{"__typename":
|
||||
/// "TweetTombstone","tombstone":{}}`). Live tweets in restricted contexts
|
||||
/// surface this way; treating them as deleted is a regression (a normal
|
||||
/// tweet must not report "deleted"). Age-restricted tombstones route here
|
||||
/// too so the logged-in GraphQL fallback can fetch the real tweet.
|
||||
/// - `Json`: an unparseable body.
|
||||
fn parse_syndication_body(text: &str) -> Result<serde_json::Value, FetchError> {
|
||||
let body: serde_json::Value = serde_json::from_str(text)?;
|
||||
if body.get("errors").is_some() {
|
||||
return Err(FetchError::NotFound);
|
||||
}
|
||||
if let Some(tombstone) = body.get("tombstone") {
|
||||
// Only a tombstone with an explicit reason means the tweet is gone;
|
||||
// a missing reason (empty `tombstone: {}`) or an age-restricted
|
||||
// reason means the tweet exists but is withheld.
|
||||
let reason = tombstone
|
||||
.get("text")
|
||||
.and_then(|t| t.get("text"))
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("");
|
||||
if reason.is_empty() || reason.to_ascii_lowercase().contains("age-restricted") {
|
||||
return Err(FetchError::Sensitive);
|
||||
}
|
||||
return Err(FetchError::NotFound);
|
||||
}
|
||||
if body.get("id_str").is_none() {
|
||||
// Syndication answers an empty `{}` for withheld (NSFW /
|
||||
// age-restricted) tweets: the documented case, kept as `Sensitive`
|
||||
// because it is what triggers the logged-in auth fallback.
|
||||
if body.as_object().is_some_and(|object| object.is_empty()) {
|
||||
return Err(FetchError::Sensitive);
|
||||
}
|
||||
// Any other shape is not a tweet: an interstitial, a truncated body,
|
||||
// a change on their side. Reporting that as withheld content told the
|
||||
// user to set TWITTER_AUTH_TOKEN for something auth cannot fix.
|
||||
return Err(FetchError::Transient(
|
||||
"unexpected syndication body".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// The syndication token: JS `((id / 1e15) * PI).toString(36)` (the
|
||||
/// `replace('0.','')` is a no-op for realistic tweet ids). The endpoint
|
||||
/// currently serves public tweets regardless of the token; the formula is
|
||||
/// kept for parity with the known-good client behavior.
|
||||
fn syndication_token(id: u64) -> String {
|
||||
let value = (id as f64 / 1e15) * std::f64::consts::PI;
|
||||
let integer = value.trunc() as u64;
|
||||
let mut fraction = value.fract();
|
||||
let mut digits = String::new();
|
||||
if integer == 0 {
|
||||
digits.push('0');
|
||||
} else {
|
||||
let mut n = integer;
|
||||
let mut buf = Vec::new();
|
||||
while n > 0 {
|
||||
buf.push(char::from_digit((n % 36) as u32, 36).unwrap());
|
||||
n /= 36;
|
||||
}
|
||||
digits.extend(buf.into_iter().rev());
|
||||
}
|
||||
digits.push('.');
|
||||
for _ in 0..10 {
|
||||
fraction *= 36.0;
|
||||
let digit = fraction.trunc() as u32;
|
||||
digits.push(char::from_digit(digit.min(35), 36).unwrap());
|
||||
fraction -= digit as f64;
|
||||
if fraction == 0.0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
digits
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Tweet {
|
||||
id: String,
|
||||
text: String,
|
||||
author: String,
|
||||
author_id: String,
|
||||
media: Vec<Media>,
|
||||
sensitive: bool,
|
||||
}
|
||||
|
||||
impl Tweet {
|
||||
fn url(&self) -> String {
|
||||
format!("{}/status/{}", self.author_url(), self.id)
|
||||
}
|
||||
|
||||
fn author_url(&self) -> String {
|
||||
format!("https://x.com/{}", self.author_id)
|
||||
}
|
||||
|
||||
pub fn caption(&self) -> String {
|
||||
format!(
|
||||
"{url}\n<a href=\"{author_url}\">{author}</a>: {text}",
|
||||
url = encode_double_quoted_attribute(&self.url()),
|
||||
author_url = encode_double_quoted_attribute(&self.author_url()),
|
||||
author = encode_text(&self.author),
|
||||
text = encode_text(&self.text),
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds a tweet from an already-parsed syndication body. Takes the value
|
||||
/// rather than JSON text so a caller that had to parse it anyway (the
|
||||
/// fetch path classifies the raw shape; the auth fallback builds the shape
|
||||
/// itself) does not pay for a second scan — `from_value` moves the strings
|
||||
/// out instead.
|
||||
pub fn from_syndication_value(body: serde_json::Value) -> Result<Self, serde_json::Error> {
|
||||
let json: model::SyndicationTweet = serde_json::from_value(body)?;
|
||||
let id = json.id_str;
|
||||
// Expand the user's t.co short links to their real destinations and
|
||||
// strip the appended media short link, mirroring FxEmbed's linkFixer
|
||||
// (no display_text_range arithmetic — see expand_links).
|
||||
let text = expand_links(&json.text, &json.entities.urls);
|
||||
// Twitter APIs (syndication AND GraphQL full_text) return the text
|
||||
// pre-escaped for HTML (`>` `<` `&` `'` …): decode it so
|
||||
// the stored text is raw. The caption's own escaping then produces
|
||||
// the rendered form exactly once — without this, `>^ω^<` would
|
||||
// be double-escaped to `&gt;^ω^&lt;` and the sent message
|
||||
// would show literal `>^ω^<`.
|
||||
let text = decode_html_entities(&text).into_owned();
|
||||
// `name` is the display name, `screen_name` the handle (Python's
|
||||
// vxtwitter mapping: author = display name, author_id = handle).
|
||||
// Display names can carry the same pre-escaped entities.
|
||||
let author = decode_html_entities(&json.user.name).into_owned();
|
||||
let author_id = json.user.screen_name;
|
||||
let mut media = vec![];
|
||||
for item in json.media_details {
|
||||
match item.media_type.as_str() {
|
||||
"photo" => media.push(Media::Illustration {
|
||||
title: None,
|
||||
url: original_twimg_url(&item.media_url_https),
|
||||
thumbnail_url: None,
|
||||
// The param-less base URL is a reduced-size variant;
|
||||
// used as the fallback when the original is too large.
|
||||
fallback_url: Some(item.media_url_https.clone()),
|
||||
}),
|
||||
"video" => media.push(Media::Video {
|
||||
title: None,
|
||||
url: mp4_variant(&item),
|
||||
thumbnail_url: item.media_url_https,
|
||||
}),
|
||||
"animated_gif" => media.push(Media::Animated {
|
||||
title: None,
|
||||
url: mp4_variant(&item),
|
||||
thumbnail_url: item.media_url_https,
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let sensitive = json.possibly_sensitive.unwrap_or(false);
|
||||
Ok(Self {
|
||||
id,
|
||||
text,
|
||||
author,
|
||||
author_id,
|
||||
media,
|
||||
sensitive,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors FxEmbed's `linkFixer` (link-fixer.ts): expand every t.co short
|
||||
/// link that has an entity mapping to its real destination, drop internal
|
||||
/// `x.com/i/web/status/…` plumbing links, then strip any remaining t.co
|
||||
/// short link (the appended media link and other unmapped short links).
|
||||
/// Pure content matching — no `display_text_range` arithmetic, so the
|
||||
/// endpoint's inconsistent index units (UTF-16 vs code points, see the
|
||||
/// deleted `strip_trailing_short_links`) never matter.
|
||||
fn expand_links(text: &str, urls: &[model::SyndicationEntityUrl]) -> String {
|
||||
let mut out = text.to_string();
|
||||
for entity in urls {
|
||||
let Some(expanded) = &entity.expanded_url else {
|
||||
continue;
|
||||
};
|
||||
let replacement = if WEB_STATUS_URL.is_match(expanded) {
|
||||
""
|
||||
} else {
|
||||
expanded
|
||||
};
|
||||
out = out.replace(&entity.url, replacement);
|
||||
}
|
||||
TCO_LINK.replace_all(&out, "").into_owned()
|
||||
}
|
||||
|
||||
/// Internal x.com page links (reply / quote plumbing) expand to
|
||||
/// `x.com/i/web/status/<id>`; FxEmbed drops them — the tweet's own content
|
||||
/// already carries the information.
|
||||
static WEB_STATUS_URL: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^https://(?:x\.com|twitter\.com)/i/web/status/\w+").unwrap());
|
||||
|
||||
/// A t.co short link, optionally preceded by a space. Any leftover
|
||||
/// occurrence (unmapped — e.g. the appended media link) is removed,
|
||||
/// mirroring FxEmbed. Real short-link codes are 10 alphanumerics; the
|
||||
/// length-agnostic class keeps fixtures and hypothetical odd lengths safe.
|
||||
static TCO_LINK: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r" ?https?://t\.co/[A-Za-z0-9]+").unwrap());
|
||||
|
||||
/// pbs.twimg.com serves a reduced default size without size params; `name=orig`
|
||||
/// returns the original file (fxtwitter used to hand out the original
|
||||
/// directly, the syndication API does not). Non-twimg URLs pass through
|
||||
/// unchanged.
|
||||
fn original_twimg_url(url: &str) -> String {
|
||||
if url.starts_with("https://pbs.twimg.com/") && (url.ends_with(".jpg") || url.ends_with(".png"))
|
||||
{
|
||||
format!("{url}?name=orig")
|
||||
} else {
|
||||
url.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn mp4_variant(item: &model::SyndicationMedia) -> String {
|
||||
item.video_info
|
||||
.as_ref()
|
||||
.and_then(|info| {
|
||||
info.variants
|
||||
.iter()
|
||||
.find(|variant| variant.content_type == "video/mp4")
|
||||
})
|
||||
.map(|variant| variant.url.clone())
|
||||
.unwrap_or_else(|| item.media_url_https.clone())
|
||||
}
|
||||
|
||||
impl From<Tweet> for Fetched {
|
||||
fn from(tweet: Tweet) -> Self {
|
||||
let url = tweet.url();
|
||||
let author_url = tweet.author_url();
|
||||
// A tweet has no title: its text is all content.
|
||||
let render_data = Some(crate::site::RenderData {
|
||||
url: url.clone(),
|
||||
author: encode_text(&tweet.author).into_owned(),
|
||||
author_url: author_url.clone(),
|
||||
title: String::new(),
|
||||
content: encode_text(&tweet.text).into_owned(),
|
||||
tags: String::new(),
|
||||
});
|
||||
Fetched {
|
||||
source_url: url,
|
||||
caption: tweet.caption(),
|
||||
title: String::new(),
|
||||
content: tweet.text.clone(),
|
||||
media: tweet.media,
|
||||
sensitive: tweet.sensitive,
|
||||
site_id: "twitter",
|
||||
render_data,
|
||||
_keep_alive: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture(media_details: serde_json::Value) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"__typename": "Tweet",
|
||||
"id_str": "861627479294746624",
|
||||
"text": "a & b <c>",
|
||||
"user": { "name": "Display Name", "screen_name": "author_handle" },
|
||||
"possibly_sensitive": true,
|
||||
"mediaDetails": media_details
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_matches_all_domains() {
|
||||
for url in [
|
||||
"https://x.com/user/status/1234567890",
|
||||
"https://twitter.com/user/status/1234567890",
|
||||
"https://mobile.twitter.com/user/status/1234567890",
|
||||
"https://www.x.com/user/status/1234567890",
|
||||
"https://fxtwitter.com/user/status/1234567890",
|
||||
"https://fixupx.com/user/status/1234567890",
|
||||
"https://fixvx.com/user/status/1234567890",
|
||||
"https://vxtwitter.com/user/status/1234567890",
|
||||
] {
|
||||
let caps = PATTERN.captures(url).unwrap_or_else(|| panic!("{url}"));
|
||||
assert_eq!(caps.get(1).unwrap().as_str(), "1234567890");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_rejects_non_tweet_urls() {
|
||||
for url in [
|
||||
"https://x.com/user",
|
||||
"https://x.com/user/status/abc",
|
||||
"https://bsky.app/profile/u/post/3xxxx",
|
||||
"https://pixiv.net/artworks/123",
|
||||
"https://example.com/x.com/user/status/123",
|
||||
] {
|
||||
assert!(!PATTERN.is_match(url), "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syndication_text_is_unescaped_before_storing() {
|
||||
// Real API shape: the text arrives pre-escaped for HTML — e.g. the
|
||||
// tweet `>^ω^<` comes back as `>^ω^<` (fxtwitter's raw_text for
|
||||
// 2060196388252827954) and apostrophes as `'`. Storing it raw and
|
||||
// escaping once at caption build avoids the double-escape that would
|
||||
// show literal `>`/`<`/`&` in the sent message.
|
||||
let raw = serde_json::json!({
|
||||
"__typename": "Tweet",
|
||||
"id_str": "1",
|
||||
"text": ">^ω^< & more 'quoted' https://t.co/abc123",
|
||||
"user": { "name": "O'Brien", "screen_name": "h" },
|
||||
"entities": { "urls": [] },
|
||||
"mediaDetails": []
|
||||
});
|
||||
let tweet = Tweet::from_syndication_value(raw).unwrap();
|
||||
// The appended media short link is stripped, then entities decoded.
|
||||
assert_eq!(tweet.text, ">^ω^< & more 'quoted'");
|
||||
assert_eq!(tweet.author, "O'Brien");
|
||||
let fetched: Fetched = tweet.into();
|
||||
assert_eq!(fetched.title, "");
|
||||
assert_eq!(fetched.content, ">^ω^< & more 'quoted'");
|
||||
// The caption escapes the raw text exactly once (encode_text covers
|
||||
// & < >; apostrophes stay literal — they are harmless in text).
|
||||
assert!(
|
||||
fetched.caption.contains(">^ω^< & more 'quoted'"),
|
||||
"caption: {}",
|
||||
fetched.caption
|
||||
);
|
||||
assert!(
|
||||
!fetched.caption.contains("&gt;"),
|
||||
"double-escaped text: {}",
|
||||
fetched.caption
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_key_prefixes_tweet_id() {
|
||||
assert_eq!(
|
||||
cache_key("https://x.com/user/status/1234567890"),
|
||||
Some("twitter:1234567890".into())
|
||||
);
|
||||
assert_eq!(cache_key("https://example.com/1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_retryable_classifies_transient_and_permanent() {
|
||||
// Transient: network errors and explicit transient statuses (the
|
||||
// `Http` arm shares this match arm with `Transient`).
|
||||
assert!(is_retryable(&FetchError::Transient("429".into())));
|
||||
// Permanent: gone, blocked, withheld, oversized, unparseable.
|
||||
assert!(!is_retryable(&FetchError::NotFound));
|
||||
assert!(!is_retryable(&FetchError::Blocked));
|
||||
assert!(!is_retryable(&FetchError::Sensitive));
|
||||
assert!(!is_retryable(&FetchError::TooLarge));
|
||||
let json_err = serde_json::from_str::<serde_json::Value>("x").unwrap_err();
|
||||
assert!(!is_retryable(&FetchError::Json(json_err)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syndication_json_converts_to_fetched() {
|
||||
let raw = fixture(serde_json::json!([
|
||||
{ "type": "photo", "media_url_https": "https://pbs.twimg.com/media/photo.jpg" },
|
||||
{
|
||||
"type": "video",
|
||||
"media_url_https": "https://pbs.twimg.com/thumb.jpg",
|
||||
"video_info": {
|
||||
"variants": [
|
||||
{ "content_type": "application/x-mpegURL", "url": "https://x.com/pl.m3u8" },
|
||||
{ "content_type": "video/mp4", "url": "https://video.twimg.com/v.mp4" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]));
|
||||
let tweet = Tweet::from_syndication_value(raw).unwrap();
|
||||
let fetched: Fetched = tweet.into();
|
||||
assert_eq!(
|
||||
fetched.source_url,
|
||||
"https://x.com/author_handle/status/861627479294746624"
|
||||
);
|
||||
assert_eq!(fetched.title, "");
|
||||
assert_eq!(fetched.content, "a & b <c>");
|
||||
assert!(fetched.sensitive);
|
||||
assert_eq!(fetched.media.len(), 2);
|
||||
match &fetched.media[0] {
|
||||
Media::Illustration { url, .. } => {
|
||||
// Photo URL is rewritten to request the original file.
|
||||
assert_eq!(url, "https://pbs.twimg.com/media/photo.jpg?name=orig");
|
||||
}
|
||||
other => panic!("expected illustration, got {other:?}"),
|
||||
}
|
||||
match &fetched.media[1] {
|
||||
Media::Video {
|
||||
url, thumbnail_url, ..
|
||||
} => {
|
||||
assert_eq!(url, "https://video.twimg.com/v.mp4");
|
||||
assert_eq!(thumbnail_url, "https://pbs.twimg.com/thumb.jpg");
|
||||
}
|
||||
other => panic!("expected video, got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
fetched.caption.contains(
|
||||
"<a href=\"https://x.com/author_handle\">Display Name</a>: a & b <c>"
|
||||
),
|
||||
"caption: {}",
|
||||
fetched.caption
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syndication_gif_maps_to_animated() {
|
||||
let raw = fixture(serde_json::json!([
|
||||
{
|
||||
"type": "animated_gif",
|
||||
"media_url_https": "https://pbs.twimg.com/g.jpg",
|
||||
"video_info": {
|
||||
"variants": [{ "content_type": "video/mp4", "url": "https://video.twimg.com/g.mp4" }]
|
||||
}
|
||||
}
|
||||
]));
|
||||
let tweet = Tweet::from_syndication_value(raw).unwrap();
|
||||
assert!(matches!(&tweet.media[0], Media::Animated { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syndication_text_strips_trailing_media_short_link() {
|
||||
// Real syndication shape: the appended media short link sits after the
|
||||
// visible text and there are no URL entities, so the unmapped t.co link
|
||||
// is stripped by content alone. The second row is real tweet
|
||||
// 2084567054481571919 (30 code points but 41 UTF-16 units, and the two
|
||||
// endpoints historically reported `display_text_range` in different
|
||||
// units): a content-based strip cannot leave a partial link behind for
|
||||
// either unit system.
|
||||
for (text, visible) in [
|
||||
("hello world https://t.co/abc123", "hello world"),
|
||||
(
|
||||
"妄想𝑨𝒅𝒅𝒊𝒄𝒕𝒊𝒐𝒏…🩷💚❤️\n#ゼンゼロ #zzzero https://t.co/XnIi83EkEB",
|
||||
"妄想𝑨𝒅𝒅𝒊𝒄𝒕𝒊𝒐𝒏…🩷💚❤️\n#ゼンゼロ #zzzero",
|
||||
),
|
||||
] {
|
||||
let raw = serde_json::json!({
|
||||
"__typename": "Tweet",
|
||||
"id_str": "1",
|
||||
"text": text,
|
||||
"user": { "name": "N", "screen_name": "h" },
|
||||
"mediaDetails": []
|
||||
});
|
||||
let tweet = Tweet::from_syndication_value(raw).unwrap();
|
||||
assert_eq!(tweet.text, visible, "left a partial link in {text:?}");
|
||||
assert!(!tweet.caption().contains("t.co"), "{text:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syndication_text_expands_url_entities() {
|
||||
// Real FloodSocial shape: the user's own link is a t.co short link in
|
||||
// the text; the entity mapping expands it, the trailing media short
|
||||
// link is stripped.
|
||||
let raw = serde_json::json!({
|
||||
"__typename": "Tweet",
|
||||
"id_str": "1",
|
||||
"text": "Test Tweet with @mentionThis $twtr https://t.co/RzmrQ6wAzD #hashtag https://t.co/9r69akA484",
|
||||
"user": { "name": "N", "screen_name": "h" },
|
||||
"entities": {
|
||||
"urls": [{
|
||||
"url": "https://t.co/RzmrQ6wAzD",
|
||||
"expanded_url": "http://bit.ly/2pUk4be",
|
||||
"display_url": "bit.ly/2pUk4be"
|
||||
}]
|
||||
},
|
||||
"mediaDetails": []
|
||||
});
|
||||
let tweet = Tweet::from_syndication_value(raw).unwrap();
|
||||
assert_eq!(
|
||||
tweet.text,
|
||||
"Test Tweet with @mentionThis $twtr http://bit.ly/2pUk4be #hashtag"
|
||||
);
|
||||
assert!(!tweet.caption().contains("t.co"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syndication_text_strips_unmapped_short_links() {
|
||||
// FxEmbed parity: short links without an entity mapping (appended
|
||||
// media link, embedded unmapped links) are stripped, not kept.
|
||||
let raw = serde_json::json!({
|
||||
"__typename": "Tweet",
|
||||
"id_str": "1",
|
||||
"text": "check https://t.co/abc123 #tag https://t.co/def456",
|
||||
"user": { "name": "N", "screen_name": "h" },
|
||||
"mediaDetails": []
|
||||
});
|
||||
let tweet = Tweet::from_syndication_value(raw).unwrap();
|
||||
assert_eq!(tweet.text, "check #tag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syndication_text_drops_internal_web_status_links() {
|
||||
// FxEmbed parity: a mapped link expanding to an internal
|
||||
// x.com/i/web/status/... page (reply/quote plumbing) is removed
|
||||
// instead of being shown.
|
||||
let raw = serde_json::json!({
|
||||
"__typename": "Tweet",
|
||||
"id_str": "1",
|
||||
"text": "see https://t.co/xyz1234567 for context",
|
||||
"user": { "name": "N", "screen_name": "h" },
|
||||
"entities": {
|
||||
"urls": [{
|
||||
"url": "https://t.co/xyz1234567",
|
||||
"expanded_url": "https://x.com/i/web/status/9876543210",
|
||||
"display_url": "x.com/i/web/status/9876543210"
|
||||
}]
|
||||
},
|
||||
"mediaDetails": []
|
||||
});
|
||||
let tweet = Tweet::from_syndication_value(raw).unwrap();
|
||||
assert_eq!(tweet.text, "see for context");
|
||||
assert!(!tweet.caption().contains("t.co"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_twimg_url_rewrites_photo_urls() {
|
||||
assert_eq!(
|
||||
original_twimg_url("https://pbs.twimg.com/media/C_UdnvPUwAE3Dnn.jpg"),
|
||||
"https://pbs.twimg.com/media/C_UdnvPUwAE3Dnn.jpg?name=orig"
|
||||
);
|
||||
assert_eq!(
|
||||
original_twimg_url("https://pbs.twimg.com/media/abc.png"),
|
||||
"https://pbs.twimg.com/media/abc.png?name=orig"
|
||||
);
|
||||
// Non-twimg URLs (videos, animated gifs) pass through unchanged.
|
||||
assert_eq!(
|
||||
original_twimg_url("https://video.twimg.com/v.mp4"),
|
||||
"https://video.twimg.com/v.mp4"
|
||||
);
|
||||
assert_eq!(
|
||||
original_twimg_url("https://pbs.twimg.com/media/abc.webp"),
|
||||
"https://pbs.twimg.com/media/abc.webp"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syndication_token_matches_js_formula() {
|
||||
// JS: ((861627479294746624 / 1e15) * PI).toString(36) == "236.vrsocvda".
|
||||
// This loop truncates ten base-36 fraction digits instead of rendering
|
||||
// the shortest round-tripping one, so it agrees with JS on the stem and
|
||||
// diverges in the tail (`…d9ui` vs `…da`). Pinned exactly, because the
|
||||
// token is a fixed function of the id: a stub or a wrong constant must
|
||||
// not pass. The endpoint currently serves public tweets regardless of
|
||||
// the token, which is why the tail is left as is.
|
||||
assert_eq!(syndication_token(861627479294746624), "236.vrsocvd9ui");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syndication_tombstone_maps_to_not_found() {
|
||||
// Deleted tweets answer HTTP 200 with a TweetTombstone carrying a
|
||||
// reason (no `errors`, no `id_str`); they must not fall through to
|
||||
// Sensitive, which would make the bot reply "No media found" for a
|
||||
// deleted tweet.
|
||||
let raw = serde_json::json!({
|
||||
"__typename": "TweetTombstone",
|
||||
"tombstone": {
|
||||
"text": { "rtl": false, "text": "This Post was deleted by the Post author. Learn more" }
|
||||
}
|
||||
});
|
||||
assert!(matches!(
|
||||
parse_syndication_body(&raw.to_string()),
|
||||
Err(FetchError::NotFound)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syndication_empty_tombstone_maps_to_sensitive() {
|
||||
// Regression: live tweets in restricted contexts answer with an
|
||||
// EMPTY tombstone (`{"__typename":"TweetTombstone","tombstone":{}}`)
|
||||
// — no deletion reason. They must not be reported as deleted.
|
||||
let raw = serde_json::json!({ "__typename": "TweetTombstone", "tombstone": {} });
|
||||
assert!(matches!(
|
||||
parse_syndication_body(&raw.to_string()),
|
||||
Err(FetchError::Sensitive)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syndication_age_restricted_tombstone_maps_to_sensitive() {
|
||||
// An age-restricted tombstone withholds a live tweet; route it to
|
||||
// the logged-in fallback instead of reporting it as gone.
|
||||
let raw = serde_json::json!({
|
||||
"__typename": "TweetTombstone",
|
||||
"tombstone": {
|
||||
"text": { "rtl": false, "text": "Age-restricted adult content" }
|
||||
}
|
||||
});
|
||||
assert!(matches!(
|
||||
parse_syndication_body(&raw.to_string()),
|
||||
Err(FetchError::Sensitive)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syndication_errors_maps_to_not_found() {
|
||||
// The classic gone shape: {"errors": [...]}.
|
||||
let raw = serde_json::json!({ "errors": [{ "message": "Couldn't find Tweet" }] });
|
||||
assert!(matches!(
|
||||
parse_syndication_body(&raw.to_string()),
|
||||
Err(FetchError::NotFound)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syndication_empty_object_maps_to_sensitive() {
|
||||
// NSFW / age-restricted withholding: an empty `{}`.
|
||||
assert!(matches!(
|
||||
parse_syndication_body("{}"),
|
||||
Err(FetchError::Sensitive)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syndication_unexpected_shape_is_transient_not_withheld() {
|
||||
// A 200 that is not a tweet at all (an interstitial, a truncated
|
||||
// body) must not be reported as withheld content: that message tells
|
||||
// the user to set TWITTER_AUTH_TOKEN, which cannot fix it.
|
||||
match parse_syndication_body("{\"foo\":1}") {
|
||||
Err(FetchError::Transient(_)) => {}
|
||||
other => panic!("expected Transient, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syndication_tweet_body_passes() {
|
||||
let raw = fixture(serde_json::json!([]));
|
||||
assert!(parse_syndication_body(&raw.to_string()).is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"]
|
||||
async fn live_fetch_with_photos() {
|
||||
let fetched = fetch("861627479294746624").await.unwrap();
|
||||
assert_eq!(fetched.media.len(), 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"]
|
||||
async fn live_fetch_text_only() {
|
||||
let fetched = fetch("1992471125734142256").await.unwrap();
|
||||
assert!(fetched.media.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"]
|
||||
async fn live_fetch_deleted_tweet_is_not_found() {
|
||||
// Deleted tweet: the syndication endpoint answers with errors.
|
||||
let result = fetch("0").await;
|
||||
assert!(
|
||||
matches!(result, Err(FetchError::NotFound)),
|
||||
"got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"]
|
||||
async fn live_fetch_tombstone_deleted_tweet_is_not_found() {
|
||||
// Regression: a real deleted tweet answering with a TweetTombstone
|
||||
// (HTTP 200, no errors/id_str) used to surface as Sensitive and
|
||||
// degrade to an empty result ("No media found").
|
||||
let result = fetch("2085948045967986859").await;
|
||||
assert!(
|
||||
matches!(result, Err(FetchError::NotFound)),
|
||||
"got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"]
|
||||
async fn live_fetch_empty_tombstone_is_sensitive() {
|
||||
// Regression: a LIVE tweet (verified via a third-party API) answers
|
||||
// syndication with an empty TweetTombstone; it must surface as
|
||||
// Sensitive (withheld), never as NotFound (deleted).
|
||||
let result = fetch("2087851366253555752").await;
|
||||
assert!(
|
||||
matches!(result, Err(FetchError::Sensitive)),
|
||||
"got {result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mod auth;
|
||||
mod interface;
|
||||
mod model;
|
||||
|
||||
pub use interface::{
|
||||
PATTERN, Tweet, TwitterSite, cache_key, enabled, fetch_from_url, is_retryable, media_headers,
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Response shape of the syndication endpoint
|
||||
/// (`cdn.syndication.twimg.com/tweet-result`).
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct SyndicationTweet {
|
||||
pub id_str: String,
|
||||
pub text: String,
|
||||
pub user: SyndicationUser,
|
||||
#[serde(default)]
|
||||
pub possibly_sensitive: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub entities: SyndicationEntities,
|
||||
#[serde(default, rename = "mediaDetails")]
|
||||
pub media_details: Vec<SyndicationMedia>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Default)]
|
||||
pub struct SyndicationEntities {
|
||||
#[serde(default)]
|
||||
pub urls: Vec<SyndicationEntityUrl>,
|
||||
}
|
||||
|
||||
/// A URL entity: `url` is the t.co short link as it appears in the text,
|
||||
/// `expanded_url` the real destination.
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct SyndicationEntityUrl {
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub expanded_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct SyndicationUser {
|
||||
pub name: String,
|
||||
pub screen_name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct SyndicationMedia {
|
||||
#[serde(rename = "type")]
|
||||
pub media_type: String,
|
||||
pub media_url_https: String,
|
||||
#[serde(default)]
|
||||
pub video_info: Option<SyndicationVideoInfo>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct SyndicationVideoInfo {
|
||||
#[serde(default)]
|
||||
pub variants: Vec<SyndicationVariant>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct SyndicationVariant {
|
||||
pub content_type: String,
|
||||
pub url: String,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "xmedia-bot"
|
||||
version = "1.9.1"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
teloxide = { version = "0.17", default-features = false, features = ["webhooks-axum", "macros", "rustls", "ctrlc_handler"] }
|
||||
tokio = { version = "1.40", features = ["rt-multi-thread", "macros", "time", "sync"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
log = "0.4"
|
||||
pretty_env_logger = "0.5"
|
||||
dotenv = "0.15"
|
||||
url = "2.5.2"
|
||||
html-escape = "0.2"
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
rand = "0.10"
|
||||
tempfile = "3"
|
||||
parking_lot = "0.12"
|
||||
bytes = "1"
|
||||
png = "0.18"
|
||||
zune-jpeg = "0.5"
|
||||
fast_image_resize = "6"
|
||||
jpeg-encoder = "0.7"
|
||||
x-media = { path = "../x-media" }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.40", features = ["test-util"] }
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Central env handling. The only other places that read env are
|
||||
//! `Bot::from_env` (TELOXIDE_TOKEN) and x-media (PIXIV_REFRESH_TOKEN,
|
||||
//! TWITTER_AUTH_TOKEN, BILIBILI_COOKIE).
|
||||
|
||||
use std::env;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct Config {
|
||||
/// BOT_ADMIN: comma-separated ints; empty when unset.
|
||||
pub admin_ids: Vec<i64>,
|
||||
/// EDIT_MESSAGE_TTL_SECONDS, default 86400 (24h).
|
||||
pub edit_message_ttl: Duration,
|
||||
/// LINK_CACHE_TTL_SECONDS, default 604800 (7 days).
|
||||
pub link_cache_ttl: Duration,
|
||||
/// CAPTION_QUOTE_TEXT_CHARS, default 200: a post whose text (title plus
|
||||
/// content) is at least this many characters gets that text wrapped in an
|
||||
/// expandable blockquote inside its caption. `0` disables the wrap.
|
||||
pub caption_quote_text_chars: usize,
|
||||
// Webhook settings (moved out of main; names/defaults unchanged).
|
||||
pub webhook_enabled: bool,
|
||||
pub webhook_url: Option<url::Url>,
|
||||
pub webhook_listen: Option<IpAddr>,
|
||||
pub webhook_port: Option<u16>,
|
||||
pub webhook_cert: Option<String>,
|
||||
pub webhook_secret_token: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Config {
|
||||
// Fail-fast helpers: a misspelled value must not silently fall back
|
||||
// to a default and run with different behavior than the operator
|
||||
// intended — log a loud warning naming the variable instead.
|
||||
fn parse_u64(name: &str, default: u64) -> u64 {
|
||||
match env::var(name) {
|
||||
Ok(v) => v.parse::<u64>().unwrap_or_else(|_| {
|
||||
log::warn!("invalid {name}={v:?}; using default {default}");
|
||||
default
|
||||
}),
|
||||
Err(_) => default,
|
||||
}
|
||||
}
|
||||
|
||||
let admin_ids = match env::var("BOT_ADMIN") {
|
||||
Ok(s) => {
|
||||
let (ids, bad): (Vec<_>, Vec<_>) = s
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.is_empty())
|
||||
.partition(|part| part.parse::<i64>().is_ok());
|
||||
if !bad.is_empty() {
|
||||
log::warn!("BOT_ADMIN: ignoring non-numeric ids: {bad:?}");
|
||||
}
|
||||
ids.into_iter()
|
||||
.filter_map(|p| p.parse::<i64>().ok())
|
||||
.collect()
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
let edit_message_ttl =
|
||||
Duration::from_secs(parse_u64("EDIT_MESSAGE_TTL_SECONDS", 24 * 3600));
|
||||
let link_cache_ttl =
|
||||
Duration::from_secs(parse_u64("LINK_CACHE_TTL_SECONDS", 7 * 24 * 3600));
|
||||
let caption_quote_text_chars = parse_u64("CAPTION_QUOTE_TEXT_CHARS", 200) as usize;
|
||||
|
||||
let webhook_enabled = env::var("WEBHOOK")
|
||||
.is_ok_and(|v| matches!(v.to_lowercase().as_str(), "true" | "yes" | "1"));
|
||||
// The webhook settings are consumed by `.expect()` in main when
|
||||
// WEBHOOK=true, so an unparseable value fails fast at startup with a
|
||||
// clear message; still log here for the WEBHOOK=false case.
|
||||
let webhook_url = env::var("WEBHOOK_URL").ok().and_then(|s| {
|
||||
s.parse::<url::Url>().ok().or_else(|| {
|
||||
log::warn!("invalid WEBHOOK_URL={s:?}");
|
||||
None
|
||||
})
|
||||
});
|
||||
let webhook_listen = env::var("WEBHOOK_LISTEN").ok().and_then(|s| {
|
||||
s.parse::<IpAddr>().ok().or_else(|| {
|
||||
log::warn!("invalid WEBHOOK_LISTEN={s:?}");
|
||||
None
|
||||
})
|
||||
});
|
||||
let webhook_port = env::var("WEBHOOK_PORT").ok().and_then(|s| {
|
||||
s.parse::<u16>().ok().or_else(|| {
|
||||
log::warn!("invalid WEBHOOK_PORT={s:?}");
|
||||
None
|
||||
})
|
||||
});
|
||||
// Empty strings count as unset (e.g. `-e WEBHOOK_CERT=` to disable a
|
||||
// value that would otherwise come from `.env`).
|
||||
let webhook_cert = env::var("WEBHOOK_CERT").ok().filter(|s| !s.is_empty());
|
||||
let webhook_secret_token = env::var("WEBHOOK_SECRET_TOKEN")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
Config {
|
||||
admin_ids,
|
||||
edit_message_ttl,
|
||||
link_cache_ttl,
|
||||
caption_quote_text_chars,
|
||||
webhook_enabled,
|
||||
webhook_url,
|
||||
webhook_listen,
|
||||
webhook_port,
|
||||
webhook_cert,
|
||||
webhook_secret_token,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Runtime context: the collaborators a handler needs, injected as one struct
|
||||
//! so tests can substitute a scripted sender and tempdir-backed stores.
|
||||
//!
|
||||
//! The production context is assembled from the process-wide statics
|
||||
//! ([`AppContext::from_statics`]); the spawned worker closures hold
|
||||
//! [`CONTEXT`], which is `'static` for that reason.
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::handlers::{CHAT_STORE, CONFIG, LINK_CACHE, TASK_QUEUE};
|
||||
use crate::link_cache::LinkCache;
|
||||
use crate::media_sender::MediaSender;
|
||||
use crate::queue::PersistentTaskQueue;
|
||||
use crate::send::BOT;
|
||||
use crate::state::ChatStore;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
pub struct AppContext<'a> {
|
||||
pub sender: &'a dyn MediaSender,
|
||||
pub chat_store: &'a ChatStore,
|
||||
pub task_queue: &'a PersistentTaskQueue,
|
||||
pub link_cache: &'a LinkCache,
|
||||
pub config: &'a Config,
|
||||
}
|
||||
|
||||
impl<'a> AppContext<'a> {
|
||||
/// The stores are the process-wide statics; `sender` is whatever the caller
|
||||
/// was handed (the dispatcher's `Bot` clone for update handlers, the shared
|
||||
/// queue `Bot` for the worker loops). Update handlers build their own
|
||||
/// context from the `Bot` they received so the same code path works with an
|
||||
/// injected mock in tests.
|
||||
pub fn from_statics(sender: &'a dyn MediaSender) -> AppContext<'a> {
|
||||
AppContext {
|
||||
sender,
|
||||
chat_store: &CHAT_STORE,
|
||||
task_queue: &TASK_QUEUE,
|
||||
link_cache: &LINK_CACHE,
|
||||
config: &CONFIG,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The URL/queue workers' context: `'static` because `tokio::spawn`ed closures
|
||||
/// and the queue's handler type require it.
|
||||
pub static CONTEXT: LazyLock<AppContext<'static>> =
|
||||
LazyLock::new(|| AppContext::from_statics(&*BOT));
|
||||
|
||||
/// Test support: a tempdir-backed set of stores plus the context borrowing
|
||||
/// them, so a handler test needs one line of setup.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support {
|
||||
use super::*;
|
||||
use crate::link_cache::{CachedMedia, CachedMediaKind, CachedPost};
|
||||
use crate::state::EditMessage;
|
||||
use std::sync::Arc;
|
||||
use teloxide::{ApiError, RequestError};
|
||||
|
||||
/// The edit-before-forward prompt's message id, and the message the prompt
|
||||
/// refers to (the one whose caption a reply swaps).
|
||||
pub(crate) const PROMPT_ID: i64 = 7;
|
||||
pub(crate) const FORWARDED_ID: i64 = 9;
|
||||
|
||||
/// A Telegram API error, for the tests that script a failure.
|
||||
pub(crate) fn api_error(message: &str) -> RequestError {
|
||||
RequestError::Api(ApiError::Unknown(message.to_string()))
|
||||
}
|
||||
|
||||
/// The cached post every test that touches the link cache starts from: one
|
||||
/// photo with a Telegram file id at the canonical URL (key `twitter:1`).
|
||||
/// Tests that need another field mutate the returned value.
|
||||
pub(crate) fn cached_photo() -> CachedPost {
|
||||
CachedPost {
|
||||
url: "https://x.com/u/status/1".into(),
|
||||
caption: "cap".into(),
|
||||
title: "t".into(),
|
||||
content: "c".into(),
|
||||
author: "a".into(),
|
||||
author_url: "au".into(),
|
||||
tags: String::new(),
|
||||
sensitive: false,
|
||||
media: vec![CachedMedia {
|
||||
kind: CachedMediaKind::Photo,
|
||||
file_id: "AgAC-file-id".into(),
|
||||
url: "https://pbs.twimg.com/media/photo.jpg".into(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// Seeds the live prompt a post-send leaves behind in chat 1: the chat's
|
||||
/// template, a bound forward channel (the prompt's "forward" button
|
||||
/// branches on it) and the record for [`PROMPT_ID`] pointing at
|
||||
/// [`FORWARDED_ID`]. `template` is the record's template — what a reply
|
||||
/// swaps the caption through, `""` for none — and `created_at` backdates
|
||||
/// the record for the expiry cases.
|
||||
pub(crate) async fn seed_prompt(ctx: &AppContext<'_>, template: &str, created_at: i64) {
|
||||
ctx.chat_store
|
||||
.update(1, |data| {
|
||||
data.forward_channel_id = Some(2);
|
||||
data.template
|
||||
.insert("tpl".to_string(), "<b>[]</b>".to_string());
|
||||
data.edit_message.insert(
|
||||
PROMPT_ID,
|
||||
EditMessage {
|
||||
url: "https://x.com/u/status/1".into(),
|
||||
chat_id: 1,
|
||||
forward_message_ids: vec![FORWARDED_ID],
|
||||
template: template.to_string(),
|
||||
created_at,
|
||||
},
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) struct TestStores {
|
||||
_dir: tempfile::TempDir,
|
||||
pool: Arc<crate::db::DbPool>,
|
||||
chat_store: ChatStore,
|
||||
task_queue: PersistentTaskQueue,
|
||||
link_cache: LinkCache,
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl TestStores {
|
||||
pub(crate) fn new() -> Self {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pool = crate::db::open_store(dir.path().join("ctx.db").to_str().unwrap()).unwrap();
|
||||
TestStores {
|
||||
_dir: dir,
|
||||
chat_store: ChatStore::new(Arc::clone(&pool)),
|
||||
task_queue: PersistentTaskQueue::new(Arc::clone(&pool)),
|
||||
link_cache: LinkCache::new(Arc::clone(&pool)),
|
||||
config: Config::load(),
|
||||
pool,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ctx<'a>(&'a self, sender: &'a dyn MediaSender) -> AppContext<'a> {
|
||||
AppContext {
|
||||
sender,
|
||||
chat_store: &self.chat_store,
|
||||
task_queue: &self.task_queue,
|
||||
link_cache: &self.link_cache,
|
||||
config: &self.config,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn chat_store(&self) -> &ChatStore {
|
||||
&self.chat_store
|
||||
}
|
||||
|
||||
/// The parsed config, mutable so a test can pin a knob (e.g. the
|
||||
/// caption-quote threshold) instead of depending on the environment.
|
||||
pub(crate) fn config_mut(&mut self) -> &mut Config {
|
||||
&mut self.config
|
||||
}
|
||||
|
||||
pub(crate) fn link_cache(&self) -> &LinkCache {
|
||||
&self.link_cache
|
||||
}
|
||||
|
||||
pub(crate) fn task_queue(&self) -> &PersistentTaskQueue {
|
||||
&self.task_queue
|
||||
}
|
||||
|
||||
/// Rows persisted in the task queue: what "queued for retry" looks like
|
||||
/// from the outside.
|
||||
pub(crate) async fn queued_tasks(&self) -> i64 {
|
||||
let pool = Arc::clone(&self.pool);
|
||||
pool.with_conn(|conn| {
|
||||
conn.query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get(0))
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The single queued task payload, for asserting what was rescheduled.
|
||||
pub(crate) async fn queued_payload(&self) -> serde_json::Value {
|
||||
let pool = Arc::clone(&self.pool);
|
||||
let payload: String = pool
|
||||
.with_conn(|conn| {
|
||||
conn.query_row("SELECT payload FROM tasks LIMIT 1", [], |row| row.get(0))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
serde_json::from_str(&payload).unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
//! Shared SQLite plumbing for the three tables in `data/task_queue.db`
|
||||
//! (`tasks` in queue.rs, `chat_state` in state.rs, `link_cache` in
|
||||
//! link_cache.rs).
|
||||
//!
|
||||
//! All I/O runs inside `spawn_blocking` via [`DbPool::with_conn`] — rusqlite
|
||||
//! connections are not Send-friendly to hold across an await point, and
|
||||
//! blocking the async executor stalls every handler. Connections are reused
|
||||
//! through a small per-store pool instead of opening a fresh connection per
|
||||
//! operation: WAL lets readers run alongside writer leases, and the pool's
|
||||
//! semaphore bounds how many DB operations run concurrently, giving natural
|
||||
//! backpressure on hot paths (every message / URL / callback touches
|
||||
//! chat_state or the link cache).
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Upper bound on pooled (reused) connections and on concurrent DB
|
||||
/// operations per store. Small on purpose: the queue's `BEGIN IMMEDIATE`
|
||||
/// leases serialize writes anyway, and WAL readers rarely need more.
|
||||
const POOL_SIZE: usize = 4;
|
||||
|
||||
/// A tiny connection pool for one SQLite file. Connections are checked out
|
||||
/// on a blocking thread and returned afterwards; `acquire` opens a new
|
||||
/// connection only when the idle list is empty, so the steady-state cost of
|
||||
/// an operation is a list pop instead of a fresh open (+ busy timeout + WAL
|
||||
/// pragma). The semaphore caps the number of concurrent operations, so a
|
||||
/// burst of handlers queues up instead of opening unbounded connections.
|
||||
pub struct DbPool {
|
||||
// Arc so [`DbPool::with_conn`] can hand an owned handle to
|
||||
// `spawn_blocking` without borrowing across the await point.
|
||||
inner: Arc<PoolInner>,
|
||||
}
|
||||
|
||||
struct PoolInner {
|
||||
path: String,
|
||||
permits: tokio::sync::Semaphore,
|
||||
idle: Mutex<Vec<Connection>>,
|
||||
}
|
||||
|
||||
impl DbPool {
|
||||
pub fn new(path: &str) -> Self {
|
||||
DbPool {
|
||||
inner: Arc::new(PoolInner {
|
||||
path: path.to_string(),
|
||||
permits: tokio::sync::Semaphore::new(POOL_SIZE),
|
||||
idle: Mutex::new(Vec::new()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f` against a pooled connection on a blocking thread, returning
|
||||
/// the closure's result. Owns the semaphore + `spawn_blocking` +
|
||||
/// `expect` ceremony shared by every table access; the caller maps
|
||||
/// errors to its own log line.
|
||||
pub async fn with_conn<T, F>(&self, f: F) -> rusqlite::Result<T>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce(&mut Connection) -> rusqlite::Result<T> + Send + 'static,
|
||||
{
|
||||
let _permit = self
|
||||
.inner
|
||||
.permits
|
||||
.acquire()
|
||||
.await
|
||||
.expect("db pool semaphore closed");
|
||||
let inner = Arc::clone(&self.inner);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut conn = inner.acquire()?;
|
||||
let result = f(&mut conn);
|
||||
inner.release(conn);
|
||||
result
|
||||
})
|
||||
.await
|
||||
.expect("db worker panicked")
|
||||
}
|
||||
|
||||
/// The database file this pool serves (used by tests that need a raw
|
||||
/// connection, e.g. to seed rows directly).
|
||||
#[cfg(test)]
|
||||
pub fn path(&self) -> &str {
|
||||
&self.inner.path
|
||||
}
|
||||
}
|
||||
|
||||
impl PoolInner {
|
||||
/// Reuses an idle connection or opens a fresh one.
|
||||
fn acquire(&self) -> rusqlite::Result<Connection> {
|
||||
if let Some(conn) = self.idle.lock().pop() {
|
||||
return Ok(conn);
|
||||
}
|
||||
open_db(&self.path)
|
||||
}
|
||||
|
||||
/// Returns a connection to the pool (dropped when the pool is full).
|
||||
fn release(&self, conn: Connection) {
|
||||
let mut idle = self.idle.lock();
|
||||
if idle.len() < POOL_SIZE {
|
||||
idle.push(conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the shared DB with a busy timeout.
|
||||
pub fn open_db(path: &str) -> rusqlite::Result<Connection> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.busy_timeout(Duration::from_secs(5))?;
|
||||
// WAL lets readers run alongside writer leases instead of blocking on
|
||||
// the rollback journal; the mode persists in the DB header, so the
|
||||
// idempotent pragma here and in ensure_schema only needs to win once.
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Opens the shared DB file, runs the merged schema for all three tables and
|
||||
/// returns a pool for it. One call per process in production (the stores
|
||||
/// share the returned pool); tests call it per tempdir.
|
||||
pub fn open_store(path: &str) -> rusqlite::Result<Arc<DbPool>> {
|
||||
if let Some(parent) = std::path::Path::new(path).parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(parent).map_err(rusqlite_error)?;
|
||||
}
|
||||
let conn = open_db(path)?;
|
||||
schema_init(&conn)?;
|
||||
migrate(&conn)?;
|
||||
Ok(Arc::new(DbPool::new(path)))
|
||||
}
|
||||
|
||||
/// Schema migrations, applied in order and tracked by `PRAGMA user_version`
|
||||
/// (the index in this array + 1 is the version a statement brings the
|
||||
/// database to). Append only — never edit or reorder an entry, or databases
|
||||
/// already past it would skip or repeat work.
|
||||
const MIGRATIONS: &[&str] = &[
|
||||
// 1: lease fencing. A worker's write-backs (`delete`/`reschedule`/the
|
||||
// lease heartbeat) are guarded by the token it was leased with, so a
|
||||
// lease that expired and was re-leased by another worker can no longer be
|
||||
// written by its former holder — which used to duplicate a send or drop
|
||||
// the new holder's retry state, silently.
|
||||
"ALTER TABLE tasks ADD COLUMN lease_token TEXT",
|
||||
// 2: the 300 s sweep prunes the link cache by `created_at`
|
||||
// (`DELETE FROM link_cache WHERE created_at < ?`). Without an index that
|
||||
// is a full scan of every post sent inside the TTL window — up to a week
|
||||
// of them — on every sweep; the `url` primary key cannot serve it.
|
||||
"CREATE INDEX IF NOT EXISTS idx_link_cache_created_at ON link_cache(created_at)",
|
||||
];
|
||||
|
||||
/// Brings an existing database up to [`MIGRATIONS`]. Idempotent: a database
|
||||
/// already at the latest version does no work.
|
||||
fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||
let version: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
|
||||
for (index, statement) in MIGRATIONS.iter().enumerate() {
|
||||
let target = index as i64 + 1;
|
||||
if version >= target {
|
||||
continue;
|
||||
}
|
||||
conn.execute_batch(statement)?;
|
||||
// `PRAGMA` does not take bind parameters; the value is our own index.
|
||||
conn.execute_batch(&format!("PRAGMA user_version = {target}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rusqlite_error(e: std::io::Error) -> rusqlite::Error {
|
||||
rusqlite::Error::ToSqlConversionFailure(Box::new(e))
|
||||
}
|
||||
|
||||
/// Creates the `tasks`, `chat_state` and `link_cache` tables (idempotent).
|
||||
/// The three stores used to own their own schema; keeping it in one place
|
||||
/// means one initialization for the whole database file.
|
||||
///
|
||||
/// This is the **baseline** schema (version 0): a fresh database is created
|
||||
/// exactly like this, and anything that must *change* an existing one is
|
||||
/// appended to [`MIGRATIONS`] instead of being edited in here — otherwise a
|
||||
/// database created before the change would never gain the new column and a
|
||||
/// freshly created one would try to apply the migration a second time.
|
||||
pub fn schema_init(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS tasks (id TEXT PRIMARY KEY, payload TEXT NOT NULL, \
|
||||
run_after REAL NOT NULL, attempts INTEGER NOT NULL, status TEXT NOT NULL, \
|
||||
locked_until REAL NOT NULL, created_at REAL NOT NULL); \
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_pending ON tasks(status, run_after); \
|
||||
CREATE TABLE IF NOT EXISTS chat_state (chat_id TEXT PRIMARY KEY, payload TEXT NOT NULL); \
|
||||
CREATE TABLE IF NOT EXISTS link_cache (url TEXT PRIMARY KEY, payload TEXT NOT NULL, \
|
||||
created_at REAL NOT NULL);",
|
||||
)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Unix timestamp in whole seconds. Same clock as [`now_f64`], for fields
|
||||
/// that store integer seconds (chat-state expiry, edit prompts).
|
||||
pub fn unix_now() -> i64 {
|
||||
now_f64() as i64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// The schema as it shipped *before* the first migration: what an existing
|
||||
/// deployment has on disk when it starts on the new binary. Written out
|
||||
/// literally rather than derived from `schema_init`, so an edit to the
|
||||
/// baseline shows up here instead of being followed silently.
|
||||
const V0_SCHEMA: &str = "CREATE TABLE tasks (id TEXT PRIMARY KEY, payload TEXT NOT NULL, \
|
||||
run_after REAL NOT NULL, attempts INTEGER NOT NULL, status TEXT NOT NULL, \
|
||||
locked_until REAL NOT NULL, created_at REAL NOT NULL); \
|
||||
CREATE INDEX idx_tasks_pending ON tasks(status, run_after); \
|
||||
CREATE TABLE chat_state (chat_id TEXT PRIMARY KEY, payload TEXT NOT NULL); \
|
||||
CREATE TABLE link_cache (url TEXT PRIMARY KEY, payload TEXT NOT NULL, \
|
||||
created_at REAL NOT NULL);";
|
||||
|
||||
/// The migrations that have already shipped, verbatim. Appending is the only
|
||||
/// allowed change: editing one that a database has already applied leaves
|
||||
/// deployments on different schemas with nothing to notice it — the version
|
||||
/// counter says "done" and skips the new text.
|
||||
const SHIPPED_MIGRATIONS: &[&str] = &["ALTER TABLE tasks ADD COLUMN lease_token TEXT"];
|
||||
|
||||
fn columns(conn: &Connection, table: &str) -> Vec<String> {
|
||||
let mut stmt = conn
|
||||
.prepare(&format!("PRAGMA table_info({table})"))
|
||||
.unwrap();
|
||||
let mut names: Vec<String> = stmt
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.unwrap()
|
||||
.map(Result::unwrap)
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
fn user_version(conn: &Connection) -> i64 {
|
||||
conn.query_row("PRAGMA user_version", [], |row| row.get(0))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_pre_migration_database_upgrades_and_keeps_its_rows() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("old.db");
|
||||
{
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute_batch(V0_SCHEMA).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (id, payload, run_after, attempts, status, locked_until, created_at) \
|
||||
VALUES ('task_old', '{\"chat_id\":1}', 0, 0, 'pending', 0, 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(user_version(&conn), 0, "the fixture starts un-migrated");
|
||||
assert!(
|
||||
!columns(&conn, "tasks").contains(&"lease_token".to_string()),
|
||||
"the fixture is the pre-migration shape"
|
||||
);
|
||||
}
|
||||
|
||||
let pool = open_store(path.to_str().unwrap()).unwrap();
|
||||
pool.with_conn(|conn| {
|
||||
assert_eq!(user_version(conn), MIGRATIONS.len() as i64);
|
||||
let mut expected = vec![
|
||||
"id",
|
||||
"payload",
|
||||
"run_after",
|
||||
"attempts",
|
||||
"status",
|
||||
"locked_until",
|
||||
"created_at",
|
||||
"lease_token",
|
||||
];
|
||||
expected.sort();
|
||||
assert_eq!(
|
||||
columns(conn, "tasks"),
|
||||
expected,
|
||||
"an upgrade must add the migration's column and nothing else"
|
||||
);
|
||||
let payload: String = conn
|
||||
.query_row(
|
||||
"SELECT payload FROM tasks WHERE id = 'task_old'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(payload, "{\"chat_id\":1}", "rows survive the upgrade");
|
||||
// The link-cache prune's index arrives with the migrations (the
|
||||
// baseline schema has none): without it every sweep scans the
|
||||
// whole table.
|
||||
let index: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master \
|
||||
WHERE type = 'index' AND name = 'idx_link_cache_created_at'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(index, 1, "the migration's index must exist");
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shipped_migrations_are_frozen() {
|
||||
assert!(
|
||||
MIGRATIONS.len() >= SHIPPED_MIGRATIONS.len(),
|
||||
"migrations were removed or reordered, not appended"
|
||||
);
|
||||
for (index, (shipped, current)) in SHIPPED_MIGRATIONS.iter().zip(MIGRATIONS).enumerate() {
|
||||
assert_eq!(
|
||||
shipped,
|
||||
current,
|
||||
"migration {} already shipped: append a new one instead of editing it",
|
||||
index + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_fresh_database_lands_at_the_latest_version() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("fresh.db");
|
||||
let pool = open_store(path.to_str().unwrap()).unwrap();
|
||||
// Every migration is applied on creation, so a deployment that only ever
|
||||
// saw fresh databases is on the same schema as an upgraded one.
|
||||
pool.with_conn(|conn| {
|
||||
assert_eq!(user_version(conn), MIGRATIONS.len() as i64);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
// Opening the same file again is a no-op (the version gate skips it).
|
||||
open_store(path.to_str().unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
//! Callback query handling: the edit-before-forward prompt's `"forward"` and
|
||||
//! `"template|<name>"` buttons.
|
||||
//!
|
||||
//! [`callback_query_handler`] is the dptree entry; it only pulls the plain
|
||||
//! values out of the teloxide update and hands them to [`handle_callback`],
|
||||
//! which holds the button logic and is driven directly by tests.
|
||||
|
||||
use crate::ctx::AppContext;
|
||||
use crate::db::unix_now;
|
||||
use crate::send::{self, Task};
|
||||
use teloxide::RequestError;
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::types::{CallbackQuery, CallbackQueryId, MessageId};
|
||||
|
||||
/// The `"forward"` button's data.
|
||||
const FORWARD: &str = "forward";
|
||||
/// The `"skip"` button's data: drop the prompt without forwarding.
|
||||
const SKIP: &str = "skip";
|
||||
/// Prefix of a template button's data: `"template|<name>"`.
|
||||
const TEMPLATE_PREFIX: &str = "template|";
|
||||
|
||||
pub async fn callback_query_handler(bot: Bot, query: CallbackQuery) -> Result<(), RequestError> {
|
||||
let Some(message) = &query.message else {
|
||||
return respond(());
|
||||
};
|
||||
let Some(data) = query.data.clone() else {
|
||||
return respond(());
|
||||
};
|
||||
let ctx = AppContext::from_statics(&bot);
|
||||
handle_callback(
|
||||
&ctx,
|
||||
query.id.clone(),
|
||||
message.chat().id.0,
|
||||
message.id().0 as i64,
|
||||
&data,
|
||||
)
|
||||
.await;
|
||||
respond(())
|
||||
}
|
||||
|
||||
/// Handles one button press on the edit-before-forward prompt.
|
||||
async fn handle_callback(
|
||||
ctx: &AppContext<'_>,
|
||||
callback_query_id: CallbackQueryId,
|
||||
chat_id: i64,
|
||||
prompt_message_id: i64,
|
||||
data: &str,
|
||||
) {
|
||||
let ttl_secs = ctx.config.edit_message_ttl.as_secs() as i64;
|
||||
let chat_data = ctx.chat_store.get(chat_id).await;
|
||||
let edit = chat_data.edit_message.get(&prompt_message_id).cloned();
|
||||
let Some(edit) = edit else {
|
||||
log::debug!("callback from {chat_id}: no edit record for prompt {prompt_message_id}");
|
||||
let _ = ctx
|
||||
.sender
|
||||
.answer_callback_query(callback_query_id, Some("Expired".to_string()))
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
// Lazy expiry: a stale record (past the TTL, not yet swept) is dropped.
|
||||
if edit.created_at + ttl_secs <= unix_now() {
|
||||
ctx.chat_store
|
||||
.update(chat_id, |data| {
|
||||
data.edit_message.remove(&prompt_message_id);
|
||||
})
|
||||
.await;
|
||||
let _ = ctx
|
||||
.sender
|
||||
.answer_callback_query(callback_query_id, Some("Expired".to_string()))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!("callback from {chat_id} on prompt {prompt_message_id}: {data}");
|
||||
if data == SKIP {
|
||||
// Skip works with or without a forward channel: it is the explicit
|
||||
// "do not forward this" answer, and it drops the record so the forward
|
||||
// can never happen later.
|
||||
log::info!("edit-before-forward prompt {prompt_message_id} skipped");
|
||||
ctx.chat_store
|
||||
.update(chat_id, |data| {
|
||||
data.edit_message.remove(&prompt_message_id);
|
||||
})
|
||||
.await;
|
||||
let _ = ctx
|
||||
.sender
|
||||
.delete_message(ChatId(chat_id), MessageId(prompt_message_id as i32))
|
||||
.await;
|
||||
let _ = ctx
|
||||
.sender
|
||||
.answer_callback_query(
|
||||
callback_query_id,
|
||||
Some("Skipped — nothing was forwarded.".to_string()),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
if data == FORWARD {
|
||||
match chat_data.forward_channel_id {
|
||||
Some(channel_id) => {
|
||||
let forward_task = Task::ForwardMessages {
|
||||
from_chat_id: edit.chat_id,
|
||||
to_chat_id: channel_id,
|
||||
message_ids: edit.forward_message_ids.clone(),
|
||||
notify_chat_id: Some(chat_id),
|
||||
notify_message_id: Some(prompt_message_id),
|
||||
};
|
||||
let (answer, settled) = match send::forward_messages(ctx, &forward_task).await {
|
||||
Ok(()) => {
|
||||
log::info!(
|
||||
"forwarded {} message(s) to channel {channel_id}",
|
||||
edit.forward_message_ids.len()
|
||||
);
|
||||
("✅ Forwarded".to_string(), true)
|
||||
}
|
||||
Err(send::SendError::Retryable {
|
||||
delay_seconds,
|
||||
task,
|
||||
}) => {
|
||||
// The queued row owns the forward from here (it carries
|
||||
// the message ids itself), so the prompt is settled
|
||||
// either way: leaving it live let a second Confirm copy
|
||||
// the same messages to the channel twice, and let Skip
|
||||
// answer "nothing was forwarded" while the row still
|
||||
// delivered it.
|
||||
let queued =
|
||||
send::enqueue_retry(ctx.task_queue, &task, delay_seconds).await;
|
||||
if queued {
|
||||
log::info!("forward queued for retry in {delay_seconds:.1}s");
|
||||
("Forward queued for retry.".to_string(), true)
|
||||
} else {
|
||||
log::error!("forward retry could not be queued");
|
||||
(
|
||||
"Forward failed and the retry could not be queued.".to_string(),
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(send::SendError::Permanent { message, .. }) => {
|
||||
log::error!("forward failed permanently: {message}");
|
||||
(format!("Forward failed: {message}"), false)
|
||||
}
|
||||
};
|
||||
if settled {
|
||||
// The prompt is done: drop it and its record.
|
||||
let _ = ctx
|
||||
.sender
|
||||
.delete_message(ChatId(chat_id), MessageId(prompt_message_id as i32))
|
||||
.await;
|
||||
ctx.chat_store
|
||||
.update(chat_id, |data| {
|
||||
data.edit_message.remove(&prompt_message_id);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
let _ = ctx
|
||||
.sender
|
||||
.answer_callback_query(callback_query_id, Some(answer))
|
||||
.await;
|
||||
}
|
||||
None => {
|
||||
log::debug!("forward callback without a forward channel set");
|
||||
let _ = ctx
|
||||
.sender
|
||||
.answer_callback_query(
|
||||
callback_query_id,
|
||||
Some("No forward channel set.".to_string()),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(name) = data.strip_prefix(TEMPLATE_PREFIX) {
|
||||
let mut answer = None;
|
||||
if let Some(template_html) = chat_data.template.get(name).cloned()
|
||||
&& let Some(first_forward_id) = edit.forward_message_ids.first().copied()
|
||||
{
|
||||
// Raw template including the [] placeholder (Python parity).
|
||||
match super::apply_caption_edit(
|
||||
ctx.sender,
|
||||
ChatId(chat_id),
|
||||
MessageId(first_forward_id as i32),
|
||||
template_html,
|
||||
)
|
||||
.await
|
||||
{
|
||||
super::EditOutcome::Applied => {
|
||||
ctx.chat_store
|
||||
.update(chat_id, |data| {
|
||||
if let Some(entry) = data.edit_message.get_mut(&prompt_message_id) {
|
||||
entry.template = name.to_string();
|
||||
}
|
||||
})
|
||||
.await;
|
||||
log::info!("template '{name}' applied to prompt {prompt_message_id}");
|
||||
}
|
||||
// Nothing was applied, so nothing is recorded either: the
|
||||
// prompt keeps rendering through whatever it used before, and
|
||||
// the toast says why (a silently "successful" press left the
|
||||
// caption unchanged).
|
||||
super::EditOutcome::Failed(reason) => {
|
||||
log::error!("template '{name}' could not be applied: {reason}");
|
||||
answer = Some(format!("Could not apply the template: {reason}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = ctx
|
||||
.sender
|
||||
.answer_callback_query(callback_query_id, answer)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ctx::test_support::{FORWARDED_ID, PROMPT_ID, TestStores, api_error, seed_prompt};
|
||||
use crate::media_sender::test_support::{MockSender, Outcome};
|
||||
|
||||
/// The Telegram wording the mocks answer with: a chat the bot cannot reach.
|
||||
const API_ERROR: &str = "Bad Request: chat not found";
|
||||
|
||||
fn callback_id() -> CallbackQueryId {
|
||||
CallbackQueryId("cb-1".to_string())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn template_button_swaps_the_caption_and_records_the_choice() {
|
||||
let sender = MockSender::scripted(vec![Outcome::EditOk], || api_error(API_ERROR));
|
||||
let stores = TestStores::new();
|
||||
let ctx = stores.ctx(&sender);
|
||||
seed_prompt(&ctx, "", crate::db::unix_now()).await;
|
||||
|
||||
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "template|tpl").await;
|
||||
|
||||
assert_eq!(
|
||||
sender.calls(),
|
||||
vec!["edit_message_caption", "answer_callback_query"]
|
||||
);
|
||||
// The raw template, including the [] the user edits into.
|
||||
assert_eq!(sender.captions(), vec!["<b>[]</b>"]);
|
||||
assert_eq!(sender.answers(), vec![None]);
|
||||
let data = ctx.chat_store.get(1).await;
|
||||
assert_eq!(data.edit_message[&PROMPT_ID].template, "tpl");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_template_swap_is_reported_in_the_toast() {
|
||||
let sender = MockSender::scripted(vec![Outcome::EditErr], || api_error(API_ERROR));
|
||||
let stores = TestStores::new();
|
||||
let ctx = stores.ctx(&sender);
|
||||
seed_prompt(&ctx, "", crate::db::unix_now()).await;
|
||||
|
||||
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "template|tpl").await;
|
||||
|
||||
// The caption never changed, so the toast says so and the record does
|
||||
// not claim the template was applied.
|
||||
let toast = sender.answers().last().cloned().flatten();
|
||||
assert!(
|
||||
toast
|
||||
.as_deref()
|
||||
.is_some_and(|t| t.contains("Could not apply the template")),
|
||||
"{toast:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
ctx.chat_store.get(1).await.edit_message[&PROMPT_ID].template,
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forward_button_copies_then_clears_the_prompt() {
|
||||
let sender = MockSender::scripted(vec![Outcome::CopyOk], || api_error(API_ERROR));
|
||||
let stores = TestStores::new();
|
||||
let ctx = stores.ctx(&sender);
|
||||
seed_prompt(&ctx, "", crate::db::unix_now()).await;
|
||||
|
||||
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await;
|
||||
|
||||
assert_eq!(
|
||||
sender.calls(),
|
||||
vec!["copy_messages", "delete_message", "answer_callback_query"]
|
||||
);
|
||||
assert_eq!(sender.answers(), vec![Some("✅ Forwarded".to_string())]);
|
||||
assert!(
|
||||
ctx.chat_store.get(1).await.edit_message.is_empty(),
|
||||
"a settled prompt must drop its record"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skip_drops_the_prompt_without_forwarding() {
|
||||
// "skip" needs no forward channel and no scripted outcomes: it deletes
|
||||
// the prompt and drops the record, so no forward can ever happen.
|
||||
let sender = MockSender::scripted(vec![], || api_error(API_ERROR));
|
||||
let stores = TestStores::new();
|
||||
let ctx = stores.ctx(&sender);
|
||||
seed_prompt(&ctx, "", crate::db::unix_now()).await;
|
||||
|
||||
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "skip").await;
|
||||
|
||||
assert_eq!(
|
||||
sender.calls(),
|
||||
vec!["delete_message", "answer_callback_query"]
|
||||
);
|
||||
assert_eq!(
|
||||
sender.answers(),
|
||||
vec![Some("Skipped — nothing was forwarded.".to_string())]
|
||||
);
|
||||
assert!(
|
||||
ctx.chat_store.get(1).await.edit_message.is_empty(),
|
||||
"a skipped prompt must drop its record"
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole callback path against a stand-in API through a real `Bot`:
|
||||
/// copy, delete, toast, carrying the ids the prompt held. The scripted
|
||||
/// mock records that a call happened; this records what the API received.
|
||||
#[tokio::test]
|
||||
async fn the_forward_button_talks_to_the_api_through_a_real_bot() {
|
||||
use crate::media_sender::test_support::fake_api::FakeApi;
|
||||
use teloxide::Bot;
|
||||
|
||||
let api = FakeApi::start().await;
|
||||
let bot = Bot::new("42:TEST").set_api_url(api.url());
|
||||
let stores = TestStores::new();
|
||||
let ctx = stores.ctx(&bot);
|
||||
seed_prompt(&ctx, "", crate::db::unix_now()).await;
|
||||
|
||||
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await;
|
||||
|
||||
assert_eq!(
|
||||
api.methods(),
|
||||
vec!["CopyMessages", "DeleteMessage", "AnswerCallbackQuery"]
|
||||
);
|
||||
let copy = api.body("CopyMessages");
|
||||
assert_eq!(copy["chat_id"], 2, "the prompt's channel");
|
||||
assert_eq!(copy["from_chat_id"], 1);
|
||||
assert_eq!(copy["message_ids"], serde_json::json!([FORWARDED_ID]));
|
||||
assert_eq!(api.body("AnswerCallbackQuery")["text"], "✅ Forwarded");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forward_without_a_channel_is_reported() {
|
||||
let sender = MockSender::scripted(vec![], || api_error(API_ERROR));
|
||||
let stores = TestStores::new();
|
||||
let ctx = stores.ctx(&sender);
|
||||
seed_prompt(&ctx, "", crate::db::unix_now()).await;
|
||||
ctx.chat_store
|
||||
.update(1, |data| data.forward_channel_id = None)
|
||||
.await;
|
||||
|
||||
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await;
|
||||
|
||||
assert_eq!(sender.calls(), vec!["answer_callback_query"]);
|
||||
assert_eq!(
|
||||
sender.answers(),
|
||||
vec![Some("No forward channel set.".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retryable_forward_is_queued_and_settles_the_prompt() {
|
||||
use teloxide::types::Seconds;
|
||||
let sender = MockSender::scripted(vec![Outcome::CopyErr], || {
|
||||
RequestError::RetryAfter(Seconds::from_seconds(7))
|
||||
});
|
||||
let stores = TestStores::new();
|
||||
let ctx = stores.ctx(&sender);
|
||||
seed_prompt(&ctx, "", crate::db::unix_now()).await;
|
||||
|
||||
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await;
|
||||
|
||||
// The queued row carries the message ids itself, so it owns the
|
||||
// forward from here and the prompt is closed with it. Keeping it live
|
||||
// (the old behaviour) let a second Confirm copy the same messages to
|
||||
// the channel twice, and let Skip answer "nothing was forwarded" while
|
||||
// the row still delivered it.
|
||||
assert_eq!(
|
||||
sender.calls(),
|
||||
vec!["copy_messages", "delete_message", "answer_callback_query"]
|
||||
);
|
||||
assert_eq!(
|
||||
sender.answers(),
|
||||
vec![Some("Forward queued for retry.".to_string())]
|
||||
);
|
||||
assert_eq!(stores.queued_tasks().await, 1);
|
||||
assert!(
|
||||
!ctx.chat_store
|
||||
.get(1)
|
||||
.await
|
||||
.edit_message
|
||||
.contains_key(&PROMPT_ID),
|
||||
"the record must be dropped so the prompt cannot be used again"
|
||||
);
|
||||
|
||||
// A second tap finds no record: it cannot enqueue a duplicate copy.
|
||||
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await;
|
||||
assert_eq!(
|
||||
sender.calls(),
|
||||
vec![
|
||||
"copy_messages",
|
||||
"delete_message",
|
||||
"answer_callback_query",
|
||||
"answer_callback_query"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
sender.answers().last().map(|a| a.as_deref()),
|
||||
Some(Some("Expired"))
|
||||
);
|
||||
assert_eq!(stores.queued_tasks().await, 1, "no second forward row");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_and_expired_prompts_answer_expired() {
|
||||
let sender = MockSender::scripted(vec![], || api_error(API_ERROR));
|
||||
let stores = TestStores::new();
|
||||
let ctx = stores.ctx(&sender);
|
||||
|
||||
// No record at all.
|
||||
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await;
|
||||
assert_eq!(sender.answers(), vec![Some("Expired".to_string())]);
|
||||
|
||||
// A record past its TTL (nothing swept it yet) is dropped on use.
|
||||
let stale = crate::db::unix_now() - ctx.config.edit_message_ttl.as_secs() as i64 - 1;
|
||||
seed_prompt(&ctx, "", stale).await;
|
||||
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "forward").await;
|
||||
assert_eq!(
|
||||
sender.answers(),
|
||||
vec![Some("Expired".to_string()), Some("Expired".to_string())]
|
||||
);
|
||||
assert!(
|
||||
ctx.chat_store.get(1).await.edit_message.is_empty(),
|
||||
"the expired record must be dropped"
|
||||
);
|
||||
assert_eq!(sender.calls(), vec!["answer_callback_query"; 2]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,324 @@
|
||||
//! Inline query handling with a keystroke debounce: only a query stable for
|
||||
//! [`INLINE_DEBOUNCE`] triggers a fetch, and repeats are served by Telegram's
|
||||
//! inline cache instead of re-fetching.
|
||||
|
||||
use super::log_key;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use teloxide::RequestError;
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::types::{
|
||||
InlineQuery, InlineQueryResult, InlineQueryResultMpeg4Gif, InlineQueryResultPhoto,
|
||||
InlineQueryResultVideo, ParseMode,
|
||||
};
|
||||
use x_media::media::Media;
|
||||
|
||||
/// Debounce window for inline queries: Telegram fires an inline query on
|
||||
/// every keystroke, and each prefix of a pasted URL (e.g. `.../status/12`,
|
||||
/// `.../status/123`, ...) already matches the site patterns. Without a
|
||||
/// debounce every keystroke triggers a fetch (3 attempts!) of a half-typed
|
||||
/// post id. Only answer once the query has been stable for this long.
|
||||
const INLINE_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(800);
|
||||
|
||||
/// How long a debounce entry is worth keeping: the window Telegram caches an
|
||||
/// inline answer for (`answer_inline_query` asks for `cache_time(300)`). Past
|
||||
/// it a repeat is sent to the bot again and has to be answered fresh, so the
|
||||
/// entry would only suppress a fetch the user is waiting for.
|
||||
const INLINE_STATE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
|
||||
|
||||
/// Last seen inline query per user and whether it was already answered.
|
||||
/// Guards the debounce timer: a repeat of an answered query is served by
|
||||
/// Telegram's inline cache (see `cache_time`), not by another fetch. Keyed by
|
||||
/// user id — a single shared slot would let one user's typing burst (or a
|
||||
/// different user's query) cancel another user's pending answer.
|
||||
struct InlineDebounceState {
|
||||
query: String,
|
||||
answered: bool,
|
||||
/// When a query last touched this entry, so the periodic sweep can drop
|
||||
/// one per user who ever used inline mode (the map had no eviction at all,
|
||||
/// unlike the rate limiter's buckets and the chat store).
|
||||
last_seen: std::time::Instant,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DebounceStates(HashMap<u64, InlineDebounceState>);
|
||||
|
||||
impl DebounceStates {
|
||||
/// Records `query` as the user's newest query. Returns false when it is a
|
||||
/// repeat whose answer already went out (Telegram's inline cache serves
|
||||
/// it; re-fetching would only hit the source site again).
|
||||
fn note(&mut self, user_id: u64, query: &str) -> bool {
|
||||
if let Some(prev) = self.0.get(&user_id)
|
||||
&& prev.query == query
|
||||
&& prev.answered
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.0.insert(
|
||||
user_id,
|
||||
InlineDebounceState {
|
||||
query: query.to_string(),
|
||||
answered: false,
|
||||
last_seen: std::time::Instant::now(),
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// Drops entries no query has touched for `idle_for`. Split from the clock
|
||||
/// so the boundary is testable without ageing a monotonic instant.
|
||||
fn prune_idle_at(&mut self, now: std::time::Instant, idle_for: std::time::Duration) -> usize {
|
||||
let before = self.0.len();
|
||||
self.0
|
||||
.retain(|_, state| now.saturating_duration_since(state.last_seen) < idle_for);
|
||||
before - self.0.len()
|
||||
}
|
||||
|
||||
/// Claims the answer for the user's newest query; false when a newer query
|
||||
/// superseded it or the answer was already claimed.
|
||||
fn claim(&mut self, user_id: u64, query: &str) -> bool {
|
||||
let Some(state) = self.0.get_mut(&user_id) else {
|
||||
return false;
|
||||
};
|
||||
if state.query != query || state.answered {
|
||||
return false;
|
||||
}
|
||||
state.answered = true;
|
||||
state.last_seen = std::time::Instant::now();
|
||||
true
|
||||
}
|
||||
|
||||
/// Releases a claimed-but-unsent answer so a repeat can retry the fetch.
|
||||
fn release(&mut self, user_id: u64, query: &str) {
|
||||
if let Some(state) = self.0.get_mut(&user_id)
|
||||
&& state.query == query
|
||||
{
|
||||
state.answered = false;
|
||||
state.last_seen = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops debounce entries idle for [`INLINE_STATE_TTL`]; the 300 s sweep calls
|
||||
/// this next to the rate limiter's prune. Returns how many were dropped.
|
||||
pub(crate) fn prune_idle_states() -> usize {
|
||||
INLINE_DEBOUNCE_STATE
|
||||
.lock()
|
||||
.prune_idle_at(std::time::Instant::now(), INLINE_STATE_TTL)
|
||||
}
|
||||
|
||||
static INLINE_DEBOUNCE_STATE: LazyLock<parking_lot::Mutex<DebounceStates>> =
|
||||
LazyLock::new(|| parking_lot::Mutex::new(DebounceStates::default()));
|
||||
|
||||
pub async fn inline_query_handler(bot: Bot, query: InlineQuery) -> Result<(), RequestError> {
|
||||
if query.query.is_empty() {
|
||||
return respond(());
|
||||
}
|
||||
// Only run a fetch for something that is actually a supported post URL.
|
||||
if x_media::site::cache_key(&query.query).is_none() {
|
||||
return respond(());
|
||||
}
|
||||
// Debounce: record the query and answer only after it has been stable for
|
||||
// INLINE_DEBOUNCE (the timer below). An already-answered repeat of the
|
||||
// same query is left to Telegram's inline cache instead of re-fetching.
|
||||
let user_id = query.from.id.0;
|
||||
if !INLINE_DEBOUNCE_STATE.lock().note(user_id, &query.query) {
|
||||
return respond(());
|
||||
}
|
||||
let query_text = query.query.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(INLINE_DEBOUNCE).await;
|
||||
// Only the user's last query of a typing burst survives: earlier
|
||||
// timers see the query changed and give up without answering.
|
||||
if !INLINE_DEBOUNCE_STATE.lock().claim(user_id, &query_text) {
|
||||
return;
|
||||
}
|
||||
match answer_inline_query(bot, query).await {
|
||||
Ok(true) => {}
|
||||
// The fetch or the answer call failed: release so a repeat of the
|
||||
// same query may retry it. An *empty* answer is a real answer
|
||||
// (`Ok(true)`), so a link whose media Telegram cannot fetch is not
|
||||
// re-fetched on every keystroke.
|
||||
Ok(false) | Err(_) => INLINE_DEBOUNCE_STATE.lock().release(user_id, &query_text),
|
||||
}
|
||||
});
|
||||
respond(())
|
||||
}
|
||||
|
||||
/// Fetches the post behind an inline query and answers it. The caller has
|
||||
/// already applied the debounce. Returns `true` when an answer was sent.
|
||||
async fn answer_inline_query(bot: Bot, query: InlineQuery) -> Result<bool, RequestError> {
|
||||
// The query is user input: `debug` keeps only its normalized key, the
|
||||
// text itself is `trace` (same split as the message handler).
|
||||
log::debug!("inline query [key={}]", log_key(&query.query));
|
||||
log::trace!("inline query: {}", query.query);
|
||||
// No retries: the debounce plus a 1s/2s backoff would outlast the inline
|
||||
// query the answer belongs to.
|
||||
match x_media::site::fetch_once(&query.query).await {
|
||||
Ok(Some(fetched)) => {
|
||||
let mut results: Vec<InlineQueryResult> = Vec::new();
|
||||
// Inline results have the same 1024-char caption limit as regular
|
||||
// messages; truncate once here for all items, then apply the same
|
||||
// long-post quoting as the send paths. `answer_inline_query` has no
|
||||
// `AppContext` (the debounce spawns it), so the parsed config comes
|
||||
// from the process-wide static, and the text is the *escaped*
|
||||
// title/content the built-in caption embeds (the raw
|
||||
// `Fetched.title`/`content` differ whenever the post contains
|
||||
// `<`/`&`).
|
||||
let caption = x_media::site::truncate_caption(&fetched.caption);
|
||||
let text = fetched
|
||||
.render_fields()
|
||||
.map(|(_, _, title, content, _)| x_media::site::compose_text(title, content))
|
||||
.unwrap_or_default();
|
||||
let caption = crate::send::quote_long_caption(
|
||||
&caption,
|
||||
&text,
|
||||
super::CONFIG.caption_quote_text_chars,
|
||||
);
|
||||
for (i, media) in fetched.media.iter().enumerate() {
|
||||
let id = format!("{i}");
|
||||
// Telegram fetches an inline result's URL itself and cannot
|
||||
// send site-specific headers, so hotlink-protected media
|
||||
// (pixiv's pximg.net) would render as a broken file there.
|
||||
// Locally produced media (ugoira MP4, bsky remux) is a local
|
||||
// path and does not parse as a URL at all — same skip.
|
||||
if x_media::site::needs_media_headers(media.url()) {
|
||||
log::debug!("inline: skipping hotlink-protected media {id}");
|
||||
continue;
|
||||
}
|
||||
let Some(url) = url::Url::parse(media.url()).ok() else {
|
||||
continue;
|
||||
};
|
||||
let thumbnail = media
|
||||
.thumbnail_url()
|
||||
.and_then(|t| url::Url::parse(t).ok())
|
||||
.unwrap_or_else(|| url.clone());
|
||||
let caption = caption.clone().into_owned();
|
||||
let result = match media {
|
||||
Media::Illustration { .. } => {
|
||||
// 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)
|
||||
.parse_mode(ParseMode::Html),
|
||||
)
|
||||
}
|
||||
Media::Video { .. } => InlineQueryResult::Video(
|
||||
InlineQueryResultVideo::new(
|
||||
id,
|
||||
url,
|
||||
"video/mp4".parse().expect("valid mime"),
|
||||
thumbnail,
|
||||
fetched.title.clone(),
|
||||
)
|
||||
.caption(caption)
|
||||
.parse_mode(ParseMode::Html),
|
||||
),
|
||||
Media::Animated { .. } => InlineQueryResult::Mpeg4Gif(
|
||||
InlineQueryResultMpeg4Gif::new(id, url, thumbnail)
|
||||
.caption(caption)
|
||||
.parse_mode(ParseMode::Html),
|
||||
),
|
||||
};
|
||||
results.push(result);
|
||||
}
|
||||
if !results.is_empty() {
|
||||
// Explicit cache window: repeats of the same query within 5
|
||||
// minutes are served by Telegram without hitting the bot.
|
||||
bot.answer_inline_query(query.id, results)
|
||||
.cache_time(300)
|
||||
.await?;
|
||||
return Ok(true);
|
||||
}
|
||||
// Every item was skipped: Telegram fetches an inline result's URL
|
||||
// itself, so pixiv's hotlink-protected media (and a local ugoira /
|
||||
// bsky MP4) can never be one. Answer *empty* — the client stops
|
||||
// spinning, and the same query is not re-fetched on every
|
||||
// keystroke: an unanswered query releases the debounce below
|
||||
// (`Ok(false)`), which is what made this re-run the fetch each
|
||||
// time, and the window lets Telegram serve the repeats itself.
|
||||
log::debug!("inline: nothing Telegram can fetch for the query; answering empty");
|
||||
bot.answer_inline_query(query.id, Vec::new())
|
||||
.cache_time(300)
|
||||
.await?;
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => log::error!("inline fetch [key={}]: {e}", log_key(&query.query)),
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DebounceStates, INLINE_STATE_TTL};
|
||||
|
||||
const URL_A: &str = "https://x.com/a/status/1";
|
||||
const URL_B: &str = "https://x.com/b/status/2";
|
||||
|
||||
#[test]
|
||||
fn debounce_state_is_per_user() {
|
||||
let mut states = DebounceStates::default();
|
||||
// Two users query different links: both proceed, and neither timer
|
||||
// cancels the other (a single shared slot dropped one of them).
|
||||
assert!(states.note(1, URL_A));
|
||||
assert!(states.note(2, URL_B));
|
||||
assert!(states.claim(1, URL_A), "user 1's answer was cancelled");
|
||||
assert!(states.claim(2, URL_B), "user 2's answer was cancelled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answered_query_is_suppressed_per_user_only() {
|
||||
let mut states = DebounceStates::default();
|
||||
assert!(states.note(1, URL_A));
|
||||
assert!(states.claim(1, URL_A));
|
||||
// A repeat of the answered query by the same user is left to
|
||||
// Telegram's inline cache.
|
||||
assert!(!states.note(1, URL_A));
|
||||
// Another user pasting the same link still gets an answer.
|
||||
assert!(states.note(2, URL_A));
|
||||
assert!(states.claim(2, URL_A));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_states_are_pruned_and_live_ones_kept() {
|
||||
let mut states = DebounceStates::default();
|
||||
assert!(states.note(1, URL_A));
|
||||
let first = states.0[&1].last_seen;
|
||||
// Entry 2 is strictly newer, so one timestamp can sit exactly on the
|
||||
// window's edge for one and comfortably inside it for the other.
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
assert!(states.note(2, URL_B));
|
||||
|
||||
assert_eq!(
|
||||
states.prune_idle_at(first + INLINE_STATE_TTL, INLINE_STATE_TTL),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
!states.0.contains_key(&1),
|
||||
"the entry past the window must go"
|
||||
);
|
||||
assert!(states.0.contains_key(&2), "the live entry must stay");
|
||||
// A pruned user's repeat is answered fresh instead of suppressed.
|
||||
assert!(states.note(1, URL_A));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_query_supersedes_and_failed_answer_is_released() {
|
||||
let mut states = DebounceStates::default();
|
||||
assert!(states.note(1, URL_A));
|
||||
assert!(states.note(1, URL_B));
|
||||
// The stale timer for the half-typed query gives up…
|
||||
assert!(!states.claim(1, URL_A));
|
||||
// …and the newest one answers.
|
||||
assert!(states.claim(1, URL_B));
|
||||
// No results → release so a repeat may retry the fetch.
|
||||
states.release(1, URL_B);
|
||||
assert!(states.claim(1, URL_B));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
//! Update handlers and the per-URL media pipeline.
|
||||
//!
|
||||
//! Split into per-concern modules: [`commands`] (the `/`-command executor),
|
||||
//! [`urls`] (URL extraction + the bounded worker pool + send dispatch),
|
||||
//! [`inline`] (debounced inline queries), [`callback`] (edit-before-forward
|
||||
//! buttons) and [`statics`] (the shared process-wide stores). This module
|
||||
//! holds the message entry point and the helpers the others share.
|
||||
|
||||
mod callback;
|
||||
mod commands;
|
||||
mod inline;
|
||||
mod statics;
|
||||
mod urls;
|
||||
|
||||
pub use callback::callback_query_handler;
|
||||
pub use commands::register_commands;
|
||||
pub use inline::inline_query_handler;
|
||||
pub(crate) use inline::prune_idle_states;
|
||||
/// The resolved `$DATA_DIR/task_queue.db` path, for the startup config line.
|
||||
pub(crate) use statics::db_path;
|
||||
pub use statics::{CHAT_STORE, CONFIG, LINK_CACHE, TASK_QUEUE};
|
||||
pub(crate) use urls::repair_lost_local_media;
|
||||
pub use urls::{start_url_workers, stop_url_workers};
|
||||
|
||||
use crate::ctx::AppContext;
|
||||
use crate::media_sender::MediaSender;
|
||||
use commands::{Command, execute_command};
|
||||
use teloxide::RequestError;
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::types::{
|
||||
ChatId, ChatKind, Message, MessageId, ParseMode, PublicChatKind, ReplyParameters,
|
||||
};
|
||||
use teloxide::utils::command::BotCommands;
|
||||
use urls::{URL_JOBS, extract_urls};
|
||||
|
||||
/// Reply to a message by id, keeping the reply decoration even if the
|
||||
/// original was already deleted. Returns the reply's message id.
|
||||
pub(crate) async fn reply(
|
||||
sender: &dyn MediaSender,
|
||||
chat_id: i64,
|
||||
reply_to: MessageId,
|
||||
text: impl Into<String>,
|
||||
) -> Result<i64, RequestError> {
|
||||
sender
|
||||
.send_message(ChatId(chat_id), text.into(), Some(reply_to), None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Reply to a message by id with HTML parse mode (same reply decoration as
|
||||
/// [`reply`]). Used by `/test`, whose report is an HTML message (the caption
|
||||
/// is wrapped in a `<blockquote>` to show it exactly as it will render).
|
||||
pub(crate) async fn reply_html(
|
||||
bot: &Bot,
|
||||
chat_id: i64,
|
||||
reply_to: MessageId,
|
||||
text: String,
|
||||
) -> Result<i64, RequestError> {
|
||||
// `<Bot as Requester>::` disambiguates from the MediaSender trait's
|
||||
// same-named method (see media_sender.rs).
|
||||
<Bot as Requester>::send_message(bot, ChatId(chat_id), text)
|
||||
.parse_mode(ParseMode::Html)
|
||||
.reply_parameters(ReplyParameters::new(reply_to).allow_sending_without_reply())
|
||||
.await
|
||||
.map(|message| message.id.0 as i64)
|
||||
}
|
||||
|
||||
/// Log prefix tying the whole lifecycle of one link (fetch → send → cache →
|
||||
/// forward) together: the normalized cache key (`twitter:123…`, `pixiv:123`,
|
||||
/// `bsky:handle/rkey`, `bilibili:123…`) instead of the raw URL, so logs stay
|
||||
/// short and do not echo full user-submitted URLs at info level.
|
||||
pub fn log_key(url: &str) -> String {
|
||||
x_media::site::cache_key(url).unwrap_or_else(|| "<unsupported>".to_string())
|
||||
}
|
||||
|
||||
/// How long a caption edit may sleep before it gives up on retrying: the reply
|
||||
/// (or button press) that carried the text is already consumed, so the update
|
||||
/// must not stall the chat's queue behind a long flood-control wait — the user
|
||||
/// is told to send it again instead.
|
||||
const CAPTION_EDIT_MAX_RETRY_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
/// Whether a caption edit landed.
|
||||
enum EditOutcome {
|
||||
Applied,
|
||||
/// The API's reason, for the message the user gets.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Applies a caption edit, retrying once when the API names a short retryable
|
||||
/// delay (`RetryAfter`/network/5xx). A failed edit used to be logged and
|
||||
/// swallowed while the record was updated anyway: the user saw nothing, the
|
||||
/// caption never changed, and the text they typed was gone. Callers report
|
||||
/// [`EditOutcome::Failed`] instead.
|
||||
async fn apply_caption_edit(
|
||||
sender: &dyn MediaSender,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
caption: String,
|
||||
) -> EditOutcome {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
match sender
|
||||
.edit_message_caption(chat_id, message_id, caption.clone())
|
||||
.await
|
||||
{
|
||||
Ok(()) => return EditOutcome::Applied,
|
||||
Err(e) => {
|
||||
let reason = e.to_string();
|
||||
if attempt == 0
|
||||
&& let crate::send::Classification::Retryable { delay_seconds } =
|
||||
crate::send::classify_request_error(&e)
|
||||
&& std::time::Duration::from_secs_f64(delay_seconds)
|
||||
<= CAPTION_EDIT_MAX_RETRY_WAIT
|
||||
{
|
||||
attempt = 1;
|
||||
log::debug!("caption edit failed ({reason}), retrying once");
|
||||
tokio::time::sleep(std::time::Duration::from_secs_f64(delay_seconds)).await;
|
||||
continue;
|
||||
}
|
||||
log::error!("edit_message_caption failed: {reason}");
|
||||
return EditOutcome::Failed(reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit-before-forward: a reply to the prompt swaps the caption of the first
|
||||
/// forwarded message. Returns true when the message was consumed as an edit.
|
||||
/// Body of [`message_handler`]'s edit branch, without teloxide update types so
|
||||
/// it can be driven by tests.
|
||||
async fn edit_message_handler(
|
||||
ctx: &AppContext<'_>,
|
||||
chat_id: i64,
|
||||
reply_to_message_id: i64,
|
||||
text: &str,
|
||||
) -> bool {
|
||||
let chat_data = ctx.chat_store.get(chat_id).await;
|
||||
let Some(edit) = chat_data.edit_message.get(&reply_to_message_id) else {
|
||||
return false;
|
||||
};
|
||||
let Some(first_forward_id) = edit.forward_message_ids.first() else {
|
||||
return false;
|
||||
};
|
||||
let link = format!(
|
||||
"<a href=\"{0}\">{1}</a>",
|
||||
html_escape::encode_double_quoted_attribute(&edit.url),
|
||||
html_escape::encode_text(text)
|
||||
);
|
||||
let new_text = if edit.template.is_empty() {
|
||||
link
|
||||
} else {
|
||||
chat_data
|
||||
.template
|
||||
.get(&edit.template)
|
||||
.map(|template| template.replace("[]", &link))
|
||||
.unwrap_or(link)
|
||||
};
|
||||
match apply_caption_edit(
|
||||
ctx.sender,
|
||||
ChatId(chat_id),
|
||||
MessageId(*first_forward_id as i32),
|
||||
new_text,
|
||||
)
|
||||
.await
|
||||
{
|
||||
EditOutcome::Applied => log::info!(
|
||||
"edit-before-forward: caption swapped on message {first_forward_id} for prompt {reply_to_message_id}"
|
||||
),
|
||||
// The reply was a caption for this prompt, so it stays consumed either
|
||||
// way — but the user is told the swap failed instead of losing it
|
||||
// silently (and can send it again).
|
||||
EditOutcome::Failed(reason) => {
|
||||
let _ = reply(
|
||||
ctx.sender,
|
||||
chat_id,
|
||||
MessageId(reply_to_message_id as i32),
|
||||
format!("Could not update the caption ({reason}). Send it again to retry."),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The `dptree` entry point: the process-wide context, plus the bot the
|
||||
/// dispatcher handed us (used for the replies this module sends itself).
|
||||
pub async fn message_handler(bot: Bot, message: Message) -> Result<(), RequestError> {
|
||||
handle_message(&AppContext::from_statics(&bot), &bot, message).await
|
||||
}
|
||||
|
||||
/// Body of [`message_handler`], taking its context. Every branch here — the
|
||||
/// edit-reply interception, the command path, the private-chat link enqueue and
|
||||
/// the group hint — is otherwise reachable only through the process-wide
|
||||
/// statics, which is why none of them had a test.
|
||||
pub(crate) async fn handle_message(
|
||||
ctx: &AppContext<'_>,
|
||||
bot: &Bot,
|
||||
message: Message,
|
||||
) -> Result<(), RequestError> {
|
||||
let is_private = matches!(message.chat.kind, ChatKind::Private(_));
|
||||
let sender = message
|
||||
.from
|
||||
.as_ref()
|
||||
.map(|from| from.full_name())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let text_preview = message
|
||||
.text()
|
||||
.map(|t| {
|
||||
let end = t.floor_char_boundary(120.min(t.len()));
|
||||
&t[..end]
|
||||
})
|
||||
.unwrap_or("<no text>");
|
||||
// Per-request detail: who and where at `debug`; the message text itself is
|
||||
// user data and only ever appears at `trace`, so a `debug` log can be
|
||||
// shared without leaking what people pasted.
|
||||
log::debug!(
|
||||
"message from {sender} in {} (private={is_private})",
|
||||
message.chat.id
|
||||
);
|
||||
log::trace!("message text: {text_preview}");
|
||||
// URL/edit flows only run in private chats; commands run in any chat.
|
||||
if is_private
|
||||
&& let Some(reply) = message.reply_to_message()
|
||||
&& let Some(text) = message.text()
|
||||
&& edit_message_handler(ctx, message.chat.id.0, reply.id.0 as i64, text).await
|
||||
{
|
||||
return respond(());
|
||||
}
|
||||
if let Some(text) = message.text()
|
||||
&& let Ok(command) = Command::parse(text, "")
|
||||
{
|
||||
// The command name is what the operator needs at `debug`; its argument
|
||||
// may be a user-supplied URL, which stays at `trace`.
|
||||
log::debug!(
|
||||
"command from {}: {}",
|
||||
message.chat.id,
|
||||
text.split_whitespace().next().unwrap_or("<empty>")
|
||||
);
|
||||
log::trace!("command text: {text_preview}");
|
||||
execute_command(bot, &message, command).await?;
|
||||
return respond(());
|
||||
}
|
||||
if is_private {
|
||||
// Only links a site adapter claims: an unsupported URL never gets a
|
||||
// media message, so enqueuing it would spend a queue slot, a worker
|
||||
// wake-up and (through `run_with_chat_action`) a Telegram call on
|
||||
// nothing. Same test the group branch below makes for its hint.
|
||||
let urls: Vec<String> = extract_urls(&message)
|
||||
.into_iter()
|
||||
.filter(|url| x_media::site::cache_key(url).is_some())
|
||||
.collect();
|
||||
if !urls.is_empty() {
|
||||
// Debug only, and echo the normalized keys instead of the raw URLs.
|
||||
let keys: Vec<String> = urls.iter().map(|u| log_key(u)).collect();
|
||||
log::debug!("queuing {} supported URL(s): {keys:?}", urls.len());
|
||||
}
|
||||
for url in urls {
|
||||
// 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;
|
||||
};
|
||||
// A closed channel means the workers are stopping (shutdown):
|
||||
// report the dropped link instead of losing it silently.
|
||||
if tx.send((message.clone(), url)).await.is_err() {
|
||||
log::warn!("url workers stopped; dropping link");
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if is_group(&message.chat.kind)
|
||||
&& extract_urls(&message)
|
||||
.iter()
|
||||
.any(|url| x_media::site::cache_key(url).is_some())
|
||||
{
|
||||
// A supported link in a group used to be dropped in silence, which
|
||||
// reads as a broken bot (the command menu is registered globally, so
|
||||
// the expectation is there). Unsupported links stay ignored; the hint
|
||||
// names the two paths that do work. Channels are excluded — the reply
|
||||
// would be posted into the channel itself.
|
||||
let _ = reply(ctx.sender, message.chat.id.0, message.id, GROUP_LINK_HINT).await;
|
||||
}
|
||||
respond(())
|
||||
}
|
||||
|
||||
/// Answer for a link posted where the pipeline does not run (a group): links
|
||||
/// are private-chat only, inline mode is the group path.
|
||||
const GROUP_LINK_HINT: &str =
|
||||
"Links are handled in private chat only — send me this link there, or use inline mode here.";
|
||||
|
||||
/// Groups and supergroups, as opposed to private chats and channels.
|
||||
fn is_group(kind: &ChatKind) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
ChatKind::Public(chat)
|
||||
if matches!(
|
||||
chat.kind,
|
||||
PublicChatKind::Group | PublicChatKind::Supergroup(_)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ctx::test_support::{FORWARDED_ID, PROMPT_ID, TestStores, api_error, seed_prompt};
|
||||
use crate::media_sender::test_support::{MockSender, Outcome};
|
||||
use teloxide::RequestError;
|
||||
|
||||
/// The Telegram wording the mocks answer with: a message the bot cannot
|
||||
/// edit (the prompt was deleted).
|
||||
const API_ERROR: &str = "Bad Request: message not found";
|
||||
|
||||
#[tokio::test]
|
||||
async fn reply_to_a_prompt_swaps_the_caption_through_its_template() {
|
||||
let sender = MockSender::scripted(vec![Outcome::EditOk], || api_error(API_ERROR));
|
||||
let stores = TestStores::new();
|
||||
let ctx = stores.ctx(&sender);
|
||||
seed_prompt(&ctx, "tpl", crate::db::unix_now()).await;
|
||||
|
||||
let consumed = edit_message_handler(&ctx, 1, PROMPT_ID, "new caption").await;
|
||||
|
||||
assert!(consumed, "a reply to the prompt must be consumed");
|
||||
assert_eq!(
|
||||
sender.captions(),
|
||||
vec!["<b><a href=\"https://x.com/u/status/1\">new caption</a></b>"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reply_text_and_url_are_escaped_into_the_caption() {
|
||||
let sender = MockSender::scripted(vec![Outcome::EditOk], || api_error(API_ERROR));
|
||||
let stores = TestStores::new();
|
||||
let ctx = stores.ctx(&sender);
|
||||
seed_prompt(&ctx, "", crate::db::unix_now()).await;
|
||||
|
||||
edit_message_handler(&ctx, 1, PROMPT_ID, "<script>alert(1)</script>").await;
|
||||
|
||||
// No raw markup from user text may reach the HTML caption.
|
||||
assert_eq!(
|
||||
sender.captions(),
|
||||
vec!["<a href=\"https://x.com/u/status/1\"><script>alert(1)</script></a>"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_caption_swap_is_reported_and_consumed() {
|
||||
// The script is per call, in order: the edit fails, the notice follows.
|
||||
let sender = MockSender::scripted(vec![Outcome::EditErr, Outcome::MessageOk], || {
|
||||
api_error(API_ERROR)
|
||||
});
|
||||
let stores = TestStores::new();
|
||||
let ctx = stores.ctx(&sender);
|
||||
seed_prompt(&ctx, "tpl", crate::db::unix_now()).await;
|
||||
|
||||
// The edit failed (message deleted etc.); the reply must still be
|
||||
// swallowed instead of being treated as a link to fetch — and the user
|
||||
// must be told, because the text they sent is gone either way.
|
||||
assert!(edit_message_handler(&ctx, 1, PROMPT_ID, "new caption").await);
|
||||
assert_eq!(sender.calls(), vec!["edit_message_caption", "send_message"]);
|
||||
let notice = sender.messages().join(" ");
|
||||
assert!(notice.contains("Could not update the caption"), "{notice}");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_short_retryable_caption_failure_is_retried_once() {
|
||||
use teloxide::types::Seconds;
|
||||
// A one-second flood-control wait is worth honouring: the retry lands
|
||||
// and the user never hears about it.
|
||||
let sender = MockSender::scripted(vec![Outcome::EditErr, Outcome::EditOk], || {
|
||||
RequestError::RetryAfter(Seconds::from_seconds(1))
|
||||
});
|
||||
let stores = TestStores::new();
|
||||
let ctx = stores.ctx(&sender);
|
||||
seed_prompt(&ctx, "tpl", crate::db::unix_now()).await;
|
||||
|
||||
assert!(edit_message_handler(&ctx, 1, PROMPT_ID, "new caption").await);
|
||||
assert_eq!(
|
||||
sender.calls(),
|
||||
vec!["edit_message_caption", "edit_message_caption"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_long_retryable_caption_failure_is_not_retried() {
|
||||
use teloxide::types::Seconds;
|
||||
// A minute-long wait must not stall the chat's update queue behind it:
|
||||
// the user is told to send the caption again instead.
|
||||
let sender = MockSender::scripted(vec![Outcome::EditErr, Outcome::MessageOk], || {
|
||||
RequestError::RetryAfter(Seconds::from_seconds(60))
|
||||
});
|
||||
let stores = TestStores::new();
|
||||
let ctx = stores.ctx(&sender);
|
||||
seed_prompt(&ctx, "tpl", crate::db::unix_now()).await;
|
||||
|
||||
assert!(edit_message_handler(&ctx, 1, PROMPT_ID, "new caption").await);
|
||||
assert_eq!(sender.calls(), vec!["edit_message_caption", "send_message"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reply_to_an_unrelated_message_is_not_consumed() {
|
||||
let sender = MockSender::scripted(vec![], || api_error(API_ERROR));
|
||||
let stores = TestStores::new();
|
||||
let ctx = stores.ctx(&sender);
|
||||
|
||||
// No prompt record for that message id → the reply runs the normal
|
||||
// (URL/command) path instead.
|
||||
assert!(!edit_message_handler(&ctx, 1, PROMPT_ID, "hello").await);
|
||||
assert!(sender.calls().is_empty());
|
||||
}
|
||||
|
||||
/// A reply driven through the real message entry point into a real `Bot`:
|
||||
/// the routing (reply-to-prompt → caption swap, before the command and URL
|
||||
/// branches) and the request teloxide builds.
|
||||
#[tokio::test]
|
||||
async fn a_prompt_reply_reaches_the_api_as_a_caption_edit() {
|
||||
use crate::media_sender::test_support::fake_api::FakeApi;
|
||||
use teloxide::Bot;
|
||||
|
||||
let api = FakeApi::start().await;
|
||||
let bot = Bot::new("42:TEST").set_api_url(api.url());
|
||||
let stores = TestStores::new();
|
||||
let ctx = stores.ctx(&bot);
|
||||
seed_prompt(&ctx, "", crate::db::unix_now()).await;
|
||||
let message: Message = serde_json::from_value(serde_json::json!({
|
||||
"message_id": PROMPT_ID + 1,
|
||||
"date": 0,
|
||||
"chat": { "id": 1, "type": "private" },
|
||||
"from": { "id": 5, "is_bot": false, "first_name": "u" },
|
||||
"reply_to_message": {
|
||||
"message_id": PROMPT_ID,
|
||||
"date": 0,
|
||||
"chat": { "id": 1, "type": "private" },
|
||||
"text": "prompt",
|
||||
},
|
||||
"text": "new caption",
|
||||
}))
|
||||
.expect("a minimal message deserializes");
|
||||
|
||||
handle_message(&ctx, &bot, message).await.unwrap();
|
||||
|
||||
assert_eq!(api.methods(), vec!["EditMessageCaption"]);
|
||||
let body = api.body("EditMessageCaption");
|
||||
assert_eq!(body["chat_id"], 1);
|
||||
assert_eq!(body["message_id"], FORWARDED_ID);
|
||||
assert_eq!(
|
||||
body["caption"],
|
||||
"<a href=\"https://x.com/u/status/1\">new caption</a>"
|
||||
);
|
||||
|
||||
// The other branch of the same entry point: a supported link in a group
|
||||
// gets the one explanatory reply (the link pipeline is private-chat only,
|
||||
// and dropping it in silence reads as a broken bot).
|
||||
let group: Message = serde_json::from_value(serde_json::json!({
|
||||
"message_id": 2,
|
||||
"date": 0,
|
||||
"chat": { "id": -100, "type": "group", "title": "g" },
|
||||
"from": { "id": 5, "is_bot": false, "first_name": "u" },
|
||||
"text": "https://x.com/u/status/1",
|
||||
"entities": [{ "type": "url", "offset": 0, "length": 24 }],
|
||||
}))
|
||||
.expect("a minimal group message deserializes");
|
||||
|
||||
handle_message(&ctx, &bot, group).await.unwrap();
|
||||
|
||||
assert_eq!(api.methods(), vec!["EditMessageCaption", "SendMessage"]);
|
||||
assert_eq!(api.body("SendMessage")["text"], GROUP_LINK_HINT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_link_hint_is_for_groups_only() {
|
||||
use teloxide::types::{ChatPrivate, ChatPublic, PublicChatChannel, PublicChatSupergroup};
|
||||
|
||||
let group = ChatKind::Public(ChatPublic {
|
||||
title: None,
|
||||
kind: PublicChatKind::Group,
|
||||
});
|
||||
let supergroup = ChatKind::Public(ChatPublic {
|
||||
title: None,
|
||||
kind: PublicChatKind::Supergroup(PublicChatSupergroup {
|
||||
username: None,
|
||||
is_forum: false,
|
||||
}),
|
||||
});
|
||||
// A channel must stay silent: the hint reply would be posted into the
|
||||
// channel itself.
|
||||
let channel = ChatKind::Public(ChatPublic {
|
||||
title: None,
|
||||
kind: PublicChatKind::Channel(PublicChatChannel { username: None }),
|
||||
});
|
||||
let private = ChatKind::Private(ChatPrivate {
|
||||
username: None,
|
||||
first_name: None,
|
||||
last_name: None,
|
||||
});
|
||||
|
||||
assert!(is_group(&group));
|
||||
assert!(is_group(&supergroup));
|
||||
assert!(!is_group(&channel));
|
||||
assert!(!is_group(&private));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Process-wide singletons shared by the handler modules: the one SQLite
|
||||
//! pool (and the three stores built on it) plus the configuration.
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::db::{self};
|
||||
use crate::link_cache::LinkCache;
|
||||
use crate::queue::PersistentTaskQueue;
|
||||
use crate::state::ChatStore;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
/// One shared SQLite pool for the three stores (chat state, task queue, link
|
||||
/// cache): a single pool bounds concurrent DB work on `data/task_queue.db`
|
||||
/// instead of three independent pools competing for the same file. The schema
|
||||
/// for all three tables is initialized once, here.
|
||||
static DB: LazyLock<Arc<db::DbPool>> = LazyLock::new(|| {
|
||||
let path = db_path();
|
||||
db::open_store(&path.to_string_lossy()).expect("failed to open database")
|
||||
});
|
||||
|
||||
/// DB file location: `$DATA_DIR/task_queue.db` (default `data`, relative to
|
||||
/// the working directory — keeps the docker-compose `./data` mount and local
|
||||
/// runs unchanged). The directory is created if missing: SQLite does not
|
||||
/// create parent dirs, so the old hardcoded `data/task_queue.db` failed with
|
||||
/// a confusing error when started from a directory without `data/`, and a
|
||||
/// CWD-relative path is a footgun for systemd / cron deployments — `DATA_DIR`
|
||||
/// lets them pin the state anywhere. Also read by the startup config line, so
|
||||
/// the log says where the state actually landed.
|
||||
pub(crate) fn db_path() -> std::path::PathBuf {
|
||||
let dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "data".to_string());
|
||||
let dir_path = std::path::Path::new(&dir);
|
||||
std::fs::create_dir_all(dir_path).expect("failed to create data directory");
|
||||
dir_path.join("task_queue.db")
|
||||
}
|
||||
|
||||
pub static CHAT_STORE: LazyLock<ChatStore> = LazyLock::new(|| ChatStore::new(Arc::clone(&DB)));
|
||||
pub static TASK_QUEUE: LazyLock<PersistentTaskQueue> =
|
||||
LazyLock::new(|| PersistentTaskQueue::new(Arc::clone(&DB)));
|
||||
pub static LINK_CACHE: LazyLock<LinkCache> = LazyLock::new(|| LinkCache::new(Arc::clone(&DB)));
|
||||
pub static CONFIG: LazyLock<Config> = LazyLock::new(Config::load);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,370 @@
|
||||
//! 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::params;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
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,
|
||||
/// The media URL the send used, kept so an entry whose file ids stopped
|
||||
/// working can still be re-sent without touching the source site (see the
|
||||
/// bot's `invalidate_cache`). Empty for entries written before this field
|
||||
/// existed — those can only be dropped and re-fetched.
|
||||
#[serde(default)]
|
||||
pub url: 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,
|
||||
/// The post's body text. Defaulted on read: entries written before the
|
||||
/// title/content split carry it inside `title`.
|
||||
#[serde(default)]
|
||||
pub content: 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 shared pool, see [`crate::db::open_store`]).
|
||||
pub struct LinkCache {
|
||||
pool: Arc<crate::db::DbPool>,
|
||||
}
|
||||
|
||||
impl LinkCache {
|
||||
/// Wraps the shared DB pool (the `link_cache` table lives in the merged
|
||||
/// schema alongside `tasks` and `chat_state`).
|
||||
pub fn new(pool: Arc<crate::db::DbPool>) -> Self {
|
||||
LinkCache { pool }
|
||||
}
|
||||
|
||||
/// Returns the cached post if present and not expired; a stale entry is
|
||||
/// removed on the spot.
|
||||
pub async fn get(&self, key: &str, ttl: Duration) -> Option<CachedPost> {
|
||||
let key = key.to_string();
|
||||
let ttl = ttl.as_secs_f64();
|
||||
let result = self
|
||||
.pool
|
||||
.with_conn(move |conn| {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT payload, created_at FROM link_cache WHERE url = ?1")?;
|
||||
let mut rows = stmt.query(params![key])?;
|
||||
let Some(row) = rows.next()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let payload: String = row.get(0)?;
|
||||
let created_at: f64 = row.get(1)?;
|
||||
if now_f64() - created_at > ttl {
|
||||
conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key])?;
|
||||
return Ok(None);
|
||||
}
|
||||
match serde_json::from_str::<CachedPost>(&payload) {
|
||||
Ok(post) => Ok(Some(post)),
|
||||
Err(e) => {
|
||||
// Unreadable payload (e.g. an older schema): drop it
|
||||
// instead of re-failing the parse on every later hit.
|
||||
conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key])?;
|
||||
Err(rusqlite::Error::ToSqlConversionFailure(Box::new(e)))
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!("link cache read failed: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn put(&self, key: &str, post: &CachedPost) {
|
||||
let key = key.to_string();
|
||||
let payload = serde_json::to_string(post).expect("cached post serializes");
|
||||
let result = self
|
||||
.pool
|
||||
.with_conn(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO link_cache (url, payload, created_at) VALUES (?1, ?2, ?3)",
|
||||
params![key, payload, now_f64()],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
log::warn!("link cache write failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops an entry (e.g. a cached file id that turned out invalid).
|
||||
pub async fn remove(&self, key: &str) {
|
||||
let key = key.to_string();
|
||||
let result = self
|
||||
.pool
|
||||
.with_conn(move |conn| {
|
||||
conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key])?;
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
log::warn!("link cache delete failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes expired entries; returns how many were deleted.
|
||||
pub async fn prune(&self, ttl: Duration) -> usize {
|
||||
let cutoff = now_f64() - ttl.as_secs_f64();
|
||||
let result = self
|
||||
.pool
|
||||
.with_conn(move |conn| {
|
||||
conn.execute(
|
||||
"DELETE FROM link_cache WHERE created_at < ?1",
|
||||
params![cutoff],
|
||||
)
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
log::warn!("link cache prune failed: {e}");
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes one entry (by normalized cache key) or the whole cache when
|
||||
/// `key` is `None`. Returns how many rows were removed.
|
||||
pub async fn clear(&self, key: Option<&str>) -> usize {
|
||||
let key = key.map(str::to_string);
|
||||
let result = self
|
||||
.pool
|
||||
.with_conn(move |conn| match &key {
|
||||
Some(key) => conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key]),
|
||||
None => conn.execute("DELETE FROM link_cache", []),
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
log::warn!("link cache clear failed: {e}");
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ctx::test_support::cached_photo;
|
||||
|
||||
/// A payload written before the title/content split has no `content`
|
||||
/// field. It must still read back — the cache deletes what it cannot
|
||||
/// parse — with its text left where it was stored (`title`) and the
|
||||
/// caption it replays untouched. No migration: a self-hosted cache entry
|
||||
/// lives one TTL, and moving the text would only reshuffle `/set_format`
|
||||
/// placeholders until it expires.
|
||||
#[tokio::test]
|
||||
async fn pre_split_entry_still_parses() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = LinkCache::new(
|
||||
crate::db::open_store(dir.path().join("c.db").to_str().unwrap()).unwrap(),
|
||||
);
|
||||
let legacy = serde_json::json!({
|
||||
"url": "https://x.com/u/status/1",
|
||||
"caption": "https://x.com/u/status/1\n<a href=\"au\">a</a>: old text",
|
||||
"title": "old text",
|
||||
"author": "a",
|
||||
"author_url": "au",
|
||||
"tags": "",
|
||||
"sensitive": false,
|
||||
"media": [{"kind": "photo", "file_id": "AgAC..."}]
|
||||
});
|
||||
{
|
||||
let conn = rusqlite::Connection::open(dir.path().join("c.db")).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO link_cache (url, payload, created_at) VALUES (?1, ?2, ?3)",
|
||||
params!["twitter:1", legacy.to_string(), now_f64()],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let got = cache
|
||||
.get("twitter:1", Duration::from_secs(3600))
|
||||
.await
|
||||
.expect("a pre-split payload must not be dropped");
|
||||
assert_eq!(got.title, "old text");
|
||||
assert_eq!(got.content, "");
|
||||
assert_eq!(
|
||||
got.caption,
|
||||
"https://x.com/u/status/1\n<a href=\"au\">a</a>: old text"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_get_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = LinkCache::new(
|
||||
crate::db::open_store(dir.path().join("c.db").to_str().unwrap()).unwrap(),
|
||||
);
|
||||
cache.put("twitter:1", &cached_photo()).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-file-id");
|
||||
// The source URL rides along: it is what a degraded entry falls back to.
|
||||
assert_eq!(got.media[0].url, "https://pbs.twimg.com/media/photo.jpg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_entry_removed_on_read() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = LinkCache::new(
|
||||
crate::db::open_store(dir.path().join("c.db").to_str().unwrap()).unwrap(),
|
||||
);
|
||||
cache.put("twitter:1", &cached_photo()).await;
|
||||
// Force the row into the past so a 1s TTL expires it.
|
||||
{
|
||||
let conn = rusqlite::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 unreadable_entry_is_dropped_on_read() {
|
||||
// A payload from an older schema must not be re-parsed on every hit:
|
||||
// the row is removed and the read reports a miss.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db_path = dir.path().join("c.db");
|
||||
let cache = LinkCache::new(crate::db::open_store(db_path.to_str().unwrap()).unwrap());
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO link_cache (url, payload, created_at) VALUES (?1, ?2, ?3)",
|
||||
params!["twitter:1", "{not json", now_f64()],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert!(
|
||||
cache
|
||||
.get("twitter:1", Duration::from_secs(3600))
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
// Dropped, not left behind for the next hit.
|
||||
assert_eq!(cache.clear(None).await, 0, "corrupted row still present");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_and_prune() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = LinkCache::new(
|
||||
crate::db::open_store(dir.path().join("c.db").to_str().unwrap()).unwrap(),
|
||||
);
|
||||
cache.put("twitter:1", &cached_photo()).await;
|
||||
cache.put("pixiv:2", &cached_photo()).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 = rusqlite::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::new(
|
||||
crate::db::open_store(dir.path().join("c.db").to_str().unwrap()).unwrap(),
|
||||
);
|
||||
cache.put("twitter:1", &cached_photo()).await;
|
||||
cache.put("pixiv:2", &cached_photo()).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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
use dotenv::dotenv;
|
||||
use std::time::Duration;
|
||||
use teloxide::dptree::endpoint;
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::stop::StopToken;
|
||||
use teloxide::types::{ChatId, InputFile, MessageId};
|
||||
use teloxide::update_listeners::{self, UpdateListener, webhooks};
|
||||
use tokio::sync::watch;
|
||||
use x_media::site;
|
||||
|
||||
mod config;
|
||||
mod ctx;
|
||||
mod db;
|
||||
mod handlers;
|
||||
mod link_cache;
|
||||
mod media_sender;
|
||||
mod photo;
|
||||
mod queue;
|
||||
mod rate_limit;
|
||||
mod send;
|
||||
mod state;
|
||||
|
||||
use ctx::CONTEXT;
|
||||
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) {}
|
||||
|
||||
/// A leftover temp file must be at least this old before the startup sweep
|
||||
/// touches it. Orphans come from a *previous* run; anything younger could
|
||||
/// belong to a second instance sharing the temp directory (a misconfiguration,
|
||||
/// but one that must not cost it its in-flight download).
|
||||
const ORPHAN_TEMP_AGE: Duration = Duration::from_secs(3600);
|
||||
|
||||
/// Removes this project's own leftover temp entries (`x_media::TEMP_FILE_PREFIX`)
|
||||
/// from `dir` once they are older than `older_than`. Returns how many were
|
||||
/// removed. Entries that are not ours, or are too young, or cannot be dated,
|
||||
/// are left alone: the OS temp directory is shared, and the marker prefix plus
|
||||
/// the age gate are the only two things that make deleting here safe.
|
||||
fn sweep_temp_dir(dir: &std::path::Path, older_than: Duration) -> usize {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return 0;
|
||||
};
|
||||
let cutoff = std::time::SystemTime::now() - older_than;
|
||||
let mut removed = 0;
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
if !name
|
||||
.to_string_lossy()
|
||||
.starts_with(x_media::TEMP_FILE_PREFIX)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let old_enough = entry
|
||||
.metadata()
|
||||
.and_then(|meta| meta.modified())
|
||||
.is_ok_and(|modified| modified < cutoff);
|
||||
if !old_enough {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
let result = if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
|
||||
std::fs::remove_dir_all(&path)
|
||||
} else {
|
||||
std::fs::remove_file(&path)
|
||||
};
|
||||
match result {
|
||||
Ok(()) => removed += 1,
|
||||
// Not worth a warning per entry: a file another process removed
|
||||
// first (or one we may not delete) is not a problem here.
|
||||
Err(e) => log::debug!("could not remove orphaned temp entry {path:?}: {e}"),
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
dotenv().ok();
|
||||
// Without RUST_LOG nothing at all was logged (env_logger falls back to
|
||||
// `error`), so a deployment that forgot the variable looked like a bot
|
||||
// with no logs; and at `debug` the HTTP client's own lines (hyper_util,
|
||||
// reqwest) outnumbered the bot's by two to one. The timed builder adds
|
||||
// the timestamp the plain `init` omitted, so a line can be compared with
|
||||
// a user's report. An explicit RUST_LOG still wins outright — but a blank
|
||||
// one (`RUST_LOG=` in `.env`, which is not "unset") must not silence the
|
||||
// log the way its absence used to.
|
||||
let filter = std::env::var("RUST_LOG")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "info,hyper_util=warn,reqwest=warn".to_string());
|
||||
pretty_env_logger::formatted_timed_builder()
|
||||
.parse_filters(&filter)
|
||||
.init();
|
||||
log::info!("Starting bot");
|
||||
|
||||
// Temp media (downloaded files, ugoira/remux dirs) is cleaned up by
|
||||
// `TempDir`/`NamedTempFile` on drop — which a killed process never runs.
|
||||
// Without this sweep every hard restart left its downloads behind (up to
|
||||
// hundreds of MB each) and nothing could tell them apart from a live
|
||||
// process's files or from anything else in the OS temp dir. See
|
||||
// [`sweep_temp_dir`] for why the age gate makes that safe.
|
||||
let orphans = sweep_temp_dir(&std::env::temp_dir(), ORPHAN_TEMP_AGE);
|
||||
if orphans > 0 {
|
||||
log::info!("swept {orphans} orphaned temp file(s) from a previous run");
|
||||
}
|
||||
|
||||
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}");
|
||||
}
|
||||
|
||||
// The effective tunables, so an operator can see what the process actually
|
||||
// resolved (a mistyped DATA_DIR or a forgotten TTL override is otherwise
|
||||
// invisible until it bites). The proxy URL is never printed — it may embed
|
||||
// credentials — and admin ids are chat identifiers, so they stay at debug.
|
||||
let quote_chars = match CONFIG.caption_quote_text_chars {
|
||||
0 => "off".to_string(),
|
||||
n => format!("{n} chars"),
|
||||
};
|
||||
log::info!(
|
||||
"config: {} admin(s), state {}, edit-message TTL {}s, link cache TTL {}s, caption quote {quote_chars}, proxy={}",
|
||||
CONFIG.admin_ids.len(),
|
||||
crate::handlers::db_path().display(),
|
||||
CONFIG.edit_message_ttl.as_secs(),
|
||||
CONFIG.link_cache_ttl.as_secs(),
|
||||
if std::env::var("TELOXIDE_PROXY").is_ok() {
|
||||
"yes"
|
||||
} else {
|
||||
"no"
|
||||
}
|
||||
);
|
||||
log::debug!("config: admin ids {:?}", CONFIG.admin_ids);
|
||||
|
||||
// Startup repair, before any worker runs: a queued retry whose media was a
|
||||
// local file (ugoira MP4, bsky remux, a downloaded temp file) can never
|
||||
// succeed after a restart — the registry that kept those files alive is in
|
||||
// memory — so those rows are re-fetched from their post instead of
|
||||
// dead-lettering the user's link.
|
||||
let repaired = handlers::repair_lost_local_media(&CONTEXT).await;
|
||||
if repaired > 0 {
|
||||
log::info!("startup repair: re-fetched {repaired} queued task(s)");
|
||||
}
|
||||
|
||||
// Queue worker: handles typed tasks, dead-letters failed sends to the
|
||||
// task's chat. Both closures use the shared context (the queue requires
|
||||
// 'static handlers, and the statics are process-wide anyway).
|
||||
TASK_QUEUE
|
||||
.start(
|
||||
|payload| send::handle_task(&CONTEXT, payload),
|
||||
|payload, message| send::dead_letter_notify(&CONTEXT, payload, message),
|
||||
)
|
||||
.await;
|
||||
log::info!("task queue worker started");
|
||||
|
||||
// URL job workers: bounded channel + fixed pool for per-URL work.
|
||||
handlers::start_url_workers().await;
|
||||
log::info!("url workers started");
|
||||
|
||||
// Site login validation (user request): a failed login notifies the
|
||||
// admin and the site disables itself for this process (pixiv).
|
||||
let failures = site::validate_all().await;
|
||||
if failures.is_empty() {
|
||||
log::info!("site logins validated");
|
||||
} else {
|
||||
for (site_id, message) in &failures {
|
||||
log::error!("{site_id} login failed: {message}");
|
||||
if let Some(admin) = CONFIG.admin_ids.first() {
|
||||
let _ = bot
|
||||
.send_message(ChatId(*admin), format!("{site_id} login failed: {message}"))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Background sweep: expires the edit prompts and prunes what has aged out.
|
||||
log::info!(
|
||||
"edit-expiry sweep: every {}s, ttl {}",
|
||||
SWEEP_INTERVAL.as_secs(),
|
||||
CONFIG.edit_message_ttl.as_secs()
|
||||
);
|
||||
let (stop_tx, stop_rx) = watch::channel(false);
|
||||
{
|
||||
let bot = bot.clone();
|
||||
tokio::spawn(async move {
|
||||
periodic_sweep(
|
||||
&bot,
|
||||
&CHAT_STORE,
|
||||
&LINK_CACHE,
|
||||
&TASK_QUEUE,
|
||||
&CONFIG,
|
||||
stop_rx,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
let handler = dptree::entry()
|
||||
.branch(Update::filter_message().branch(endpoint(handlers::message_handler)))
|
||||
.branch(Update::filter_inline_query().branch(endpoint(handlers::inline_query_handler)))
|
||||
.branch(Update::filter_callback_query().branch(endpoint(handlers::callback_query_handler)));
|
||||
|
||||
let mut dispatcher = Dispatcher::builder(bot.clone(), handler)
|
||||
.dependencies(dptree::deps![""])
|
||||
.enable_ctrlc_handler()
|
||||
.build();
|
||||
|
||||
if CONFIG.webhook_enabled {
|
||||
log::info!("running in webhook mode");
|
||||
let url = CONFIG.webhook_url.clone().expect("WEBHOOK_URL is not set");
|
||||
// `webhooks::axum` calls set_webhook itself (with the full options,
|
||||
// secret token included) — no explicit registration here.
|
||||
let listen = CONFIG.webhook_listen.expect("WEBHOOK_LISTEN is not set");
|
||||
let port = CONFIG.webhook_port.expect("WEBHOOK_PORT is not set");
|
||||
let mut options = webhooks::Options::new((listen, port).into(), url);
|
||||
if let Some(cert) = &CONFIG.webhook_cert {
|
||||
options = options.certificate(InputFile::file(cert));
|
||||
}
|
||||
if let Some(secret) = &CONFIG.webhook_secret_token {
|
||||
options = options.secret_token(secret.clone());
|
||||
}
|
||||
|
||||
let mut listener = webhooks::axum(bot.clone(), options)
|
||||
.await
|
||||
.expect("Failed to create webhook listener");
|
||||
let stop_token = listener.stop_token();
|
||||
spawn_sigterm_handler(stop_token);
|
||||
|
||||
dispatcher
|
||||
.dispatch_with_listener(
|
||||
listener,
|
||||
LoggingErrorHandler::with_custom_text("Error from update listener"),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
log::info!("running in polling mode");
|
||||
// Same listener `dispatch()` builds internally — using
|
||||
// `dispatch_with_listener` just exposes its stop token so SIGTERM can
|
||||
// unwind the dispatcher before the graceful shutdown below.
|
||||
let mut listener = update_listeners::polling_default(bot.clone()).await;
|
||||
let stop_token = listener.stop_token();
|
||||
spawn_sigterm_handler(stop_token);
|
||||
|
||||
dispatcher
|
||||
.dispatch_with_listener(
|
||||
listener,
|
||||
LoggingErrorHandler::with_custom_text("Error from update listener"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Graceful stop (Ctrl+C / SIGTERM): stop the sweep, notify the admin,
|
||||
// drain the queue. Bounded: a worker mid-download (30 s timeout) or a
|
||||
// long ugoira encode must not hold the shutdown hostage forever.
|
||||
log::info!("Stopping bot");
|
||||
const SHUTDOWN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
let shutdown = async {
|
||||
let _ = stop_tx.send(true);
|
||||
handlers::stop_url_workers().await;
|
||||
if let Some(admin) = CONFIG.admin_ids.first() {
|
||||
let _ = bot.send_message(ChatId(*admin), "Shutting down...").await;
|
||||
}
|
||||
TASK_QUEUE.stop().await;
|
||||
};
|
||||
if tokio::time::timeout(SHUTDOWN_TIMEOUT, shutdown)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
log::warn!("graceful shutdown timed out after {SHUTDOWN_TIMEOUT:?}; exiting");
|
||||
} else {
|
||||
log::info!("Bot stopped");
|
||||
}
|
||||
}
|
||||
|
||||
/// How often [`periodic_sweep`] runs.
|
||||
const SWEEP_INTERVAL: Duration = Duration::from_secs(300);
|
||||
|
||||
/// The background sweep: rewrites the expired edit prompts in place, prunes the
|
||||
/// link cache, the idle rate-limit buckets and the idle inline-query entries,
|
||||
/// and reports the queue only when it is not empty.
|
||||
///
|
||||
/// Takes its collaborators instead of reaching for the statics so a test can
|
||||
/// drive a tick with a paused clock: a sleeping task nothing drives is how the
|
||||
/// queue's own sweep kept a missing worker wake-up.
|
||||
async fn periodic_sweep(
|
||||
sender: &dyn crate::media_sender::MediaSender,
|
||||
chat_store: &crate::state::ChatStore,
|
||||
link_cache: &crate::link_cache::LinkCache,
|
||||
task_queue: &crate::queue::PersistentTaskQueue,
|
||||
config: &crate::config::Config,
|
||||
mut stop: watch::Receiver<bool>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = stop.changed() => break,
|
||||
_ = tokio::time::sleep(SWEEP_INTERVAL) => {}
|
||||
}
|
||||
let removed = chat_store.prune_expired(config.edit_message_ttl).await;
|
||||
let pruned = link_cache.prune(config.link_cache_ttl).await;
|
||||
if pruned > 0 {
|
||||
log::info!("link cache: pruned {pruned} expired entr(ies)");
|
||||
}
|
||||
let idle_limiters = crate::rate_limit::prune_idle();
|
||||
if idle_limiters > 0 {
|
||||
log::debug!("rate limiter: dropped {idle_limiters} idle bucket(s)");
|
||||
}
|
||||
// Entries past Telegram's own inline cache window: a repeat is sent to
|
||||
// the bot again anyway, so keeping them would suppress a fetch the user
|
||||
// is waiting for (and the map grew one entry per user, forever).
|
||||
let idle_inline = handlers::prune_idle_states();
|
||||
if idle_inline > 0 {
|
||||
log::debug!("inline queries: dropped {idle_inline} idle entry(ies)");
|
||||
}
|
||||
// Only speaks up when the queue is not empty: a healthy bot has nothing
|
||||
// to report, and a periodic "0 pending" line is noise that hides the
|
||||
// lines that matter.
|
||||
if let Some((pending, oldest_run_after)) = task_queue.pending_backlog().await {
|
||||
let overdue = crate::db::now_f64() - oldest_run_after;
|
||||
if overdue >= 0.0 {
|
||||
log::info!("queue: {pending} pending task(s), oldest {overdue:.0}s overdue");
|
||||
} else {
|
||||
log::info!(
|
||||
"queue: {pending} pending task(s), oldest retry in {:.0}s",
|
||||
-overdue
|
||||
);
|
||||
}
|
||||
}
|
||||
for (chat_id, prompt_message_id) in removed {
|
||||
// Rewritten in place, not announced: the sweep is a background
|
||||
// timer, and a fresh message would wake the chat up to a full TTL
|
||||
// later about a prompt the user already walked away from. The edit
|
||||
// drops the buttons too. If the prompt was already deleted this
|
||||
// fails with a 400 "message to edit not found" — log and ignore.
|
||||
if let Err(e) = sender
|
||||
.edit_message_text(
|
||||
ChatId(chat_id),
|
||||
MessageId(prompt_message_id as i32),
|
||||
send::EDIT_PROMPT_EXPIRED_TEXT.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::info!("edit-expiry sweep: prompt message gone: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sweep_removes_only_our_old_temp_entries() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let old = std::time::SystemTime::now() - Duration::from_secs(7200);
|
||||
let make = |name: &str, aged: bool| {
|
||||
let path = dir.path().join(name);
|
||||
std::fs::write(&path, b"x").unwrap();
|
||||
if aged {
|
||||
let file = std::fs::File::options().write(true).open(&path).unwrap();
|
||||
file.set_modified(old).unwrap();
|
||||
}
|
||||
path
|
||||
};
|
||||
let ours_old = make(&format!("{}photo-old.jpg", x_media::TEMP_FILE_PREFIX), true);
|
||||
let ours_fresh = make(
|
||||
&format!("{}photo-new.jpg", x_media::TEMP_FILE_PREFIX),
|
||||
false,
|
||||
);
|
||||
let theirs = make("someone-elses-file", true);
|
||||
|
||||
assert_eq!(sweep_temp_dir(dir.path(), Duration::from_secs(3600)), 1);
|
||||
assert!(!ours_old.exists(), "an old leftover of ours is removed");
|
||||
assert!(ours_fresh.exists(), "a fresh file may belong to a live run");
|
||||
assert!(
|
||||
theirs.exists(),
|
||||
"files without our prefix are never touched"
|
||||
);
|
||||
|
||||
// A caller with no age gate also reaches the directory branch (aging a
|
||||
// *directory* is not portable, so the gate is what the first half
|
||||
// above proves): the fresh dir and file go, the unrelated file stays.
|
||||
let leftover_dir = dir
|
||||
.path()
|
||||
.join(format!("{}ugoira", x_media::TEMP_FILE_PREFIX));
|
||||
std::fs::create_dir(&leftover_dir).unwrap();
|
||||
std::fs::write(leftover_dir.join("frame.png"), b"x").unwrap();
|
||||
assert_eq!(sweep_temp_dir(dir.path(), Duration::ZERO), 2);
|
||||
assert!(
|
||||
!leftover_dir.exists(),
|
||||
"leftover dirs go with their contents"
|
||||
);
|
||||
assert!(!ours_fresh.exists(), "no age gate: ours, however fresh");
|
||||
assert!(theirs.exists());
|
||||
}
|
||||
|
||||
/// The sweep's tick: an expired prompt is rewritten in place (buttons
|
||||
/// dropped) while a live one is left alone. Driven through the loop's own
|
||||
/// timer on a paused clock — the loop is what a hand-called helper would
|
||||
/// leave untested, which is how the queue's sweep kept a missing wake-up.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn the_sweep_expires_only_the_prompts_past_their_ttl() {
|
||||
use crate::ctx::test_support::{
|
||||
FORWARDED_ID, PROMPT_ID, TestStores, api_error, seed_prompt,
|
||||
};
|
||||
use crate::media_sender::test_support::MockSender;
|
||||
use crate::state::EditMessage;
|
||||
|
||||
// The interval is pinned here because no assertion on the edits can see
|
||||
// it: a shorter interval produces the same single edit (the record is
|
||||
// gone after the first tick), and the paused clock can jump past the
|
||||
// boundary while a tick's DB work is in flight.
|
||||
assert_eq!(SWEEP_INTERVAL, Duration::from_secs(300));
|
||||
|
||||
let config = crate::config::Config::load();
|
||||
let stores = TestStores::new();
|
||||
let sender = MockSender::scripted(vec![], || {
|
||||
api_error("Bad Request: message to edit not found")
|
||||
});
|
||||
let ctx = stores.ctx(&sender);
|
||||
// Chat 1 holds a prompt past its ttl; chat 2 a live one.
|
||||
let stale = crate::db::unix_now() - config.edit_message_ttl.as_secs() as i64 - 1;
|
||||
seed_prompt(&ctx, "", stale).await;
|
||||
stores
|
||||
.chat_store()
|
||||
.update(2, |data| {
|
||||
data.edit_message.insert(
|
||||
PROMPT_ID,
|
||||
EditMessage {
|
||||
url: "https://x.com/u/status/1".into(),
|
||||
chat_id: 2,
|
||||
forward_message_ids: vec![FORWARDED_ID],
|
||||
template: String::new(),
|
||||
created_at: crate::db::unix_now(),
|
||||
},
|
||||
);
|
||||
})
|
||||
.await;
|
||||
|
||||
let (stop_tx, stop_rx) = watch::channel(false);
|
||||
let sweep = periodic_sweep(
|
||||
&sender,
|
||||
stores.chat_store(),
|
||||
stores.link_cache(),
|
||||
stores.task_queue(),
|
||||
&config,
|
||||
stop_rx,
|
||||
);
|
||||
tokio::pin!(sweep);
|
||||
|
||||
// One second short of the interval: nothing has been touched. The
|
||||
// select is what polls the loop (a pinned future nobody awaits never
|
||||
// runs), and the paused clock makes this the loop's own timer.
|
||||
tokio::select! {
|
||||
_ = &mut sweep => unreachable!("the sweep only returns on stop"),
|
||||
_ = tokio::time::sleep(SWEEP_INTERVAL - Duration::from_secs(1)) => {}
|
||||
}
|
||||
assert!(
|
||||
sender.edited_texts().is_empty(),
|
||||
"the sweep ran before its interval"
|
||||
);
|
||||
|
||||
// The second that crosses the interval: the tick fires.
|
||||
tokio::select! {
|
||||
_ = &mut sweep => unreachable!("the sweep only returns on stop"),
|
||||
_ = tokio::time::sleep(Duration::from_secs(2)) => {}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
sender.edited_texts(),
|
||||
vec![(1, PROMPT_ID, send::EDIT_PROMPT_EXPIRED_TEXT.to_string())],
|
||||
"exactly the expired prompt, rewritten in place"
|
||||
);
|
||||
assert!(
|
||||
!ctx.chat_store
|
||||
.get(1)
|
||||
.await
|
||||
.edit_message
|
||||
.contains_key(&PROMPT_ID),
|
||||
"the expired record is gone"
|
||||
);
|
||||
assert!(
|
||||
ctx.chat_store
|
||||
.get(2)
|
||||
.await
|
||||
.edit_message
|
||||
.contains_key(&PROMPT_ID),
|
||||
"a live prompt keeps its record and its buttons"
|
||||
);
|
||||
|
||||
stop_tx.send(true).unwrap();
|
||||
sweep.await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
//! Send abstraction: the message-sending surface [`send`](crate::send)
|
||||
//! needs, so the send pipeline can be tested with a scripted mock instead of
|
||||
//! a live teloxide `Bot`.
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use teloxide::RequestError;
|
||||
use teloxide::prelude::Requester;
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::types::{
|
||||
CallbackQueryId, ChatAction, ChatId, InlineKeyboardMarkup, InputFile, InputMedia, Message,
|
||||
MessageId, ParseMode, ReplyParameters,
|
||||
};
|
||||
|
||||
/// Boxed, `Send` future returned by a [`MediaSender`] method (`async fn` in
|
||||
/// traits is not dyn-compatible).
|
||||
type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
|
||||
|
||||
/// The message-sending surface the send pipeline uses. The production
|
||||
/// implementation is teloxide's [`Bot`]; tests inject a scripted mock to
|
||||
/// cover the fallback and classification logic without touching the
|
||||
/// Telegram API.
|
||||
pub trait MediaSender: Send + Sync {
|
||||
/// Sends a media group, replying to `reply_to`.
|
||||
fn send_media_group(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
reply_to: MessageId,
|
||||
items: Vec<InputMedia>,
|
||||
) -> BoxFuture<'_, Result<Vec<Message>, RequestError>>;
|
||||
|
||||
/// Sends a lone animation, replying to `reply_to`.
|
||||
fn send_animation<'a>(
|
||||
&'a self,
|
||||
chat_id: ChatId,
|
||||
reply_to: MessageId,
|
||||
caption: &'a str,
|
||||
spoiler: bool,
|
||||
file: InputFile,
|
||||
) -> BoxFuture<'a, Result<Message, RequestError>>;
|
||||
|
||||
/// Copies messages between chats (forward to channel).
|
||||
fn copy_messages(
|
||||
&self,
|
||||
to: ChatId,
|
||||
from: ChatId,
|
||||
ids: Vec<MessageId>,
|
||||
) -> BoxFuture<'_, Result<Vec<MessageId>, RequestError>>;
|
||||
|
||||
/// Sends a plain text message, optionally replying to `reply_to` and
|
||||
/// attaching `reply_markup`. Returns the sent message's id: the bot only
|
||||
/// ever needs that (the edit-before-forward prompt's record is keyed by
|
||||
/// it), and returning the whole `Message` would force every test mock to
|
||||
/// construct one.
|
||||
fn send_message(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
text: String,
|
||||
reply_to: Option<MessageId>,
|
||||
reply_markup: Option<InlineKeyboardMarkup>,
|
||||
) -> BoxFuture<'_, Result<i64, RequestError>>;
|
||||
|
||||
/// Answers a callback query, optionally with a toast `text` shown to the
|
||||
/// user who pressed the button.
|
||||
fn answer_callback_query(
|
||||
&self,
|
||||
id: CallbackQueryId,
|
||||
text: Option<String>,
|
||||
) -> BoxFuture<'_, Result<(), RequestError>>;
|
||||
|
||||
/// Rewrites a message's text and drops its inline keyboard: the
|
||||
/// edit-expiry sweep rewriting a prompt whose record expired (a button left
|
||||
/// behind could only answer "Expired").
|
||||
fn edit_message_text(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
text: String,
|
||||
) -> BoxFuture<'_, Result<(), RequestError>>;
|
||||
|
||||
/// Rewrites a message's caption, always with HTML parse mode (every caller
|
||||
/// in this bot renders escaped HTML: templates and edit-before-forward
|
||||
/// links).
|
||||
fn edit_message_caption(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
caption: String,
|
||||
) -> BoxFuture<'_, Result<(), RequestError>>;
|
||||
|
||||
/// Deletes a message (the edit-before-forward prompt after a forward).
|
||||
fn delete_message(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
) -> BoxFuture<'_, Result<(), RequestError>>;
|
||||
|
||||
/// Sets the chat's "typing / uploading …" indicator (cosmetic).
|
||||
fn send_chat_action(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
action: ChatAction,
|
||||
) -> BoxFuture<'_, Result<(), RequestError>>;
|
||||
}
|
||||
|
||||
impl MediaSender for Bot {
|
||||
fn send_media_group(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
reply_to: MessageId,
|
||||
items: Vec<InputMedia>,
|
||||
) -> BoxFuture<'_, Result<Vec<Message>, RequestError>> {
|
||||
Box::pin(async move {
|
||||
// Pace media sends per chat (one token per item) so bursts do not
|
||||
// trip Telegram's flood control.
|
||||
crate::rate_limit::limiter_for(chat_id.0)
|
||||
.acquire(items.len() as f64)
|
||||
.await;
|
||||
// Same spend against the bot-wide budget: a fan-out over chats is
|
||||
// invisible to the per-chat buckets.
|
||||
crate::rate_limit::acquire_global(items.len() as f64).await;
|
||||
// `<Bot as Requester>::` disambiguates from this trait's same-named
|
||||
// method (teloxide's API lives in the `Requester` trait).
|
||||
<Bot as Requester>::send_media_group(self, chat_id, items)
|
||||
.reply_parameters(ReplyParameters::new(reply_to).allow_sending_without_reply())
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
fn send_animation<'a>(
|
||||
&'a self,
|
||||
chat_id: ChatId,
|
||||
reply_to: MessageId,
|
||||
caption: &'a str,
|
||||
spoiler: bool,
|
||||
file: InputFile,
|
||||
) -> BoxFuture<'a, Result<Message, RequestError>> {
|
||||
Box::pin(async move {
|
||||
crate::rate_limit::limiter_for(chat_id.0).acquire(1.0).await;
|
||||
crate::rate_limit::acquire_global(1.0).await;
|
||||
let mut request = <Bot as Requester>::send_animation(self, chat_id, file)
|
||||
.caption(caption)
|
||||
.parse_mode(ParseMode::Html)
|
||||
.reply_parameters(ReplyParameters::new(reply_to).allow_sending_without_reply());
|
||||
if spoiler {
|
||||
request = request.has_spoiler(true);
|
||||
}
|
||||
request.await
|
||||
})
|
||||
}
|
||||
|
||||
fn copy_messages(
|
||||
&self,
|
||||
to: ChatId,
|
||||
from: ChatId,
|
||||
ids: Vec<MessageId>,
|
||||
) -> BoxFuture<'_, Result<Vec<MessageId>, RequestError>> {
|
||||
Box::pin(async move {
|
||||
// Channel forwards are the burstiest path (batch copies); pace
|
||||
// them per message against the channel's budget.
|
||||
crate::rate_limit::limiter_for(to.0)
|
||||
.acquire(ids.len() as f64)
|
||||
.await;
|
||||
crate::rate_limit::acquire_global(ids.len() as f64).await;
|
||||
<Bot as Requester>::copy_messages(self, to, from, ids).await
|
||||
})
|
||||
}
|
||||
|
||||
fn send_message(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
text: String,
|
||||
reply_to: Option<MessageId>,
|
||||
reply_markup: Option<InlineKeyboardMarkup>,
|
||||
) -> BoxFuture<'_, Result<i64, RequestError>> {
|
||||
Box::pin(async move {
|
||||
let mut request = <Bot as Requester>::send_message(self, chat_id, text);
|
||||
if let Some(reply_to) = reply_to {
|
||||
request = request
|
||||
.reply_parameters(ReplyParameters::new(reply_to).allow_sending_without_reply());
|
||||
}
|
||||
if let Some(markup) = reply_markup {
|
||||
request = request.reply_markup(markup);
|
||||
}
|
||||
request.await.map(|message| message.id.0 as i64)
|
||||
})
|
||||
}
|
||||
|
||||
fn answer_callback_query(
|
||||
&self,
|
||||
id: CallbackQueryId,
|
||||
text: Option<String>,
|
||||
) -> BoxFuture<'_, Result<(), RequestError>> {
|
||||
Box::pin(async move {
|
||||
let mut request = <Bot as Requester>::answer_callback_query(self, id);
|
||||
if let Some(text) = text {
|
||||
request = request.text(text);
|
||||
}
|
||||
request.await.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
fn edit_message_text(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
text: String,
|
||||
) -> BoxFuture<'_, Result<(), RequestError>> {
|
||||
Box::pin(async move {
|
||||
<Bot as Requester>::edit_message_text(self, chat_id, message_id, text)
|
||||
.reply_markup(InlineKeyboardMarkup::default())
|
||||
.await
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
fn edit_message_caption(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
caption: String,
|
||||
) -> BoxFuture<'_, Result<(), RequestError>> {
|
||||
Box::pin(async move {
|
||||
<Bot as Requester>::edit_message_caption(self, chat_id, message_id)
|
||||
.caption(caption)
|
||||
.parse_mode(ParseMode::Html)
|
||||
.await
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_message(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
) -> BoxFuture<'_, Result<(), RequestError>> {
|
||||
Box::pin(async move {
|
||||
<Bot as Requester>::delete_message(self, chat_id, message_id)
|
||||
.await
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
fn send_chat_action(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
action: ChatAction,
|
||||
) -> BoxFuture<'_, Result<(), RequestError>> {
|
||||
Box::pin(async move {
|
||||
// teloxide's `send_chat_action` returns `Result<True, _>` (its
|
||||
// unit marker type); map the success to `()`.
|
||||
<Bot as Requester>::send_chat_action(self, chat_id, action)
|
||||
.await
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Test support: a scripted [`MediaSender`] mock (no Telegram API involved).
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support {
|
||||
use super::*;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
/// One scripted outcome, consumed front-to-back; the last entry repeats
|
||||
/// for further calls of the same method kind.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Outcome {
|
||||
GroupOk,
|
||||
GroupErr,
|
||||
AnimationErr,
|
||||
CopyOk,
|
||||
CopyErr,
|
||||
/// An error from `send_message` (replies are fire-and-forget, so an
|
||||
/// error is fine for tests).
|
||||
MessageErr,
|
||||
/// A successful `send_message`, returning message id [`MockSender::SENT_ID`].
|
||||
MessageOk,
|
||||
EditOk,
|
||||
EditErr,
|
||||
}
|
||||
|
||||
/// A stand-in for `api.telegram.org` for the tests that must drive a real
|
||||
/// `Bot` — its request building, the per-chat limiter, the bot-wide budget
|
||||
/// — which the scripted mock bypasses entirely. Records every call and
|
||||
/// answers the smallest result each method needs.
|
||||
pub(crate) mod fake_api {
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
pub(crate) struct FakeApi {
|
||||
url: url::Url,
|
||||
calls: Arc<Mutex<Vec<(String, serde_json::Value)>>>,
|
||||
server: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl FakeApi {
|
||||
/// Binds an ephemeral port and serves until dropped.
|
||||
pub(crate) async fn start() -> FakeApi {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let recorded = Arc::clone(&calls);
|
||||
let server = tokio::spawn(async move {
|
||||
while let Ok((mut socket, _)) = listener.accept().await {
|
||||
let recorded = Arc::clone(&recorded);
|
||||
tokio::spawn(async move {
|
||||
let Some((method, body)) = read_request(&mut socket).await else {
|
||||
return;
|
||||
};
|
||||
recorded.lock().push((method.clone(), body));
|
||||
let payload = serde_json::json!({
|
||||
"ok": true,
|
||||
"result": canned_result(&method),
|
||||
})
|
||||
.to_string();
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
|
||||
content-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
payload.len(),
|
||||
payload
|
||||
);
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
let _ = socket.flush().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
FakeApi {
|
||||
// Trailing slash: teloxide appends `bot<token>/<method>`.
|
||||
url: url::Url::parse(&format!("http://{addr}/")).unwrap(),
|
||||
calls,
|
||||
server,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where to point a `Bot`: `Bot::new(token).set_api_url(api.url())`.
|
||||
pub(crate) fn url(&self) -> url::Url {
|
||||
self.url.clone()
|
||||
}
|
||||
|
||||
/// Method names in call order.
|
||||
pub(crate) fn methods(&self) -> Vec<String> {
|
||||
self.calls.lock().iter().map(|(m, _)| m.clone()).collect()
|
||||
}
|
||||
|
||||
/// The JSON body of the first call to `method` (`Null` for a body
|
||||
/// that is not JSON, i.e. a multipart upload).
|
||||
pub(crate) fn body(&self, method: &str) -> serde_json::Value {
|
||||
self.calls
|
||||
.lock()
|
||||
.iter()
|
||||
.find(|(m, _)| m == method)
|
||||
.map(|(_, body)| body.clone())
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FakeApi {
|
||||
fn drop(&mut self) {
|
||||
self.server.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/// The smallest result teloxide can deserialize for a method. The names
|
||||
/// arrive as the payload type's own — `SendMediaGroup`, not
|
||||
/// `sendMediaGroup`: teloxide builds the URL from that, and the Bot API
|
||||
/// accepts the spelling.
|
||||
fn canned_result(method: &str) -> serde_json::Value {
|
||||
match method {
|
||||
"CopyMessages" => serde_json::json!([{ "message_id": 11 }]),
|
||||
"SendMediaGroup" => serde_json::json!([minimal_message()]),
|
||||
"SendMessage" | "SendAnimation" | "EditMessageCaption" => minimal_message(),
|
||||
_ => serde_json::Value::Bool(true),
|
||||
}
|
||||
}
|
||||
|
||||
fn minimal_message() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"message_id": 1,
|
||||
"date": 0,
|
||||
"chat": { "id": 1, "type": "private" },
|
||||
})
|
||||
}
|
||||
|
||||
/// One HTTP/1.1 request: the head up to the blank line, then
|
||||
/// `content-length` bytes of body — JSON for most methods, multipart
|
||||
/// for the media ones (teloxide sends `SendMediaGroup` that way).
|
||||
async fn read_request(socket: &mut TcpStream) -> Option<(String, serde_json::Value)> {
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = [0u8; 4096];
|
||||
loop {
|
||||
let n = socket.read(&mut chunk).await.ok()?;
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
let Some(headers_end) = find(&buf, b"\r\n\r\n") else {
|
||||
continue;
|
||||
};
|
||||
let head = String::from_utf8_lossy(&buf[..headers_end]).to_string();
|
||||
let length: usize = head
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.to_ascii_lowercase()
|
||||
.strip_prefix("content-length:")
|
||||
.and_then(|v| v.trim().parse().ok())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let body_start = headers_end + 4;
|
||||
while buf.len() < body_start + length {
|
||||
let n = socket.read(&mut chunk).await.ok()?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
}
|
||||
let method = head
|
||||
.lines()
|
||||
.next()
|
||||
// `POST /bot<token>/<method>`
|
||||
.and_then(|line| line.split(' ').nth(1))
|
||||
.and_then(|path| path.rsplit('/').next())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let body = parse_body(&buf[body_start..], &head);
|
||||
return Some((method, body));
|
||||
}
|
||||
}
|
||||
|
||||
/// The request body as JSON: either the JSON body itself, or a
|
||||
/// multipart form flattened into an object (each part's value parsed as
|
||||
/// JSON when it is one, so `media` comes back as its array).
|
||||
fn parse_body(body: &[u8], head: &str) -> serde_json::Value {
|
||||
let content_type = head
|
||||
.lines()
|
||||
.find(|line| line.to_ascii_lowercase().starts_with("content-type:"))
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
let Some(boundary) = content_type
|
||||
.split("boundary=")
|
||||
.nth(1)
|
||||
.map(|b| b.trim().trim_matches('"').to_string())
|
||||
else {
|
||||
return serde_json::from_slice(body).unwrap_or_default();
|
||||
};
|
||||
let text = String::from_utf8_lossy(body);
|
||||
let mut fields = serde_json::Map::new();
|
||||
for part in text.split(&format!("--{boundary}")).skip(1) {
|
||||
let Some((part_head, value)) = part.split_once("\r\n\r\n") else {
|
||||
continue;
|
||||
};
|
||||
let Some(name) = part_head
|
||||
.split("name=\"")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split('"').next())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let value = value.trim_end_matches("\r\n");
|
||||
fields.insert(
|
||||
name.to_string(),
|
||||
serde_json::from_str(value).unwrap_or_else(|_| value.into()),
|
||||
);
|
||||
}
|
||||
serde_json::Value::Object(fields)
|
||||
}
|
||||
|
||||
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.position(|window| window == needle)
|
||||
}
|
||||
}
|
||||
|
||||
/// Replays a script and records what was sent, so tests can assert the
|
||||
/// user-visible text a path produced.
|
||||
pub(crate) struct MockSender {
|
||||
script: Mutex<Vec<Outcome>>,
|
||||
cursor: Mutex<usize>,
|
||||
calls: Mutex<Vec<&'static str>>,
|
||||
messages: Mutex<Vec<String>>,
|
||||
captions: Mutex<Vec<String>>,
|
||||
answers: Mutex<Vec<Option<String>>>,
|
||||
/// `(chat, message, text)` of every text rewrite, in order.
|
||||
edited_texts: Mutex<Vec<(i64, i64, String)>>,
|
||||
/// Builds the error every `*Err` outcome returns (RequestError is not
|
||||
/// cloneable, so the factory recreates it per call).
|
||||
error: Box<dyn Fn() -> RequestError + Send + Sync>,
|
||||
}
|
||||
|
||||
impl MockSender {
|
||||
/// The message id a successful `send_message` reports.
|
||||
pub(crate) const SENT_ID: i64 = 1;
|
||||
|
||||
pub(crate) fn scripted(
|
||||
script: Vec<Outcome>,
|
||||
error: impl Fn() -> RequestError + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
MockSender {
|
||||
script: Mutex::new(script),
|
||||
cursor: Mutex::new(0),
|
||||
calls: Mutex::new(Vec::new()),
|
||||
messages: Mutex::new(Vec::new()),
|
||||
captions: Mutex::new(Vec::new()),
|
||||
answers: Mutex::new(Vec::new()),
|
||||
edited_texts: Mutex::new(Vec::new()),
|
||||
error: Box::new(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Method names in call order (e.g. `["send_media_group",
|
||||
/// "send_media_group"]` proves the fallback re-sent).
|
||||
pub(crate) fn calls(&self) -> Vec<&'static str> {
|
||||
self.calls.lock().clone()
|
||||
}
|
||||
|
||||
/// Texts of the plain messages sent, in order.
|
||||
pub(crate) fn messages(&self) -> Vec<String> {
|
||||
self.messages.lock().clone()
|
||||
}
|
||||
|
||||
/// Captions passed to `edit_message_caption`, in order.
|
||||
pub(crate) fn captions(&self) -> Vec<String> {
|
||||
self.captions.lock().clone()
|
||||
}
|
||||
|
||||
/// Toast texts of the answered callback queries, in order.
|
||||
pub(crate) fn answers(&self) -> Vec<Option<String>> {
|
||||
self.answers.lock().clone()
|
||||
}
|
||||
|
||||
/// `(chat, message, text)` of every `edit_message_text`, in order.
|
||||
pub(crate) fn edited_texts(&self) -> Vec<(i64, i64, String)> {
|
||||
self.edited_texts.lock().clone()
|
||||
}
|
||||
|
||||
fn next(&self, kind: &'static str) -> Outcome {
|
||||
self.calls.lock().push(kind);
|
||||
let script = self.script.lock();
|
||||
let mut cursor = self.cursor.lock();
|
||||
if script.is_empty() {
|
||||
panic!("mock script exhausted: {kind}");
|
||||
}
|
||||
let idx = (*cursor).min(script.len() - 1);
|
||||
*cursor = idx + 1;
|
||||
script[idx]
|
||||
}
|
||||
|
||||
fn error(&self) -> RequestError {
|
||||
(self.error)()
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSender for MockSender {
|
||||
fn send_media_group(
|
||||
&self,
|
||||
_chat_id: ChatId,
|
||||
_reply_to: MessageId,
|
||||
items: Vec<InputMedia>,
|
||||
) -> BoxFuture<'_, Result<Vec<Message>, RequestError>> {
|
||||
Box::pin(async move {
|
||||
// Record the captions exactly as Telegram receives them (only
|
||||
// the first item of a group carries one), so tests can assert
|
||||
// what a recipient sees.
|
||||
self.captions
|
||||
.lock()
|
||||
.extend(items.iter().filter_map(|item| match item {
|
||||
InputMedia::Photo(photo) => photo.caption.clone(),
|
||||
InputMedia::Video(video) => video.caption.clone(),
|
||||
InputMedia::Animation(animation) => animation.caption.clone(),
|
||||
_ => None,
|
||||
}));
|
||||
match self.next("send_media_group") {
|
||||
Outcome::GroupOk => Ok(Vec::new()),
|
||||
Outcome::GroupErr => Err(self.error()),
|
||||
other => panic!("unexpected outcome {other:?} for send_media_group"),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn send_animation<'a>(
|
||||
&'a self,
|
||||
_chat_id: ChatId,
|
||||
_reply_to: MessageId,
|
||||
_caption: &'a str,
|
||||
_spoiler: bool,
|
||||
_file: InputFile,
|
||||
) -> BoxFuture<'a, Result<Message, RequestError>> {
|
||||
Box::pin(async move {
|
||||
match self.next("send_animation") {
|
||||
Outcome::AnimationErr => Err(self.error()),
|
||||
other => panic!("unexpected outcome {other:?} for send_animation"),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn copy_messages(
|
||||
&self,
|
||||
_to: ChatId,
|
||||
_from: ChatId,
|
||||
_ids: Vec<MessageId>,
|
||||
) -> BoxFuture<'_, Result<Vec<MessageId>, RequestError>> {
|
||||
Box::pin(async move {
|
||||
match self.next("copy_messages") {
|
||||
Outcome::CopyOk => Ok(vec![MessageId(1)]),
|
||||
Outcome::CopyErr => Err(self.error()),
|
||||
other => panic!("unexpected outcome {other:?} for copy_messages"),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn send_message(
|
||||
&self,
|
||||
_chat_id: ChatId,
|
||||
text: String,
|
||||
_reply_to: Option<MessageId>,
|
||||
_reply_markup: Option<InlineKeyboardMarkup>,
|
||||
) -> BoxFuture<'_, Result<i64, RequestError>> {
|
||||
Box::pin(async move {
|
||||
self.messages.lock().push(text);
|
||||
match self.next("send_message") {
|
||||
Outcome::MessageOk => Ok(MockSender::SENT_ID),
|
||||
Outcome::MessageErr => Err(self.error()),
|
||||
other => panic!("unexpected outcome {other:?} for send_message"),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn answer_callback_query(
|
||||
&self,
|
||||
_id: CallbackQueryId,
|
||||
text: Option<String>,
|
||||
) -> BoxFuture<'_, Result<(), RequestError>> {
|
||||
// Always succeeds: the toast is cosmetic, so the script stays
|
||||
// focused on the outcomes a test cares about.
|
||||
Box::pin(async move {
|
||||
self.calls.lock().push("answer_callback_query");
|
||||
self.answers.lock().push(text);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn edit_message_text(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
text: String,
|
||||
) -> BoxFuture<'_, Result<(), RequestError>> {
|
||||
// Always succeeds: the only caller is the expiry sweep, which
|
||||
// tolerates a failure (a prompt the user already deleted), so the
|
||||
// script stays free for the call the test is about.
|
||||
Box::pin(async move {
|
||||
self.calls.lock().push("edit_message_text");
|
||||
self.edited_texts
|
||||
.lock()
|
||||
.push((chat_id.0, message_id.0 as i64, text));
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn edit_message_caption(
|
||||
&self,
|
||||
_chat_id: ChatId,
|
||||
_message_id: MessageId,
|
||||
caption: String,
|
||||
) -> BoxFuture<'_, Result<(), RequestError>> {
|
||||
Box::pin(async move {
|
||||
self.captions.lock().push(caption);
|
||||
match self.next("edit_message_caption") {
|
||||
Outcome::EditOk => Ok(()),
|
||||
Outcome::EditErr => Err(self.error()),
|
||||
other => panic!("unexpected outcome {other:?} for edit_message_caption"),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_message(
|
||||
&self,
|
||||
_chat_id: ChatId,
|
||||
_message_id: MessageId,
|
||||
) -> BoxFuture<'_, Result<(), RequestError>> {
|
||||
// Deletion is fire-and-forget in every caller; always succeeds.
|
||||
Box::pin(async move {
|
||||
self.calls.lock().push("delete_message");
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn send_chat_action(
|
||||
&self,
|
||||
_chat_id: ChatId,
|
||||
_action: ChatAction,
|
||||
) -> BoxFuture<'_, Result<(), RequestError>> {
|
||||
Box::pin(async move {
|
||||
self.calls.lock().push("send_chat_action");
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
//! 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 std::sync::LazyLock;
|
||||
|
||||
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.
|
||||
pub(crate) const MAX_DECODE_BYTES: u64 = 512 * 1024 * 1024;
|
||||
/// Cap for *downloading* a photo in the send fallback, kept separate from the
|
||||
/// decode budget above: the whole body is buffered before it is processed, once
|
||||
/// per download slot in flight, while the decode budget is about a single
|
||||
/// buffer. Telegram's upload cap is 10 MiB, so a photo this large can only be
|
||||
/// sent after a downscale that its reduced variant serves just as well — over
|
||||
/// the cap the item degrades to the smaller URL
|
||||
/// (`FallbackError::MediaTooLarge`), it is never an error.
|
||||
pub(crate) const MAX_PHOTO_DOWNLOAD_BYTES: u64 = 32 * 1024 * 1024;
|
||||
|
||||
/// Size of one memory-budget unit. Small enough that ordinary photos do not
|
||||
/// queue behind each other, coarse enough that the semaphore is not a counter
|
||||
/// per megabyte.
|
||||
const MEMORY_UNIT_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
/// Process-wide memory budget for photo preparation, in [`MEMORY_UNIT_BYTES`]
|
||||
/// units: 512 MiB. `PREP_SLOTS` bounds how many items are prepared at once but
|
||||
/// not how much memory they hold — one photo's decode buffer can be up to
|
||||
/// [`MAX_DECODE_BYTES`] (512 MiB), and the guard that refuses a bigger one is
|
||||
/// per photo, so six concurrent photos could peak near 3 GiB on a host sized
|
||||
/// for a fraction of that. Each item charges what it actually holds (its
|
||||
/// downloaded bytes plus the decode buffer its header predicts), so a 10-image
|
||||
/// album of ordinary photos still runs several at a time while huge ones
|
||||
/// serialize.
|
||||
const MEMORY_UNITS: u32 = 8;
|
||||
|
||||
static MEMORY_BUDGET: LazyLock<std::sync::Arc<tokio::sync::Semaphore>> =
|
||||
LazyLock::new(|| std::sync::Arc::new(tokio::sync::Semaphore::new(MEMORY_UNITS as usize)));
|
||||
|
||||
/// The buffer `w`×`h` needs in `channels` output channels — the one number the
|
||||
/// per-photo guards and the reservation below both use, so they cannot drift.
|
||||
fn decode_bytes(w: u32, h: u32, channels: usize) -> u64 {
|
||||
(w as u64) * (h as u64) * channels as u64
|
||||
}
|
||||
|
||||
/// Units to charge for `bytes`, clamped to the whole budget: an item must never
|
||||
/// ask for more than exists, or it would wait for itself forever.
|
||||
fn memory_units(bytes: u64) -> u32 {
|
||||
bytes
|
||||
.div_ceil(MEMORY_UNIT_BYTES)
|
||||
.clamp(1, MEMORY_UNITS as u64) as u32
|
||||
}
|
||||
|
||||
/// Reserves `bytes` of the preparation budget until the returned permit drops.
|
||||
pub(crate) async fn reserve_memory(bytes: u64) -> tokio::sync::OwnedSemaphorePermit {
|
||||
reserve(std::sync::Arc::clone(&MEMORY_BUDGET), bytes).await
|
||||
}
|
||||
|
||||
/// [`reserve_memory`] against a caller-chosen budget; the tests pass their own
|
||||
/// so they do not fight over the process-wide one.
|
||||
async fn reserve(
|
||||
budget: std::sync::Arc<tokio::sync::Semaphore>,
|
||||
bytes: u64,
|
||||
) -> tokio::sync::OwnedSemaphorePermit {
|
||||
budget
|
||||
.acquire_many_owned(memory_units(bytes))
|
||||
.await
|
||||
.expect("memory budget semaphore closed")
|
||||
}
|
||||
|
||||
/// The decode buffer a downloaded photo will allocate, from its header alone —
|
||||
/// zero when it is already within Telegram's limits and is uploaded as-is, zero
|
||||
/// for a format [`prepare_photo`] does not decode. Mirrors the early return and
|
||||
/// the guard of the two branches below.
|
||||
pub(crate) fn decode_budget_bytes(bytes: &[u8]) -> u64 {
|
||||
if let Some((w, h, _depth, color)) = parse_png_header(bytes) {
|
||||
if within_limits(w, h, bytes) {
|
||||
return 0;
|
||||
}
|
||||
return decode_bytes(w, h, output_channels(color));
|
||||
}
|
||||
if let Some((w, h)) = jpeg_dims(bytes) {
|
||||
if within_limits(w, h, bytes) {
|
||||
return 0;
|
||||
}
|
||||
return decode_bytes(w, h, 3);
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// Whether a photo is uploaded untouched (Telegram's dimension sum, and the
|
||||
/// upload cap its bytes are compared against).
|
||||
fn within_limits(w: u32, h: u32, bytes: &[u8]) -> bool {
|
||||
w + h <= PHOTO_MAX_DIMENSION_SUM && bytes.len() as u64 <= MAX_UPLOAD_BYTES
|
||||
}
|
||||
|
||||
/// JPEG dimensions from the headers, without decoding any pixels.
|
||||
fn jpeg_dims(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let mut decoder = zune_jpeg::JpegDecoder::new(std::io::Cursor::new(bytes));
|
||||
decoder.decode_headers().ok()?;
|
||||
let info = decoder.info()?;
|
||||
Some((info.width as u32, info.height as u32))
|
||||
}
|
||||
/// JPEG output quality (1-100).
|
||||
const JPEG_QUALITY: u8 = 90;
|
||||
|
||||
/// What to upload for a downloaded photo.
|
||||
pub enum PhotoPrep {
|
||||
/// Upload this file (the original when within limits, else the processed
|
||||
/// copy).
|
||||
Upload(NamedTempFile),
|
||||
/// The photo cannot be brought within Telegram's limits — the caller
|
||||
/// falls back to the item's smaller URL.
|
||||
UseFallback,
|
||||
}
|
||||
|
||||
/// A decoded image buffer tagged with its channel layout.
|
||||
#[derive(Debug)]
|
||||
enum PixBuf {
|
||||
Gray(Vec<u8>),
|
||||
GrayAlpha(Vec<u8>),
|
||||
Rgb(Vec<u8>),
|
||||
}
|
||||
|
||||
impl PixBuf {
|
||||
fn pixel_type(&self) -> fir::PixelType {
|
||||
match self {
|
||||
PixBuf::Gray(_) => fir::PixelType::U8,
|
||||
PixBuf::GrayAlpha(_) => fir::PixelType::U8x2,
|
||||
PixBuf::Rgb(_) => fir::PixelType::U8x3,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_vec(self) -> Vec<u8> {
|
||||
match self {
|
||||
PixBuf::Gray(v) | PixBuf::GrayAlpha(v) | PixBuf::Rgb(v) => v,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Entry point: detects the format and processes the photo if needed.
|
||||
/// The caller hands in the already-downloaded bytes (they are in memory from
|
||||
/// the download anyway; re-reading the temp file would double the I/O).
|
||||
pub fn prepare_photo(file: NamedTempFile, bytes: &[u8]) -> Result<PhotoPrep, String> {
|
||||
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
prepare_png(file, bytes)
|
||||
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||
prepare_jpeg(file, bytes)
|
||||
} else {
|
||||
log::warn!("photo in unsupported format; falling back to smaller media");
|
||||
Ok(PhotoPrep::UseFallback)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the PNG IHDR (bytes 8..26: signature + length + "IHDR" + width +
|
||||
/// height + bit depth + color type).
|
||||
fn parse_png_header(bytes: &[u8]) -> Option<(u32, u32, png::BitDepth, png::ColorType)> {
|
||||
if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 26 {
|
||||
return None;
|
||||
}
|
||||
let w = u32::from_be_bytes(bytes.get(16..20)?.try_into().ok()?);
|
||||
let h = u32::from_be_bytes(bytes.get(20..24)?.try_into().ok()?);
|
||||
let depth = match *bytes.get(24)? {
|
||||
1 => png::BitDepth::One,
|
||||
2 => png::BitDepth::Two,
|
||||
4 => png::BitDepth::Four,
|
||||
8 => png::BitDepth::Eight,
|
||||
16 => png::BitDepth::Sixteen,
|
||||
_ => return None,
|
||||
};
|
||||
let color = match *bytes.get(25)? {
|
||||
0 => png::ColorType::Grayscale,
|
||||
2 => png::ColorType::Rgb,
|
||||
3 => png::ColorType::Indexed,
|
||||
4 => png::ColorType::GrayscaleAlpha,
|
||||
6 => png::ColorType::Rgba,
|
||||
_ => return None,
|
||||
};
|
||||
Some((w, h, depth, color))
|
||||
}
|
||||
|
||||
/// Output channels of a decoded frame for the given color type (post
|
||||
/// STRIP_16; palette expands to RGB).
|
||||
fn output_channels(color: png::ColorType) -> usize {
|
||||
match color {
|
||||
png::ColorType::Grayscale => 1,
|
||||
png::ColorType::GrayscaleAlpha => 2,
|
||||
png::ColorType::Rgb | png::ColorType::Indexed => 3,
|
||||
png::ColorType::Rgba => 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// The 32→24 rule: RGBA (32-bit) becomes RGB with alpha composited onto
|
||||
/// white; 16-bit per channel was already stripped to 8-bit at decode.
|
||||
fn flatten_rgba_to_rgb(rgba: &[u8]) -> Vec<u8> {
|
||||
let mut rgb = Vec::with_capacity(rgba.len() / 4 * 3);
|
||||
for px in rgba.as_chunks::<4>().0 {
|
||||
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
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.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()
|
||||
.prefix(x_media::TEMP_FILE_PREFIX)
|
||||
.suffix(&format!(".{ext}"))
|
||||
.tempfile()
|
||||
.map_err(|e| format!("temp file failed: {e}"))?;
|
||||
file.as_file_mut()
|
||||
.write_all(bytes)
|
||||
.map_err(|e| format!("temp file write failed: {e}"))?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn target_dims(w: u32, h: u32) -> (u32, u32) {
|
||||
let scale = PHOTO_TARGET_DIMENSION_SUM as f64 / (w + h) as f64;
|
||||
(
|
||||
((w as f64 * scale).round() as u32).max(1),
|
||||
((h as f64 * scale).round() as u32).max(1),
|
||||
)
|
||||
}
|
||||
|
||||
/// PNG branch: decode (16→8, palette→RGB; gray/GA stay), flatten RGBA to
|
||||
/// RGB, Lanczos-downscale beyond the dimension cap, encode PNG — a PNG still
|
||||
/// over the upload cap afterwards becomes JPEG.
|
||||
fn prepare_png(file: NamedTempFile, bytes: &[u8]) -> Result<PhotoPrep, String> {
|
||||
let (w, h, _bit_depth, color_type) = parse_png_header(bytes).ok_or("invalid PNG header")?;
|
||||
let size_over = bytes.len() as u64 > MAX_UPLOAD_BYTES;
|
||||
if w + h <= PHOTO_MAX_DIMENSION_SUM && !size_over {
|
||||
return Ok(PhotoPrep::Upload(file));
|
||||
}
|
||||
log::debug!(
|
||||
"photo {w}x{h} ({_bit_depth:?} {color_type:?}, {} bytes) needs processing",
|
||||
bytes.len()
|
||||
);
|
||||
|
||||
let channels = output_channels(color_type);
|
||||
if decode_bytes(w, h, channels) > MAX_DECODE_BYTES {
|
||||
log::warn!("photo decode buffer exceeds the memory budget; falling back to smaller media");
|
||||
return Ok(PhotoPrep::UseFallback);
|
||||
}
|
||||
|
||||
// STRIP_16 drops 16-bit to 8-bit (the depth-reduction step); palette
|
||||
// expands to RGB (resampling requires it). Gray and gray-alpha are kept.
|
||||
let transforms = match color_type {
|
||||
png::ColorType::Indexed => png::Transformations::EXPAND,
|
||||
_ => png::Transformations::STRIP_16,
|
||||
};
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
|
||||
decoder.set_transformations(transforms);
|
||||
let mut reader = decoder
|
||||
.read_info()
|
||||
.map_err(|e| format!("png decode: {e}"))?;
|
||||
let out_w = reader.info().width;
|
||||
let out_h = reader.info().height;
|
||||
let mut buf = vec![
|
||||
0u8;
|
||||
reader
|
||||
.output_buffer_size()
|
||||
.ok_or("png output buffer size")?
|
||||
];
|
||||
reader
|
||||
.next_frame(&mut buf)
|
||||
.map_err(|e| format!("png frame: {e}"))?;
|
||||
|
||||
let mut pix = match color_type {
|
||||
png::ColorType::Rgba => PixBuf::Rgb(flatten_rgba_to_rgb(&buf)),
|
||||
png::ColorType::Grayscale => PixBuf::Gray(buf),
|
||||
png::ColorType::GrayscaleAlpha => PixBuf::GrayAlpha(buf),
|
||||
png::ColorType::Rgb | png::ColorType::Indexed => PixBuf::Rgb(buf),
|
||||
};
|
||||
|
||||
let (mut w, mut h) = (out_w, out_h);
|
||||
if w + h > PHOTO_MAX_DIMENSION_SUM {
|
||||
let (nw, nh) = target_dims(w, h);
|
||||
pix = resize_pix(pix, w, h, nw, nh)?;
|
||||
(w, h) = (nw, nh);
|
||||
log::debug!("downscaled photo to {w}x{h} (Lanczos3)");
|
||||
}
|
||||
|
||||
let mut png_bytes = Vec::new();
|
||||
encode_png(&mut png_bytes, &pix, w, h).map_err(|e| format!("png encode: {e}"))?;
|
||||
if png_bytes.len() as u64 <= MAX_UPLOAD_BYTES {
|
||||
return Ok(PhotoPrep::Upload(write_temp(&png_bytes, "png")?));
|
||||
}
|
||||
log::debug!("PNG still over the upload cap after processing; transcoding to JPEG");
|
||||
let jpeg_bytes = encode_jpeg(&pix, w, h)?;
|
||||
if jpeg_bytes.len() as u64 <= MAX_UPLOAD_BYTES {
|
||||
return Ok(PhotoPrep::Upload(write_temp(&jpeg_bytes, "jpg")?));
|
||||
}
|
||||
log::warn!("processed photo still exceeds the upload cap; falling back to smaller media");
|
||||
Ok(PhotoPrep::UseFallback)
|
||||
}
|
||||
|
||||
/// JPEG branch: zune-jpeg decode → Lanczos downscale → jpeg-encoder output.
|
||||
fn prepare_jpeg(file: NamedTempFile, bytes: &[u8]) -> Result<PhotoPrep, String> {
|
||||
let mut decoder = zune_jpeg::JpegDecoder::new(std::io::Cursor::new(bytes));
|
||||
// Decodes to RGB by default. Headers first so dimensions are known before
|
||||
// the (potentially huge) pixel decode.
|
||||
decoder
|
||||
.decode_headers()
|
||||
.map_err(|e| format!("jpeg headers: {e}"))?;
|
||||
let info = decoder.info().ok_or("jpeg info unavailable")?;
|
||||
let (w, h) = (info.width as u32, info.height as u32);
|
||||
let size_over = bytes.len() as u64 > MAX_UPLOAD_BYTES;
|
||||
if w + h <= PHOTO_MAX_DIMENSION_SUM && !size_over {
|
||||
return Ok(PhotoPrep::Upload(file));
|
||||
}
|
||||
if decode_bytes(w, h, 3) > MAX_DECODE_BYTES {
|
||||
log::warn!("photo decode buffer exceeds the memory budget; falling back to smaller media");
|
||||
return Ok(PhotoPrep::UseFallback);
|
||||
}
|
||||
let pixels = decoder.decode().map_err(|e| format!("jpeg decode: {e}"))?;
|
||||
let mut pix = PixBuf::Rgb(pixels);
|
||||
let (mut w, mut h) = (w, h);
|
||||
if w + h > PHOTO_MAX_DIMENSION_SUM {
|
||||
let (nw, nh) = target_dims(w, h);
|
||||
pix = resize_pix(pix, w, h, nw, nh)?;
|
||||
(w, h) = (nw, nh);
|
||||
log::debug!("downscaled jpeg to {w}x{h} (Lanczos3)");
|
||||
}
|
||||
let jpeg_bytes = encode_jpeg(&pix, w, h)?;
|
||||
if jpeg_bytes.len() as u64 <= MAX_UPLOAD_BYTES {
|
||||
return Ok(PhotoPrep::Upload(write_temp(&jpeg_bytes, "jpg")?));
|
||||
}
|
||||
log::warn!("processed photo still exceeds the upload cap; falling back to smaller media");
|
||||
Ok(PhotoPrep::UseFallback)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// The budget is a *process-wide* memory bound: `PREP_SLOTS` (6) caps how
|
||||
/// many photos are prepared at once, but six max-size photos would still
|
||||
/// hold six decode buffers of up to 512 MiB each.
|
||||
#[tokio::test]
|
||||
async fn huge_decodes_cannot_overlap_but_do_run_alone() {
|
||||
let budget = std::sync::Arc::new(tokio::sync::Semaphore::new(MEMORY_UNITS as usize));
|
||||
let max_photo = MAX_DECODE_BYTES + MAX_PHOTO_DOWNLOAD_BYTES;
|
||||
|
||||
// One max-size photo fits (clamped to the whole budget), so it can
|
||||
// never wait for budget that cannot exist.
|
||||
let first = tokio::time::timeout(
|
||||
Duration::from_millis(50),
|
||||
reserve(budget.clone(), max_photo),
|
||||
)
|
||||
.await
|
||||
.expect("a max-size photo must not wait");
|
||||
// A second one of the same size has to wait for the first to finish.
|
||||
assert!(
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(50),
|
||||
reserve(budget.clone(), max_photo)
|
||||
)
|
||||
.await
|
||||
.is_err(),
|
||||
"two max-size decodes overlapped"
|
||||
);
|
||||
drop(first);
|
||||
assert!(
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(50),
|
||||
reserve(budget.clone(), max_photo)
|
||||
)
|
||||
.await
|
||||
.is_ok(),
|
||||
"the budget was not released"
|
||||
);
|
||||
}
|
||||
|
||||
/// A 10-image album of ordinary photos must not serialize: they charge
|
||||
/// their real (small) buffers, not a fixed heavyweight slot.
|
||||
#[tokio::test]
|
||||
async fn ordinary_photos_share_the_budget() {
|
||||
let budget = std::sync::Arc::new(tokio::sync::Semaphore::new(MEMORY_UNITS as usize));
|
||||
// A 4 MiB photo that decodes to ~36 MiB (4000x3000 RGB).
|
||||
let ordinary = 4 * 1024 * 1024 + 36 * 1024 * 1024;
|
||||
let mut held = Vec::new();
|
||||
for i in 0..MEMORY_UNITS {
|
||||
held.push(
|
||||
tokio::time::timeout(Duration::from_millis(50), reserve(budget.clone(), ordinary))
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("ordinary photo {i} waited for budget")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_units_round_up_and_clamp() {
|
||||
assert_eq!(memory_units(1), 1);
|
||||
assert_eq!(memory_units(MEMORY_UNIT_BYTES), 1);
|
||||
assert_eq!(memory_units(MEMORY_UNIT_BYTES + 1), 2);
|
||||
// Never more than exists, or the item waits for itself forever.
|
||||
assert_eq!(memory_units(u64::MAX), MEMORY_UNITS);
|
||||
// One item's worst case (a max download plus a max decode) takes the
|
||||
// whole budget by itself.
|
||||
assert_eq!(
|
||||
memory_units(MAX_DECODE_BYTES + MAX_PHOTO_DOWNLOAD_BYTES),
|
||||
MEMORY_UNITS
|
||||
);
|
||||
}
|
||||
|
||||
/// What the reservation is charged is decided by the header, and it has to
|
||||
/// agree with what the pipeline does: a photo uploaded as-is costs nothing,
|
||||
/// one that gets processed costs its decoded buffer.
|
||||
#[test]
|
||||
fn decode_budget_follows_the_processing_decision() {
|
||||
// 9999x2 (sum 10001) is over the dimension cap → processed → charged.
|
||||
let oversized = png_header(9999, 2, 8, 2); // 8-bit RGB
|
||||
assert_eq!(decode_budget_bytes(&oversized), 9999 * 2 * 3);
|
||||
// Inside the limits (dimensions *and* bytes) → uploaded as-is.
|
||||
let small = png_header(100, 100, 8, 2);
|
||||
assert_eq!(decode_budget_bytes(&small), 0);
|
||||
// A format the pipeline does not decode costs nothing either.
|
||||
assert_eq!(decode_budget_bytes(b"GIF89a not a photo"), 0);
|
||||
|
||||
// JPEG: 9999x2 is over the cap, so its RGB decode buffer is charged.
|
||||
let (w, h) = (9999u16, 2u16);
|
||||
let rgb = vec![90u8; w as usize * h as usize * 3];
|
||||
let mut bytes = Vec::new();
|
||||
jpeg_encoder::Encoder::new(&mut bytes, 90)
|
||||
.encode(&rgb, w, h, jpeg_encoder::ColorType::Rgb)
|
||||
.unwrap();
|
||||
assert_eq!(decode_budget_bytes(&bytes), 9999 * 2 * 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_png_header() {
|
||||
let bytes = png_header(8979, 5316, 16, 6); // 16-bit RGBA
|
||||
let (w, h, depth, color) = parse_png_header(&bytes).unwrap();
|
||||
assert_eq!((w, h), (8979, 5316));
|
||||
assert_eq!(depth, png::BitDepth::Sixteen);
|
||||
assert_eq!(color, png::ColorType::Rgba);
|
||||
|
||||
let (_, _, depth, color) = parse_png_header(&png_header(10, 10, 8, 0)).unwrap();
|
||||
assert_eq!(depth, png::BitDepth::Eight);
|
||||
assert_eq!(color, png::ColorType::Grayscale);
|
||||
|
||||
assert!(parse_png_header(b"not a png").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatten_rgba_to_rgb_composites_over_white() {
|
||||
// opaque red stays red
|
||||
assert_eq!(flatten_rgba_to_rgb(&[255, 0, 0, 255]), vec![255, 0, 0]);
|
||||
// fully transparent → white
|
||||
assert_eq!(flatten_rgba_to_rgb(&[0, 0, 0, 0]), vec![255, 255, 255]);
|
||||
// half alpha red → (255+255)/2 = 255, (0*128 + 255*127)/255 = 127
|
||||
let out = flatten_rgba_to_rgb(&[255, 0, 0, 128]);
|
||||
assert_eq!(out[0], 255);
|
||||
assert_eq!(out[1], 127);
|
||||
assert_eq!(out[2], 127);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_dims_stay_under_the_cap() {
|
||||
for (w, h) in [(12000u32, 7000u32), (10000, 10000), (8979, 5316)] {
|
||||
let (nw, nh) = target_dims(w, h);
|
||||
assert!(nw + nh <= PHOTO_MAX_DIMENSION_SUM, "{w}x{h} -> {nw}x{nh}");
|
||||
assert!(nw >= 1 && nh >= 1);
|
||||
}
|
||||
// already within limits: no change expected from the caller, but the
|
||||
// helper must not produce zero dimensions.
|
||||
let (nw, nh) = target_dims(500, 400);
|
||||
assert!(nw >= 1 && nh >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_pix_changes_dimensions() {
|
||||
// 300x200 RGB → 100x66
|
||||
let buf: Vec<u8> = (0..300 * 200 * 3).map(|i| (i % 251) as u8).collect();
|
||||
let resized = resize_pix(PixBuf::Rgb(buf), 300, 200, 100, 66).unwrap();
|
||||
match resized {
|
||||
PixBuf::Rgb(v) => assert_eq!(v.len(), 100 * 66 * 3),
|
||||
other => panic!("expected rgb, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_encode_roundtrip_keeps_gray() {
|
||||
let gray = vec![128u8; 4 * 4];
|
||||
let mut out = Vec::new();
|
||||
encode_png(&mut out, &PixBuf::Gray(gray), 4, 4).unwrap();
|
||||
assert!(!out.is_empty());
|
||||
let (_, _, depth, color) = parse_png_header(&out).unwrap();
|
||||
assert_eq!(depth, png::BitDepth::Eight);
|
||||
assert_eq!(color, png::ColorType::Grayscale);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_encode_produces_bytes() {
|
||||
let rgb = vec![128u8; 8 * 8 * 3];
|
||||
let out = encode_jpeg(&PixBuf::Rgb(rgb), 8, 8).unwrap();
|
||||
assert!(out.len() > 100);
|
||||
assert!(out.starts_with(&[0xFF, 0xD8]));
|
||||
}
|
||||
|
||||
/// Writes a small dimension-oversized PNG (9999x2 → sum 10001) to a temp
|
||||
/// file and runs the full pipeline.
|
||||
fn run_pipeline(w: u32, h: u32, color: png::ColorType, fill: u8) -> Result<PhotoPrep, String> {
|
||||
let (channels, data): (usize, Vec<u8>) = match color {
|
||||
png::ColorType::Grayscale => (1, vec![fill; (w * h) as usize]),
|
||||
png::ColorType::Rgb => (3, vec![fill; (w * h * 3) as usize]),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut bytes, w, h);
|
||||
encoder.set_color(color);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer.write_image_data(&data).unwrap();
|
||||
}
|
||||
assert_eq!(data.len(), channels * (w * h) as usize);
|
||||
|
||||
let mut file = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
|
||||
std::io::Write::write_all(file.as_file_mut(), &bytes).unwrap();
|
||||
prepare_photo(file, &bytes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_downscales_oversized_png_keeping_format() {
|
||||
let prep = run_pipeline(9999, 2, png::ColorType::Rgb, 128).unwrap();
|
||||
match prep {
|
||||
PhotoPrep::Upload(file) => {
|
||||
let out = std::fs::read(file.path()).unwrap();
|
||||
let (w, h, depth, color) = parse_png_header(&out).unwrap();
|
||||
assert!(w + h <= PHOTO_MAX_DIMENSION_SUM, "{w}x{h}");
|
||||
assert_eq!(depth, png::BitDepth::Eight);
|
||||
assert_eq!(color, png::ColorType::Rgb);
|
||||
}
|
||||
PhotoPrep::UseFallback => panic!("over-dimension PNG should have been resized"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_keeps_gray_png_gray() {
|
||||
let prep = run_pipeline(9999, 2, png::ColorType::Grayscale, 200).unwrap();
|
||||
match prep {
|
||||
PhotoPrep::Upload(file) => {
|
||||
let out = std::fs::read(file.path()).unwrap();
|
||||
let (_, _, _, color) = parse_png_header(&out).unwrap();
|
||||
assert_eq!(color, png::ColorType::Grayscale, "gray must not upconvert");
|
||||
}
|
||||
PhotoPrep::UseFallback => panic!("over-dimension gray PNG should have been resized"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_resizes_oversized_jpeg() {
|
||||
// Build a small over-dimension JPEG with jpeg-encoder: 9999x2 sums to
|
||||
// one over the cap. The output's own headers are what must show the
|
||||
// resize — a copy-through is a perfectly valid JPEG, so magic bytes
|
||||
// and a non-empty buffer used to pass for nothing.
|
||||
let (w, h) = (9999u16, 2u16);
|
||||
let rgb = vec![90u8; (w as usize) * (h as usize) * 3];
|
||||
let mut bytes = Vec::new();
|
||||
{
|
||||
let encoder = jpeg_encoder::Encoder::new(&mut bytes, 90);
|
||||
encoder
|
||||
.encode(&rgb, w, h, jpeg_encoder::ColorType::Rgb)
|
||||
.unwrap();
|
||||
}
|
||||
let mut file = tempfile::Builder::new().suffix(".jpg").tempfile().unwrap();
|
||||
std::io::Write::write_all(file.as_file_mut(), &bytes).unwrap();
|
||||
match prepare_photo(file, &bytes).unwrap() {
|
||||
PhotoPrep::Upload(file) => {
|
||||
let out = std::fs::read(file.path()).unwrap();
|
||||
assert!(out.starts_with(&[0xFF, 0xD8]), "output must stay jpeg");
|
||||
let mut decoder = zune_jpeg::JpegDecoder::new(std::io::Cursor::new(out.as_slice()));
|
||||
decoder.decode_headers().unwrap();
|
||||
let info = decoder.info().unwrap();
|
||||
let (nw, nh) = (info.width as u32, info.height as u32);
|
||||
assert!(
|
||||
nw + nh <= PHOTO_MAX_DIMENSION_SUM,
|
||||
"still over the cap: {nw}x{nh}"
|
||||
);
|
||||
assert_ne!((nw, nh), (w as u32, h as u32), "output was not resized");
|
||||
}
|
||||
PhotoPrep::UseFallback => panic!("over-dimension JPEG should have been resized"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "heavy: generates a >10 MiB PNG (run explicitly)"]
|
||||
fn pipeline_transcodes_oversized_png_to_jpeg() {
|
||||
// 6000x4000 (sum 10000 — under the dimension cap) smooth gradient with
|
||||
// small per-pixel noise: PNG-incompressible (delta filters defeated)
|
||||
// but JPEG-friendly (DCT smooths the small noise). Verified with
|
||||
// ffmpeg: 8000x6000 amp-5 variant is a 59 MB PNG / 3.3 MB JPEG.
|
||||
let (w, h) = (6000u32, 4000u32);
|
||||
let mut rng = 0x1234_5678_9abc_def0u64;
|
||||
let mut data = Vec::with_capacity((w * h * 3) as usize);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let base = (x + y) * 255 / (w + h);
|
||||
rng = rng
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
let n = ((rng >> 33) % 11) as i32 - 5; // noise in [-5, 5]
|
||||
let v = (base as i32 + n).clamp(0, 255) as u8;
|
||||
data.extend_from_slice(&[v, v, v]);
|
||||
}
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut bytes, w, h);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer.write_image_data(&data).unwrap();
|
||||
}
|
||||
assert!(
|
||||
bytes.len() as u64 > MAX_UPLOAD_BYTES,
|
||||
"test needs a >10MiB PNG, got {}",
|
||||
bytes.len()
|
||||
);
|
||||
|
||||
let mut file = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
|
||||
std::io::Write::write_all(file.as_file_mut(), &bytes).unwrap();
|
||||
match prepare_photo(file, &bytes).unwrap() {
|
||||
PhotoPrep::Upload(file) => {
|
||||
let out = std::fs::read(file.path()).unwrap();
|
||||
assert!(out.starts_with(&[0xFF, 0xD8]), "must transcode to JPEG");
|
||||
assert!(out.len() as u64 <= MAX_UPLOAD_BYTES);
|
||||
}
|
||||
PhotoPrep::UseFallback => panic!("PNG over the byte cap must transcode to JPEG"),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,226 @@
|
||||
//! Per-chat token-bucket rate limiting.
|
||||
//!
|
||||
//! Telegram throttles bots on two budgets: one per chat (roughly 20
|
||||
//! messages/min for channels/groups) and a bot-wide one (~30 messages per
|
||||
//! second). Both are smoothed here *before* the burst reaches the API — the
|
||||
//! per-chat bucket charges one token per message, and [`acquire_global`]
|
||||
//! charges the same spend against the bot-wide budget, which no per-chat
|
||||
//! bucket can see (a forward fanned out over many chats spends one token in
|
||||
//! each and nothing anywhere). The queue retry stays as the safety net for
|
||||
//! whatever neither bucket models.
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Burst capacity: how many messages may be sent at once without waiting.
|
||||
const CAPACITY: f64 = 20.0;
|
||||
/// Sustained refill: ~20 messages per minute.
|
||||
const REFILL_PER_SEC: f64 = 20.0 / 60.0;
|
||||
|
||||
/// The bot-wide budget: Telegram allows roughly 30 messages per second for a
|
||||
/// bot in total, independently of the per-chat limits. Set to the documented
|
||||
/// ceiling, so it only ever binds on a cross-chat burst.
|
||||
const GLOBAL_CAPACITY: f64 = 30.0;
|
||||
const GLOBAL_REFILL_PER_SEC: f64 = 30.0;
|
||||
|
||||
struct State {
|
||||
/// Current token balance; may go negative (debt from an acquire larger
|
||||
/// than the capacity, repaid by subsequent refills).
|
||||
tokens: f64,
|
||||
last_refill: tokio::time::Instant,
|
||||
}
|
||||
|
||||
/// A token bucket: at most `CAPACITY` tokens accumulate, refilled at
|
||||
/// `REFILL_PER_SEC`. [`TokenBucket::acquire`] consumes `n` tokens, waiting
|
||||
/// for the deficit (a single acquire may exceed the capacity and goes into
|
||||
/// debt, which the refill repays).
|
||||
pub struct TokenBucket {
|
||||
capacity: f64,
|
||||
refill_per_sec: f64,
|
||||
state: Mutex<State>,
|
||||
}
|
||||
|
||||
impl TokenBucket {
|
||||
fn new(capacity: f64, refill_per_sec: f64) -> Self {
|
||||
TokenBucket {
|
||||
capacity,
|
||||
refill_per_sec,
|
||||
state: Mutex::new(State {
|
||||
tokens: capacity,
|
||||
last_refill: tokio::time::Instant::now(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the elapsed refill to `state`. Shared by [`Self::acquire`] and
|
||||
/// the idle check so the two cannot drift apart.
|
||||
fn refill(&self, state: &mut State) {
|
||||
let now = tokio::time::Instant::now();
|
||||
let elapsed = now
|
||||
.saturating_duration_since(state.last_refill)
|
||||
.as_secs_f64();
|
||||
// Refill up to the capacity; a debt (negative balance) is repaid
|
||||
// before any surplus accumulates.
|
||||
state.tokens = (state.tokens + elapsed * self.refill_per_sec).min(self.capacity);
|
||||
state.last_refill = now;
|
||||
}
|
||||
|
||||
/// Waits until `n` tokens are available, consuming them. The wait is
|
||||
/// bounded: the deficit is committed as debt and repaid over time, so a
|
||||
/// large acquire returns once its share of the refill budget has passed.
|
||||
pub async fn acquire(&self, n: f64) {
|
||||
// The parking_lot guard is confined to this block: only the plain
|
||||
// `wait` duration crosses the await (a guard across an await point
|
||||
// would make the future !Send).
|
||||
let wait = {
|
||||
let mut state = self.state.lock();
|
||||
self.refill(&mut state);
|
||||
if state.tokens >= n {
|
||||
state.tokens -= n;
|
||||
return;
|
||||
}
|
||||
// Commit the whole consumption now; the caller proceeds once the
|
||||
// deficit's worth of refill time has passed.
|
||||
let debt = n - state.tokens;
|
||||
state.tokens = -debt;
|
||||
debt / self.refill_per_sec
|
||||
};
|
||||
tokio::time::sleep(Duration::from_secs_f64(wait)).await;
|
||||
}
|
||||
|
||||
/// Current balance, for the tests that assert a call site charged the
|
||||
/// bucket (a charge is otherwise only observable as a delay).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn tokens(&self) -> f64 {
|
||||
let mut state = self.state.lock();
|
||||
self.refill(&mut state);
|
||||
state.tokens
|
||||
}
|
||||
|
||||
/// True when the bucket has refilled to capacity: no debt outstanding, so
|
||||
/// the chat has not sent anything recently.
|
||||
fn is_idle(&self) -> bool {
|
||||
let mut state = self.state.lock();
|
||||
self.refill(&mut state);
|
||||
state.tokens >= self.capacity
|
||||
}
|
||||
}
|
||||
|
||||
/// One limiter per chat, created on first use. Per-chat so one chat's burst
|
||||
/// never throttles another.
|
||||
static LIMITERS: LazyLock<Mutex<HashMap<i64, Arc<TokenBucket>>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Returns the shared limiter for a chat, creating it on first use.
|
||||
pub fn limiter_for(chat_id: i64) -> Arc<TokenBucket> {
|
||||
LIMITERS
|
||||
.lock()
|
||||
.entry(chat_id)
|
||||
.or_insert_with(|| Arc::new(TokenBucket::new(CAPACITY, REFILL_PER_SEC)))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The one bucket every chat shares: Telegram's bot-wide budget.
|
||||
static GLOBAL_LIMITER: LazyLock<TokenBucket> =
|
||||
LazyLock::new(|| TokenBucket::new(GLOBAL_CAPACITY, GLOBAL_REFILL_PER_SEC));
|
||||
|
||||
/// Waits for `n` messages' worth of the bot-wide budget. Called by the send
|
||||
/// paths next to their per-chat [`limiter_for`]: at ~30/s it does not bind on
|
||||
/// a single chat, but a batch fanned out over many chats has no other guard.
|
||||
pub async fn acquire_global(n: f64) {
|
||||
GLOBAL_LIMITER.acquire(n).await;
|
||||
}
|
||||
|
||||
/// Drops limiters that are idle (refilled to capacity, so the chat has not
|
||||
/// sent recently) and are not still held by an in-flight sender. The map
|
||||
/// would otherwise keep one bucket per chat that ever sent media, forever.
|
||||
/// Called from the periodic sweep; returns how many were dropped.
|
||||
pub fn prune_idle() -> usize {
|
||||
let mut limiters = LIMITERS.lock();
|
||||
let before = limiters.len();
|
||||
// Lock order map → bucket, the only order taken anywhere.
|
||||
limiters.retain(|_, bucket| Arc::strong_count(bucket) > 1 || !bucket.is_idle());
|
||||
before - limiters.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn limiter_for_reuses_the_per_chat_bucket() {
|
||||
let a = limiter_for(1);
|
||||
let b = limiter_for(1);
|
||||
let c = limiter_for(2);
|
||||
assert!(Arc::ptr_eq(&a, &b), "same chat → same bucket");
|
||||
assert!(!Arc::ptr_eq(&a, &c), "different chat → different bucket");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn burst_is_consumed_instantly_then_refill_waits() {
|
||||
let bucket = TokenBucket::new(3.0, 1.0);
|
||||
// A burst within capacity passes without waiting.
|
||||
bucket.acquire(3.0).await;
|
||||
// The bucket is empty now; one token needs 1s of refill.
|
||||
let start = tokio::time::Instant::now();
|
||||
bucket.acquire(1.0).await;
|
||||
assert!(
|
||||
start.elapsed() >= Duration::from_secs(1),
|
||||
"elapsed {:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn acquire_larger_than_capacity_waits_for_the_deficit() {
|
||||
let bucket = TokenBucket::new(2.0, 1.0);
|
||||
// 5 tokens with a capacity of 2: the 3-token deficit takes 3s.
|
||||
let start = tokio::time::Instant::now();
|
||||
bucket.acquire(5.0).await;
|
||||
assert!(
|
||||
start.elapsed() >= Duration::from_secs(3),
|
||||
"elapsed {:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn the_global_budget_is_paced_and_shared() {
|
||||
// Drain the process-wide budget (no other test touches it: the send
|
||||
// paths that use it are mocked), then prove the next message waits for
|
||||
// the refill instead of going out instantly.
|
||||
acquire_global(GLOBAL_CAPACITY).await;
|
||||
let start = tokio::time::Instant::now();
|
||||
acquire_global(1.0).await;
|
||||
assert!(
|
||||
start.elapsed() >= Duration::from_secs_f64(1.0 / GLOBAL_REFILL_PER_SEC),
|
||||
"a fanned-out burst must be paced: elapsed {:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn prune_idle_drops_full_unheld_buckets_only() {
|
||||
// Held by this task: kept even at full capacity, a sender has it.
|
||||
let held = limiter_for(9_001);
|
||||
assert!(held.is_idle(), "a fresh bucket is full");
|
||||
// Only the map holds this one and it is full → dropped.
|
||||
limiter_for(9_002);
|
||||
// Mid-debt (an acquire larger than the capacity): kept.
|
||||
{
|
||||
let bucket = Arc::new(TokenBucket::new(CAPACITY, REFILL_PER_SEC));
|
||||
bucket.state.lock().tokens = -1.0;
|
||||
LIMITERS.lock().insert(9_003, bucket);
|
||||
}
|
||||
|
||||
assert!(prune_idle() >= 1);
|
||||
|
||||
let limiters = LIMITERS.lock();
|
||||
assert!(limiters.contains_key(&9_001), "held bucket pruned");
|
||||
assert!(!limiters.contains_key(&9_002), "idle unheld bucket kept");
|
||||
assert!(limiters.contains_key(&9_003), "indebted bucket pruned");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Payload → Telegram input types: `InputFile` selection (cached file id /
|
||||
//! URL / local path), the per-kind `InputMedia` builders and the media-group
|
||||
//! assembly with its caption rule.
|
||||
|
||||
use super::MediaItemPayload;
|
||||
use teloxide::types::{
|
||||
InputFile, InputMedia, InputMediaAnimation, InputMediaPhoto, InputMediaVideo, ParseMode,
|
||||
};
|
||||
|
||||
fn parse_media_url(s: &str) -> Result<url::Url, String> {
|
||||
url::Url::parse(s).map_err(|e| format!("invalid media URL: {e}"))
|
||||
}
|
||||
|
||||
pub(super) fn item_url(item: &MediaItemPayload) -> &str {
|
||||
match item {
|
||||
MediaItemPayload::Photo { media, .. }
|
||||
| MediaItemPayload::Video { media, .. }
|
||||
| MediaItemPayload::Animation { media, .. } => media,
|
||||
}
|
||||
}
|
||||
|
||||
/// Remote http(s) URLs are handed to Telegram to fetch; everything else
|
||||
/// (e.g. a locally encoded ugoira MP4) is uploaded directly.
|
||||
pub(super) fn input_file_for(media: &str) -> Result<InputFile, String> {
|
||||
if media.starts_with("http://") || media.starts_with("https://") {
|
||||
Ok(InputFile::url(parse_media_url(media)?))
|
||||
} else if !std::path::Path::new(media).exists() {
|
||||
// A retried task may reference a temp file the original send's
|
||||
// TempDir already cleaned up; fail fast and permanent instead of
|
||||
// burning retries on a file that can never come back.
|
||||
Err(format!("local media file missing: {media}"))
|
||||
} else {
|
||||
Ok(InputFile::file(media))
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaItemPayload {
|
||||
/// The input for a send: a cached file id goes out as `InputFile::file_id`
|
||||
/// (no fetch, no upload), URLs go to Telegram, anything else is a local
|
||||
/// path (transient upload fallback).
|
||||
fn input_file(&self) -> Result<InputFile, String> {
|
||||
match self {
|
||||
MediaItemPayload::Photo {
|
||||
media,
|
||||
file_id: true,
|
||||
..
|
||||
}
|
||||
| MediaItemPayload::Video {
|
||||
media,
|
||||
file_id: true,
|
||||
..
|
||||
}
|
||||
| MediaItemPayload::Animation {
|
||||
media,
|
||||
file_id: true,
|
||||
..
|
||||
} => Ok(InputFile::file_id(media.clone().into())),
|
||||
_ => input_file_for(item_url(self)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn photo_media(file: InputFile, caption: Option<&str>, spoiler: bool) -> InputMedia {
|
||||
let mut photo = InputMediaPhoto::new(file).parse_mode(ParseMode::Html);
|
||||
if let Some(caption) = caption {
|
||||
photo = photo.caption(caption);
|
||||
}
|
||||
if spoiler {
|
||||
photo = photo.spoiler();
|
||||
}
|
||||
InputMedia::Photo(photo)
|
||||
}
|
||||
|
||||
pub(super) fn video_media(file: InputFile, caption: Option<&str>, spoiler: bool) -> InputMedia {
|
||||
let mut video = InputMediaVideo::new(file).parse_mode(ParseMode::Html);
|
||||
if let Some(caption) = caption {
|
||||
video = video.caption(caption);
|
||||
}
|
||||
if spoiler {
|
||||
video = video.spoiler();
|
||||
}
|
||||
InputMedia::Video(video)
|
||||
}
|
||||
|
||||
pub(super) fn animation_media(file: InputFile, caption: Option<&str>, spoiler: bool) -> InputMedia {
|
||||
let mut animation = InputMediaAnimation::new(file).parse_mode(ParseMode::Html);
|
||||
if let Some(caption) = caption {
|
||||
animation = animation.caption(caption);
|
||||
}
|
||||
if spoiler {
|
||||
animation = animation.spoiler();
|
||||
}
|
||||
InputMedia::Animation(animation)
|
||||
}
|
||||
|
||||
/// Builds a media group from payloads; only the first item of the batch gets
|
||||
/// the caption (Telegram rejects captions on later items).
|
||||
pub(super) fn build_media_group(
|
||||
batch: &[MediaItemPayload],
|
||||
caption: Option<&str>,
|
||||
) -> Result<Vec<InputMedia>, String> {
|
||||
batch
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| {
|
||||
let item_caption = if i == 0 { caption } else { None };
|
||||
Ok(match item {
|
||||
MediaItemPayload::Photo { has_spoiler, .. } => {
|
||||
photo_media(item.input_file()?, item_caption, *has_spoiler)
|
||||
}
|
||||
MediaItemPayload::Video {
|
||||
has_spoiler,
|
||||
thumbnail,
|
||||
..
|
||||
} => {
|
||||
let mut video = video_media(item.input_file()?, item_caption, *has_spoiler);
|
||||
if let (Some(thumb), InputMedia::Video(v)) = (thumbnail, &mut video) {
|
||||
*v = v.clone().thumbnail(input_file_for(thumb)?);
|
||||
}
|
||||
video
|
||||
}
|
||||
MediaItemPayload::Animation { has_spoiler, .. } => {
|
||||
animation_media(item.input_file()?, item_caption, *has_spoiler)
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,545 @@
|
||||
//! Everything around a send: the link-cache write that follows one, the
|
||||
//! keep-alive registry for locally produced media, task settlement, the
|
||||
//! post-send actions (edit prompt / channel forward) and the queue entry
|
||||
//! points.
|
||||
|
||||
use super::{SendError, Task, forward_messages, send_animation, send_media_sequence};
|
||||
use crate::ctx::AppContext;
|
||||
use crate::db::{now_f64, unix_now};
|
||||
use crate::handlers::log_key;
|
||||
use crate::link_cache::{CachedMedia, CachedMediaKind};
|
||||
use crate::media_sender::MediaSender;
|
||||
use crate::queue::{PersistentTaskQueue, QueueError};
|
||||
use crate::state::EditMessage;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use teloxide::types::{ChatId, InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageId};
|
||||
|
||||
/// Persists a successful send under the post's cache key. Skips a send that was
|
||||
/// served from the cache — its entry already holds the file ids the next repeat
|
||||
/// wants — *unless* the entry was degraded (no file ids left, see
|
||||
/// `invalidate_cache`): then the ids this send just produced are written back,
|
||||
/// which is what returns a degraded entry to the fast path instead of leaving
|
||||
/// it to re-upload the media on every repeat.
|
||||
pub(super) async fn cache_sent_task(ctx: &AppContext<'_>, task: &Task, media: Vec<CachedMedia>) {
|
||||
let Some(cache_data) = task.cache_data() else {
|
||||
return;
|
||||
};
|
||||
if cache_data.media.iter().any(|m| !m.file_id.is_empty()) || media.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut post = cache_data.clone();
|
||||
post.media = media;
|
||||
if let Some(key) = x_media::site::cache_key(&post.url) {
|
||||
ctx.link_cache.put(&key, &post).await;
|
||||
log::debug!("cached send for [key={}]", log_key(&post.url));
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists a lone animation send under the post's cache key.
|
||||
pub(super) async fn cache_animation_send(
|
||||
ctx: &AppContext<'_>,
|
||||
task: &Task,
|
||||
message: &Message,
|
||||
source_url: &str,
|
||||
) {
|
||||
if let Some(file_id) = message.animation().map(|a| a.file.id.to_string()) {
|
||||
cache_sent_task(
|
||||
ctx,
|
||||
task,
|
||||
vec![CachedMedia {
|
||||
kind: CachedMediaKind::Animation,
|
||||
file_id,
|
||||
url: source_url.to_string(),
|
||||
}],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// How a task ended. The two states differ only in whether a link-cache entry
|
||||
/// may still be holding the (now unusable) media.
|
||||
pub(crate) enum Settled {
|
||||
Sent,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Every path that ends a task's life — sent, permanently failed, or
|
||||
/// dead-lettered after the last retry — funnels through here, so the cleanup a
|
||||
/// settled task owes cannot be forgotten by a new path: release the keep-alive
|
||||
/// temp media (retryable tasks keep it, they will be resent) and deal with the
|
||||
/// link-cache entry a failed send's stale file ids would keep poisoning
|
||||
/// (degraded to its source URLs, dropped once those fail too).
|
||||
pub(crate) async fn settle_task(ctx: &AppContext<'_>, task: &Task, outcome: Settled) {
|
||||
if matches!(outcome, Settled::Failed) {
|
||||
invalidate_cache(ctx, task).await;
|
||||
}
|
||||
release_keep_alive(task);
|
||||
}
|
||||
|
||||
/// A cached Telegram file id failed permanently (stale/expired). The media
|
||||
/// itself is usually fine, so the entry is *degraded* rather than dropped: its
|
||||
/// file ids go away and the source URLs stay, and the next request re-sends the
|
||||
/// post from those — no source request, no ugoira encode, no HLS remux — with
|
||||
/// the media fetched by Telegram (or by the upload fallback). An entry that is
|
||||
/// already degraded, or whose older rows carry no URLs, is removed instead: its
|
||||
/// URLs did not work either, and the next request should fetch the post again
|
||||
/// and report what the source says.
|
||||
async fn invalidate_cache(ctx: &AppContext<'_>, task: &Task) {
|
||||
if !task.is_cached_send() {
|
||||
return;
|
||||
}
|
||||
let Some(url) = task.source_url() else {
|
||||
return;
|
||||
};
|
||||
let Some(key) = x_media::site::cache_key(url) else {
|
||||
return;
|
||||
};
|
||||
let Some(mut entry) = ctx.link_cache.get(&key, ctx.config.link_cache_ttl).await else {
|
||||
return;
|
||||
};
|
||||
let degradable = entry.media.iter().all(|m| !m.url.is_empty())
|
||||
&& entry.media.iter().any(|m| !m.file_id.is_empty());
|
||||
if !degradable {
|
||||
log::debug!("removing stale link cache entry for [key={}]", log_key(url));
|
||||
ctx.link_cache.remove(&key).await;
|
||||
return;
|
||||
}
|
||||
log::debug!(
|
||||
"degrading stale link cache entry to its source URLs for [key={}]",
|
||||
log_key(url)
|
||||
);
|
||||
for media in &mut entry.media {
|
||||
media.file_id.clear();
|
||||
}
|
||||
ctx.link_cache.put(&key, &entry).await;
|
||||
}
|
||||
|
||||
/// Locally produced media files (ugoira MP4, bsky remux MP4) whose temp dirs
|
||||
/// must stay alive while their task may be retried by the queue. The fetch
|
||||
/// pipeline hands a reference here via [`x_media::site::Fetched::keep_alive`]
|
||||
/// before that [`x_media::site::Fetched`] is dropped; a queued retry runs after
|
||||
/// that drop, so without this the local file would be gone by the time the
|
||||
/// retry sends it. `Arc` because one fetch can serve several tasks (a
|
||||
/// concurrent duplicate of the same link shares it): each holder keeps the
|
||||
/// directory alive until its own task settles. Entries are removed when the
|
||||
/// task settles (see [`release_keep_alive`]).
|
||||
pub(crate) static KEEP_ALIVE: LazyLock<parking_lot::Mutex<Vec<std::sync::Arc<tempfile::TempDir>>>> =
|
||||
LazyLock::new(|| parking_lot::Mutex::new(Vec::new()));
|
||||
|
||||
/// Drops the keep-alive temp dirs holding media referenced by `task` (matched
|
||||
/// by path prefix). Called once a task settles — sent or permanently failed —
|
||||
/// so retry-only temp files do not leak; retryable tasks keep them alive.
|
||||
pub(crate) fn release_keep_alive(task: &Task) {
|
||||
let paths = task.local_media_paths();
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut alive = KEEP_ALIVE.lock();
|
||||
alive.retain(|dir| {
|
||||
let dir_path = dir.path();
|
||||
!paths.iter().any(|p| p.starts_with(dir_path))
|
||||
});
|
||||
}
|
||||
|
||||
/// The edit-before-forward prompt's text. It names both controls and the TTL,
|
||||
/// because the buttons alone left users waiting for a forward that never came
|
||||
/// (nothing is forwarded until Confirm).
|
||||
pub(super) fn edit_prompt_text(ttl: std::time::Duration) -> String {
|
||||
format!(
|
||||
"Reply to edit the caption, or tap a template, then ↩️ Confirm to forward. \
|
||||
Expires in {}. Nothing is forwarded until you confirm.",
|
||||
coarsest_unit(ttl)
|
||||
)
|
||||
}
|
||||
|
||||
/// Text the prompt is rewritten to once its record expires. The sweep edits
|
||||
/// the prompt in place (see `main`): announcing the expiry with a new message
|
||||
/// would wake the chat up to a full TTL later about a prompt nobody is
|
||||
/// waiting on.
|
||||
pub(crate) const EDIT_PROMPT_EXPIRED_TEXT: &str = "⌛ Expired — nothing was forwarded.";
|
||||
|
||||
/// `24h` / `90m` / `45s`: the coarsest whole unit, so the prompt stays short.
|
||||
fn coarsest_unit(ttl: std::time::Duration) -> String {
|
||||
let secs = ttl.as_secs();
|
||||
if secs >= 3600 {
|
||||
format!("{}h", secs / 3600)
|
||||
} else if secs >= 60 {
|
||||
format!("{}m", secs / 60)
|
||||
} else {
|
||||
format!("{secs}s")
|
||||
}
|
||||
}
|
||||
|
||||
/// Templates per keyboard row. Telegram rejects a keyboard with more than 100
|
||||
/// buttons *outright*, which would silently drop the whole prompt, so the
|
||||
/// names are folded and capped rather than listed one per row.
|
||||
pub(super) const TEMPLATE_BUTTONS_PER_ROW: usize = 3;
|
||||
/// Hard cap on template buttons; the prompt text names the ones not shown.
|
||||
pub(super) const MAX_TEMPLATE_BUTTONS: usize = 60;
|
||||
|
||||
/// Template buttons ([`TEMPLATE_BUTTONS_PER_ROW`] per row, at most
|
||||
/// [`MAX_TEMPLATE_BUTTONS`]), then the confirm/skip pair. Sorted by name: the
|
||||
/// templates live in a `HashMap`, so an unsorted walk would reshuffle the
|
||||
/// buttons between prompts.
|
||||
pub(super) fn build_edit_markup(templates: &HashMap<String, String>) -> InlineKeyboardMarkup {
|
||||
let mut names: Vec<&String> = templates.keys().collect();
|
||||
names.sort();
|
||||
let shown = names.len().min(MAX_TEMPLATE_BUTTONS);
|
||||
let mut rows = Vec::with_capacity(shown / TEMPLATE_BUTTONS_PER_ROW + 2);
|
||||
for chunk in names[..shown].chunks(TEMPLATE_BUTTONS_PER_ROW) {
|
||||
rows.push(
|
||||
chunk
|
||||
.iter()
|
||||
.map(|name| {
|
||||
InlineKeyboardButton::callback(name.as_str(), format!("template|{name}"))
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
// Skip exists because the prompt holds the forward hostage until Confirm:
|
||||
// without it the only escape was deleting the message and waiting out the
|
||||
// TTL for a forward that then never happens.
|
||||
rows.push(vec![
|
||||
InlineKeyboardButton::callback("↩️ Confirm", "forward"),
|
||||
InlineKeyboardButton::callback("🛑 Skip", "skip"),
|
||||
]);
|
||||
InlineKeyboardMarkup::new(rows)
|
||||
}
|
||||
|
||||
/// How many templates the markup could not fit, for the prompt text.
|
||||
pub(super) fn hidden_template_count(templates: &HashMap<String, String>) -> usize {
|
||||
templates.len().saturating_sub(MAX_TEMPLATE_BUTTONS)
|
||||
}
|
||||
|
||||
/// Notifies a chat about a dead-lettered task (skips when `notify_chat_id` is
|
||||
/// absent).
|
||||
pub(crate) async fn notify_failure(
|
||||
sender: &dyn MediaSender,
|
||||
chat_id: Option<i64>,
|
||||
message_id: Option<i64>,
|
||||
message: &str,
|
||||
) {
|
||||
let Some(chat_id) = chat_id else { return };
|
||||
let reply_to = message_id.map(|id| MessageId(id as i32));
|
||||
if let Err(e) = sender
|
||||
.send_message(ChatId(chat_id), message.to_string(), reply_to, None)
|
||||
.await
|
||||
{
|
||||
log::error!("failed to notify about failed task: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// After a successful send: either open the edit-before-forward prompt or
|
||||
/// forward to the configured channel (with retry/queue handling).
|
||||
pub(crate) async fn post_send_actions(ctx: &AppContext<'_>, task: &Task, message_ids: Vec<i64>) {
|
||||
let (
|
||||
chat_id,
|
||||
reply_to,
|
||||
source_url,
|
||||
edit_before_forward,
|
||||
forward_channel_id,
|
||||
notify_chat_id,
|
||||
notify_message_id,
|
||||
) = match task {
|
||||
Task::SendMediaSequence {
|
||||
chat_id,
|
||||
reply_to_message_id,
|
||||
source_url,
|
||||
edit_before_forward,
|
||||
forward_channel_id,
|
||||
notify_chat_id,
|
||||
notify_message_id,
|
||||
..
|
||||
}
|
||||
| Task::SendAnimation {
|
||||
chat_id,
|
||||
reply_to_message_id,
|
||||
source_url,
|
||||
edit_before_forward,
|
||||
forward_channel_id,
|
||||
notify_chat_id,
|
||||
notify_message_id,
|
||||
..
|
||||
} => (
|
||||
*chat_id,
|
||||
*reply_to_message_id,
|
||||
source_url.clone(),
|
||||
*edit_before_forward,
|
||||
*forward_channel_id,
|
||||
*notify_chat_id,
|
||||
*notify_message_id,
|
||||
),
|
||||
Task::ForwardMessages { .. } => return,
|
||||
};
|
||||
|
||||
if edit_before_forward {
|
||||
let templates = ctx.chat_store.get(chat_id).await.template;
|
||||
let keyboard = build_edit_markup(&templates);
|
||||
let mut text = edit_prompt_text(ctx.config.edit_message_ttl);
|
||||
let hidden = hidden_template_count(&templates);
|
||||
if hidden > 0 {
|
||||
// The keyboard is capped; say so instead of silently hiding them.
|
||||
text.push_str(&format!(
|
||||
"\n({hidden} more templates not shown — /remove_template to prune.)"
|
||||
));
|
||||
}
|
||||
let prompt = ctx
|
||||
.sender
|
||||
.send_message(
|
||||
ChatId(chat_id),
|
||||
text,
|
||||
Some(MessageId(reply_to as i32)),
|
||||
Some(keyboard),
|
||||
)
|
||||
.await;
|
||||
match prompt {
|
||||
Ok(prompt_id) => {
|
||||
log::info!(
|
||||
"edit-before-forward prompt {prompt_id} opened for {} message(s) [key={}] chat={chat_id}",
|
||||
message_ids.len(),
|
||||
log_key(&source_url)
|
||||
);
|
||||
let source_url = source_url.clone();
|
||||
ctx.chat_store
|
||||
.update(chat_id, move |data| {
|
||||
data.edit_message.insert(
|
||||
prompt_id,
|
||||
EditMessage {
|
||||
url: source_url,
|
||||
chat_id,
|
||||
forward_message_ids: message_ids,
|
||||
template: String::new(),
|
||||
created_at: unix_now(),
|
||||
},
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("failed to send edit prompt: {e}");
|
||||
// Nothing is forwarded until the prompt is confirmed, so a
|
||||
// prompt that never arrived means this post is never forwarded.
|
||||
// Tell the chat instead of letting it wait for a prompt that
|
||||
// will not come.
|
||||
notify_failure(
|
||||
ctx.sender,
|
||||
notify_chat_id,
|
||||
notify_message_id,
|
||||
"Could not open the edit-before-forward prompt — nothing was forwarded.",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(channel_id) = forward_channel_id {
|
||||
log::info!(
|
||||
"forwarding {} message(s) to channel {channel_id} from chat {chat_id} [key={}]",
|
||||
message_ids.len(),
|
||||
log_key(&source_url)
|
||||
);
|
||||
let forward_task = Task::ForwardMessages {
|
||||
from_chat_id: chat_id,
|
||||
to_chat_id: channel_id,
|
||||
message_ids,
|
||||
notify_chat_id,
|
||||
notify_message_id,
|
||||
};
|
||||
match forward_messages(ctx, &forward_task).await {
|
||||
Ok(()) => {}
|
||||
Err(SendError::Retryable {
|
||||
delay_seconds,
|
||||
task,
|
||||
}) => {
|
||||
// The forward is already committed from the user's side; if it
|
||||
// cannot be queued, say so rather than going quiet.
|
||||
if !enqueue_retry(ctx.task_queue, &task, delay_seconds).await {
|
||||
notify_failure(
|
||||
ctx.sender,
|
||||
notify_chat_id,
|
||||
notify_message_id,
|
||||
&failure_text(task.source_url(), "retry could not be queued"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(SendError::Permanent { message, .. }) => {
|
||||
notify_failure(
|
||||
ctx.sender,
|
||||
notify_chat_id,
|
||||
notify_message_id,
|
||||
&failure_text(None, &message),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enqueues a task for a later attempt (retry / forward resume). Returns
|
||||
/// whether the retry is actually persisted: when the enqueue itself fails the
|
||||
/// task can never run again, so its keep-alive temp media is released instead
|
||||
/// of leaking until process exit — and the caller must not tell the user a
|
||||
/// retry is coming (nothing would ever deliver it).
|
||||
pub(crate) async fn enqueue_retry(
|
||||
queue: &PersistentTaskQueue,
|
||||
task: &Task,
|
||||
delay_seconds: f64,
|
||||
) -> bool {
|
||||
let payload = serde_json::to_value(task).expect("task serializes");
|
||||
let run_after = now_f64() + delay_seconds;
|
||||
if let Err(e) = queue.enqueue(payload, run_after).await {
|
||||
log::error!("failed to enqueue retry: {e}");
|
||||
release_keep_alive(task);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Queue entry point: parses the stored task and dispatches.
|
||||
pub(crate) async fn handle_task(
|
||||
ctx: &AppContext<'_>,
|
||||
payload: serde_json::Value,
|
||||
) -> Result<(), QueueError> {
|
||||
let task: Task = match serde_json::from_value(payload.clone()) {
|
||||
Ok(task) => task,
|
||||
Err(e) => {
|
||||
return Err(QueueError::Permanent {
|
||||
message: format!("invalid task payload: {e}"),
|
||||
payload,
|
||||
});
|
||||
}
|
||||
};
|
||||
match task {
|
||||
Task::SendMediaSequence { .. } | Task::SendAnimation { .. } => {
|
||||
let message_ids = match send_media_or_animation(ctx, &task).await {
|
||||
Ok(ids) => ids,
|
||||
Err(SendError::Retryable {
|
||||
delay_seconds,
|
||||
task,
|
||||
}) => {
|
||||
return Err(QueueError::Retryable {
|
||||
delay_seconds,
|
||||
payload: serde_json::to_value(task).expect("task serializes"),
|
||||
});
|
||||
}
|
||||
Err(SendError::Permanent { message, task }) => {
|
||||
settle_task(ctx, &task, Settled::Failed).await;
|
||||
return Err(QueueError::Permanent {
|
||||
message,
|
||||
payload: serde_json::to_value(task).expect("task serializes"),
|
||||
});
|
||||
}
|
||||
};
|
||||
// A task only reaches the queue after a failed send, so this
|
||||
// successful run is the first time post_send_actions can fire —
|
||||
// the fresh attempt failed before it ever got here. Run it
|
||||
// unconditionally: `post_send_actions` executes once, after the
|
||||
// whole sequence (every batch) completed, so the channel forward
|
||||
// and the edit-before-forward prompt must not be lost just
|
||||
// because the send needed a retry.
|
||||
post_send_actions(ctx, &task, message_ids).await;
|
||||
settle_task(ctx, &task, Settled::Sent).await;
|
||||
Ok(())
|
||||
}
|
||||
Task::ForwardMessages { .. } => match forward_messages(ctx, &task).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(SendError::Retryable {
|
||||
delay_seconds,
|
||||
task,
|
||||
}) => Err(QueueError::Retryable {
|
||||
delay_seconds,
|
||||
payload: serde_json::to_value(task).expect("task serializes"),
|
||||
}),
|
||||
Err(SendError::Permanent { message, task }) => {
|
||||
settle_task(ctx, &task, Settled::Failed).await;
|
||||
Err(QueueError::Permanent {
|
||||
message,
|
||||
payload: serde_json::to_value(task).expect("task serializes"),
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_media_or_animation(ctx: &AppContext<'_>, task: &Task) -> Result<Vec<i64>, SendError> {
|
||||
match task {
|
||||
Task::SendMediaSequence { .. } => send_media_sequence(ctx, task).await,
|
||||
Task::SendAnimation { .. } => send_animation(ctx, task).await,
|
||||
Task::ForwardMessages { .. } => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing text for a task that will never run again: which link died and
|
||||
/// why. The raw error alone left the user guessing which post it was about.
|
||||
pub(super) fn failure_text(source_url: Option<&str>, message: &str) -> String {
|
||||
match source_url.map(log_key) {
|
||||
Some(key) => format!("Send failed permanently for {key}: {message}"),
|
||||
// `ForwardMessages` carries no source URL (and neither does an
|
||||
// unparsable payload): that failure is about the channel copy, not
|
||||
// about a post.
|
||||
None => format!("Forward failed permanently: {message}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The post a stored payload is about, without parsing it into a [`Task`]:
|
||||
/// used when the payload no longer deserializes (written by an older version,
|
||||
/// or corrupted) but its identity fields are still readable.
|
||||
fn payload_source_url(payload: &serde_json::Value) -> Option<&str> {
|
||||
payload.get("source_url").and_then(|v| v.as_str())
|
||||
}
|
||||
|
||||
/// Whether a stored payload was a *cached* send (see `Task::is_cached_send`),
|
||||
/// read straight off the JSON — the unparsable case still has to know whether
|
||||
/// a link-cache entry may be holding the media that failed.
|
||||
fn payload_is_cached_send(payload: &serde_json::Value) -> bool {
|
||||
payload
|
||||
.get("cache_data")
|
||||
.and_then(|data| data.get("media"))
|
||||
.and_then(|media| media.as_array())
|
||||
.is_some_and(|media| !media.is_empty())
|
||||
}
|
||||
|
||||
/// Dead-letter callback wired to the queue in main: settles the task and
|
||||
/// notifies its chat.
|
||||
pub(crate) async fn dead_letter_notify(
|
||||
ctx: &AppContext<'_>,
|
||||
payload: serde_json::Value,
|
||||
message: String,
|
||||
) {
|
||||
// A dead-lettered task never runs again, and the queue dead-letters retry
|
||||
// exhaustion itself (the handler is not called again), so this is the only
|
||||
// place that sees the final payload.
|
||||
let task = serde_json::from_value::<Task>(payload.clone()).ok();
|
||||
if let Some(task) = &task {
|
||||
settle_task(ctx, task, Settled::Failed).await;
|
||||
} else {
|
||||
// A payload that no longer parses (an older version's row shape, a
|
||||
// corrupted one) still says which post it was about: drop the stale
|
||||
// cache entry the same way, instead of leaving a bad file id to be
|
||||
// re-sent forever — and name the post in the notification rather than
|
||||
// reporting a *forward* failure for a send task.
|
||||
if payload_is_cached_send(&payload)
|
||||
&& let Some(key) = payload_source_url(&payload).and_then(x_media::site::cache_key)
|
||||
{
|
||||
log::debug!("removing stale link cache entry for [key={key}]");
|
||||
ctx.link_cache.remove(&key).await;
|
||||
}
|
||||
}
|
||||
let notify_chat_id = payload.get("notify_chat_id").and_then(|v| v.as_i64());
|
||||
let notify_message_id = payload.get("notify_message_id").and_then(|v| v.as_i64());
|
||||
notify_failure(
|
||||
ctx.sender,
|
||||
notify_chat_id,
|
||||
notify_message_id,
|
||||
&failure_text(
|
||||
task.as_ref()
|
||||
.and_then(|task| task.source_url())
|
||||
.or_else(|| payload_source_url(&payload)),
|
||||
&message,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
//! Download-and-reupload fallback: when Telegram cannot fetch a media URL
|
||||
//! itself (hotlink protection), the bot downloads the file, shrinks photos
|
||||
//! that exceed Telegram's limits and uploads the batch via multipart.
|
||||
|
||||
use super::input_media::{animation_media, input_file_for, item_url, photo_media, video_media};
|
||||
use super::{MediaItemPayload, SendError, Task, classify_to_send_error, retry_delay_seconds};
|
||||
use crate::media_sender::MediaSender;
|
||||
use crate::photo::{self, MAX_UPLOAD_BYTES, PhotoPrep};
|
||||
use std::sync::LazyLock;
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::types::{ChatId, InputFile, InputMedia, MessageId};
|
||||
use tempfile::NamedTempFile;
|
||||
use x_media::site::FetchError;
|
||||
|
||||
/// How many fallback items may be downloaded and processed at once, across the
|
||||
/// whole process. A per-batch bound is not a memory bound: `URL_WORKERS` (8)
|
||||
/// and the queue's workers (4) can each be inside a batch, so a per-batch three
|
||||
/// allowed two dozen downloads in flight, each buffering a whole photo
|
||||
/// (up to [`photo::MAX_PHOTO_DOWNLOAD_BYTES`]) before it is processed. This is
|
||||
/// the only admission control on the media path; the send itself is paced by
|
||||
/// the rate limiter.
|
||||
const PREP_CONCURRENCY: usize = 6;
|
||||
static PREP_SLOTS: LazyLock<tokio::sync::Semaphore> =
|
||||
LazyLock::new(|| tokio::sync::Semaphore::new(PREP_CONCURRENCY));
|
||||
|
||||
/// Infers a file extension from magic bytes so Telegram detects the mime type
|
||||
/// on multipart uploads.
|
||||
pub(super) fn sniff_ext(bytes: &[u8]) -> &'static str {
|
||||
if bytes.starts_with(&[0xFF, 0xD8]) {
|
||||
"jpg"
|
||||
} else if bytes.starts_with(b"\x89PNG") {
|
||||
"png"
|
||||
} else if bytes.starts_with(b"RIFF") && bytes.len() >= 12 && &bytes[8..12] == b"WEBP" {
|
||||
"webp"
|
||||
} else if bytes.starts_with(b"GIF8") {
|
||||
"gif"
|
||||
} else if bytes.len() >= 12 && &bytes[4..8] == b"ftyp" {
|
||||
"mp4"
|
||||
} else {
|
||||
"bin"
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum FallbackError {
|
||||
Retryable {
|
||||
delay_seconds: f64,
|
||||
},
|
||||
Permanent {
|
||||
message: String,
|
||||
},
|
||||
/// The downloaded file exceeds the upload cap; the caller falls back to
|
||||
/// the item's smaller URL.
|
||||
MediaTooLarge,
|
||||
}
|
||||
|
||||
/// Brings a downloaded photo within Telegram's limits via the pure-Rust
|
||||
/// chain in [`crate::photo`] (no ffmpeg): dimension cap / upload cap
|
||||
/// exceeded photos are decoded, downscaled with Lanczos3, PNG bit depth
|
||||
/// reduced (>24-bit → 24-bit RGB, ≤24-bit untouched) and transcoded to JPEG
|
||||
/// only if still too big. Anything that cannot be fixed falls back to the
|
||||
/// item's smaller URL.
|
||||
///
|
||||
/// Downloads one media item to a temp file (deleted on drop), returning the
|
||||
/// file plus the downloaded bytes (photos keep the bytes for
|
||||
/// [`photo::prepare_photo`] — re-reading the file would double the I/O).
|
||||
/// Network errors are retryable; size over the upload cap and other download
|
||||
/// errors are not.
|
||||
async fn download_to_temp(
|
||||
item: &MediaItemPayload,
|
||||
) -> Result<(NamedTempFile, bytes::Bytes), FallbackError> {
|
||||
let media_url = match item {
|
||||
MediaItemPayload::Photo { media, .. }
|
||||
| MediaItemPayload::Video { media, .. }
|
||||
| MediaItemPayload::Animation { media, .. } => media,
|
||||
};
|
||||
// Photos are downloaded even over the upload cap so `prepare_photo` can
|
||||
// downscale / transcode them, up to their own download cap; videos and
|
||||
// animations are refused as soon as the declared size crosses the upload
|
||||
// cap. The limit is that cap, not `cap + 1`: a file of exactly the cap is
|
||||
// admitted (`len > max_bytes` is false), and one byte over is not — the
|
||||
// same boundary the size probe this replaced drew.
|
||||
let limit = if matches!(item, MediaItemPayload::Photo { .. }) {
|
||||
photo::MAX_PHOTO_DOWNLOAD_BYTES
|
||||
} else {
|
||||
MAX_UPLOAD_BYTES
|
||||
};
|
||||
let bytes = match x_media::site::download_media_limited(media_url, limit).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => return Err(classify_download_error(e)),
|
||||
};
|
||||
let ext = sniff_ext(&bytes);
|
||||
let mut file = tempfile::Builder::new()
|
||||
.prefix(x_media::TEMP_FILE_PREFIX)
|
||||
.suffix(&format!(".{ext}"))
|
||||
.tempfile()
|
||||
.map_err(|e| FallbackError::Permanent {
|
||||
message: format!("temp file failed: {e}"),
|
||||
})?;
|
||||
use std::io::Write;
|
||||
// A write failure is resource exhaustion far more often than a broken temp
|
||||
// dir (ENOSPC / EDQUOT), and that clears on its own — worth an attempt
|
||||
// instead of dropping the post on the first try. Creating the file (above)
|
||||
// stays permanent: a temp dir that cannot be created at all is a
|
||||
// deployment fault that should fail loudly and immediately. `Retryable`
|
||||
// carries no message, so the cause is logged here.
|
||||
file.as_file_mut().write_all(&bytes).map_err(|e| {
|
||||
log::error!("temp file write failed: {e}");
|
||||
FallbackError::Retryable {
|
||||
delay_seconds: retry_delay_seconds(0),
|
||||
}
|
||||
})?;
|
||||
Ok((file, bytes))
|
||||
}
|
||||
|
||||
/// Which failure class a media download belongs to. Transport errors and
|
||||
/// server-side hiccups (429/5xx, see `download_media_limited`) are worth
|
||||
/// another attempt; a 4xx means the media itself is gone or refused, and a
|
||||
/// retry could only ask the same URL again.
|
||||
fn classify_download_error(err: FetchError) -> FallbackError {
|
||||
match err {
|
||||
FetchError::Http(_) | FetchError::Transient(_) => FallbackError::Retryable {
|
||||
delay_seconds: retry_delay_seconds(0),
|
||||
},
|
||||
FetchError::TooLarge => FallbackError::MediaTooLarge,
|
||||
e => FallbackError::Permanent {
|
||||
message: format!("download failed: {e}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the media group item from an uploaded file.
|
||||
fn media_from_file(
|
||||
item: &MediaItemPayload,
|
||||
path: std::path::PathBuf,
|
||||
caption: Option<&str>,
|
||||
thumbnail: Option<&str>,
|
||||
) -> Result<InputMedia, String> {
|
||||
let mut media = match item {
|
||||
MediaItemPayload::Photo { has_spoiler, .. } => {
|
||||
photo_media(InputFile::file(path), caption, *has_spoiler)
|
||||
}
|
||||
MediaItemPayload::Video { has_spoiler, .. } => {
|
||||
video_media(InputFile::file(path), caption, *has_spoiler)
|
||||
}
|
||||
MediaItemPayload::Animation { has_spoiler, .. } => {
|
||||
animation_media(InputFile::file(path), caption, *has_spoiler)
|
||||
}
|
||||
};
|
||||
if let (Some(thumb), InputMedia::Video(v)) = (thumbnail, &mut media) {
|
||||
*v = v.clone().thumbnail(input_file_for(thumb)?);
|
||||
}
|
||||
Ok(media)
|
||||
}
|
||||
|
||||
/// Builds the media group item from a (smaller) URL.
|
||||
fn media_from_url(
|
||||
item: &MediaItemPayload,
|
||||
url: &str,
|
||||
caption: Option<&str>,
|
||||
thumbnail: Option<&str>,
|
||||
) -> Result<InputMedia, String> {
|
||||
let mut media = match item {
|
||||
MediaItemPayload::Photo { has_spoiler, .. } => {
|
||||
photo_media(input_file_for(url)?, caption, *has_spoiler)
|
||||
}
|
||||
MediaItemPayload::Video { has_spoiler, .. } => {
|
||||
video_media(input_file_for(url)?, caption, *has_spoiler)
|
||||
}
|
||||
MediaItemPayload::Animation { has_spoiler, .. } => {
|
||||
animation_media(input_file_for(url)?, caption, *has_spoiler)
|
||||
}
|
||||
};
|
||||
if let (Some(thumb), InputMedia::Video(v)) = (thumbnail, &mut media) {
|
||||
*v = v.clone().thumbnail(input_file_for(thumb)?);
|
||||
}
|
||||
Ok(media)
|
||||
}
|
||||
|
||||
/// One item prepared for the upload fallback: the ready-to-send media plus
|
||||
/// the temp file that must stay on disk until the group request completes.
|
||||
pub(super) struct PreparedItem {
|
||||
/// Original position in the batch (concurrent prep completes out of order).
|
||||
pub(super) index: usize,
|
||||
pub(super) media: InputMedia,
|
||||
pub(super) keep_alive: Option<NamedTempFile>,
|
||||
}
|
||||
|
||||
/// Downloads / processes one media item for the upload fallback (see
|
||||
/// [`send_batch_via_upload`]). Local files are uploaded directly; oversized
|
||||
/// items fall back to their smaller URL; photos are downscaled/transcoded.
|
||||
pub(super) async fn prepare_upload_item(
|
||||
item: MediaItemPayload,
|
||||
index: usize,
|
||||
caption: Option<&str>,
|
||||
) -> Result<PreparedItem, FallbackError> {
|
||||
// Locally produced files (ugoira / bsky remux MP4): nothing to download
|
||||
// or shrink — upload the file directly. The send is a multipart upload,
|
||||
// so the only remaining failure is an upload-cap error, which is
|
||||
// permanent (a video cannot be re-encoded here).
|
||||
let media_url = item_url(&item);
|
||||
if !media_url.starts_with("http://") && !media_url.starts_with("https://") {
|
||||
let media = media_from_file(
|
||||
&item,
|
||||
std::path::PathBuf::from(media_url),
|
||||
caption,
|
||||
item.thumbnail_url(),
|
||||
)
|
||||
.map_err(|message| FallbackError::Permanent { message })?;
|
||||
return Ok(PreparedItem {
|
||||
index,
|
||||
media,
|
||||
keep_alive: None,
|
||||
});
|
||||
}
|
||||
// Whether a file is over the cap is settled by the download itself:
|
||||
// `download_media_limited` reads the declared Content-Length before any
|
||||
// body byte and aborts with `FetchError::TooLarge`, which arrives here as
|
||||
// `FallbackError::MediaTooLarge` — turned into the item's smaller URL by
|
||||
// the match below. A separate size probe used to issue a second GET of the
|
||||
// same URL for an answer this path already has (and issued it for photos,
|
||||
// whose answer was discarded one line later).
|
||||
match download_to_temp(&item).await {
|
||||
Ok((file, bytes)) => {
|
||||
if matches!(item, MediaItemPayload::Photo { .. }) {
|
||||
// Telegram rejects photos wider+taller than 10000 px combined
|
||||
// (PHOTO_INVALID_DIMENSIONS): downscale the downloaded file
|
||||
// before uploading; photos that cannot be brought within the
|
||||
// limits degrade to the smaller URL. CPU-heavy work runs off
|
||||
// the async executor thread.
|
||||
//
|
||||
// The header decides what that will cost in memory, so the
|
||||
// probe travels with the downloaded bytes (both stay alive
|
||||
// through the decode) and the reservation covers their sum:
|
||||
// `PREP_SLOTS` bounds how many photos are prepared at once,
|
||||
// this bounds what they hold between them — 512 MiB, whatever
|
||||
// the batch looks like.
|
||||
let (bytes, decode) = tokio::task::spawn_blocking(move || {
|
||||
let decode = photo::decode_budget_bytes(&bytes);
|
||||
(bytes, decode)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| FallbackError::Permanent {
|
||||
message: format!("photo worker panicked: {e}"),
|
||||
})?;
|
||||
let _budget = photo::reserve_memory(bytes.len() as u64 + decode).await;
|
||||
let prep = tokio::task::spawn_blocking(move || photo::prepare_photo(file, &bytes))
|
||||
.await
|
||||
.map_err(|e| FallbackError::Permanent {
|
||||
message: format!("photo worker panicked: {e}"),
|
||||
})?
|
||||
.map_err(|message| FallbackError::Permanent { message })?;
|
||||
match prep {
|
||||
PhotoPrep::Upload(upload) => {
|
||||
let path = upload.path().to_path_buf();
|
||||
let media = media_from_file(&item, path, caption, item.thumbnail_url())
|
||||
.map_err(|message| FallbackError::Permanent { message })?;
|
||||
Ok(PreparedItem {
|
||||
index,
|
||||
media,
|
||||
keep_alive: Some(upload),
|
||||
})
|
||||
}
|
||||
PhotoPrep::UseFallback => {
|
||||
let url = item.fallback_url().ok_or_else(|| FallbackError::Permanent {
|
||||
message: "photo dimensions exceed Telegram limits and no smaller variant is available"
|
||||
.into(),
|
||||
})?;
|
||||
let media = media_from_url(&item, url, caption, item.thumbnail_url())
|
||||
.map_err(|message| FallbackError::Permanent { message })?;
|
||||
Ok(PreparedItem {
|
||||
index,
|
||||
media,
|
||||
keep_alive: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let path = file.path().to_path_buf();
|
||||
let media = media_from_file(&item, path, caption, item.thumbnail_url())
|
||||
.map_err(|message| FallbackError::Permanent { message })?;
|
||||
Ok(PreparedItem {
|
||||
index,
|
||||
media,
|
||||
keep_alive: Some(file),
|
||||
})
|
||||
}
|
||||
}
|
||||
Err(FallbackError::MediaTooLarge) => {
|
||||
let url = item
|
||||
.fallback_url()
|
||||
.ok_or_else(|| FallbackError::Permanent {
|
||||
message: "media too large".into(),
|
||||
})?;
|
||||
let media = media_from_url(&item, url, caption, item.thumbnail_url())
|
||||
.map_err(|message| FallbackError::Permanent { message })?;
|
||||
Ok(PreparedItem {
|
||||
index,
|
||||
media,
|
||||
keep_alive: None,
|
||||
})
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Download-and-reupload fallback for one media batch. Files over the upload
|
||||
/// cap are not downloaded/uploaded; the item falls back to its smaller URL
|
||||
/// (which Telegram fetches itself). Items are prepared concurrently because the
|
||||
/// downloads are network-bound, under one process-wide bound ([`PREP_SLOTS`] —
|
||||
/// the URL and queue workers can each be inside a batch, so a per-batch bound
|
||||
/// would multiply); the batch is then uploaded in its original order. Returns
|
||||
/// the fallback-error without the task attached; callers wrap it with the
|
||||
/// updated task state.
|
||||
pub(super) async fn send_batch_via_upload(
|
||||
sender: &dyn MediaSender,
|
||||
chat_id: i64,
|
||||
reply_to: i64,
|
||||
batch: &[MediaItemPayload],
|
||||
caption: Option<&str>,
|
||||
task: Task,
|
||||
) -> Result<Vec<Message>, SendError> {
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for (i, item) in batch.iter().enumerate() {
|
||||
let item_caption = if i == 0 {
|
||||
caption.map(str::to_string)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let item = item.clone();
|
||||
set.spawn(async move {
|
||||
let _permit = PREP_SLOTS.acquire().await.expect("upload semaphore closed");
|
||||
prepare_upload_item(item, i, item_caption.as_deref()).await
|
||||
});
|
||||
}
|
||||
let mut prepared: Vec<Option<InputMedia>> = (0..batch.len()).map(|_| None).collect();
|
||||
let mut keep_alive: Vec<NamedTempFile> = Vec::new();
|
||||
while let Some(joined) = set.join_next().await {
|
||||
let item = match joined {
|
||||
Ok(Ok(item)) => item,
|
||||
// Dropping the JoinSet aborts the remaining prep tasks; their
|
||||
// temp files are cleaned up on drop (short-circuit like before).
|
||||
Ok(Err(e)) => return Err(SendError::from_fallback(e, task.clone())),
|
||||
Err(e) => {
|
||||
return Err(SendError::Permanent {
|
||||
message: format!("upload worker panicked: {e}"),
|
||||
task: Box::new(task),
|
||||
});
|
||||
}
|
||||
};
|
||||
let PreparedItem {
|
||||
index,
|
||||
media,
|
||||
keep_alive: file_opt,
|
||||
} = item;
|
||||
if let Some(file) = file_opt {
|
||||
keep_alive.push(file);
|
||||
}
|
||||
prepared[index] = Some(media);
|
||||
}
|
||||
let items: Vec<InputMedia> = prepared
|
||||
.into_iter()
|
||||
.map(|m| m.expect("every upload item was prepared"))
|
||||
.collect();
|
||||
// `keep_alive` holds the temp files until the group request completes.
|
||||
let result = sender
|
||||
.send_media_group(ChatId(chat_id), MessageId(reply_to as i32), items)
|
||||
.await;
|
||||
drop(keep_alive);
|
||||
match result {
|
||||
Ok(messages) => Ok(messages),
|
||||
Err(e) => Err(classify_to_send_error(&e, task, "upload failed")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod download_class_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn download_errors_split_by_whether_a_retry_can_help() {
|
||||
// Transport failure and a server-side hiccup: try again.
|
||||
assert!(matches!(
|
||||
classify_download_error(FetchError::Transient("media status 503".into())),
|
||||
FallbackError::Retryable { .. }
|
||||
));
|
||||
// The media is gone / the host refuses us: a retry repeats the 4xx.
|
||||
assert!(matches!(
|
||||
classify_download_error(FetchError::NotFound),
|
||||
FallbackError::Permanent { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
classify_download_error(FetchError::Blocked),
|
||||
FallbackError::Permanent { .. }
|
||||
));
|
||||
// Over the cap: degrade to the smaller URL, never retry.
|
||||
assert!(matches!(
|
||||
classify_download_error(FetchError::TooLarge),
|
||||
FallbackError::MediaTooLarge
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Per-chat state with SQLite persistence (table `chat_state` in
|
||||
//! `data/task_queue.db`, shared with the task queue).
|
||||
|
||||
use crate::db::unix_now;
|
||||
use parking_lot::Mutex;
|
||||
use rusqlite::params;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Clone, Debug)]
|
||||
pub struct ChatData {
|
||||
pub forward_channel_id: Option<i64>,
|
||||
pub edit_before_forward: bool,
|
||||
/// Key: prompt message id.
|
||||
pub edit_message: HashMap<i64, EditMessage>,
|
||||
/// name -> HTML template containing "[]"
|
||||
pub template: HashMap<String, String>,
|
||||
/// site name (twitter/bsky/misskey/pixiv/bilibili) -> user-supplied caption format
|
||||
/// with {url} {author} {author_url} {title} {content} {tags} placeholders.
|
||||
pub message_format: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct EditMessage {
|
||||
pub url: String,
|
||||
pub chat_id: i64,
|
||||
pub forward_message_ids: Vec<i64>,
|
||||
pub template: String,
|
||||
/// Unix seconds at registration; expiry = created_at + ttl.
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
pub struct ChatStore {
|
||||
/// In-memory cache; the DB is the source of truth on first access.
|
||||
cache: Mutex<HashMap<i64, ChatData>>,
|
||||
/// Per-chat async locks serializing get→mutate→set so concurrent handler
|
||||
/// tasks (batch-forwards, callbacks) cannot clobber each other's writes.
|
||||
locks: Mutex<HashMap<i64, Arc<tokio::sync::Mutex<()>>>>,
|
||||
pool: Arc<crate::db::DbPool>,
|
||||
}
|
||||
|
||||
impl ChatStore {
|
||||
/// Wraps the shared DB pool (schema initialized once by
|
||||
/// [`crate::db::open_store`]; the `chat_state` table lives in the merged
|
||||
/// schema alongside `tasks` and `link_cache`).
|
||||
pub fn new(pool: Arc<crate::db::DbPool>) -> Self {
|
||||
ChatStore {
|
||||
cache: Mutex::new(HashMap::new()),
|
||||
locks: Mutex::new(HashMap::new()),
|
||||
pool,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(&self, chat_id: i64) -> ChatData {
|
||||
if let Some(data) = self.cache.lock().get(&chat_id) {
|
||||
return data.clone();
|
||||
}
|
||||
let chat_key = chat_id.to_string();
|
||||
let payload = self
|
||||
.pool
|
||||
.with_conn(move |conn| {
|
||||
// Concurrent handler tasks (batch-forwards) may write chat_state
|
||||
// while this read runs; the shared busy timeout handles the
|
||||
// write-lock collision instead of failing the query.
|
||||
let mut stmt = conn.prepare("SELECT payload FROM chat_state WHERE chat_id = ?1")?;
|
||||
let mut rows = stmt.query(params![chat_key])?;
|
||||
match rows.next()? {
|
||||
Some(row) => Ok(Some(row.get::<_, String>(0)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("chat_state read failed: {e}");
|
||||
None
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let data: ChatData = serde_json::from_str(&payload).unwrap_or_default();
|
||||
self.cache.lock().insert(chat_id, data.clone());
|
||||
data
|
||||
}
|
||||
|
||||
/// Write-through: update the cache and the DB.
|
||||
pub async fn set(&self, chat_id: i64, data: &ChatData) {
|
||||
self.cache.lock().insert(chat_id, data.clone());
|
||||
let payload = serde_json::to_string(data).expect("chat state serializes");
|
||||
let chat_id = chat_id.to_string();
|
||||
let result = self
|
||||
.pool
|
||||
.with_conn(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO chat_state (chat_id, payload) VALUES (?1, ?2)",
|
||||
params![chat_id, payload],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
log::warn!("chat_state write failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-chat async lock serializing get→mutate→set cycles.
|
||||
fn lock_for(&self, chat_id: i64) -> Arc<tokio::sync::Mutex<()>> {
|
||||
self.locks
|
||||
.lock()
|
||||
.entry(chat_id)
|
||||
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// 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 = self.lock_for(chat_id);
|
||||
let _guard = lock.lock().await;
|
||||
let mut data = self.get(chat_id).await;
|
||||
let r = f(&mut data);
|
||||
self.set(chat_id, &data).await;
|
||||
r
|
||||
}
|
||||
|
||||
/// Removes edit-before-forward records whose `created_at + ttl` is in the
|
||||
/// past. Returns the removed `(chat_id, prompt_message_id)` pairs so the
|
||||
/// caller can clear the prompt's buttons.
|
||||
pub async fn prune_expired(&self, ttl: Duration) -> Vec<(i64, i64)> {
|
||||
let now = unix_now();
|
||||
let ttl_secs = ttl.as_secs() as i64;
|
||||
// Chats worth looking at, from a cache snapshot: the ones with an
|
||||
// expired record, plus the ones holding no record at all. The latter
|
||||
// used to be left alone for the process lifetime — every chat that ever
|
||||
// sent a message or ran a command stayed in the cache and in the
|
||||
// per-chat lock map — even though a chat with no live prompt is exactly
|
||||
// what the eviction below is for. The pruning itself re-reads and
|
||||
// writes under the per-chat lock below; taking no lock here means a
|
||||
// chat appearing later is simply picked up by the next sweep.
|
||||
let candidates: Vec<i64> = {
|
||||
let cache = self.cache.lock();
|
||||
cache
|
||||
.iter()
|
||||
.filter(|(_, data)| {
|
||||
data.edit_message.is_empty()
|
||||
|| data
|
||||
.edit_message
|
||||
.values()
|
||||
.any(|entry| entry.created_at + ttl_secs <= now)
|
||||
})
|
||||
.map(|(chat_id, _)| *chat_id)
|
||||
.collect()
|
||||
};
|
||||
let mut removed = Vec::new();
|
||||
let mut evicted_chats = Vec::new();
|
||||
for chat_id in candidates {
|
||||
let lock = self.lock_for(chat_id);
|
||||
let _guard = lock.lock().await;
|
||||
let mut data = self.get(chat_id).await;
|
||||
let before = data.edit_message.len();
|
||||
data.edit_message.retain(|key, entry| {
|
||||
if entry.created_at + ttl_secs > now {
|
||||
return true;
|
||||
}
|
||||
removed.push((chat_id, *key));
|
||||
false
|
||||
});
|
||||
if data.edit_message.len() != before {
|
||||
self.set(chat_id, &data).await;
|
||||
}
|
||||
// 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.
|
||||
if data.edit_message.is_empty() {
|
||||
evicted_chats.push(chat_id);
|
||||
}
|
||||
}
|
||||
if !evicted_chats.is_empty() {
|
||||
let mut cache = self.cache.lock();
|
||||
let mut locks = self.locks.lock();
|
||||
for chat_id in &evicted_chats {
|
||||
cache.remove(chat_id);
|
||||
locks.remove(chat_id);
|
||||
}
|
||||
}
|
||||
if !removed.is_empty() {
|
||||
log::info!(
|
||||
"pruned {} expired edit-before-forward record(s)",
|
||||
removed.len()
|
||||
);
|
||||
}
|
||||
removed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_updates_do_not_lose_edit_records() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pool = crate::db::open_store(dir.path().join("s.db").to_str().unwrap()).unwrap();
|
||||
let store = std::sync::Arc::new(ChatStore::new(pool));
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
fn edit_entry(chat_id: i64, created_at: i64) -> EditMessage {
|
||||
EditMessage {
|
||||
url: "https://x.com/u/status/1".into(),
|
||||
chat_id,
|
||||
forward_message_ids: vec![9],
|
||||
template: String::new(),
|
||||
created_at,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prune_removes_only_expired_records() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pool = crate::db::open_store(dir.path().join("p.db").to_str().unwrap()).unwrap();
|
||||
let store = ChatStore::new(pool);
|
||||
let now = unix_now();
|
||||
store
|
||||
.update(7, |data| {
|
||||
data.template.insert("t".into(), "[]".into());
|
||||
data.edit_message.insert(1, edit_entry(7, now - 3600));
|
||||
data.edit_message.insert(2, edit_entry(7, now));
|
||||
})
|
||||
.await;
|
||||
|
||||
let removed = store.prune_expired(Duration::from_secs(60)).await;
|
||||
|
||||
assert_eq!(removed, vec![(7, 1)]);
|
||||
let data = store.get(7).await;
|
||||
assert!(data.edit_message.contains_key(&2), "live record pruned");
|
||||
assert_eq!(
|
||||
data.template.get("t").map(String::as_str),
|
||||
Some("[]"),
|
||||
"unrelated state lost by the prune"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_idle_chat_is_evicted_and_its_state_reloads() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pool = crate::db::open_store(dir.path().join("e.db").to_str().unwrap()).unwrap();
|
||||
let store = ChatStore::new(pool);
|
||||
// Durable settings and no prompt at all: this chat used to sit in the
|
||||
// cache (and in the per-chat lock map) for the process lifetime,
|
||||
// because the sweep only ever looked at chats with an *expired* record.
|
||||
store
|
||||
.update(9, |data| {
|
||||
data.forward_channel_id = Some(-100);
|
||||
data.message_format.insert("twitter".into(), "{url}".into());
|
||||
})
|
||||
.await;
|
||||
assert!(store.cache.lock().contains_key(&9));
|
||||
|
||||
let removed = store.prune_expired(Duration::from_secs(60)).await;
|
||||
|
||||
assert!(removed.is_empty(), "nothing had expired");
|
||||
assert!(
|
||||
!store.cache.lock().contains_key(&9),
|
||||
"a chat with no live prompt must leave the cache"
|
||||
);
|
||||
assert!(!store.locks.lock().contains_key(&9), "…and its lock");
|
||||
// The DB kept the row, so the next use reloads everything it held.
|
||||
let data = store.get(9).await;
|
||||
assert_eq!(data.forward_channel_id, Some(-100));
|
||||
assert_eq!(
|
||||
data.message_format.get("twitter").map(String::as_str),
|
||||
Some("{url}")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_live_prompt_keeps_its_chat_cached() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pool = crate::db::open_store(dir.path().join("k.db").to_str().unwrap()).unwrap();
|
||||
let store = ChatStore::new(pool);
|
||||
store
|
||||
.update(10, |data| {
|
||||
data.edit_message.insert(1, edit_entry(10, unix_now()));
|
||||
})
|
||||
.await;
|
||||
|
||||
store.prune_expired(Duration::from_secs(3600)).await;
|
||||
|
||||
assert!(
|
||||
store.cache.lock().contains_key(&10),
|
||||
"a live prompt holds its chat in the cache"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prune_eviction_keeps_the_persisted_state() {
|
||||
// Every record expires → the chat is evicted from the cache; the
|
||||
// pruned state must already be in the DB when that happens.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pool = crate::db::open_store(dir.path().join("p.db").to_str().unwrap()).unwrap();
|
||||
let store = ChatStore::new(pool);
|
||||
store
|
||||
.update(8, |data| {
|
||||
data.template.insert("keep".into(), "[]".into());
|
||||
data.edit_message.insert(1, edit_entry(8, 0));
|
||||
})
|
||||
.await;
|
||||
|
||||
let removed = store.prune_expired(Duration::from_secs(60)).await;
|
||||
|
||||
assert_eq!(removed, vec![(8, 1)]);
|
||||
let data = store.get(8).await;
|
||||
assert!(data.edit_message.is_empty());
|
||||
assert_eq!(
|
||||
data.template.get("keep").map(String::as_str),
|
||||
Some("[]"),
|
||||
"eviction dropped state the DB never received"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
# Deployment reference for the Docker Hub image. Instance values (token, admins,
|
||||
# site credentials, domain) live in `.env` next to this file — `docker compose`
|
||||
# substitutes every `${VAR}` from it automatically — so this file stays in the
|
||||
# repository unmodified. A variable that is not listed here is not passed into
|
||||
# the container at all.
|
||||
#
|
||||
# JSON-file logs grow without limit by default: a long-running bot (and the
|
||||
# proxy in front of it) will fill the disk. One cap, applied to every service
|
||||
# below via the anchor.
|
||||
x-logging: &default-logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: '10m'
|
||||
max-file: '3'
|
||||
|
||||
services:
|
||||
nginx-proxy:
|
||||
image: nginxproxy/nginx-proxy:1.11.6-alpine
|
||||
restart: always
|
||||
environment:
|
||||
# Routes requests with an unknown Host (i.e. plain IP access) here; set
|
||||
# DEFAULT_HOST in .env to use it.
|
||||
DEFAULT_HOST: '${DEFAULT_HOST:-}'
|
||||
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
|
||||
logging: *default-logging
|
||||
|
||||
acme-companion:
|
||||
image: nginxproxy/acme-companion
|
||||
restart: always
|
||||
environment:
|
||||
DEFAULT_EMAIL: '${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
|
||||
logging: *default-logging
|
||||
|
||||
tgxmb:
|
||||
image: yoursfunny/telegram-twitter-media-bot:latest
|
||||
restart: always
|
||||
environment:
|
||||
# From .env (the instance's own values; see the env table in README.md).
|
||||
TELOXIDE_TOKEN: '${TELOXIDE_TOKEN:-}'
|
||||
BOT_ADMIN: '${BOT_ADMIN:-}'
|
||||
PIXIV_REFRESH_TOKEN: '${PIXIV_REFRESH_TOKEN:-}'
|
||||
TWITTER_AUTH_TOKEN: '${TWITTER_AUTH_TOKEN:-}'
|
||||
BILIBILI_COOKIE: '${BILIBILI_COOKIE:-}'
|
||||
VIRTUAL_HOST: '${VIRTUAL_HOST:-}'
|
||||
WEBHOOK_URL: '${WEBHOOK_URL:-}'
|
||||
WEBHOOK_SECRET_TOKEN: '${WEBHOOK_SECRET_TOKEN:-}'
|
||||
# Defaults, listed so they are discoverable; override in .env when needed.
|
||||
LOCAL_USER_ID: '${LOCAL_USER_ID:-1000}'
|
||||
RUST_LOG: '${RUST_LOG:-info}'
|
||||
EDIT_MESSAGE_TTL_SECONDS: '${EDIT_MESSAGE_TTL_SECONDS:-86400}'
|
||||
LINK_CACHE_TTL_SECONDS: '${LINK_CACHE_TTL_SECONDS:-604800}'
|
||||
CAPTION_QUOTE_TEXT_CHARS: '${CAPTION_QUOTE_TEXT_CHARS:-200}'
|
||||
VIRTUAL_PORT: '${VIRTUAL_PORT:-8443}'
|
||||
WEBHOOK: '${WEBHOOK:-true}'
|
||||
WEBHOOK_LISTEN: '${WEBHOOK_LISTEN:-0.0.0.0}'
|
||||
WEBHOOK_PORT: '${WEBHOOK_PORT:-8443}'
|
||||
# For a certificate on a bare IP: uncomment and set ACME_HOST in .env.
|
||||
# ACME_HOST: '${ACME_HOST:-}'
|
||||
#
|
||||
# Not listed on purpose: TELOXIDE_PROXY. Docker Desktop reaches a host
|
||||
# proxy through host.docker.internal (a loopback address inside the
|
||||
# container is the container itself), and teloxide panics on an *empty*
|
||||
# value, so add the line deliberately when this deployment needs one:
|
||||
# TELOXIDE_PROXY: '${TELOXIDE_PROXY}'
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
networks: [proxy]
|
||||
depends_on:
|
||||
- nginx-proxy
|
||||
container_name: tgxmb
|
||||
logging: *default-logging
|
||||
# Webhook mode only (in polling mode there is no listener, so drop this
|
||||
# block or set WEBHOOK=true): 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/${WEBHOOK_PORT:-8443}'"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
certs:
|
||||
html:
|
||||
acme:
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
name: proxy
|
||||
@@ -1,21 +0,0 @@
|
||||
services:
|
||||
tgxmb: image: yoursfunny/telegram-twitter-media-bot: latest
|
||||
restart: always
|
||||
ports:
|
||||
- "8443:8443"
|
||||
environment:
|
||||
LOCAL_USER_ID: '1000'
|
||||
BOT_TOKEN: ''
|
||||
BOT_ADMIN: ''
|
||||
WEBHOOK: false
|
||||
WEBHOOK_LISTEN: '127.0.0.1'
|
||||
WEBHOOK_PORT: 8443
|
||||
WEBHOOK_URL: 'https://example.com'
|
||||
WEBHOOK_KEY: './cert/private.key'
|
||||
WEBHOOK_CERT: './cert/cert.pem'
|
||||
WEBHOOK_SECRET_TOKEN: 'secret-token'
|
||||
# LOG_LEVEL: 'WARNING'
|
||||
volumes:
|
||||
- ./data: /app/data
|
||||
# - ./cert:/app/cert
|
||||
container_name: tgxmb
|
||||
+17
-4
@@ -5,12 +5,25 @@ if [ "$(id -u)" -eq '0' ]
|
||||
then
|
||||
USER_ID=${LOCAL_USER_ID:-9001}
|
||||
|
||||
useradd --shell /bin/bash -u ${USER_ID} -o -c "" -m user > /dev/null 2>&1
|
||||
usermod -a -G root user > /dev/null 2>&1
|
||||
chown -R `id -u user`:`id -u user` /app > /dev/null 2>&1
|
||||
# `docker compose restart` / `docker restart` reuse the same container, so
|
||||
# the overlay fs keeps the user created on first boot. A second `useradd`
|
||||
# then fails with exit code 9, which would trip `set -e` and kill the
|
||||
# container on every restart. Create only if missing; align the UID
|
||||
# otherwise so LOCAL_USER_ID changes still apply.
|
||||
if ! id user > /dev/null 2>&1
|
||||
then
|
||||
useradd --shell /bin/bash -u ${USER_ID} -o -c "" -m user > /dev/null 2>&1 || true
|
||||
else
|
||||
usermod -u ${USER_ID} -o user > /dev/null 2>&1 || true
|
||||
fi
|
||||
# Bind-mounted volumes may not support chown; a failure here must not kill
|
||||
# the container either.
|
||||
chown -R `id -u user`:`id -u user` /app > /dev/null 2>&1 || true
|
||||
|
||||
export HOME=/home/user
|
||||
exec gosu user "$0" "$@"
|
||||
# setpriv (util-linux, present in bookworm-slim) replaces gosu: drop to the
|
||||
# target user and exec, keeping the process as PID 1.
|
||||
exec setpriv --reuid=`id -u user` --regid=`id -g user` --init-groups "$@"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# Bilibili 动态支持:研究与实现记录
|
||||
|
||||
状态:已实现(`crates/x-media/src/site/bilibili/`)。本文记录上游调研、实测数据与最终设计;
|
||||
长期契约以 `AGENTS.md` 为准。
|
||||
|
||||
范围:**只发动态里的图片与动图**。动态内嵌视频不发流,降级为封面图;`b23.tv` 短链不匹配;
|
||||
视频页 / 番剧 / 直播间 / 专栏 / 音频均不支持。
|
||||
|
||||
---
|
||||
|
||||
## 1. 上游实现研究
|
||||
|
||||
### 1.1 nazurin(`nazurin/sites/bilibili/`,4 个文件 ~6 KB)
|
||||
|
||||
- 入口正则:`t\.bilibili\.com/(\d+)`、`t\.bilibili\.com/h5/dynamic/detail/(\d+)`、`bilibili\.com/opus/(\d+)`。
|
||||
- 请求:`GET https://api.bilibili.com/x/polymer/web-dynamic/v1/detail?id={id}`,仅加 `Referer: https://t.bilibili.com/{id}`。
|
||||
**无 cookie、无 WBI 签名、无 `build` 参数**。
|
||||
- 错误:`code == 4101147` → not found;`code != 0` 或缺 `data` → 报错。
|
||||
- 媒体:只取 `item.modules.module_dynamic.major.draw.items[].src`;缩略图 `src + "@518w.jpg"`;
|
||||
`size` 字段单位是 **KB**。`major` 为空或 `draw.items` 为空 → "No image found"。
|
||||
**忽略视频、转发(forward)与纯文字动态**。
|
||||
- caption:`"#" + module_author.name` + `module_dynamic.desc.text`,链接写死 `https://www.bilibili.com/opus/{id}`。
|
||||
|
||||
### 1.2 telegram-bili-feed-helper(`biliparser/provider/bilibili/`,9 个文件 ~57 KB)
|
||||
|
||||
- 9 个策略类(Video/Opus/Live/Audio/Read + Feed 基类 + Credential + api 工具):门禁正则
|
||||
`bilibili\.com|b23\.tv|BV\w{10}|av\d+`,再分流,兜底 `client.head(url)` 跟随重定向后按子串分流。
|
||||
- 动态:`GET /x/polymer/web-dynamic/desktop/v1/detail?id={id}&build=11605`(**单条,无分页**);
|
||||
客户端带桌面 UA、随机 `buvid3={uuid}infoc`;登录态用 `bilibili-api-python` 的 `Credential`
|
||||
(Redis 持久化 `SESSDATA/bili_jct/buvid3/buvid4/ac_time_value/DedeUserID`,扫码登录)。
|
||||
- **同样没有 WBI 签名 / appkey 签名**:playurl 用的是非 WBI 的 `/x/player/playurl`。
|
||||
- 媒体:`major.type` 分派 —— DRAW 取全部 `items[].src`;ARCHIVE/PGC/ARTICLE/MUSIC/COMMON/LIVE
|
||||
只取一张 `cover`;FORWARD 取原动态作者/正文并递归进 `orig` 找媒体。
|
||||
- 视频:仅独立 video 策略解析(`qn` 720P→480P→360P 试 durl,再退 DASH + ffmpeg 合并);
|
||||
**动态内嵌视频只发封面**。
|
||||
- 错误:要求 `status==200 && code==0`;风控 `-352`/`-412` 无特殊处理。
|
||||
|
||||
### 1.3 取舍
|
||||
|
||||
| 维度 | nazurin | bff | 本仓库 |
|
||||
|---|---|---|---|
|
||||
| 接口 | `v1/detail?id=` | `desktop/v1/detail?id=&build=` | `v1/detail?id=`(实测可用) |
|
||||
| 认证 | 无 | buvid3 + SESSDATA | 默认匿名;可选 `BILIBILI_COOKIE` |
|
||||
| WBI | 无 | 无 | 不实现(无需求) |
|
||||
| 图片 | `major.draw.items` | 同 + forward 递归 | 同,加 `orig` 递归、`http→https`、`.gif → Animated` |
|
||||
| 视频 | 完全忽略 | 动态内嵌视频发封面 | 发封面(不发流) |
|
||||
| 短链 | 不匹配 | 跟随重定向 | 不匹配(多数短链是视频,会让"静默忽略"变成失败提示) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 实测验证(2026-09-17,真实请求)
|
||||
|
||||
| 验证项 | 结果 |
|
||||
|---|---|
|
||||
| `v1/detail?id=`(无 cookie、UA `Mozilla/5.0`、带 Referer) | `200 {"code":0}` ✅ |
|
||||
| 同上,不带 cookie 也不带 Referer | `200 {"code":0}` ✅(无强制鉴权) |
|
||||
| bff 的 `bilibili_pc/…Electron/22.3.27` UA | `code:-352` ❌ → **不要抄它的 UA** |
|
||||
| `desktop/v1/detail?build=11605` | `code:-352` ❌ |
|
||||
| `feed/space?host_mid=`(用户时间线) | 首次成功、随后 `-352`,也见过 HTTP 412 → **不碰** |
|
||||
| 不存在 / 已删除的动态 | `code:500` "Cannot read property 'only_fans' of undefined"(nazurin 的 4101147 已失效) |
|
||||
| 非数字 id | `code:-400` param parsing failed |
|
||||
| 图片 `i0.hdslb.com/bfs/new_dyn/*.jpg` | `HEAD 200 image/jpeg`,带/不带 Referer 均可;`+@518w.jpg` → 25–42 KB ✅ |
|
||||
| `t.bilibili.com/h5/dynamic/detail/<id>` | `200` ✅ |
|
||||
| `m.bilibili.com/dynamic/<id>` | `302 → t.bilibili.com/<id>` ✅ |
|
||||
| `www.bilibili.com/opus/<id>` | `200`,转发动态 `302 → t.bilibili.com/<id>` ✅ |
|
||||
| `b23.tv/BV1JTtt6JEZu` | `302 → www.bilibili.com/video/BV…`(视频) |
|
||||
| `b23.tv/<无效码>` | **HTTP 200** + `{"code":-404}` ⚠️ 短链判定不能只看状态码 |
|
||||
| `playurl`(仅调研用,未采用) | `fnval=1` 匿名给 durl:720P=9.18 MiB / 360P=2.97 MiB;`fnval=4048` 匿名 DASH 上限仅 480P |
|
||||
| `dyn_archive` 字段 | 有 `aid/bvid/cover/title/duration_text`,**没有 `cid`**(所以发流要再来一次 `view` 请求) |
|
||||
| **风控阶梯(同一 IP 连续请求后实测)** | ① 无 cookie → `-352`;② 仅 `buvid3` → 仍 `-352`;③ `buvid3`+`buvid4`(取自匿名 `/x/frontend/finger/spi`)→ **`code:0` 恢复**;④ 继续高频请求后 → 连同 buvid 一起 `-352`(此时只有登录 cookie 或换 IP) |
|
||||
| **正文位置(24 条真实动态逐条审计)** | 有正文的动态都在 `module_dynamic.desc.text`(图文/转发/纯文字,含 34–193 字样本);**AV(视频投稿)动态 `desc` 恒为 `null`**,内容在 `major.archive.title` / `.desc` 卡片里 → 已做 title 回退 |
|
||||
| **`features=itemOpusStyle` 的效果** | 同一端点带此参数后,图文帖改为 `major.opus` 形态:`pics[]`(图,key 是 `url`)、`summary.text`(正文,未截断,实测 307 字整段)、`title`(可选标题);不带参数则是 legacy `major.draw` + `desc`,而 **opus 图文帖的 `desc` 为 `null`、正文与标题完全丢失**(`opus/1248857553488576532`:legacy `desc:null`,带参数 `summary.text="[doge_金箍]黑白搭配"`)。AV / 转发帖不受该参数影响 → 适配器改为请求时带参数,并保留 legacy 形态兜底 |
|
||||
| feed 与 detail 的差异 | `feed/space` 的 item 会把 `desc.text` 挖空,**只有 detail 有正文** → 排查时不要用 feed 数据判断正文缺失 |
|
||||
| 不存在的 19 位 id | `4101105 请求数据发生错误`(提示可重试,但只出现在不可能存在的 id 上)→ 仍归入永久错误,见 `code_error` 注释 |
|
||||
|
||||
测试样本(live 测试用):
|
||||
|
||||
| 样本 | id | 期望 |
|
||||
|---|---|---|
|
||||
| 图片动态(2 图 + 话题) | `1245284537985925159` | 2 个 `Illustration`,`{tags}` = `ALin出道20周年快乐` |
|
||||
| 转发动态 | `1248982077447077907` | 媒体来自 `orig`(1 图),正文可含 `//@` |
|
||||
| 视频动态 | `1248717597691609105` | 封面 1 张 `Illustration` |
|
||||
| 纯文字动态 | `1246767523595026450` | `media` 为空 |
|
||||
|
||||
关键字段路径:
|
||||
|
||||
```
|
||||
data.item.id_str
|
||||
data.item.modules.module_author.{name,mid}
|
||||
data.item.modules.module_dynamic.desc.text
|
||||
data.item.modules.module_dynamic.topic.{id,name} # 单话题,{tags} 来源
|
||||
data.item.modules.module_dynamic.major.{draw.items[].src, archive.cover}
|
||||
data.item.orig # 转发时存在,结构与 item 相同
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 实现
|
||||
|
||||
```
|
||||
crates/x-media/src/site/bilibili/mod.rs # re-export
|
||||
crates/x-media/src/site/bilibili/interface.rs # PATTERN / cache_key / enabled / is_retryable /
|
||||
# media_headers / BilibiliSite / fetch / code_error /
|
||||
# From<Item> for Fetched / caption / 12 单测 + 2 live
|
||||
crates/x-media/src/site/bilibili/model.rs # 纯 Deserialize DTO(全 Option)
|
||||
```
|
||||
|
||||
- **正则**(同时用于分发、抽 id、缓存键,一个正则三用):
|
||||
`^(?:https?://)?(?:www|t|m)\.bilibili\.com/(?:opus/|dynamic/|h5/dynamic/detail/)?(\d+)`
|
||||
- **缓存键**:`bilibili:<动态 id>`;`source_url` 统一 `https://www.bilibili.com/opus/{id}`。
|
||||
- **请求**:`GET /x/polymer/web-dynamic/v1/detail?id=` + `Referer: https://www.bilibili.com/`;
|
||||
`Cookie` 头按优先级取:`BILIBILI_COOKIE` → 缓存的设备 cookie(`GET /x/frontend/finger/spi` 取 `buvid3`/`buvid4`,
|
||||
进程内缓存一次;取不到就不带 cookie,仅 debug 日志)→ 无。指纹接口本身失败**不**让抓取失败。
|
||||
走共享 `CLIENT`(UA `Mozilla/5.0`,30s 超时,`TELOXIDE_PROXY` 透传)。
|
||||
- **错误映射**:`0` → 成功;`-352/-412` 与 HTTP 412 → `Transient`(可重试,队列退避;首次记一条 warn 提示
|
||||
`BILIBILI_COOKIE`);`500`/`4101147` → `NotFound`(永久);其他 code → `Site`(永久)。
|
||||
- **媒体**:
|
||||
- `major.opus.pics[]`(带 `features=itemOpusStyle` 时的图文帖形态,字段名是 `url`)→ 每张一张图;
|
||||
其次 `major.draw.items[]`(legacy,字段名 `src`)→ 同样逐张;`http://` / `//` → `https://`,非 https 开头直接丢弃。
|
||||
`.gif` → `Media::Animated`(`thumbnail_url` 留空,Telegram 自己取首帧——`@518w.jpg` 只对 jpg/webp 实测过),
|
||||
其余 → `Media::Illustration`(`thumbnail_url = url + "@518w.jpg"`,兼作超大时的降级 URL)。
|
||||
- `major.archive.cover` → 1 张 `Illustration`(视频不发流)。
|
||||
- 转发且自身无媒体 → 递归取 `orig` 的媒体;正文拼 `//@{原作者}:\n{原文}`。
|
||||
- 其他 major(PGC/ARTICLE/MUSIC/LIVE/COMMON)不建模 → 无媒体,走既有 "No media found"。
|
||||
- **正文 / title**(按信息量从多到少回退):`major.opus.title` + `major.opus.summary.text`
|
||||
→ `module_dynamic.desc.text` → `major.archive.title`。三者分别对应:图文文档(标题+正文)、
|
||||
legacy/转发帖正文、视频投稿卡片标题。开头结尾空白做 trim;整体再由既有 `truncate_caption` 截断。
|
||||
- **caption**(与 misskey 同形):`{opus 链接}\n<a href="space.bilibili.com/{mid}">{name}</a>: {正文}`;
|
||||
`RenderData` 的 `{tags}` 来自话题名;正文由既有 `truncate_caption` 截断。
|
||||
- **注册表**:`SITES` 末尾追加 → `/set_format` 白名单、链接缓存、启动校验、日志前缀全部自动生效。
|
||||
- **bot 侧仅文案**:`handlers/commands.rs` 三处站点清单字符串 + `state.rs`/`handlers/mod.rs` 注释。
|
||||
|
||||
### 与原计划的偏差(及原因)
|
||||
|
||||
| 原计划 | 实际 | 原因 |
|
||||
|---|---|---|
|
||||
| `x/web-interface/view` + `playurl` 发视频 | 不做 | 需求收窄为图片/动图;视频只发封面 |
|
||||
| `site/mod.rs` 加 `MAX_MEDIA_UPLOAD_BYTES` 常量 | 不加 | 没有视频尺寸决策就不需要该常量,避免跨 crate 耦合 |
|
||||
| `b23.tv` 短链(跟随重定向) | 不匹配 | 多数短链指向视频,匹配后会把"静默忽略"变成用户的 "Failed to fetch media" |
|
||||
| `validate()` 校验 cookie | 不做 | 匿名可用,cookie 失效不致命;校验要额外请求一个端点,收益低 |
|
||||
| `media_headers` 给 hdslb 加 Referer | 返回 `None` | 实测图片与 durl 均无需 Referer(注释里记了这条验证) |
|
||||
| 计划阶段认为设备 cookie 是 YAGNI,不实现 | **实现**(`buvid3`+`buvid4`) | 计划之后做了对照实验:同一 IP 上"无 cookie → -352、只有 buvid3 → -352、buvid3+buvid4 → code:0",说明这是对本适配器主要失败模式的直接修复,而不是冗余保险 |
|
||||
| 只用不带参数的 `v1/detail` | 加 `features=itemOpusStyle` | 用户实测反馈"有内容的动态没有 title":不带参数时 opus 图文帖返回 legacy 形态,`desc` 为 `null`,正文与标题整个丢失。带参数后同一 ID 返回 `major.opus.summary.text` / `title` / `pics`。AV / 转发帖不受影响,legacy 形态仍保留为兜底 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 测试与验证
|
||||
|
||||
- 单元(13):正则匹配/拒绝/忽略短链、缓存键归一、图片映射(https 归一 + 缩略图 + `.gif → Animated`)、
|
||||
封面、转发取 `orig` 媒体与正文拼接、纯文字无媒体、caption 转义、业务 code 分类(可重试性)、URL 归一、
|
||||
设备 cookie 拼装。
|
||||
- live(3,`#[ignore = "live network: …"]`):设备 cookie 可取、图片动态 2 图、纯文字动态无媒体。
|
||||
CI 的 `live` job 已覆盖。动态接口被风控时这两条 live 测试打印 `skipping:` 并提前返回(与 pixiv 的
|
||||
token 门控同款约定),设备 cookie 那条仍会真实执行。
|
||||
- 实测命令:
|
||||
`cargo run -p x-media --example fetch -- https://www.bilibili.com/opus/1245284537985925159`
|
||||
(输出 2 张 `https://i0.hdslb.com/…jpg` + `@518w.jpg` 缩略图 + 话题 tags)。
|
||||
- 全套:`cargo fmt --check`、`cargo clippy --workspace --all-targets -- -D warnings`、`cargo test --workspace` 全绿。
|
||||
|
||||
## 5. 已知限制
|
||||
|
||||
- 风控按 IP/请求量漂移,阶梯见 §2 最后一行:轻度靠设备 cookie 自愈,重度需 `BILIBILI_COOKIE` 或换 IP。
|
||||
被拦时按**可重试**失败处理(队列退避)+ 一条 warn,不会静默丢帖。
|
||||
- 接口 schema 会漂移(`module_dynamic.major` 实测可为 `null` 而正文留在 `desc`);DTO 全 `Option`,
|
||||
未知形态降级为"无媒体",不 panic。
|
||||
- 动态内嵌视频只发封面图(与 bff 同策略),不下载流。
|
||||
- 纯文字动态复用既有 "No media found" 回复。
|
||||
- `b23.tv` 短链不被匹配(见上表)。
|
||||
@@ -0,0 +1,143 @@
|
||||
# 架构优化设计:可测试性接缝 + handlers 拆分
|
||||
|
||||
> 状态:**阶段 A、B、C 已实施**(A: `c9e72fd`,B: `50206a9` + `ae69d72`,C:
|
||||
> rate_limit 提交);**D 已延迟**——待下次数据库 schema 变化时实施(见 §5)。
|
||||
> 目标:把仓库最大的测试空白(`handlers.rs`/`send.rs` 的发送与分派逻辑)补上
|
||||
> 可测试接缝,并把 ~1100 行的 handlers 单体拆成模块。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状与动机
|
||||
|
||||
- `handlers.rs`(~1100 行)混装:命令解析/执行、URL 提取 + 任务通道、inline
|
||||
debounce、callback、edit-before-forward、全部全局静态。
|
||||
- 关键路径零测试:`url_media` 的分派、`dispatch_send` 的失败分类、缓存命中路径、
|
||||
edit-before-forward、转发重试——AGENTS.md 自认 "untested: handlers.rs"。
|
||||
- 根因:`handlers.rs`/`send.rs` 直接依赖 teloxide `Bot`(具体类型)与全局静态
|
||||
(`CHAT_STORE`/`TASK_QUEUE`/`LINK_CACHE`/`CONFIG`),没有注入点。
|
||||
|
||||
## 2. 阶段 A:handlers 拆分(纯组织,零风险,先行)
|
||||
|
||||
把 `handlers.rs` 拆为模块(仅移动代码,不改签名):
|
||||
|
||||
```
|
||||
handlers/
|
||||
mod.rs — 入口:message/inline/callback 分发 + 公共类型(UrlJob、log_key)
|
||||
statics.rs — CHAT_STORE / TASK_QUEUE / LINK_CACHE / DB / CONFIG / URL_JOBS
|
||||
commands.rs — Command enum + execute_command + set_forward_channel_handler
|
||||
urls.rs — extract_urls + start/stop_url_workers + url_media + build_send_task + media_to_payload
|
||||
inline.rs — inline_query_handler + debounce 状态机 + answer_inline_query
|
||||
callback.rs — callback_query_handler + edit_message_handler
|
||||
```
|
||||
|
||||
- `mod.rs` 用 `pub use` 重导出,bot 侧引用 `handlers::xxx` 不变。
|
||||
- 收益:每个模块独立审阅;后续阶段 B 的接缝改动落在明确的模块内。
|
||||
|
||||
## 3. 阶段 B:MediaSender 接缝(核心)
|
||||
|
||||
**动机**:`send.rs` 的所有发送入口(`send_media_group`/`send_animation`/
|
||||
`copy_messages`)都挂在具体 `Bot` 上;测试无法注入失败/成功。
|
||||
|
||||
**设计**:新增 `crates/xmedia-bot/src/media_sender.rs`:
|
||||
|
||||
```rust
|
||||
/// 发送抽象:生产用 teloxide Bot,测试用记录型 mock。
|
||||
/// 方法签名与 teloxide 调用点一一对应,返回 Result 以便注入任意失败。
|
||||
pub trait MediaSender: Send + Sync {
|
||||
fn send_media_group(&self, chat_id: ChatId, items: Vec<InputMedia>)
|
||||
-> BoxFuture<'_, Result<Vec<Message>, RequestError>>;
|
||||
fn send_animation(&self, chat_id: ChatId, file: InputFile, caption: Option<&str>, spoiler: bool, reply_to: i64)
|
||||
-> BoxFuture<'_, Result<Message, RequestError>>;
|
||||
fn copy_messages(&self, to: ChatId, from: ChatId, ids: Vec<MessageId>)
|
||||
-> BoxFuture<'_, Result<Vec<MessageId>, RequestError>>;
|
||||
// 按需扩展:edit_message_caption / delete_message / answer_callback_query …
|
||||
}
|
||||
|
||||
impl MediaSender for Bot { /* 委托现有 teloxide 调用 */ }
|
||||
```
|
||||
|
||||
配套:`ChatStore`/`LinkCache`/`PersistentTaskQueue` 已是具体类型——给 `send.rs`/
|
||||
`url_media` 需要的最小面加 trait(`ChatStoreReader`/`LinkCacheReader` 等),或直接
|
||||
注入具体类型(它们已有内存态,测试用真实 tempdir 即可,见阶段 B-注)。
|
||||
|
||||
**接入点**:
|
||||
- `dispatch_send` / `send_media_sequence` / `send_animation` / `forward_messages` /
|
||||
`post_send_actions` / `notify_failure` 的 `bot: &Bot` 参数改为 `sender: &dyn MediaSender`。
|
||||
- `url_media` 由 `url_media(bot, message, url)` 改为 `url_media(sender, store, queue, cache, message, url)`(或聚合为一个 `AppContext` 结构传引用)。
|
||||
|
||||
**测试策略**(仓库无 mock 框架,手写 mock):
|
||||
- `MockSender` 记录调用序列、按脚本返回 Ok/Err(覆盖:URL 发送成功、media-fetch
|
||||
失败触发兜底、RetryAfter 触发入队、Permanent 触发缓存失效)。
|
||||
- `ChatStore`/`LinkCache` 用真实 tempdir 实例(现有测试已这么做)。
|
||||
- 新增测试:`send_media_sequence` 分批续传、`send_animation` 兜底、`url_media`
|
||||
缓存命中 vs 未命中、`dispatch_send` 三分支。
|
||||
|
||||
**风险**:中。动 `send.rs`/`handlers.rs` 签名(约 15 处调用点),行为不变。
|
||||
**不做**:`main.rs` 的 teloxide 装配不抽象(那是真正的胶水,无测试价值)。
|
||||
|
||||
## 4. 阶段 C:主动限流(已实施)
|
||||
|
||||
批量转发时的突发会触发 Telegram 频道限速,现在靠 `RetryAfter → 队列重试` 被动
|
||||
应对。新增轻量令牌桶(`rate_limit.rs`):
|
||||
|
||||
```rust
|
||||
pub struct TokenBucket { capacity, refill_per_sec, state: Mutex<State> }
|
||||
impl TokenBucket {
|
||||
pub async fn acquire(&self, n: f64); // 按 n 个 token 等待并消费
|
||||
}
|
||||
pub fn limiter_for(chat_id: i64) -> Arc<TokenBucket>; // 每频道一个桶
|
||||
```
|
||||
|
||||
- 默认 `CAPACITY = 20`、`REFILL_PER_SEC = 20/60`(约 20 msg/min);
|
||||
单次 acquire 可超出容量(记为债务,由后续 refill 偿还)。
|
||||
- 挂点:`MediaSender for Bot` 的 `send_media_group`(按 items 数)、
|
||||
`copy_messages`(按 ids 数)、`send_animation`(1 token)前置 `acquire`;
|
||||
MockSender 不受影响(测试不经过限流)。
|
||||
- 收益:减少 429 → 重试 → 死信;队列重试仍是全局限速的安全网。
|
||||
- 风险:低,独立模块;`tokio::time`(paused-clock 可测)。
|
||||
|
||||
## 5. 阶段 D:DB 版本化迁移(**已延迟**)
|
||||
|
||||
> ⚠️ **待办提醒**:本阶段**推迟到下次数据库 schema 变化时实施**(给
|
||||
> `link_cache`/`chat_state`/`tasks` 加列、改结构等)。当前 `schema_init` 是
|
||||
> `CREATE TABLE IF NOT EXISTS`,无版本概念;一旦需要迁移已有线上库,必须先落地
|
||||
> 本方案(`PRAGMA user_version` 迁移链)再改 schema。`db.rs` 的 `schema_init`
|
||||
> 处已留注释指向这里。
|
||||
|
||||
```rust
|
||||
// db.rs
|
||||
const MIGRATIONS: &[&str] = &[
|
||||
// v1: 初始 schema(tasks / chat_state / link_cache)
|
||||
"CREATE TABLE IF NOT EXISTS tasks (...); ...",
|
||||
];
|
||||
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||
let v: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
||||
for (i, sql) in MIGRATIONS.iter().enumerate().skip(v as usize) {
|
||||
conn.execute_batch(sql)?;
|
||||
conn.pragma_update(None, "user_version", (i + 1) as i64)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
- 低优先级:schema 未变时无收益;将来加列/改结构时必须有。
|
||||
- `open_store` 改用 `migrate` 替换 `schema_init` 调用。
|
||||
|
||||
## 6. 明确不做
|
||||
|
||||
- **不拆 xmedia-core**:`Task`/队列/发送抽成独立 lib crate 是大工程,除非出现
|
||||
第二个客户端,否则收益不抵成本。
|
||||
- **不引入 DI 框架**:仓库惯例是 LazyLock 静态 + 显式传参,保持。
|
||||
- **不抽象 main.rs 的 teloxide 装配**。
|
||||
|
||||
## 7. 实施记录
|
||||
|
||||
| 阶段 | 提交 | 说明 |
|
||||
|---|---|---|
|
||||
| A | `c9e72fd` | handlers 拆为 `{mod, statics, commands, urls, inline, callback}` |
|
||||
| B | `50206a9` | `media_sender.rs`:`trait MediaSender` + `impl for Bot`(`<Bot as Requester>::` 消歧);send.rs 8 处签名改 `&dyn MediaSender`;`MockSender` 测试覆盖兜底触发与错误分类(+5 测试) |
|
||||
| B | `ae69d72` | `AppContext` 注入 `url_media`(sender/store/queue/cache),url_media 全链路测试(缓存命中/失效/成功/不支持 URL,+3 测试) |
|
||||
| C | rate_limit 提交 | `rate_limit.rs` 令牌桶 + 每频道注册表;`MediaSender for Bot` 的 group/copy/animation 前置 `acquire`(+3 测试) |
|
||||
| D | — | **已延迟**:待下次数据库 schema 变化时实施(见 §5) |
|
||||
|
||||
A、B、C 为核心并已实施;D 在 schema 变更时落地。
|
||||
@@ -0,0 +1,241 @@
|
||||
# 站点适配器重构方案:让新增站点变成"新模块 + 注册一行"
|
||||
|
||||
> 状态:**已实施**(阶段 1-5,提交 `7ca8fd1` / `5e23916` / `bf4e615` / `5679a8c` +
|
||||
> 本文档收尾)。目标:把"加一个新站点"从改 8-9 处收敛到 3 处,并让站点身份、
|
||||
> 重试策略、下载 header 等站点能力归位到站点模块自身。实施过程中的关键偏差
|
||||
> (async 形态)见 §3 的 "async 形态" 段——原生 AFIT 实测不可用于 dyn 分派,
|
||||
> 最终采用手写 `BoxFuture`(`SiteFuture` 别名)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状摩擦清单
|
||||
|
||||
> ⚠️ 本节记录的是**重构前**的现状:其中的行号、以及 `site/mod.rs` 里的
|
||||
> `fetch_once` 分派函数(当时的实现)都已不存在,仅作历史记录。当前形态见
|
||||
> `site/mod.rs` 的 `SITES` 注册表——新增站点 = 新模块 + 注册一行。
|
||||
|
||||
以现有三站(twitter / bsky / pixiv)为基线,新增第 4 个站点(代号 `example`)
|
||||
今天需要触碰的位置:
|
||||
|
||||
| # | 位置(当前行号) | 改动 | 必改? |
|
||||
|---|---|---|---|
|
||||
| 1 | 新目录 `crates/x-media/src/site/example/{mod,interface,model}.rs` | 新模块 | 必改 |
|
||||
| 2 | `site/mod.rs:354-365` `fetch_once` | 加一个 `if` 分派分支 | 必改 |
|
||||
| 3 | `site/mod.rs:167-178` `cache_key` | 加一个 `if` 分支 + 约定 key 前缀 `"example:..."` | 必改 |
|
||||
| 4 | `site/mod.rs:53-63` `site_name()` | 加一个 URL `contains` 嗅探分支 | 必改 |
|
||||
| 5 | `handlers.rs:405` `SetFormat` 白名单 | `["twitter","bsky","pixiv"]` 加字符串 | 必改 |
|
||||
| 6 | `config.rs` / `main.rs:74-84` | 仿 pixiv 加启动校验(token、`disable()`) | 视站点 |
|
||||
| 7 | `site/mod.rs:312-326` `fetch_error_is_retryable` | 若重试策略特殊,改中央分类函数 | 视站点 |
|
||||
| 8 | `site/mod.rs:377/391/430` 三个下载函数 | 若媒体有防盗链,加 header(现在是硬编码 pximg 判断) | 视站点 |
|
||||
| 9 | `site/mod.rs:16,180-197` `FetchError` | 若错误类型特殊,加嵌套 variant(仿 `Pixiv(PixivError)`) | 视站点 |
|
||||
|
||||
**根因**:仓库里没有"站点"这个实体。站点的四类能力——URL 识别(PATTERN +
|
||||
cache_key)、抓取、重试策略、下载 header——分别散落在中央 if 链、URL 字符串嗅探、
|
||||
魔法字符串 key 和 bot crate 的白名单里。`AGENTS.md` 现行约定 "no trait, no enum
|
||||
dispatch" 是刻意的简单性选择;本方案的目标是在**不推翻它精神的前提下**收敛摩擦,
|
||||
并在阶段 3 提供完整的 trait 注册表选项。
|
||||
|
||||
## 2. 目标架构
|
||||
|
||||
```
|
||||
crates/x-media/src/site/mod.rs
|
||||
├─ SITES: LazyLock<Vec<Box<dyn Site>>> ← 注册表(唯一的"站点列表")
|
||||
├─ find_site(url) / fetch(url) / cache_key(url) / site_ids()
|
||||
└─ 通用类型:Fetched { site_id, ... } / FetchError(通用类 + Site 变体)
|
||||
│
|
||||
├─ site/twitter/{mod,interface,model}.rs impl Site
|
||||
├─ site/bsky/… impl Site
|
||||
└─ site/pixiv/… impl Site (download_headers: pximg Referer)
|
||||
(validate: token 校验)
|
||||
|
||||
crates/xmedia-bot
|
||||
├─ handlers.rs SetFormat 白名单 ← x_media::site::ids()(不再写死)
|
||||
├─ handlers.rs site 格式查找 ← fetched.site_id(缓存/新鲜两条路径同口径)
|
||||
└─ main.rs 启动校验 ← site::validate_all()(不再特判 pixiv)
|
||||
```
|
||||
|
||||
## 3. 分阶段迁移
|
||||
|
||||
每个阶段是一个独立提交,保持 `cargo fmt` / `cargo clippy -- -D warnings` /
|
||||
`cargo test --workspace` 全绿;行为完全不变,只挪代码、不换语义。
|
||||
|
||||
### 阶段 1:站点身份单一来源(低风险,推荐先做)
|
||||
|
||||
**动机**:同一概念目前有两个来源——缓存命中路径用 `key.split(':').next()`
|
||||
(`handlers.rs:639`),新鲜抓取路径用 `fetched.site_name()`(`handlers.rs:724`);
|
||||
`site_name()` 又是对 `source_url` 的 `contains` 字符串嗅探,还有 `"unknown"`
|
||||
兜底分支。
|
||||
|
||||
**改动**:
|
||||
|
||||
1. `site/mod.rs`:`Fetched` 增加字段 `site_id: &'static str`(由各站点的
|
||||
`impl From<SiteStruct> for Fetched` 填充;`empty_fetched` 同步填)。
|
||||
`Fetched::site_name()` 改为 `return self.site_id`(保留方法名,删除
|
||||
`source_url.contains` 嗅探与 `"unknown"` 分支)。
|
||||
2. `site/mod.rs`:新增 `pub fn site_id_from_key(key: &str) -> &'static str`
|
||||
(解析 `"example:..."` 前缀,未知前缀返回 `"unknown"`),bot 缓存命中路径改用它,
|
||||
与 `fetched.site_id` 口径统一。
|
||||
3. `handlers.rs:405`:`SetFormat` 白名单改为 `x_media::site::ids()`——阶段 1 先实现
|
||||
`ids()` 为 `["twitter","bsky","pixiv"]` 的常量函数(数据源仍集中,行为不变),
|
||||
阶段 3 再改为遍历注册表。
|
||||
4. `twitter/interface.rs:48-60` / `bsky` / `pixiv` 的 `From<SiteStruct> for Fetched`
|
||||
各补 `site_id` 字段。
|
||||
|
||||
**风险**:低。纯增量字段;`site_name()` 语义不变(测试 `pixiv/interface.rs:355`
|
||||
已断言 `"pixiv"`)。
|
||||
**验证**:现有全部单测;`cache_key_normalizes_domain_variants` 等不变。
|
||||
**回滚**:revert 该提交。
|
||||
|
||||
### 阶段 2:站点能力下沉(不引入 trait,静态分派)
|
||||
|
||||
**动机**:把"每个站点自己才知道"的逻辑搬回站点模块,中央只做迭代。这是
|
||||
`AGENTS.md` 现有约定(无 trait)与完整注册表之间的折中,可独立交付。
|
||||
|
||||
**改动**:每个站点模块新增并 `mod.rs` 重新导出:
|
||||
|
||||
```rust
|
||||
// site/twitter/interface.rs(bsky/pixiv 同构)
|
||||
pub fn cache_key(url: &str) -> Option<String>; // 用自身 PATTERN,返回 "twitter:<id>"
|
||||
pub fn is_retryable(err: &FetchError) -> bool; // 默认 Http|Transient;pixiv 覆盖 PixivError 分支
|
||||
pub fn media_headers(url: &str) -> Option<Vec<(&'static str, String)>>;
|
||||
// pixiv: url 含 "pximg.net" → Referer
|
||||
```
|
||||
|
||||
`site/mod.rs` 相应改为迭代三站:
|
||||
|
||||
- `cache_key`:逐个调 `site::cache_key`,不再自己写 key 格式;
|
||||
- `fetch_error_is_retryable`:删除,`fetch()` 重试循环改调 `current_site::is_retryable`
|
||||
(`fetch_once` 已能确定站点,把站点传下去);
|
||||
- `media_size` / `download_media_limited` / `download_media_to_file` 里的
|
||||
`pximg.net → Referer` 硬编码删除,改为遍历 `SITES`(阶段 2 是遍历
|
||||
`[twitter, bsky, pixiv]` 静态列表)取 `media_headers(url)` 合并。
|
||||
|
||||
**注意**:Referer 判定依据是媒体 URL 的 host(`pximg.net`),**不是**站点
|
||||
PATTERN(pixiv 的 PATTERN 只匹配 `pixiv.net/artworks/...`),所以 `media_headers`
|
||||
不能挂在 PATTERN 匹配上,必须按 URL 独立匹配——这正是把它做成独立函数的原因。
|
||||
|
||||
**风险**:中。下载函数签名不变,行为必须逐字节不变;新增单元测试覆盖
|
||||
`media_headers("https://i.pximg.net/...") == Some(Referer)` 与
|
||||
`cache_key` 等价性(对全部既有用例断言新旧结果一致)。
|
||||
**回滚**:revert。
|
||||
|
||||
### 阶段 3:Site trait + SITES 注册表(完整方案,可选)
|
||||
|
||||
**动机**:加站点时 bot crate 与中央分派零改动;站点列表成为唯一注册点。
|
||||
|
||||
**新增**(`site/mod.rs`,按实施后的实际形态):
|
||||
|
||||
```rust
|
||||
/// Boxed, Send future produced by a Site async method. Boxed so the trait
|
||||
/// stays dyn-compatible; Send because URL/queue workers tokio::spawn these.
|
||||
type SiteFuture<'a, T, E = FetchError> =
|
||||
Pin<Box<dyn Future<Output = Result<T, E>> + Send + 'a>>;
|
||||
|
||||
pub trait Site: Send + Sync {
|
||||
fn id(&self) -> &'static str;
|
||||
fn pattern(&self) -> &'static Regex;
|
||||
fn enabled(&self) -> bool { true } // 默认: true
|
||||
fn cache_key(&self, url: &str) -> Option<String>;
|
||||
fn fetch_from_url<'a>(&'a self, url: &'a str) -> SiteFuture<'a, Fetched>;
|
||||
fn is_retryable(&self, err: &FetchError) -> bool; // 默认: Http|Transient
|
||||
fn media_headers(&self, url: &str) -> Option<Vec<(&'static str, String)>>; // 默认: None
|
||||
fn validate(&self) -> SiteFuture<'static, (), String>; // 默认: Ok(())
|
||||
}
|
||||
|
||||
static SITES: LazyLock<Vec<Box<dyn Site>>> = LazyLock::new(|| vec![
|
||||
Box::new(twitter::TwitterSite), Box::new(bsky::BskySite), Box::new(pixiv::PixivSite),
|
||||
]);
|
||||
```
|
||||
|
||||
- `fetch` → `find_site(url)`(注册表中首个 PATTERN 命中且 `enabled()` 的站点,
|
||||
返回 `&'static dyn Site`)→ `site.fetch_from_url(url).await`;
|
||||
- `cache_key` / `site_ids()` / `site_id_from_key()` / `apply_media_headers()` /
|
||||
`validate_all()` 全部遍历 `SITES`;`validate_all` 返回失败列表,pixiv 的
|
||||
`Site::validate` 失败时自行 `disable()`;
|
||||
- `match_site`/`SiteKind`(阶段 2 的静态分派)与中央 `fetch_error_is_retryable`
|
||||
删除,重试判定走 `site.is_retryable`;
|
||||
- `main.rs` 的 pixiv 特判 → `site::validate_all()` + 通用失败通知;
|
||||
- 保留各站点的 `PATTERN`/`enabled()`/`fetch_from_url()` 顶层导出(兼容既有
|
||||
测试),trait impl 只是薄壳。
|
||||
|
||||
**async 形态**(实施结论):**原生 AFIT 不可行**。
|
||||
|
||||
- 实测(rustc 1.95.0,edition 2024;**1.97.1 复测一致**):trait 里写
|
||||
`async fn` 报 "method is `async`"(非 dyn 兼容);写反糖
|
||||
`-> impl Future<...> + Send + '_` 报 "references an `impl Trait` type in its
|
||||
return type"(同样非 dyn 兼容);纯 RPITIT(无 `+ Send`)也一样。即:
|
||||
**RPITIT/AFIT 目前无法用于 `Vec<Box<dyn Site>>` 注册表**,与早期设计的
|
||||
判断相反。
|
||||
- **为什么**:dyn 分派要求调用方在编译期知道返回值大小以分配空间,而
|
||||
`async fn`/RPITIT 返回不透明的 Future——这是"非定长返回值走 dyn"的普遍问题,
|
||||
与 async 无关。Rust 1.75 稳定的 AFIT 只覆盖**静态分派**,dyn 路径被排除;
|
||||
原生 dyn 支持(AFIDT)是 2026-2027 的已接受项目目标,尚未进入 stable。
|
||||
参见 <https://rust-lang.github.io/rust-project-goals/2026/afidt-box.html>。
|
||||
- **采用 (a) 手写 `Pin<Box<dyn Future + Send + '_>>`**(`SiteFuture` 别名):
|
||||
零新依赖、dyn 兼容、future 保证 Send。签名噪音靠别名缓解;生命周期坑因
|
||||
站点是无状态单元结构体 + `'a` 同时约束 `&self` 与 `url` 而完全可控
|
||||
(future 只借用调用域内的 url)。
|
||||
- **(b) `async-trait`** 仍是可行备选(语法更干净、同样 box),但新增依赖;
|
||||
本仓库采用 (a) 后无需引入。
|
||||
- 若未来 Rust 稳定版落地 AFIDT(调用点 `dyn_box!`),可平滑迁移回原生
|
||||
`async fn`,实现体几乎不动。
|
||||
|
||||
**风险**:中。动中央分派,但每站点行为不变;注册表迭代 + `find_site` 补单测
|
||||
(`fetch`/`cache_key` 对既有 URL 集合的结果与阶段 2 完全一致)。
|
||||
**回滚**:revert。
|
||||
|
||||
### 阶段 4:FetchError 泛化(已实施)
|
||||
|
||||
**改动**:`FetchError` 新增 `Site { site: &'static str, error: Box<dyn std::error::Error + Send + Sync> }`
|
||||
变体(`Display`/`source()` 同步)。**`Pixiv(PixivError)` 变体保留**(未迁移)——
|
||||
它已有完整的 `Display`/`source()`/`is_retryable` 处理,替换纯属 churn。`Site`
|
||||
变体默认永久性(各站点 `is_retryable` 都不匹配它);需要可重试站点错误的站点
|
||||
应自行转换为 `Http`/`Transient` 再返回。
|
||||
|
||||
**风险**:低(纯增量变体)。测试:`site_error_variant_displays_and_sources`。
|
||||
|
||||
### 阶段 5:收尾
|
||||
|
||||
- 更新 `AGENTS.md` 的 "Site adapter convention" 段:写新约定(注册表 + `impl Site` +
|
||||
每站点 `cache_key`/`is_retryable`/`media_headers`),删除 "no trait" 表述;
|
||||
- `examples/fetch.rs` 不变(走 `site::fetch`);
|
||||
- 新增站点 checklist 见 §4。
|
||||
|
||||
## 4. 重构后新增站点 checklist
|
||||
|
||||
```
|
||||
1. crates/x-media/src/site/example/{mod,interface,model}.rs // 新模块
|
||||
2. impl Site for ExampleSite 并注册进 SITES // 注册一行
|
||||
3. (可选)token 读取 + validate() 实现 // 启动校验自动生效
|
||||
── bot crate 零改动 ──
|
||||
```
|
||||
|
||||
对比现状的 8-9 处,bot crate 完全不碰:`SetFormat` 白名单、格式查找口径、
|
||||
缓存 key、启动校验全部自动跟随注册表。
|
||||
|
||||
## 5. 权衡与明确不做的事
|
||||
|
||||
- **不做**:Media 类型扩展(`media.rs` + `MediaItemPayload` + `CachedMediaKind` +
|
||||
send.rs 约 10+ 处 match 的 blast radius)——这是"新增媒体类型"的摩擦,与"新增
|
||||
站点"正交,优先级低,保持现状。
|
||||
- **不做**:DI/全局注入改造(`CHAT_STORE`/`TASK_QUEUE`/`CONFIG` 的 `LazyLock` 静态
|
||||
模式是仓库惯例,与站点扩展无关)。
|
||||
- **不做**:schema 迁移——新站点只产生新的 cache key 前缀与 `message_format` JSON
|
||||
key,`link_cache`/`chat_state` 表结构均无需变化。
|
||||
- **代价**:阶段 3 引入 `dyn Site` 与 boxed future 签名(`SiteFuture`,见 §3);
|
||||
`Send` 约束前移到 trait 边界,站点 impl 的 future 必须 Send(现仅在各
|
||||
`tokio::spawn` 点检查,重构后在 impl 处即报错,提前暴露问题)。
|
||||
若站点数量长期 ≤5 且无新增迹象,阶段 2 的折中方案已够用;本次已按完整方案
|
||||
实施到阶段 4。
|
||||
|
||||
## 6. 提交序列(已按此实施)
|
||||
|
||||
| 阶段 | 提交 | hash |
|
||||
|---|---|---|
|
||||
| 1 | `refactor(site): carry site_id on Fetched; unify cache-key site lookup` | `7ca8fd1` |
|
||||
| 2 | `refactor(site): move cache_key/is_retryable/media_headers into site modules` | `5e23916` |
|
||||
| 3 | `refactor(site): introduce Site trait and SITES registry` | `bf4e615` |
|
||||
| 4 | `refactor(site): genericize FetchError::Site` | `5679a8c` |
|
||||
| 5 | `docs: update site adapter convention in AGENTS.md` | 本文档收尾提交 |
|
||||
|
||||
每阶段独立合入、独立回滚;阶段 2 完成后"加站点"摩擦已收敛,3/4 为深化。
|
||||
@@ -1,264 +0,0 @@
|
||||
import html
|
||||
from functools import wraps
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from telegram import Update, Chat, InlineKeyboardMarkup, InlineKeyboardButton, Message
|
||||
from telegram.constants import ParseMode, ChatAction, ChatType
|
||||
from telegram.ext import (
|
||||
Application,
|
||||
ApplicationBuilder,
|
||||
ContextTypes,
|
||||
Defaults,
|
||||
filters,
|
||||
InlineQueryHandler,
|
||||
PicklePersistence,
|
||||
MessageHandler,
|
||||
CommandHandler, CallbackQueryHandler
|
||||
)
|
||||
|
||||
import common
|
||||
from tweet import TGTweet
|
||||
|
||||
|
||||
def send_action(action):
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
async def command_func(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs):
|
||||
await update.effective_chat.send_action(action)
|
||||
return await func(update, context, *args, **kwargs)
|
||||
|
||||
return command_func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
async def inline_query(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
query = update.inline_query.query
|
||||
if query == "":
|
||||
return
|
||||
common.logger.info(f"Query: {query}")
|
||||
async with TGTweet(query) as tweet:
|
||||
result = list(tweet.inline_query_generator)
|
||||
await update.inline_query.answer(result)
|
||||
|
||||
|
||||
@send_action(ChatAction.UPLOAD_PHOTO)
|
||||
async def url_media(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
url = update.message.text
|
||||
common.logger.info(f"Receiving url: {url}")
|
||||
async with TGTweet(url) as tweet:
|
||||
media = list(tweet.pm_media_generator)
|
||||
message_to_send = await update.effective_message.reply_media_group(
|
||||
media,
|
||||
caption=tweet.message_text,
|
||||
reply_to_message_id=update.message.message_id,
|
||||
) if not tweet.is_single_gif else await update.effective_message.reply_animation(
|
||||
media[0][0],
|
||||
caption=tweet.message_text,
|
||||
reply_to_message_id=update.message.message_id,
|
||||
has_spoiler=media[0][1]
|
||||
)
|
||||
url = tweet.url
|
||||
if context.user_data.get('edit_before_forward', False):
|
||||
message_reply = await update.effective_message.reply_text(
|
||||
"Reply to edit message. [URL]",
|
||||
reply_markup=InlineKeyboardMarkup.from_button(
|
||||
InlineKeyboardButton("↩️ Confirm", callback_data="forward")
|
||||
),
|
||||
reply_to_message_id=update.message.message_id,
|
||||
)
|
||||
context.user_data['message_reply'] = message_reply
|
||||
context.user_data['message_to_send'] = message_to_send
|
||||
context.user_data['message_url'] = url
|
||||
return
|
||||
if 'forward_channel_id' in context.user_data:
|
||||
await forward_message(update, context, message_to_send)
|
||||
|
||||
|
||||
async def forward_message(
|
||||
update: Update,
|
||||
context: ContextTypes.DEFAULT_TYPE,
|
||||
message_to_send: tuple[Message, ...],
|
||||
) -> None:
|
||||
try:
|
||||
await update.effective_chat.copy_messages(
|
||||
context.user_data['forward_channel_id'],
|
||||
[m.id for m in message_to_send]
|
||||
)
|
||||
except Exception as e:
|
||||
await update.effective_message.reply_text(str(e))
|
||||
|
||||
|
||||
async def edit_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if 'message_reply' not in context.user_data:
|
||||
return
|
||||
if update.message.reply_to_message != context.user_data['message_reply']:
|
||||
return
|
||||
template = context.user_data.get('template', None)
|
||||
message_url = '<a href="{0}">{1}</a>'
|
||||
url = context.user_data['message_url']
|
||||
if template:
|
||||
update_text = template.replace("[]", message_url.format(
|
||||
url,
|
||||
html.escape(update.message.text)
|
||||
))
|
||||
else:
|
||||
update_text = html.escape(update.message.text)
|
||||
match = common.message_url_regex.search(update_text)
|
||||
if match:
|
||||
match = match.span()
|
||||
update_text = update_text[:match[0]] + message_url.format(
|
||||
url,
|
||||
update_text[match[0] + 1:match[1] - 1]
|
||||
) + update_text[match[1]:]
|
||||
message_to_send = context.user_data['message_to_send']
|
||||
if isinstance(message_to_send, tuple):
|
||||
await message_to_send[0].edit_text(update_text)
|
||||
else:
|
||||
await message_to_send.edit_caption(update_text)
|
||||
|
||||
|
||||
async def query_forward_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
message_to_send = context.user_data['message_to_send']
|
||||
await forward_message(update, context, message_to_send)
|
||||
await update.callback_query.answer('✅ Forwarded')
|
||||
await update.callback_query.delete_message()
|
||||
del context.user_data['message_reply']
|
||||
del context.user_data['message_to_send']
|
||||
del context.user_data['message_url']
|
||||
|
||||
|
||||
@send_action(ChatAction.TYPING)
|
||||
async def cmd_set_forward_channel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if not context.args:
|
||||
await update.effective_message.reply_text("Please provide a channel username or id.")
|
||||
return
|
||||
channel = context.args[0]
|
||||
try:
|
||||
channel: Chat = await context.bot.get_chat(channel)
|
||||
except Exception as e:
|
||||
await update.effective_message.reply_text(str(e))
|
||||
return
|
||||
if channel.type != ChatType.CHANNEL:
|
||||
await update.effective_message.reply_text("That is not a channel.")
|
||||
return
|
||||
try:
|
||||
channel_admin = await channel.get_administrators()
|
||||
except Exception as e:
|
||||
await update.effective_message.reply_text(str(e) + "\nPlease add the bot to the channel and set as admin")
|
||||
return
|
||||
user_bot = filter(lambda x: x.user.id == context.bot.id, channel_admin)
|
||||
user_bot = next(user_bot, None)
|
||||
if user_bot.can_post_messages:
|
||||
context.user_data['forward_channel_id'] = channel.id
|
||||
await update.effective_message.reply_text("Add successfully.")
|
||||
|
||||
|
||||
@send_action(ChatAction.TYPING)
|
||||
async def cmd_remove_forward_channel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if 'forward_channel_id' in context.user_data:
|
||||
del context.user_data['forward_channel_id']
|
||||
await update.effective_message.reply_text("Remove successfully.")
|
||||
return
|
||||
await update.effective_message.reply_text("No channel to remove.")
|
||||
|
||||
|
||||
@send_action(ChatAction.TYPING)
|
||||
async def cmd_edit_before_forward(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if context.user_data.get('forward_channel_id', None) is None:
|
||||
await update.effective_message.reply_text("Please enable forward channel first.")
|
||||
return
|
||||
ebf_status = context.user_data.get('edit_before_forward', False)
|
||||
if ebf_status:
|
||||
context.user_data['edit_before_forward'] = False
|
||||
context.user_data.pop('message_reply', None)
|
||||
context.user_data.pop('message_to_send', None)
|
||||
context.user_data.pop('message_url', None)
|
||||
await update.effective_message.reply_text("Disable edit before forward.")
|
||||
return
|
||||
context.user_data['edit_before_forward'] = True
|
||||
await update.effective_message.reply_text("Enable edit before forward.")
|
||||
|
||||
|
||||
@send_action(ChatAction.TYPING)
|
||||
async def cmd_set_template(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
reply = update.effective_message.reply_to_message
|
||||
if not reply:
|
||||
await update.effective_message.reply_text("Please reply to a message to set as template.")
|
||||
return
|
||||
if '[]' not in reply.text_html:
|
||||
await update.effective_message.reply_text("Please reply to a message with [] to set as template.")
|
||||
return
|
||||
context.user_data['template'] = reply.text_html
|
||||
await update.effective_message.reply_text("Template set.")
|
||||
|
||||
|
||||
@send_action(ChatAction.TYPING)
|
||||
async def cmd_user_dict(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
await update.effective_message.reply_text(str(context.user_data))
|
||||
|
||||
|
||||
async def post_init(application: Application) -> None:
|
||||
# commands = [
|
||||
# BotCommand('start', CMD_START),
|
||||
# ]
|
||||
# await application.bot.set_my_commands(commands)
|
||||
DESCRIPTION = "A bot to fetch tweets from Twitter."
|
||||
await application.bot.set_my_description(DESCRIPTION)
|
||||
await application.bot.set_my_short_description(DESCRIPTION)
|
||||
TGTweet.set_session(ClientSession())
|
||||
|
||||
|
||||
async def post_stop(application: Application) -> None:
|
||||
await application.bot.send_message(common.ADMIN[0], "Shutting down...")
|
||||
|
||||
|
||||
async def post_shutdown(application: Application) -> None:
|
||||
await TGTweet.close_session()
|
||||
|
||||
|
||||
def main():
|
||||
defaults = Defaults(parse_mode=ParseMode.HTML, allow_sending_without_reply=True)
|
||||
persistence = PicklePersistence(filepath='data/pers.pkl')
|
||||
application = (ApplicationBuilder()
|
||||
.token(common.BOT_TOKEN)
|
||||
.defaults(defaults)
|
||||
.persistence(persistence)
|
||||
.post_init(post_init)
|
||||
.post_stop(post_stop)
|
||||
.post_shutdown(post_shutdown)
|
||||
.build()
|
||||
)
|
||||
|
||||
user_filter = filters.User()
|
||||
user_filter.add_user_ids(common.ADMIN)
|
||||
|
||||
handlers = [
|
||||
MessageHandler(filters.Regex(common.x_url_regex) & filters.ChatType.PRIVATE, url_media),
|
||||
InlineQueryHandler(inline_query, common.x_url_regex),
|
||||
CommandHandler("set_forward_channel", cmd_set_forward_channel),
|
||||
CommandHandler("remove_forward_channel", cmd_remove_forward_channel),
|
||||
CommandHandler("edit_before_forward", cmd_edit_before_forward),
|
||||
CommandHandler("set_template", cmd_set_template),
|
||||
MessageHandler(~filters.COMMAND & filters.ChatType.PRIVATE, edit_message),
|
||||
CallbackQueryHandler(query_forward_message, pattern="forward"),
|
||||
CommandHandler("bot_dict", cmd_user_dict, filters=user_filter),
|
||||
]
|
||||
|
||||
application.add_handlers(handlers)
|
||||
|
||||
if common.WEBHOOK:
|
||||
application.run_webhook(
|
||||
listen=common.WEBHOOK_LISTEN,
|
||||
port=common.WEBHOOK_PORT,
|
||||
secret_token=common.WEBHOOK_SECRET_TOKEN,
|
||||
key=common.WEBHOOK_KEY,
|
||||
cert=common.WEBHOOK_CERT,
|
||||
webhook_url=common.WEBHOOK_URL
|
||||
)
|
||||
else:
|
||||
application.run_polling()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,2 +0,0 @@
|
||||
python-telegram-bot[webhooks]~=21.1.1
|
||||
aiohttp[speedups]~=3.9.3
|
||||
@@ -1,248 +0,0 @@
|
||||
import html
|
||||
from typing import Generator
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from telegram import (
|
||||
InlineQueryResultPhoto,
|
||||
InlineQueryResultVideo,
|
||||
InlineQueryResultMpeg4Gif,
|
||||
InputMediaPhoto,
|
||||
InputMediaVideo
|
||||
)
|
||||
|
||||
from common import x_url_regex, x_media_regex, x_tco_regex, logger
|
||||
|
||||
twimg_url = 'https://pbs.twimg.com/'
|
||||
vx_api_url = 'https://api.vxtwitter.com/{0}/status/{1}'
|
||||
|
||||
message_raw_text = """{url}
|
||||
<a href="{author_url}">{author}</a>: {text}
|
||||
"""
|
||||
|
||||
|
||||
async def fetch_json(session: ClientSession, url: str) -> dict:
|
||||
logger.info(f"Fetching {url}")
|
||||
async with session.get(url) as response:
|
||||
assert response.status == 200, f"Failed to fetch {url}, status code {response.status}"
|
||||
return await response.json()
|
||||
|
||||
|
||||
class TweetMedia:
|
||||
def __init__(self, url: str, thumb: str, media_type: str):
|
||||
self._url: str = url
|
||||
self._thumb: str = thumb
|
||||
self._type: str = media_type
|
||||
|
||||
def __str__(self):
|
||||
return f"Media[url: {self.url} thumb: {self.thumb} type: {self.type}]"
|
||||
|
||||
@property
|
||||
def _uri(self) -> str | None:
|
||||
match = x_media_regex.match(self._url)
|
||||
if match:
|
||||
return match.group(2).removesuffix('.jpg').removesuffix('.png')
|
||||
return None
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
match self._type:
|
||||
case "image":
|
||||
return f"{twimg_url}{self._uri}?format=jpg&name=4096x4096"
|
||||
case "video":
|
||||
return self._url
|
||||
case "gif":
|
||||
return self._url
|
||||
case _:
|
||||
return self._url
|
||||
|
||||
@property
|
||||
def thumb(self) -> str:
|
||||
match self._type:
|
||||
case "image":
|
||||
return f"{twimg_url}{self._uri}?format=jpg&name=thumb"
|
||||
case "video":
|
||||
return self._thumb
|
||||
case "gif":
|
||||
return self._thumb
|
||||
case _:
|
||||
return self._thumb
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return self._type
|
||||
|
||||
|
||||
class Tweet:
|
||||
def __init__(
|
||||
self,
|
||||
tweet_id: str,
|
||||
author: str,
|
||||
author_id: str,
|
||||
text: str,
|
||||
media: list[TweetMedia],
|
||||
sensitive: bool = False
|
||||
):
|
||||
self._id: str = tweet_id
|
||||
self._author: str = author
|
||||
self._author_id: str = author_id
|
||||
self._text: str = text
|
||||
self._media: list[TweetMedia] = media
|
||||
self._sensitive: bool = sensitive
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
return f"https://twitter.com/{self._author_id}/status/{self._id}"
|
||||
|
||||
@property
|
||||
def author(self) -> str:
|
||||
return self._author
|
||||
|
||||
@property
|
||||
def author_url(self) -> str:
|
||||
return f"https://twitter.com/{self._author_id}"
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return self._text
|
||||
|
||||
@property
|
||||
def media(self) -> list[TweetMedia]:
|
||||
return self._media
|
||||
|
||||
@property
|
||||
def sensitive(self) -> bool:
|
||||
return self._sensitive
|
||||
|
||||
|
||||
class TGTweet(Tweet):
|
||||
_session: ClientSession
|
||||
|
||||
def __init__(self, url: str):
|
||||
self._url: str = url
|
||||
self._api_param: tuple[str] = self._tweet_id
|
||||
self._is_single_gif: bool = False
|
||||
assert self._api_param
|
||||
|
||||
async def __aenter__(self):
|
||||
self._tweet: dict = await self._fetch_tweet(self._api_param)
|
||||
super().__init__(*self._init_properties)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def set_session(cls, session: ClientSession) -> None:
|
||||
cls._session = session
|
||||
|
||||
@classmethod
|
||||
async def close_session(cls) -> None:
|
||||
await cls._session.close()
|
||||
|
||||
async def _fetch_tweet(self, api_param: tuple[str]) -> dict:
|
||||
return await fetch_json(self._session, vx_api_url.format(*api_param))
|
||||
|
||||
@property
|
||||
def _tweet_id(self) -> tuple[str] | None:
|
||||
match = x_url_regex.match(self._url)
|
||||
if match:
|
||||
return match.groups()
|
||||
return None
|
||||
|
||||
@property
|
||||
def _tweet_text(self) -> str:
|
||||
match = x_tco_regex.search(self._tweet['text'])
|
||||
return self._tweet['text'][:match.start()].strip(" ") if match else self._tweet['text']
|
||||
|
||||
@property
|
||||
def _tweet_media(self) -> list[TweetMedia]:
|
||||
return [
|
||||
TweetMedia(
|
||||
url=x['url'],
|
||||
thumb=x['thumbnail_url'],
|
||||
media_type=x['type']
|
||||
)
|
||||
for x in self._tweet['media_extended']
|
||||
]
|
||||
|
||||
@property
|
||||
def _init_properties(self) -> tuple:
|
||||
id = self._tweet['tweetID']
|
||||
author = self._tweet['user_name']
|
||||
author_id = self._tweet['user_screen_name']
|
||||
text = self._tweet_text
|
||||
media = self._tweet_media
|
||||
sensitive = self._tweet['possibly_sensitive']
|
||||
return id, author, author_id, text, media, sensitive
|
||||
|
||||
@property
|
||||
def is_single_gif(self) -> bool:
|
||||
return self._is_single_gif
|
||||
|
||||
@property
|
||||
def message_text(self) -> str:
|
||||
return message_raw_text.format(
|
||||
url=self.url,
|
||||
author_url=self.author_url,
|
||||
author=html.escape(self.author),
|
||||
text=html.escape(self.text)
|
||||
)
|
||||
|
||||
@property
|
||||
def inline_query_generator(self) -> Generator[
|
||||
InlineQueryResultPhoto | InlineQueryResultVideo | InlineQueryResultMpeg4Gif, None, None
|
||||
]:
|
||||
for i, tweet_media in enumerate(self.media):
|
||||
logger.info(str(tweet_media))
|
||||
if tweet_media.type == "image":
|
||||
yield InlineQueryResultPhoto(
|
||||
id=str(i),
|
||||
photo_url=tweet_media.url,
|
||||
thumbnail_url=tweet_media.thumb,
|
||||
caption=self.message_text
|
||||
)
|
||||
elif tweet_media.type == "video":
|
||||
yield InlineQueryResultVideo(
|
||||
id=str(i),
|
||||
video_url=tweet_media.url,
|
||||
mime_type="video/mp4",
|
||||
thumbnail_url=tweet_media.thumb,
|
||||
title=self.text,
|
||||
caption=self.message_text
|
||||
)
|
||||
elif tweet_media.type == "gif":
|
||||
yield InlineQueryResultMpeg4Gif(
|
||||
id=str(i),
|
||||
mpeg4_url=tweet_media.url,
|
||||
thumbnail_url=tweet_media.thumb,
|
||||
caption=self.message_text
|
||||
)
|
||||
|
||||
@property
|
||||
def pm_media_generator(self) -> Generator[InputMediaPhoto | InputMediaVideo | tuple[str, bool], None, None]:
|
||||
for tweet_media in self.media:
|
||||
logger.info(str(tweet_media))
|
||||
if tweet_media.type == "image":
|
||||
yield InputMediaPhoto(
|
||||
media=tweet_media.url,
|
||||
has_spoiler=self.sensitive
|
||||
)
|
||||
elif tweet_media.type == "video":
|
||||
yield InputMediaVideo(
|
||||
media=tweet_media.url,
|
||||
has_spoiler=self.sensitive,
|
||||
thumbnail=tweet_media.thumb
|
||||
)
|
||||
elif tweet_media.type == "gif":
|
||||
if len(self.media) == 1:
|
||||
self._is_single_gif = True
|
||||
yield tweet_media.url, self.sensitive
|
||||
yield InputMediaVideo(
|
||||
media=tweet_media.url,
|
||||
has_spoiler=self.sensitive,
|
||||
thumbnail=tweet_media.thumb
|
||||
)
|
||||
Reference in New Issue
Block a user