mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-23 23:32:05 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f6a0f034a
|
||
|
|
90a011e978
|
||
|
|
12a065846c
|
||
|
|
dca1eff1c9
|
||
|
|
894a9ebf4a
|
||
|
|
c968891ff6
|
||
|
|
0087bd01ac
|
||
|
|
4cb40909c5
|
||
|
|
f260f41755
|
||
|
|
af901caddb
|
@@ -4,8 +4,9 @@ name: CI
|
||||
# job that exercises the real source sites and the token-gated pixiv tests.
|
||||
#
|
||||
# Layering:
|
||||
# test — fmt + clippy + the full offline unit suite. Runs on every push
|
||||
# and PR, including forks (it needs no secrets).
|
||||
# test — fmt + clippy + the full offline unit suite + 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
|
||||
@@ -42,6 +43,12 @@ jobs:
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
- name: Run offline tests
|
||||
run: cargo test --workspace
|
||||
# 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@v1
|
||||
|
||||
live:
|
||||
needs: test
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
## Project Overview
|
||||
|
||||
Telegram bot (teloxide) that turns post links from X/Twitter, Pixiv, and Bluesky into media messages (images, video, GIF) with the post's title, author, and tags. It supports batch media splitting, retry with persistence, inline queries, forward-channel rebinding with caption templates, and Pixiv ugoira→MP4 transcoding. README and user-facing strings are in Chinese. The project is a Rust port of a Python predecessor (see `queue.rs` comments referencing `utils/task_queue.py`).
|
||||
Telegram bot (teloxide) that turns post links from X/Twitter, Pixiv, and Bluesky into media messages (images, video, GIF) with the post's title, author, and tags. It supports batch media splitting, retry with persistence, inline queries, forward-channel rebinding with caption templates, and Pixiv ugoira→MP4 transcoding. README 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.3.0, edition 2024, resolver 3):
|
||||
Two-crate Cargo workspace (both v1.4.0, edition 2024, resolver 3):
|
||||
|
||||
- **`crates/x-media`** — library that fetches and normalizes media from the three sites. Pure, no Telegram knowledge.
|
||||
- **`crates/xmedia-bot`** — the bot binary: teloxide dispatcher, SQLite-backed chat state, persistent task queue.
|
||||
@@ -20,7 +20,7 @@ Telegram update → Dispatcher (polling or axum webhook) → dptree branches
|
||||
|
||||
Message flow: `message_handler` extracts URLs (from `url`/`text_link` entities, text + caption, deduped) → `x_media::site::fetch(url)` → `Fetched` → builds a `Task` → `send::send_media_sequence` (media groups ≤ 9, caption on first item) or `send::send_animation`. On Telegram URL-fetch failure or size error (`send_batch_via_upload`): download via `x_media::site::download_media` to a temp file (≤ 10 MiB), sniff magic bytes (`sniff_ext`), upload via multipart; oversized items fall back to `fallback_url`. On failure: `enqueue_retry` persists resume-state `Task` into the SQLite queue → workers lease (120 s lock TTL) → retry with exponential backoff (≤ 30 s, `MAX_RETRIES = 2`) → dead-letter → `notify_failure`. Success → `post_send_actions`: edit-before-forward prompt with inline buttons, or `copy_messages` to the bound forward channel.
|
||||
|
||||
Debug command: `/test <url>` runs the same `x_media::site::fetch` and replies with `test_parse_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 (Telegram's 4096 plain-text limit). It uses a custom `parse_test_arg` parser (whole remainder, trimmed) because teloxide's built-in `split` parser takes exactly one space-separated token.
|
||||
Debug command: `/test <url>` runs the same `x_media::site::fetch` and replies with `test_parse_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). It uses a custom `parse_test_arg` parser (whole remainder, trimmed) because teloxide's built-in `split` parser takes exactly one space-separated token.
|
||||
|
||||
The `x-media` library: `site::fetch(url)` dispatches through the `SITES` registry (per-site `impl Site`, in order twitter → bsky → pixiv) and returns `Ok(None)` for unmatched URLs. `Fetched { source_url, caption, title, media: Vec<Media>, sensitive, site_id, … }`; `caption_with(format)` substitutes `{url} {author} {author_url} {title} {tags}`.
|
||||
|
||||
@@ -29,14 +29,17 @@ The `x-media` library: `site::fetch(url)` dispatches through the `SITES` registr
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `crates/x-media/src/` | Fetch library. `site/mod.rs` = dispatcher + `Fetched`/`FetchError`/`download_media`/`media_size`; `media.rs` = `Media` enum; `examples/fetch.rs` = end-to-end usage sample |
|
||||
| `crates/x-media/src/site/<twitter\|pixiv\|bsky>/` | One directory per site: `mod.rs` (re-exports), `interface.rs` (PATTERN, `enabled()`, `fetch_from_url()`, `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`) |
|
||||
| `crates/x-media/src/site/<twitter\|pixiv\|bsky>/` | 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`). Twitter's `from_syndication_json` HTML-decodes the API text — syndication and GraphQL `full_text` both arrive pre-escaped (`>` `<` `&` `'`) — so the stored text is raw and the caption escapes exactly once |
|
||||
| `crates/xmedia-bot/src/main.rs` | Entry point: env/log init, command registration (`register_commands`), shared `send::BOT` force-init, queue worker start, site login validation (`site::validate_all`), 300 s edit-expiry sweep, dptree handler tree, webhook vs polling dispatch |
|
||||
| `crates/xmedia-bot/src/config.rs` | Manual env parsing into `Config` |
|
||||
| `crates/xmedia-bot/src/db.rs` | `DbPool`: per-store SQLite connection pool (`POOL_SIZE = 4`, WAL, busy_timeout) over `$DATA_DIR/task_queue.db` (default `data/`); `open_store` creates file + schema, `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`), `commands.rs` (teloxide `BotCommands` enum + command executor, incl. the `/test <url>` parse-only debug command), `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), `inline.rs`/`callback.rs` (inline queries / edit-before-forward buttons), `statics.rs` (global statics) |
|
||||
| `crates/xmedia-bot/src/state.rs` | `ChatStore`: parking_lot `Mutex<HashMap>` cache + SQLite write-through (`chat_state` table) |
|
||||
| `crates/xmedia-bot/src/link_cache.rs` | `LinkCache`: SQLite-backed cache (`link_cache` table) of successfully sent posts — raw caption fields + Telegram `file_id`s; repeat links re-send locally (no fetch/upload), TTL + prune, invalidated on permanent send failure |
|
||||
| `crates/xmedia-bot/src/queue.rs` | `PersistentTaskQueue`: SQLite-backed queue (`tasks` table), `QUEUE_WORKERS = 4` concurrent workers (lease via `BEGIN IMMEDIATE` + `locked_until` TTL), retry→dead-letter, `Notify::notify_waiters` wakeup, `busy_timeout` on all connections |
|
||||
| `crates/xmedia-bot/src/send.rs` | Media senders, upload fallback, error classification, queue task handlers |
|
||||
| `crates/xmedia-bot/src/media_sender.rs` | `MediaSender` trait: the send surface (`send_media_group`/`send_animation`/`copy_messages`/`send_message`/`send_chat_action`) implemented by teloxide `Bot` (per-chat rate-limited) and by a scripted `MockSender` in tests |
|
||||
| `crates/xmedia-bot/src/rate_limit.rs` | Per-chat token bucket (`CAPACITY = 20`, ~20 msg/min refill) paced before sends reach the API so batch forwards don't trip flood control |
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -55,7 +58,7 @@ Docker: `docker build -t tgxmb .` then `docker run --rm -d --name tgxmb --env-fi
|
||||
## Code Conventions & Common Patterns
|
||||
|
||||
- **Errors via `thiserror` derive** (no anyhow): the public, stringified errors — `FetchError` (`Http`/`Json`/`Pixiv`/`Site`/`NotFound`/`Blocked`) 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.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).
|
||||
- **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.
|
||||
@@ -69,7 +72,7 @@ Docker: `docker build -t tgxmb .` then `docker run --rm -d --name tgxmb --env-fi
|
||||
| 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/task_queue.db` **relative to CWD**); `commands.rs` = command dispatch (incl. the `/test <url>` parse-only debug command); `urls.rs` = URL extraction + retry enqueue; `inline.rs`/`callback.rs` = inline queries / edit-before-forward buttons |
|
||||
| `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); `commands.rs` = command dispatch (incl. the `/test <url>` parse-only debug command); `urls.rs` = URL extraction + retry enqueue; `inline.rs`/`callback.rs` = inline queries / edit-before-forward buttons |
|
||||
| `crates/xmedia-bot/src/send.rs` | Constants `MAX_MEDIA_GROUP = 9`; fallback chain; `classify_request_error`; download-and-reupload fallback triggered only by Telegram API errors (`is_media_fetch_failure` / `is_size_error`) |
|
||||
| `crates/xmedia-bot/src/photo.rs` | Pure-Rust photo processing (no ffmpeg): `png` (image-png) decode/encode + `zune-jpeg` decode + `fast_image_resize` Lanczos3 downscale + `jpeg-encoder`. Photos over Telegram's limits (width + height > 10000 px → `PHOTO_INVALID_DIMENSIONS`; bytes > 10 MiB) are decoded, downscaled keeping the format, PNG bit depth > 24 (RGBA 32-bit / 16-bit per channel) reduced to 24-bit RGB with alpha flattened white (≤24-bit untouched, never upconverted), and transcoded to JPEG only if still over the cap; memory budget guarded, otherwise the item's smaller fallback URL |
|
||||
| `crates/x-media/src/site/mod.rs` | Dispatcher, `Fetched`/`FetchError`, shared `CLIENT`, `download_media` (adds `Referer: https://www.pixiv.net/` for `pximg.net` hotlink protection) |
|
||||
@@ -85,18 +88,18 @@ Docker: `docker build -t tgxmb .` then `docker run --rm -d --name tgxmb --env-fi
|
||||
- **Rust, stable, edition 2024**, workspace resolver 3. No `rust-version`/MSRV pin, no `rust-toolchain.toml` — recent stable is assumed. No nightly features.
|
||||
- Package manager: **Cargo** (workspace with path dep `x-media` ← `xmedia-bot`). No `[workspace.package]`/shared deps — each crate lists deps independently.
|
||||
- **TLS is rustls end-to-end** (no native-tls/openssl in the tree, no libssl in the Docker runtime image): `teloxide` is declared `default-features = false` with `["webhooks-axum", "macros", "rustls", "ctrlc_handler"]` (the removed `default` also carried `native-tls` and `ctrlc_handler` — the latter must stay); x-media's reqwest is `default-features = false` with `["json", "rustls-tls"]` (webpki-roots baked in, so the image ships no CA bundle). One reqwest 0.12.28 in the lock.
|
||||
- **Versioning**: bump the version in all three places (`crates/x-media/Cargo.toml`, `crates/xmedia-bot/Cargo.toml`, `Cargo.lock`) and **keep `README.md` and `AGENTS.md` in sync with the code on every bump**, then commit (`chore: bump version to X.Y.Z`), create an annotated tag `vX.Y.Z`, and push branch + tag (the tag push triggers the Docker Hub build).
|
||||
- Config is **environment-variable driven** (dotenv loads `.env`, gitignored; no `.env.example` exists). Key vars: `TELOXIDE_TOKEN` (required), `PIXIV_REFRESH_TOKEN`, `TWITTER_AUTH_TOKEN` (optional; x.com `auth_token` cookie — enables the logged-in GraphQL fallback that fetches NSFW tweets syndication withholds), `BOT_ADMIN` (comma-separated ids), `EDIT_MESSAGE_TTL_SECONDS` (default 86400), `LINK_CACHE_TTL_SECONDS` (default 604800), `WEBHOOK`/`WEBHOOK_URL`/`WEBHOOK_LISTEN`/`WEBHOOK_PORT`/`WEBHOOK_CERT`/`WEBHOOK_SECRET_TOKEN` (webhook mode requires URL/listen/port, `.expect`ed; `WEBHOOK_CERT` is Telegram-facing self-signed validation only — TLS must be terminated by a reverse proxy), `RUST_LOG`, `TELOXIDE_PROXY`, `LOCAL_USER_ID` (entrypoint only).
|
||||
- SQLite via `rusqlite` with `bundled` feature (no system libsqlite needed). DB file `data/task_queue.db` is CWD-relative — run from the workspace root, or `/app` in Docker. Mount `./data` and `./cert` volumes.
|
||||
- **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 (the tag push triggers the Docker Hub build).
|
||||
- Config is **environment-variable driven** (dotenv loads `.env`, gitignored; no `.env.example` exists). Key vars: `TELOXIDE_TOKEN` (required), `PIXIV_REFRESH_TOKEN`, `TWITTER_AUTH_TOKEN` (optional; x.com `auth_token` cookie — enables the logged-in GraphQL fallback that fetches NSFW tweets syndication withholds), `BOT_ADMIN` (comma-separated ids), `EDIT_MESSAGE_TTL_SECONDS` (default 86400), `LINK_CACHE_TTL_SECONDS` (default 604800), `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/`, `docker-compose.yml`, `/target`, `.idea/`.
|
||||
- Docs are in Chinese; user-facing bot strings too. Keep that convention when editing captions/templates/docs.
|
||||
- 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
|
||||
|
||||
- **~80 tests, all inline `#[cfg(test)] mod tests`** — no `tests/` integration directories. Framework: built-in Rust test + `#[tokio::test]` (dev-deps only in `x-media`: tokio macros/rt-multi-thread, dotenv).
|
||||
- **~115 tests, all inline `#[cfg(test)] mod tests`** — no `tests/` integration directories. Framework: built-in Rust test + `#[tokio::test]` (dev-deps only in `x-media`: tokio macros/rt-multi-thread, dotenv).
|
||||
- No mocking framework anywhere (no mockito/wiremock/mockall). Conventions: pure-function units (regex parsing, serde round-trips, chunking, retry math) tested synchronously; async tests use real dependencies — file-backed SQLite via `tempfile` (`queue.rs::new_queue()` helper), live network fetches.
|
||||
- Live-network tests exist in `site/twitter/interface.rs` (3), `site/bsky/interface.rs` (2), `site/pixiv/interface.rs`/`api.rs`. Test gating convention (enforced by `.github/workflows/ci.yml`): pure unit tests always run; live-network tests carry `#[ignore = "live network: ..."]` (run via `cargo test --workspace -- --ignored live`); token-gated pixiv tests early-return when `PIXIV_REFRESH_TOKEN` is absent **or empty** (an unset GitHub secret arrives as `""` — `is_err()` alone would run them tokenless and fail). Run the full offline suite with `cargo test --workspace`.
|
||||
- Live-network tests exist in `site/twitter/interface.rs` (5), `site/bsky/interface.rs` (2), `site/pixiv/api.rs` (1); `photo.rs` adds one `#[ignore = "heavy: …"]` test. Test gating convention (enforced by `.github/workflows/ci.yml`): pure unit tests always run; live-network tests carry `#[ignore = "live network: ..."]` (run via `cargo test --workspace -- --ignored live`); token-gated pixiv tests early-return when `PIXIV_REFRESH_TOKEN` is absent **or empty** (an unset GitHub secret arrives as `""` — `is_err()` alone would run them tokenless and fail). Run the full offline suite with `cargo test --workspace`.
|
||||
- Fixtures are inline `serde_json::json!` builder fns (`fixture()`, `thread_json()`, `illust_json()`), not files. The shared `CLIENT` sets `pool_max_idle_per_host(0)` under `#[cfg(test)]` to avoid cross-runtime `DispatchGone`.
|
||||
- **CI** — `.github/workflows/ci.yml` runs `cargo fmt --check` + `cargo clippy --workspace --all-targets -- -D warnings` + `cargo test --workspace` (offline, no secrets, on every push/PR) and a `live` job (schedule/manual/tag only, `PIXIV_REFRESH_TOKEN`/`TWITTER_AUTH_TOKEN` from secrets, `continue-on-error`) for the `#[ignore]`d live + token tests. `.github/workflows/docker.yml` builds/pushes the image only.
|
||||
- **CI** — `.github/workflows/ci.yml` runs `cargo fmt --check` + `cargo clippy --workspace --all-targets -- -D warnings` + `cargo test --workspace` + a `actions-rust-lang/audit` dependency-vulnerability gate (offline, no secrets, on every push/PR) and a `live` job (schedule/manual/tag only, `PIXIV_REFRESH_TOKEN`/`TWITTER_AUTH_TOKEN` from secrets, `continue-on-error`) for the `#[ignore]`d live + token tests. `.github/workflows/docker.yml` builds/pushes the image only.
|
||||
- Untested and hard to test without a mock seam: `handlers.rs` (depends directly on teloxide `Bot`); `main.rs`, `config.rs`, `state.rs`; `media.rs`, `lib.rs`, all `model.rs`.
|
||||
- No coverage tracking.
|
||||
|
||||
Generated
+2
-2
@@ -2925,7 +2925,7 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "x-media"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"dotenv",
|
||||
@@ -2945,7 +2945,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "xmedia-bot"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"dotenv",
|
||||
|
||||
+2
-1
@@ -30,7 +30,7 @@ docker build -t tgxmb .
|
||||
docker run --rm -d --name tgxmb --env-file .env -v ./data:/app/data tgxmb
|
||||
```
|
||||
|
||||
Environment variables: `TELOXIDE_TOKEN` (required), `PIXIV_REFRESH_TOKEN`, `BOT_ADMIN`, `EDIT_MESSAGE_TTL_SECONDS`, `LINK_CACHE_TTL_SECONDS`, `RUST_LOG`, `WEBHOOK*`, `TWITTER_AUTH_TOKEN` (optional).
|
||||
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).
|
||||
|
||||
NSFW tweets: the public syndication endpoint does not return sensitive content. Setting `TWITTER_AUTH_TOKEN` (the `auth_token` cookie value of a logged-in x.com session) lets the bot fetch NSFW media in the logged-in state only when it hits a withheld tweet; without it, the bot reports no media.
|
||||
|
||||
@@ -84,6 +84,7 @@ Telegram only accepts ports 443/80/88/8443.
|
||||
| `BOT_ADMIN` | Admin chat IDs, comma-separated; receives start/stop notifications |
|
||||
| `EDIT_MESSAGE_TTL_SECONDS` | Edit-before-forward record expiry in seconds, default 86400 |
|
||||
| `LINK_CACHE_TTL_SECONDS` | Link-result cache expiry in seconds, default 604800 (7 days) |
|
||||
| `DATA_DIR` | Data directory (where the SQLite `task_queue.db` lives), default `data` (relative to the working directory, created automatically) |
|
||||
| `RUST_LOG` | Log level |
|
||||
| `TELOXIDE_PROXY` | HTTP proxy (e.g. `http://127.0.0.1:10808`); applies to both the Telegram Bot API and site fetches — required on restricted networks (e.g. behind the GFW) |
|
||||
| `LOCAL_USER_ID` | UID the container runs as, default 9001 |
|
||||
|
||||
@@ -30,7 +30,7 @@ docker build -t tgxmb .
|
||||
docker run --rm -d --name tgxmb --env-file .env -v ./data:/app/data tgxmb
|
||||
```
|
||||
|
||||
环境变量:`TELOXIDE_TOKEN`(必填)、`PIXIV_REFRESH_TOKEN`、`BOT_ADMIN`、`EDIT_MESSAGE_TTL_SECONDS`、`LINK_CACHE_TTL_SECONDS`、`RUST_LOG`、`WEBHOOK*`、`TWITTER_AUTH_TOKEN`(可选)。
|
||||
环境变量:`TELOXIDE_TOKEN`(必填)、`PIXIV_REFRESH_TOKEN`、`BOT_ADMIN`、`EDIT_MESSAGE_TTL_SECONDS`、`LINK_CACHE_TTL_SECONDS`、`RUST_LOG`、`TELOXIDE_PROXY`、`WEBHOOK*`、`TWITTER_AUTH_TOKEN`(可选)。
|
||||
|
||||
NSFW 推文:公开的 syndication 接口不返回敏感内容。设置 `TWITTER_AUTH_TOKEN`(登录 x.com 后浏览器 Cookie 里的 `auth_token` 值)后,bot 会仅在遇到 NSFW 推文时以登录态获取媒体;未设置则提示无媒体。
|
||||
|
||||
@@ -84,6 +84,7 @@ Telegram 只接受 443/80/88/8443 端口。
|
||||
| `BOT_ADMIN` | 管理员聊天 ID,逗号分隔;接收启动/停止通知 |
|
||||
| `EDIT_MESSAGE_TTL_SECONDS` | 转发前编辑记录过期秒数,默认 86400 |
|
||||
| `LINK_CACHE_TTL_SECONDS` | 链接结果缓存过期秒数,默认 604800(7 天) |
|
||||
| `DATA_DIR` | 数据目录(SQLite 数据库 `task_queue.db` 所在目录),默认 `data`(相对工作目录,会自动创建) |
|
||||
| `RUST_LOG` | 日志级别 |
|
||||
| `TELOXIDE_PROXY` | HTTP 代理(如 `http://127.0.0.1:10808`);同时作用于 Telegram Bot API 与站点抓取请求,网络受限环境(如 GFW)必需 |
|
||||
| `LOCAL_USER_ID` | 容器内运行用户 UID,默认 9001 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "x-media"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::model;
|
||||
use crate::media::Media;
|
||||
use crate::site::{FetchError, Fetched, Site, SiteFuture};
|
||||
use html_escape::{encode_double_quoted_attribute, encode_text};
|
||||
use html_escape::{decode_html_entities, encode_double_quoted_attribute, encode_text};
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
@@ -239,9 +239,17 @@ impl Tweet {
|
||||
// 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).
|
||||
let author = json.user.name;
|
||||
// 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 {
|
||||
@@ -409,6 +417,41 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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_json(&raw.to_string()).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, ">^ω^< & 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!(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "xmedia-bot"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Bot command parsing, the `/`-command executor and `setMyCommands`
|
||||
//! registration. URL/inline/callback flows live in their own modules.
|
||||
|
||||
use super::{CHAT_STORE, CONFIG, LINK_CACHE, log_key, reply};
|
||||
use super::{CHAT_STORE, CONFIG, LINK_CACHE, log_key, reply, reply_html};
|
||||
use teloxide::RequestError;
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::types::{ChatId, Message, Recipient};
|
||||
@@ -361,7 +361,9 @@ pub(crate) async fn execute_command(
|
||||
&fetched.caption,
|
||||
&fetched.media,
|
||||
);
|
||||
reply(bot, message.chat.id.0, message.id, report).await?;
|
||||
// HTML report: the caption renders inside a <blockquote>
|
||||
// exactly as it will appear in the sent media message.
|
||||
reply_html(bot, message.chat.id.0, message.id, report).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,12 +389,15 @@ pub async fn register_commands(bot: &Bot) -> Result<(), RequestError> {
|
||||
/// it even for very large threads (many media lines + a long caption).
|
||||
const MAX_TEST_REPORT_CHARS: usize = 4000;
|
||||
|
||||
/// Builds the plain-text report for the `/test` command: what the parser
|
||||
/// produced for a link (site, canonical URL, title/author/tags, caption and
|
||||
/// the media list) — no media is sent and nothing is cached or forwarded.
|
||||
/// Fields are passed individually so the formatter stays a pure function
|
||||
/// testable without constructing a `Fetched` (its render fields are
|
||||
/// `pub(crate)` to the x-media crate).
|
||||
/// Builds the HTML report for the `/test` command: what the parser produced
|
||||
/// for a link (site, canonical URL, title/author/tags, caption and the media
|
||||
/// list) — no media is sent and nothing is cached or forwarded. Sent with
|
||||
/// HTML parse mode: raw fields are escaped, the pre-escaped render fields are
|
||||
/// embedded as-is, and the caption is wrapped in a `<blockquote>` so it shows
|
||||
/// exactly as it will render in the sent media message. Fields are passed
|
||||
/// individually so the formatter stays a pure function testable without
|
||||
/// constructing a `Fetched` (its render fields are `pub(crate)` to the
|
||||
/// x-media crate).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn test_parse_report(
|
||||
url: &str,
|
||||
@@ -405,23 +410,37 @@ fn test_parse_report(
|
||||
media: &[x_media::media::Media],
|
||||
) -> String {
|
||||
let mut lines = vec![
|
||||
format!("Parse result for {url}"),
|
||||
format!("Parse result for {}", html_escape::encode_text(url)),
|
||||
format!("site: {site_id}"),
|
||||
format!(
|
||||
"key: {}",
|
||||
x_media::site::cache_key(url).unwrap_or_else(|| "<unsupported>".to_string())
|
||||
html_escape::encode_text(
|
||||
&x_media::site::cache_key(url).unwrap_or_else(|| "<unsupported>".to_string())
|
||||
)
|
||||
),
|
||||
];
|
||||
lines.push(format!("source_url: {source_url}"));
|
||||
lines.push(format!("title: {title}"));
|
||||
lines.push(format!(
|
||||
"source_url: {}",
|
||||
html_escape::encode_text(source_url)
|
||||
));
|
||||
lines.push(format!("title: {}", html_escape::encode_text(title)));
|
||||
if let Some((author, author_url, _title, tags)) = render {
|
||||
// The render fields are already pre-escaped for HTML captions; embed
|
||||
// them as-is so the report renders them exactly like the final
|
||||
// caption. `author_url` is raw and gets escaped here.
|
||||
lines.push(format!("author: {author}"));
|
||||
lines.push(format!("author_url: {author_url}"));
|
||||
lines.push(format!(
|
||||
"author_url: {}",
|
||||
html_escape::encode_text(author_url)
|
||||
));
|
||||
lines.push(format!("tags: {tags}"));
|
||||
}
|
||||
lines.push(format!("sensitive: {sensitive}"));
|
||||
// The caption is wrapped in a <blockquote> so the report (an HTML
|
||||
// message) shows it exactly as it will render in the sent media caption
|
||||
// — escaped text and links included.
|
||||
lines.push(format!(
|
||||
"caption: {}",
|
||||
"caption: <blockquote>{}</blockquote>",
|
||||
x_media::site::truncate_caption(caption)
|
||||
));
|
||||
lines.push(format!("media ({}):", media.len()));
|
||||
@@ -431,7 +450,11 @@ fn test_parse_report(
|
||||
x_media::media::Media::Video { .. } => "video",
|
||||
x_media::media::Media::Animated { .. } => "gif",
|
||||
};
|
||||
lines.push(format!(" {}. {kind}: {}", i + 1, item.url()));
|
||||
lines.push(format!(
|
||||
" {}. {kind}: {}",
|
||||
i + 1,
|
||||
html_escape::encode_text(item.url())
|
||||
));
|
||||
}
|
||||
let mut out = lines.join(
|
||||
"
|
||||
@@ -500,6 +523,45 @@ mod tests {
|
||||
assert!(report.contains("media (0):"), "{report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_report_wraps_caption_in_blockquote() {
|
||||
// The report is an HTML message: raw fields are escaped, pre-escaped
|
||||
// render fields are embedded as-is, and the caption is wrapped in a
|
||||
// <blockquote> so it shows exactly as it will render in the sent
|
||||
// media caption (escaped text and links included).
|
||||
let report = test_parse_report(
|
||||
"https://x.com/u/status/1",
|
||||
"twitter",
|
||||
"https://x.com/u/status/1",
|
||||
"A & B <C>",
|
||||
Some((
|
||||
"A & B",
|
||||
"https://x.com/u",
|
||||
"A & B <C>",
|
||||
"#a & #b",
|
||||
)),
|
||||
false,
|
||||
"<a href=\"https://x.com/u\">A & B</a>: C <D> & E",
|
||||
&[],
|
||||
);
|
||||
// Raw fields escaped (they render back to the original text in HTML).
|
||||
assert!(report.contains("title: A & B <C>"), "{report}");
|
||||
assert!(
|
||||
report.contains("source_url: https://x.com/u/status/1"),
|
||||
"{report}"
|
||||
);
|
||||
// Pre-escaped render fields embedded as-is.
|
||||
assert!(report.contains("author: A & B"), "{report}");
|
||||
assert!(report.contains("tags: #a & #b"), "{report}");
|
||||
// Caption wrapped in a blockquote with its HTML preserved.
|
||||
assert!(
|
||||
report.contains(
|
||||
"caption: <blockquote><a href=\"https://x.com/u\">A & B</a>: C <D> & E</blockquote>"
|
||||
),
|
||||
"{report}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_report_is_capped() {
|
||||
// 200 media lines ≈ 8 KB, comfortably over the cap.
|
||||
|
||||
@@ -22,7 +22,7 @@ use crate::media_sender::MediaSender;
|
||||
use commands::{Command, execute_command};
|
||||
use teloxide::RequestError;
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::types::{ChatId, ChatKind, Message, MessageId, ParseMode};
|
||||
use teloxide::types::{ChatId, ChatKind, Message, MessageId, ParseMode, ReplyParameters};
|
||||
use teloxide::utils::command::BotCommands;
|
||||
use urls::{URL_JOBS, extract_urls};
|
||||
|
||||
@@ -42,6 +42,23 @@ where
|
||||
.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<Message, 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
|
||||
}
|
||||
|
||||
/// 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`) instead of the raw URL, so logs stay short and do not
|
||||
|
||||
@@ -12,8 +12,24 @@ use std::sync::{Arc, LazyLock};
|
||||
/// 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(|| db::open_store("data/task_queue.db").expect("failed to open database"));
|
||||
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.
|
||||
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> =
|
||||
|
||||
@@ -307,6 +307,11 @@ impl QueueWorker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Processes one leased row, keeping the lease alive while the handler
|
||||
/// runs. Without the heartbeat a task longer than [`LOCK_TTL_SECONDS`]
|
||||
/// (slow download, ugoira encode, rate-limited batch forward) would have
|
||||
/// its lease expire mid-run; the expiry sweep would flip the row back to
|
||||
/// `pending` and another worker would process it again — duplicate sends.
|
||||
async fn process(&self, row: LeasedRow) {
|
||||
let payload: Value = match serde_json::from_str(&row.payload) {
|
||||
Ok(value) => value,
|
||||
@@ -318,7 +323,8 @@ impl QueueWorker {
|
||||
}
|
||||
};
|
||||
log::debug!("processing {} (attempt {})", row.id, row.attempts + 1);
|
||||
match (self.handler)(payload).await {
|
||||
let outcome = self.run_with_lease(&row.id, payload).await;
|
||||
match outcome {
|
||||
Ok(()) => {
|
||||
log::debug!("task {} completed", row.id);
|
||||
self.delete_row(&row.id).await;
|
||||
@@ -351,6 +357,42 @@ impl QueueWorker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives the handler to completion, refreshing the row's `locked_until`
|
||||
/// every 30 s so the expiry sweep never re-leases a still-running task.
|
||||
/// The heartbeat is part of this future, not a separate spawned task: if
|
||||
/// the worker task dies (panic) the heartbeat dies with it and the sweep
|
||||
/// recovers the row exactly as before.
|
||||
async fn run_with_lease(&self, id: &str, payload: Value) -> Result<(), QueueError> {
|
||||
let fut = (self.handler)(payload);
|
||||
tokio::pin!(fut);
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||
// The first interval tick fires immediately; skip it (the lease was
|
||||
// just set by lease_next).
|
||||
interval.tick().await;
|
||||
let id_owned = id.to_string();
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = &mut fut => return result,
|
||||
_ = interval.tick() => {
|
||||
let now = now_f64();
|
||||
let id = id_owned.clone();
|
||||
let result = self
|
||||
.pool
|
||||
.with_conn(move |conn| {
|
||||
conn.execute(
|
||||
"UPDATE tasks SET locked_until=?1 WHERE id=?2 AND status='in_progress'",
|
||||
params![now + LOCK_TTL_SECONDS, id],
|
||||
)
|
||||
})
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
log::error!("queue lease heartbeat failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_row(&self, id: &str) {
|
||||
let id = id.to_string();
|
||||
let result = self
|
||||
|
||||
@@ -1321,18 +1321,6 @@ pub async fn handle_task(payload: serde_json::Value) -> Result<(), QueueError> {
|
||||
}
|
||||
};
|
||||
let bot = BOT.clone();
|
||||
// A resumed multi-batch send already ran post_send_actions (edit prompt /
|
||||
// forward) when it first started; running them again on the resume would
|
||||
// open a duplicate edit prompt and double-forward. SendAnimation is
|
||||
// atomic (always a fresh run), so only SendMediaSequence can resume.
|
||||
let resumed = matches!(
|
||||
&task,
|
||||
Task::SendMediaSequence {
|
||||
batch_index,
|
||||
sent_message_ids,
|
||||
..
|
||||
} if *batch_index > 0 || !sent_message_ids.is_empty()
|
||||
);
|
||||
match task {
|
||||
Task::SendMediaSequence { .. } | Task::SendAnimation { .. } => {
|
||||
let message_ids = match send_media_or_animation(&bot, &task).await {
|
||||
@@ -1356,9 +1344,14 @@ pub async fn handle_task(payload: serde_json::Value) -> Result<(), QueueError> {
|
||||
});
|
||||
}
|
||||
};
|
||||
if !resumed {
|
||||
post_send_actions(&bot, &task, message_ids).await;
|
||||
}
|
||||
// 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(&bot, &task, message_ids).await;
|
||||
release_keep_alive(&task);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user