mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-11-13 07:42:45 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd141ac2ef | ||
|
|
153b43c84d
|
||
|
|
132fdd2999
|
||
|
|
8f6e78395b | ||
|
|
190cfd5e4b | ||
|
|
f11ceb29e1 | ||
|
|
1ec78b1c8d | ||
|
|
d7bec116bf | ||
|
|
214b51de28 | ||
|
|
d132885fdc | ||
|
|
0d35109f45 | ||
|
|
6b6f1f6437 | ||
|
|
50c71f516b | ||
|
|
8d46879fed | ||
|
|
cd719eba3a |
@@ -124,7 +124,7 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # 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
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2
|
||||
# All #[ignore]d live tests, by the "live" name filter, in both crates:
|
||||
# the bot crate has two of its own (repair refetch, text-only link)
|
||||
# whose fetches are network-bound but whose sends go through MockSender.
|
||||
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
-
|
||||
name: Login to Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
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.3, edition 2024, resolver 3):
|
||||
Two-crate Cargo workspace (both v1.9.4, 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.
|
||||
@@ -46,7 +46,7 @@ The `x-media` library: `site::fetch(url)` dispatches through the `SITES` registr
|
||||
| `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 (and the handler verifies ownership with a fresh read — `Lease`/`lease_still_held`, carried on a task-local — before running side effects a re-lease would duplicate: a channel forward, an edit prompt, the keep-alive release); 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 (`media: MediaRef`, i.e. `Source` URL-or-path vs `FileId` — one field used to carry both with a flag), `send_media_sequence`/`send_animation`/`forward_messages`; `send/error.rs`: the Bot API error policy (`SendError`/`Classification`, `classify_request_error`, the media-fetch/size markers); `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/send/` | `send/mod.rs`: `Task`/`MediaItemPayload` payloads (`media: MediaRef`, i.e. `Source` URL-or-path vs `FileId` — one field used to carry both with a flag), `send_media_sequence`/`send_animation`/`forward_messages`; `send/error.rs`: the Bot API error policy (`SendError`/`Classification`, `classify_request_error`, the media-fetch/size markers); `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` (the edit-before-forward prompt replies to the post's own first media message — the one whose caption it later edits — rather than the user's link message, so one message carrying several links opens several distinguishable prompts), `handle_task`/`dead_letter_notify` |
|
||||
| `crates/xmedia-bot/src/media_sender/{mod.rs,test_support.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`/`answer_inline_query`) 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.rs` (cfg(test)-only) 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 |
|
||||
|
||||
|
||||
Generated
+20
-21
@@ -717,9 +717,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "html-escape"
|
||||
version = "0.2.15"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5"
|
||||
checksum = "491194d601b4fa9fac3c9571e014070a0ad1bc3392d1bc10419695196a8b3cc3"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
@@ -1114,15 +1114,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.38.2"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
|
||||
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
@@ -1149,9 +1149,9 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
checksum = "65d91b22da179613dcf4d6ec8349563f52a363f6849e03b00dd0a185ec4a59af"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
@@ -1271,9 +1271,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
checksum = "a9cf937ca4e1d0b7578784559063422fc39ae7c5a00f02471f2614d5486fd226"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
@@ -1281,11 +1281,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
checksum = "9cfff060068cc18aa7815010fcaf80c68c7fb9e85fc7f5c0f9bf142c21d19826"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
@@ -1570,9 +1569,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
@@ -1704,9 +1703,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.40.2"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
|
||||
checksum = "9c026b4444579e8d754556c6c75d040d69c4a44283cdb648ab8a8d5965c2cfa8"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"fallible-iterator",
|
||||
@@ -2217,9 +2216,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.3"
|
||||
version = "1.54.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
|
||||
checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -2691,7 +2690,7 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
|
||||
[[package]]
|
||||
name = "x-media"
|
||||
version = "1.9.3"
|
||||
version = "1.9.4"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"dotenv",
|
||||
@@ -2711,7 +2710,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "xmedia-bot"
|
||||
version = "1.9.3"
|
||||
version = "1.9.4"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"dotenv",
|
||||
|
||||
+1
-1
@@ -121,7 +121,7 @@ Telegram only accepts ports 443/80/88/8443.
|
||||
| `/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) |
|
||||
| `/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); it replies to the post's own first media message — the one it edits — so several links in one message keep their prompts distinguishable |
|
||||
| `/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"). Names are limited to 55 UTF-8 bytes, bodies to 1024 escaped characters, and 50 templates per chat |
|
||||
| `/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 |
|
||||
|
||||
@@ -121,7 +121,7 @@ Telegram 只接受 443/80/88/8443 端口。
|
||||
| `/help` | 查看全部命令及用法(即本文档的命令表) |
|
||||
| `/set_forward_channel <频道>` | 设置转发频道,参数为 `@频道名` 或频道 ID;设置后发送的媒体消息会自动转发到该频道 |
|
||||
| `/remove_forward_channel` | 取消转发频道 |
|
||||
| `/edit_before_forward` | 开关「转发前编辑」:开启后,转发成功后 bot 会发一条提示消息,回复它可修改第一条转发消息的 caption(或点击模板按钮套用模板),再点 `↩️ Confirm` 才会真正转发,`🛑 Skip` 放弃本次转发;提示消息写明过期时间,过期后原地标记为已过期且不会转发 |
|
||||
| `/edit_before_forward` | 开关「转发前编辑」:开启后,转发成功后 bot 会发一条提示消息,回复它可修改第一条转发消息的 caption(或点击模板按钮套用模板),再点 `↩️ Confirm` 才会真正转发,`🛑 Skip` 放弃本次转发;提示消息写明过期时间,过期后原地标记为已过期且不会转发;提示消息引用本贴自己的第一条媒体消息(即它要改的那条),所以一条消息里发多个链接时各自对应、不会混淆 |
|
||||
| `/set_template <名称>` | 回复一条含 `[]` 的消息,将其保存为命名模板;转发时 `[]` 会被替换为原帖链接(配合「转发前编辑」使用)。模板名称最长 55 个 UTF-8 字节、正文最长 1024 个转义后字符,每聊天最多 50 个模板 |
|
||||
| `/remove_template <名称>` | 删除某个模板(名称见 `/settings`;提示消息的模板按钮最多显示 60 个) |
|
||||
| `/settings` | 查看本聊天配置:转发频道、转发前编辑开关、各站点 caption 格式、模板列表 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "x-media"
|
||||
version = "1.9.3"
|
||||
version = "1.9.4"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@@ -8,7 +8,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
regex = "1.12"
|
||||
html-escape = "0.2"
|
||||
html-escape = "0.3"
|
||||
url = "2.5.2"
|
||||
bytes = "1"
|
||||
# ugoira frame zips are stored/deflate only; everything in zip's default
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "xmedia-bot"
|
||||
version = "1.9.3"
|
||||
version = "1.9.4"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@@ -12,11 +12,11 @@ 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"] }
|
||||
html-escape = "0.3"
|
||||
rusqlite = { version = "0.41", features = ["bundled"] }
|
||||
rand = "0.10"
|
||||
tempfile = "3"
|
||||
parking_lot = "0.12"
|
||||
parking_lot = "0.13"
|
||||
bytes = "1"
|
||||
png = "0.18"
|
||||
zune-jpeg = "0.5"
|
||||
|
||||
@@ -227,6 +227,8 @@ pub(crate) struct MockSender {
|
||||
cursor: Mutex<usize>,
|
||||
calls: Mutex<Vec<&'static str>>,
|
||||
messages: Mutex<Vec<String>>,
|
||||
/// The reply target of every `send_message`, in order.
|
||||
message_replies: Mutex<Vec<Option<i64>>>,
|
||||
captions: Mutex<Vec<String>>,
|
||||
answers: Mutex<Vec<Option<String>>>,
|
||||
/// `(chat, message, text)` of every text rewrite, in order.
|
||||
@@ -281,6 +283,7 @@ impl MockSender {
|
||||
cursor: Mutex::new(0),
|
||||
calls: Mutex::new(Vec::new()),
|
||||
messages: Mutex::new(Vec::new()),
|
||||
message_replies: Mutex::new(Vec::new()),
|
||||
captions: Mutex::new(Vec::new()),
|
||||
answers: Mutex::new(Vec::new()),
|
||||
edited_texts: Mutex::new(Vec::new()),
|
||||
@@ -301,6 +304,12 @@ impl MockSender {
|
||||
self.messages.lock().clone()
|
||||
}
|
||||
|
||||
/// The reply target of every plain message sent, in order (`None` = no
|
||||
/// reply).
|
||||
pub(crate) fn message_replies(&self) -> Vec<Option<i64>> {
|
||||
self.message_replies.lock().clone()
|
||||
}
|
||||
|
||||
/// Captions passed to `edit_message_caption`, in order.
|
||||
pub(crate) fn captions(&self) -> Vec<String> {
|
||||
self.captions.lock().clone()
|
||||
@@ -415,11 +424,14 @@ impl MediaSender for MockSender {
|
||||
&self,
|
||||
_chat_id: ChatId,
|
||||
text: String,
|
||||
_reply_to: Option<MessageId>,
|
||||
reply_to: Option<MessageId>,
|
||||
_reply_markup: Option<InlineKeyboardMarkup>,
|
||||
) -> BoxFuture<'_, Result<i64, RequestError>> {
|
||||
Box::pin(async move {
|
||||
self.messages.lock().push(text);
|
||||
self.message_replies
|
||||
.lock()
|
||||
.push(reply_to.map(|id| id.0 as i64));
|
||||
match self.next("send_message") {
|
||||
Outcome::MessageOk => Ok(MockSender::SENT_ID),
|
||||
Outcome::MessageErr => Err(self.error()),
|
||||
|
||||
@@ -1646,6 +1646,9 @@ mod tests {
|
||||
.expect("the prompt record must be stored");
|
||||
assert_eq!(record.url, "https://x.com/u/status/1");
|
||||
assert_eq!(record.forward_message_ids, vec![10, 11]);
|
||||
// The prompt replies to the post's first media message, so several
|
||||
// links in one user message still get distinguishable prompts.
|
||||
assert_eq!(sender.message_replies(), vec![Some(10)]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -301,12 +301,18 @@ pub(crate) async fn post_send_actions(ctx: &AppContext<'_>, task: &Task, message
|
||||
"\n({hidden} more templates not shown — /remove_template to prune.)"
|
||||
));
|
||||
}
|
||||
// Reply to this post's own first media message rather than the user's
|
||||
// link message: one message carrying several links opens several
|
||||
// prompts, and replying them all to that one message made each
|
||||
// indistinguishable from the others. The media message is the thing
|
||||
// the prompt actually edits and forwards.
|
||||
let prompt_reply_to = message_ids.first().copied().unwrap_or(reply_to);
|
||||
let prompt = ctx
|
||||
.sender
|
||||
.send_message(
|
||||
ChatId(chat_id),
|
||||
text,
|
||||
Some(MessageId(reply_to as i32)),
|
||||
Some(MessageId(prompt_reply_to as i32)),
|
||||
Some(keyboard),
|
||||
)
|
||||
.await;
|
||||
|
||||
Reference in New Issue
Block a user