diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index bd3d7995..4e5d23b5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -18,8 +18,3 @@ Instead, this happened: _explanation_ ## Meta - `teloxide` version: -- rustc version: - ``` - - ``` - diff --git a/.github/ISSUE_TEMPLATE/parse-error.md b/.github/ISSUE_TEMPLATE/parse-error.md index 641c99e4..b5606563 100644 --- a/.github/ISSUE_TEMPLATE/parse-error.md +++ b/.github/ISSUE_TEMPLATE/parse-error.md @@ -19,11 +19,6 @@ When using `<...>` method I've got `RequestError::InvalidJson` error with the f ## Meta - `teloxide` version: -- rustc version: - ``` - - ``` - ### Additional context diff --git a/.github/ISSUE_TEMPLATE/unknown-telegram-error.md b/.github/ISSUE_TEMPLATE/unknown-telegram-error.md index 98ab04dd..6421b271 100644 --- a/.github/ISSUE_TEMPLATE/unknown-telegram-error.md +++ b/.github/ISSUE_TEMPLATE/unknown-telegram-error.md @@ -19,11 +19,6 @@ When using `<...>` method I've got `ApiError::Unknown` error with the following ## Meta - `teloxide` version: -- rustc version: - ``` - - ``` - ### Additional context diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f33adb24..1e292726 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - uses: actions-rs/toolchain@v1 with: profile: minimal - toolchain: nightly + toolchain: nightly-2022-02-02 override: true components: rustfmt @@ -34,7 +34,7 @@ jobs: - uses: actions-rs/toolchain@v1 with: profile: minimal - toolchain: nightly + toolchain: nightly-2022-02-02 override: true components: clippy @@ -91,18 +91,6 @@ jobs: build-example: runs-on: ubuntu-latest - strategy: - matrix: - example: [ - admin_bot, - dialogue_bot, - heroku_ping_pong_bot, - ngrok_ping_pong_bot, - dices_bot, - shared_state_bot, - simple_commands_bot, - redis_remember_bot, - ] steps: - uses: actions/checkout@v2 - uses: actions-rs/toolchain@v1 @@ -110,5 +98,5 @@ jobs: profile: minimal toolchain: stable override: true - - name: Check the example - run: cd examples && cd ${{ matrix.example }} && cargo check + - name: Check the examples + run: cargo check --examples --features="full" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dbdb17f..97602010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,29 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [unreleased] +## unreleased + +## 0.6.0 + +### Added + + - `BotCommand::bot_commands` to obtain Telegram API commands ([issue 262](https://github.com/teloxide/teloxide/issues/262)). + - The `dispatching2` and `prelude2` modules. They presents a new dispatching model based on `dptree`. + +### Changed + +- Require that `AsUpdateStream::Stream` is `Send`. +- Restrict a user crate by `CARGO_CRATE_NAME` instead of `CARGO_PKG_NAME` in `enable_logging!` and `enable_logging_with_filter!`. +- Updated `teloxide-core` to v0.4.0, see [its changelog](https://github.com/teloxide/teloxide-core/blob/master/CHANGELOG.md#040---2022-02-03). + +### Deprecated + + - The `dispatching` and `prelude` modules. + +### Fixed + +- Infinite retries while stopping polling listener ([issue 496](https://github.com/teloxide/teloxide/issues/496)) +- `polling{,_default}` and it's `Stream` and `StopToken` not being `Send` (and by extension fix the same problem with `repl`s) ## 0.5.3 - 2021-10-25 @@ -12,19 +34,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Compilation when the `ctrlc_handler` feature is disabled ([issue 462](https://github.com/teloxide/teloxide/issues/462)) -## [0.5.2] - 2021-08-25 +## 0.5.2 - 2021-08-25 ### Fixed - Depend on a correct `futures` version (v0.3.15). -## [0.5.1] - 2021-08-05 +## 0.5.1 - 2021-08-05 ### Changed - Improved log messages when `^C` is received with `^C` handler set up -## [0.5.0] - 2021-07-08 +## 0.5.0 - 2021-07-08 ### Added @@ -70,7 +92,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Log errors from `Storage::{remove_dialogue, update_dialogue}` in `DialogueDispatcher` ([issue 302](https://github.com/teloxide/teloxide/issues/302)). - Mark all the functions of `Storage` as `#[must_use]`. -## [0.4.0] - 2021-03-22 +## 0.4.0 - 2021-03-22 ### Added - Integrate [teloxide-core]. @@ -107,28 +129,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [issue 253]: https://github.com/teloxide/teloxide/issues/253 [pr 257]: https://github.com/teloxide/teloxide/pull/257 -## [0.3.4] - 2020-01-13 +## 0.3.4 - 2020-01-13 ### Fixed - Failing compilation with `serde::export` ([issue 328](https://github.com/teloxide/teloxide/issues/328)). -## [0.3.3] - 2020-10-30 +## 0.3.3 - 2020-10-30 ### Fixed - The `dice` field from `MessageDice` is public now ([issue 306](https://github.com/teloxide/teloxide/issues/306)) -## [0.3.2] - 2020-10-23 +## 0.3.2 - 2020-10-23 ### Added - `LoginUrl::new` ([issue 298](https://github.com/teloxide/teloxide/issues/298)) -## [0.3.1] - 2020-08-25 +## 0.3.1 - 2020-08-25 ### Added - `Bot::builder` method ([PR 269](https://github.com/teloxide/teloxide/pull/269)). -## [0.3.0] - 2020-07-31 +## 0.3.0 - 2020-07-31 ### Added - Support for typed bot commands ([issue 152](https://github.com/teloxide/teloxide/issues/152)). - `BotBuilder`, which allows setting a default `ParseMode`. @@ -165,7 +187,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Now methods which can send file to Telegram returns `tokio::io::Result`. Early its could panic ([issue 216](https://github.com/teloxide/teloxide/issues/216)). - If a bot wasn't triggered for several days, it stops responding ([issue 223](https://github.com/teloxide/teloxide/issues/223)). -## [0.2.0] - 2020-02-25 +## 0.2.0 - 2020-02-25 ### Added - The functionality to parse commands only with a correct bot's name (breaks backwards compatibility) ([Issue 168](https://github.com/teloxide/teloxide/issues/168)). - This `CHANGELOG.md`. @@ -180,6 +202,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [either](https://crates.io/crates/either) from the dependencies in `Cargo.toml`. - `teloxide-macros` migrated into [the separate repository](https://github.com/teloxide/teloxide-macros) to easier releases and testing. -## [0.1.0] - 2020-02-19 +## 0.1.0 - 2020-02-19 ### Added - This project. diff --git a/Cargo.toml b/Cargo.toml index cbb0272b..82a11fa9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "teloxide" -version = "0.5.3" +version = "0.6.0" edition = "2018" description = "An elegant Telegram bots framework for Rust" repository = "https://github.com/teloxide/teloxide" @@ -10,22 +10,11 @@ keywords = ["teloxide", "telegram", "telegram-bot", "telegram-bot-api"] categories = ["web-programming", "api-bindings", "asynchronous"] license = "MIT" exclude = ["media"] -authors = [ - "Hirrolot ", - "Waffle Lapkin ", - "p0lunin ", - "Mishko torop'izhko", - "Mr-Andersen", - "Sergey Levitin ", - "Rustem B. ", - "Alexey Fedechkin " -] - -[badges] -maintenance = { status = "actively-developed" } [features] -default = ["native-tls", "ctrlc_handler", "teloxide-core/default"] +default = ["native-tls", "ctrlc_handler", "teloxide-core/default", "auto-send", "cache-me", "dispatching2"] + +dispatching2 = ["dptree"] sqlite-storage = ["sqlx"] redis-storage = ["redis"] @@ -68,13 +57,14 @@ full = [ ] [dependencies] -teloxide-core = { version = "0.3.3", default-features = false } -#teloxide-core = { git = "https://github.com/teloxide/teloxide-core.git", rev = "...", default-features = false } -teloxide-macros = { version = "0.4", optional = true } +teloxide-core = { version = "0.4", default-features = false } +teloxide-macros = { version = "0.5", optional = true } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } +dptree = { version = "0.1.0", optional = true } + tokio = { version = "1.8", features = ["fs"] } tokio-util = "0.6" tokio-stream = "0.1" @@ -100,18 +90,27 @@ redis = { version = "0.20", features = ["tokio-comp"], optional = true } serde_cbor = { version = "0.11", optional = true } bincode = { version = "1.3", optional = true } frunk = { version = "0.4", optional = true } +aquamarine = "0.1.11" [dev-dependencies] smart-default = "0.6.0" rand = "0.8.3" pretty_env_logger = "0.4.0" +once_cell = "1.9.0" lazy_static = "1.4.0" +anyhow = "1.0.52" +serde = "1" +serde_json = "1" tokio = { version = "1.8", features = ["fs", "rt-multi-thread", "macros"] } +warp = "0.3.0" +reqwest = "0.10.4" +chrono = "0.4" [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs", "-Znormalize-docs"] rustc-args = ["--cfg", "dep_docsrs"] +cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples=examples"] [[test]] name = "redis" @@ -122,3 +121,35 @@ required-features = ["redis-storage", "cbor-serializer", "bincode-serializer"] name = "sqlite" path = "tests/sqlite.rs" required-features = ["sqlite-storage", "cbor-serializer", "bincode-serializer"] + +[[example]] +name = "dialogue" +required-features = ["macros"] + +[[example]] +name = "sqlite_remember" +required-features = ["sqlite-storage", "bincode-serializer", "redis-storage", "macros"] + +[[example]] +name = "simple_commands" +required-features = ["macros"] + +[[example]] +name = "redis_remember" +required-features = ["redis-storage", "bincode-serializer", "macros"] + +[[example]] +name = "inline" +required-features = ["macros"] + +[[example]] +name = "buttons" +required-features = ["macros"] + +[[example]] +name = "admin" +required-features = ["macros"] + +[[example]] +name = "dispatching2_features" +required-features = ["macros"] diff --git a/LICENSE b/LICENSE index 2aa8a5ce..8e5427af 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019-2020 teloxide +Copyright (c) 2019-2022 teloxide Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index d945a6e6..df86b4c4 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -1,6 +1,33 @@ This document describes breaking changes of `teloxide` crate, as well as the ways to update code. Note that the list of required changes is not fully exhaustive and it may lack something in rare cases. +## 0.5 -> 0.6 + +### core + + - `InputFile` now can't be created like `InputFile::Url(url)` or matched on, use constructors like `InputFile::url`, `InputFile::file`, etc. + - `RequestError` and `DownloadError` error variants were slightly renamed +- `ChatPermissions` is now bitflags. + +### teloxide + +v0.6 of teloxide introduces a new dispatching model based on the [chain of responsibility pattern]. To use it, you need to replace `prelude` with `prelude2` and `dispatching` with `dispatching2`. Instead of using old REPLs, you should now use `teloxide::repls2`. + +The whole design is different than the previous one based on Tokio streams. In this section, we are only to address the most common usage scenarios. + +First of all, now there are no streams. Instead of using streams, you use [`dptree`], which is a more suitable alternative for our purposes. Thus, if you previously used `Dispatcher::messages_handler`, now you should use `Update::filter_message()`, and so on. + +Secondly, `Dispatcher` has been split into two separate abstractions: `DispatcherBuilder` and `Dispatcher`. The calling sequence is simple: you call `Dispatcher::builder(bot, handler)`, set up your stuff, and then call `.build()` to obtain `Dispatcher`. Later, you can `.setup_ctrlc_handler()` on it and finally `.dispatch()` (or `.dispatch_with_listener()`). + +Lastly, the dialogue management system has been greatly simplified. Just compare the [new `examples/dialogue.rs`](https://github.com/teloxide/teloxide/blob/25f863402d4f377f573ce2ba394f5b768ee8052e/examples/dialogue.rs) with the [old one](https://github.com/teloxide/teloxide/tree/2a6067fe94773a0015627a6aaa1930b8f88b6da0/examples/dialogue_bot/src) to see the difference. Now you don't need `TransitionIn`, `TransitionOut`, `#[teloxide(subtransition)]`, etc. All you need is to derive `DialogueState` for your FSM enumeration, call `.enter_dialogue()` and write handlers for each of a dialogue's states. Instead of supplying dependencies in the `aux` parameter of `Transition::react`, you can just call `.dependencies()` while setting up the dispatcher and all the dependencies will be passed to your handler functions as parameters. + +For more information, please look at the appropriate documentation pages and the [updated examples](https://github.com/teloxide/teloxide/tree/master/examples). Note that, in one of the upcoming releases, the old dispatching model will be removed, so we highly encourage you to migrate your bots to the new one. + +Thanks for using teloxide! + +[chain of responsibility pattern]: https://en.wikipedia.org/wiki/Chain-of-responsibility_pattern +[`dptree`]: https://github.com/p0lunin/dptree + ## 0.4 -> 0.5 ### core diff --git a/README.md b/README.md index 919500f9..fc4edbbb 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[_v0.4.0 => v0.5.0 migration guide >>_](MIGRATION_GUIDE.md#04---05) +> [v0.5 -> v0.6 migration guide >>](MIGRATION_GUIDE.md#05---06)
@@ -16,7 +16,7 @@ - + @@ -27,23 +27,24 @@ ## Highlights - - **Functional reactive design.** teloxide follows [functional reactive design], allowing you to declaratively manipulate streams of updates from Telegram using filters, maps, folds, zips, and a lot of [other adaptors]. + - **Declarative design.** teloxide is based upon [`dptree`], a functional-style [chain of responsibility] pattern that allows you to express pipelines of message processing in a highly declarative and extensible style. -[functional reactive design]: https://en.wikipedia.org/wiki/Functional_reactive_programming -[other adaptors]: https://docs.rs/futures/latest/futures/stream/trait.StreamExt.html +[`dptree`]: https://github.com/p0lunin/dptree +[chain of responsibility]: https://en.wikipedia.org/wiki/Chain-of-responsibility_pattern - - **Dialogues management subsystem.** We have designed our dialogues management subsystem to be easy-to-use, and, furthermore, to be agnostic of how/where dialogues are stored. For example, you can just replace a one line to achieve [persistence]. Out-of-the-box storages include [Redis] and [Sqlite]. + - **Dialogues management subsystem.** Our dialogues management subsystem is simple and easy-to-use, and, furthermore, is agnostic of how/where dialogues are stored. For example, you can just replace a one line to achieve [persistence]. Out-of-the-box storages include [Redis] and [Sqlite]. [persistence]: https://en.wikipedia.org/wiki/Persistence_(computer_science) [Redis]: https://redis.io/ [Sqlite]: https://www.sqlite.org - - **Strongly typed bot commands.** You can describe bot commands as enumerations, and then they'll be automatically constructed from strings — just like JSON structures in [serde-json] and command-line arguments in [structopt]. + - **Strongly typed commands.** You can describe bot commands as enumerations, and then they'll be automatically constructed from strings — just like JSON structures in [`serde-json`] and command-line arguments in [`structopt`]. -[structopt]: https://github.com/TeXitoi/structopt -[serde-json]: https://github.com/serde-rs/json +[`structopt`]: https://github.com/TeXitoi/structopt +[`serde-json`]: https://github.com/serde-rs/json ## Setting up your environment + 1. [Download Rust](http://rustup.rs/). 2. Create a new bot using [@Botfather](https://t.me/botfather) to get a token in the format `123456789:blablabla`. 3. Initialise the `TELOXIDE_TOKEN` environmental variable to your token: @@ -51,8 +52,12 @@ # Unix-like $ export TELOXIDE_TOKEN= -# Windows +# Windows command line $ set TELOXIDE_TOKEN= + +# Windows PowerShell +$ $env:TELOXIDE_TOKEN= + ``` 4. Make sure that your Rust compiler is up to date: ```bash @@ -68,20 +73,22 @@ $ rustup override set nightly 5. Run `cargo new my_bot`, enter the directory and put these lines into your `Cargo.toml`: ```toml [dependencies] -teloxide = { version = "0.4", features = ["auto-send", "macros"] } -log = "0.4.8" +teloxide = { version = "0.5", features = ["macros", "auto-send"] } +log = "0.4" pretty_env_logger = "0.4.0" -tokio = { version = "1.3", features = ["rt-multi-thread", "macros"] } +tokio = { version = "1.8", features = ["rt-multi-thread", "macros"] } ``` ## API overview ### The dices bot + This bot replies with a dice throw to each received message: -([Full](./examples/dices_bot/src/main.rs)) +([Full](examples/dices.rs)) + ```rust,no_run -use teloxide::prelude::*; +use teloxide::prelude2::*; #[tokio::main] async fn main() { @@ -90,8 +97,8 @@ async fn main() { let bot = Bot::from_env().auto_send(); - teloxide::repl(bot, |message| async move { - message.answer_dice().await?; + teloxide::repls2::repl(bot, |message: Message, bot: AutoSend| async move { + bot.send_dice(message.chat.id).await?; respond(()) }) .await; @@ -105,6 +112,7 @@ async fn main() {
### Commands + Commands are strongly typed and defined declaratively, similar to how we define CLI using [structopt] and JSON structures in [serde-json]. The following bot accepts these commands: - `/username ` @@ -114,13 +122,14 @@ Commands are strongly typed and defined declaratively, similar to how we define [structopt]: https://docs.rs/structopt/0.3.9/structopt/ [serde-json]: https://github.com/serde-rs/json -([Full](./examples/simple_commands_bot/src/main.rs)) +([Full](examples/simple_commands.rs)) + ```rust,no_run -use teloxide::{prelude::*, utils::command::BotCommand}; +use teloxide::{prelude2::*, utils::command::BotCommand}; use std::error::Error; -#[derive(BotCommand)] +#[derive(BotCommand, Clone)] #[command(rename = "lowercase", description = "These commands are supported:")] enum Command { #[command(description = "display this text.")] @@ -132,16 +141,21 @@ enum Command { } async fn answer( - cx: UpdateWithCx, Message>, + bot: AutoSend, + message: Message, command: Command, ) -> Result<(), Box> { match command { - Command::Help => cx.answer(Command::descriptions()).await?, + Command::Help => bot.send_message(message.chat.id, Command::descriptions()).await?, Command::Username(username) => { - cx.answer(format!("Your username is @{}.", username)).await? + bot.send_message(message.chat.id, format!("Your username is @{}.", username)).await? } Command::UsernameAndAge { username, age } => { - cx.answer(format!("Your username is @{} and age is {}.", username, age)).await? + bot.send_message( + message.chat.id, + format!("Your username is @{} and age is {}.", username, age), + ) + .await? } }; @@ -155,8 +169,7 @@ async fn main() { let bot = Bot::from_env().auto_send(); - let bot_name: String = panic!("Your bot's name here"); - teloxide::commands_repl(bot, bot_name, answer).await; + teloxide::repls2::commands_repl(bot, answer, Command::ty()).await; } ``` @@ -167,145 +180,41 @@ async fn main() { ### Dialogues management -A dialogue is described by an enumeration where each variant is one of possible dialogue's states. There are also _subtransition functions_, which turn a dialogue from one state to another, thereby forming an [FSM]. + +A dialogue is typically described by an enumeration where each variant is one of possible dialogue's states. There are also _state handler functions_, which may turn a dialogue from one state to another, thereby forming an [FSM]. [FSM]: https://en.wikipedia.org/wiki/Finite-state_machine -Below is a bot that asks you three questions and then sends the answers back to you. First, let's start with an enumeration (a collection of our dialogue's states): +Below is a bot that asks you three questions and then sends the answers back to you: + +([Full](examples/dialogue.rs)) -([dialogue_bot/src/dialogue/mod.rs](./examples/dialogue_bot/src/dialogue/mod.rs)) ```rust,ignore -// Imports are omitted... +use teloxide::{dispatching2::dialogue::InMemStorage, macros::DialogueState, prelude2::*}; -#[derive(Transition, From)] -pub enum Dialogue { - Start(StartState), - ReceiveFullName(ReceiveFullNameState), - ReceiveAge(ReceiveAgeState), - ReceiveLocation(ReceiveLocationState), +type MyDialogue = Dialogue>; + +#[derive(DialogueState, Clone)] +#[handler_out(anyhow::Result<()>)] +pub enum State { + #[handler(handle_start)] + Start, + + #[handler(handle_receive_full_name)] + ReceiveFullName, + + #[handler(handle_receive_age)] + ReceiveAge { full_name: String }, + + #[handler(handle_receive_location)] + ReceiveLocation { full_name: String, age: u8 }, } -impl Default for Dialogue { +impl Default for State { fn default() -> Self { - Self::Start(StartState) + Self::Start } } -``` - -When a user sends a message to our bot and such a dialogue does not exist yet, a `Dialogue::default()` is invoked, which is a `Dialogue::Start` in this case. Every time a message is received, an associated dialogue is extracted and then passed to a corresponding subtransition function: - -
- Dialogue::Start - -([dialogue_bot/src/dialogue/states/start.rs](./examples/dialogue_bot/src/dialogue/states/start.rs)) -```rust,ignore -// Imports are omitted... - -pub struct StartState; - -#[teloxide(subtransition)] -async fn start( - _state: StartState, - cx: TransitionIn>, - _ans: String, -) -> TransitionOut { - cx.answer("Let's start! What's your full name?").await?; - next(ReceiveFullNameState) -} -``` - -
- -
- Dialogue::ReceiveFullName - -([dialogue_bot/src/dialogue/states/receive_full_name.rs](./examples/dialogue_bot/src/dialogue/states/receive_full_name.rs)) -```rust,ignore -// Imports are omitted... - -#[derive(Generic)] -pub struct ReceiveFullNameState; - -#[teloxide(subtransition)] -async fn receive_full_name( - state: ReceiveFullNameState, - cx: TransitionIn>, - ans: String, -) -> TransitionOut { - cx.answer("How old are you?").await?; - next(ReceiveAgeState::up(state, ans)) -} -``` - -
- -
- Dialogue::ReceiveAge - -([dialogue_bot/src/dialogue/states/receive_age.rs](./examples/dialogue_bot/src/dialogue/states/receive_age.rs)) -```rust,ignore -// Imports are omitted... - -#[derive(Generic)] -pub struct ReceiveAgeState { - pub full_name: String, -} - -#[teloxide(subtransition)] -async fn receive_age_state( - state: ReceiveAgeState, - cx: TransitionIn>, - ans: String, -) -> TransitionOut { - match ans.parse::() { - Ok(ans) => { - cx.answer("What's your location?").await?; - next(ReceiveLocationState::up(state, ans)) - } - _ => { - cx.answer("Send me a number.").await?; - next(state) - } - } -} -``` - -
- -
- Dialogue::ReceiveLocation - -([dialogue_bot/src/dialogue/states/receive_location.rs](./examples/dialogue_bot/src/dialogue/states/receive_location.rs)) -```rust,ignore -// Imports are omitted... - -#[derive(Generic)] -pub struct ReceiveLocationState { - pub full_name: String, - pub age: u8, -} - -#[teloxide(subtransition)] -async fn receive_location( - state: ReceiveLocationState, - cx: TransitionIn>, - ans: String, -) -> TransitionOut { - cx.answer(format!("Full name: {}\nAge: {}\nLocation: {}", state.full_name, state.age, ans)) - .await?; - exit() -} -``` - -
- -All these subtransition functions accept a corresponding state (one of the many variants of `Dialogue`), a context, and a textual message. They return `TransitionOut`, e.g. a mapping from `` to `Dialogue`. - -Finally, the `main` function looks like this: - -([dialogue_bot/src/main.rs](./examples/dialogue_bot/src/main.rs)) -```rust,ignore -// Imports are omitted... #[tokio::main] async fn main() { @@ -314,23 +223,84 @@ async fn main() { let bot = Bot::from_env().auto_send(); - teloxide::dialogues_repl(bot, |message, dialogue| async move { - handle_message(message, dialogue).await.expect("Something wrong with the bot!") - }) + Dispatcher::builder( + bot, + Update::filter_message() + .enter_dialogue::, State>() + .dispatch_by::(), + ) + .dependencies(dptree::deps![InMemStorage::::new()]) + .build() + .setup_ctrlc_handler() + .dispatch() .await; } -async fn handle_message( - cx: UpdateWithCx, Message>, - dialogue: Dialogue, -) -> TransitionOut { - match cx.update.text().map(ToOwned::to_owned) { - None => { - cx.answer("Send me a text message.").await?; - next(dialogue) +async fn handle_start( + bot: AutoSend, + msg: Message, + dialogue: MyDialogue, +) -> anyhow::Result<()> { + bot.send_message(msg.chat.id, "Let's start! What's your full name?").await?; + dialogue.update(State::ReceiveFullName).await?; + Ok(()) +} + +async fn handle_receive_full_name( + bot: AutoSend, + msg: Message, + dialogue: MyDialogue, +) -> anyhow::Result<()> { + match msg.text() { + Some(text) => { + bot.send_message(msg.chat.id, "How old are you?").await?; + dialogue.update(State::ReceiveAge { full_name: text.into() }).await?; + } + None => { + bot.send_message(msg.chat.id, "Send me plain text.").await?; } - Some(ans) => dialogue.react(cx, ans).await, } + + Ok(()) +} + +async fn handle_receive_age( + bot: AutoSend, + msg: Message, + dialogue: MyDialogue, + (full_name,): (String,), // Available from `State::ReceiveAge`. +) -> anyhow::Result<()> { + match msg.text().map(|text| text.parse::()) { + Some(Ok(age)) => { + bot.send_message(msg.chat.id, "What's your location?").await?; + dialogue.update(State::ReceiveLocation { full_name, age }).await?; + } + _ => { + bot.send_message(msg.chat.id, "Send me a number.").await?; + } + } + + Ok(()) +} + +async fn handle_receive_location( + bot: AutoSend, + msg: Message, + dialogue: MyDialogue, + (full_name, age): (String, u8), // Available from `State::ReceiveLocation`. +) -> anyhow::Result<()> { + match msg.text() { + Some(location) => { + let message = format!("Full name: {}\nAge: {}\nLocation: {}", full_name, age, location); + bot.send_message(msg.chat.id, message).await?; + dialogue.exit().await?; + } + None => { + bot.send_message(msg.chat.id, "Send me plain text.").await?; + } + } + + Ok(()) } ``` @@ -340,60 +310,29 @@ async fn handle_message( -[More examples!](./examples) - -## Recommendations - - Use this pattern: - - ```rust - #[tokio::main] - async fn main() { - run().await; - } - - async fn run() { - // Your logic here... - } - ``` - - Instead of this: - - ```rust -#[tokio::main] - async fn main() { - // Your logic here... - } - ``` - -The second one produces very strange compiler messages due to the `#[tokio::main]` macro. However, the examples in this README use the second variant for brevity. +[More examples >>](examples/) ## FAQ + **Q: Where I can ask questions?** -A: [Issues](https://github.com/teloxide/teloxide/issues) is a good place for well-formed questions, for example, about: +A: - - the library design; - - enhancements; - - bug reports; - - ... + - [Issues] is a good place for well-formed questions about the library design, enhancements, and bug reports. + - [GitHub Discussions] is a place where you can ask us for help in a less formal manner. + - If you need quick help in real-time, you should ask a question in [our official Telegram group]. -If you can't compile your bot due to compilation errors and need quick help, feel free to ask in [our official Telegram group](https://t.me/teloxide). +[Issues]: https://github.com/teloxide/teloxide/issues +[our official Telegram group]: https://t.me/teloxide +[GitHub Discussions]: https://github.com/teloxide/teloxide/discussions **Q: Do you support the Telegram API for clients?** A: No, only the bots API. -**Q: Why Rust?** - -A: Most programming languages have their own implementations of Telegram bots frameworks, so why not Rust? We think Rust provides a good enough ecosystem and the language for it to be suitable for writing bots. - -UPD: The current design relies on wide and deep trait bounds, thereby increasing cognitive complexity. It can be avoided using [mux-stream], but currently the stable Rust channel doesn't support necessary features to use [mux-stream] conveniently. Furthermore, the [mux-stream] could help to make a library out of teloxide, not a framework, since the design in this case could be defined by just combining streams of updates. - -[mux-stream]: https://github.com/Hirrolot/mux-stream - **Q: Can I use webhooks?** -A: teloxide doesn't provide special API for working with webhooks due to their nature with lots of subtle settings. Instead, you should setup your webhook by yourself, as shown in [`examples/ngrok_ping_pong_bot`](./examples/ngrok_ping_pong_bot/src/main.rs) and [`examples/heroku_ping_pong_bot`](./examples/heroku_ping_pong_bot/src/main.rs). +A: teloxide doesn't provide special API for working with webhooks due to their nature with lots of subtle settings. Instead, you should setup your webhook by yourself, as shown in [`examples/ngrok_ping_pong_bot`](examples/ngrok_ping_pong_bot/src/main.rs) and [`examples/heroku_ping_pong_bot`](examples/heroku_ping_pong_bot/src/main.rs). Associated links: - [Marvin's Marvellous Guide to All Things Webhook](https://core.telegram.org/bots/webhooks) @@ -409,24 +348,29 @@ A: Yes. You can setup any logger, for example, [fern], e.g. teloxide has no spec [`enable_logging_with_filter!`]: https://docs.rs/teloxide/latest/teloxide/macro.enable_logging_with_filter.html ## Community bots + Feel free to propose your own bot to our collection! - [WaffleLapkin/crate_upd_bot](https://github.com/WaffleLapkin/crate_upd_bot) -- A bot that notifies about crate updates. - - [dracarys18/grpmr-rs](https://github.com/dracarys18/grpmr-rs) -- A telegram group manager bot with variety of extra features. + - [mxseev/logram](https://github.com/mxseev/logram) -- Utility that takes logs from anywhere and sends them to Telegram. + - [alexkonovalov/PedigreeBot](https://github.com/alexkonovalov/PedigreeBot) -- A Telegram bot for building family trees. + - [Hermitter/tepe](https://github.com/Hermitter/tepe) -- A CLI to command a bot to send messages and files over Telegram. + - [dracarys18/grpmr-rs](https://github.com/dracarys18/grpmr-rs) -- A Telegram group manager bot with variety of extra features. - [steadylearner/subreddit_reader](https://github.com/steadylearner/Rust-Full-Stack/tree/master/commits/teloxide/subreddit_reader) -- A bot that shows the latest posts at Rust subreddit. + - [myblackbeard/basketball-betting-bot](https://github.com/myblackbeard/basketball-betting-bot) -- The bot lets you bet on NBA games against your buddies. - [ArtHome12/vzmuinebot](https://github.com/ArtHome12/vzmuinebot) -- Telegram bot for food menu navigate. - [ArtHome12/cognito_bot](https://github.com/ArtHome12/cognito_bot) -- The bot is designed to anonymize messages to a group. - - [Hermitter/tepe](https://github.com/Hermitter/tepe) -- A CLI to command a bot to send messages and files over Telegram. - [pro-vim/tg-vimhelpbot](https://github.com/pro-vim/tg-vimhelpbot) -- Link `:help` for Vim in Telegram. - [sschiz/janitor-bot](https://github.com/sschiz/janitor-bot) -- A bot that removes users trying to join to a chat that is designed for comments. - - [myblackbeard/basketball-betting-bot](https://github.com/myblackbeard/basketball-betting-bot) -- The bot lets you bet on NBA games against your buddies. - [slondr/BeerHolderBot](https://gitlab.com/slondr/BeerHolderBot) -- A bot that holds your beer. - - [mxseev/logram](https://github.com/mxseev/logram) -- Utility that takes logs from anywhere and sends them to Telegram. - - [msfjarvis/walls-bot-rs](https://github.com/msfjarvis/walls-bot-rs) -- Telegram bot for my wallpapers collection, in Rust. - - [MustafaSalih1993/Miss-Vodka-Telegram-Bot](https://github.com/MustafaSalih1993/Miss-Vodka-Telegram-Bot) -- A telegram bot written in rust using "Teloxide" library. + - [MustafaSalih1993/Miss-Vodka-Telegram-Bot](https://github.com/MustafaSalih1993/Miss-Vodka-Telegram-Bot) -- A Telegram bot written in rust using "Teloxide" library. - [x13a/tg-prompt](https://github.com/x13a/tg-prompt) -- Telegram prompt. - [magnickolas/remindee-bot](https://github.com/magnickolas/remindee-bot) -- Telegram bot for managing reminders. - - [cyberknight777/knight-bot](https://gitlab.com/cyberknight777/knight-bot) -- A telegram bot with variety of fun features. - + - [cyberknight777/knight-bot](https://gitlab.com/cyberknight777/knight-bot) -- A Telegram bot with variety of fun features. + - [wa7sa34cx/the-black-box-bot](https://github.com/wa7sa34cx/the-black-box-bot) -- This is the Black Box Telegram bot. You can hold any items in it. + - [crapstone/hsctt](https://codeberg.org/crapstones-bots/hsctt) -- A Telegram bot that searches for HTTP status codes in all messages and replies with the text form. + - [alenpaul2001/AurSearchBot](https://gitlab.com/alenpaul2001/aursearchbot) -- Telegram bot for searching AUR in inline mode. + ## Contributing -See [CONRIBUTING.md](https://github.com/teloxide/teloxide/blob/master/CONTRIBUTING.md). + +See [`CONRIBUTING.md`](CONTRIBUTING.md). diff --git a/examples/heroku_ping_pong_bot/Procfile b/examples/Procfile similarity index 100% rename from examples/heroku_ping_pong_bot/Procfile rename to examples/Procfile diff --git a/examples/README.md b/examples/README.md index 21515200..c3081bfc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,12 +1,7 @@ -# Examples -Just enter the directory (for example, `cd dialogue_bot`) and execute `cargo run` to run an example. Don't forget to initialise the `TELOXIDE_TOKEN` environmental variable. -| Bot | Description | -|---|-----------| -| [dices_bot](dices_bot) | Throws a dice on each incoming message. | -| [ngrok_ping_pong_bot](ngrok_ping_pong_bot) | The ngrok version of ping-pong-bot that uses webhooks. | -| [heroku_ping_pong_bot](heroku_ping_pong_bot) | The Heroku version of ping-pong-bot that uses webhooks. | -| [simple_commands_bot](simple_commands_bot) | Shows how to deal with bot's commands. | -| [redis_remember_bot](redis_remember_bot) | Uses `RedisStorage` instead of `InMemStorage`. | -| [dialogue_bot](dialogue_bot) | How to deal with dialogues. | -| [admin_bot](admin_bot) | Ban, kick, and mute on a command. | -| [shared_state_bot](shared_state_bot) | How to deal with shared state. | +# Usage + +``` +$ cargo run --example --features="" +``` + +Don't forget to initialise the `TELOXIDE_TOKEN` environmental variable. diff --git a/examples/admin.rs b/examples/admin.rs new file mode 100644 index 00000000..3c7b9995 --- /dev/null +++ b/examples/admin.rs @@ -0,0 +1,153 @@ +use std::{error::Error, str::FromStr}; + +use chrono::Duration; +use teloxide::{prelude2::*, types::ChatPermissions, utils::command::BotCommand}; + +// Derive BotCommand to parse text with a command into this enumeration. +// +// 1. rename = "lowercase" turns all the commands into lowercase letters. +// 2. `description = "..."` specifies a text before all the commands. +// +// That is, you can just call Command::descriptions() to get a description of +// your commands in this format: +// %GENERAL-DESCRIPTION% +// %PREFIX%%COMMAND% - %DESCRIPTION% +#[derive(BotCommand, Clone)] +#[command( + rename = "lowercase", + description = "Use commands in format /%command% %num% %unit%", + parse_with = "split" +)] +enum Command { + #[command(description = "kick user from chat.")] + Kick, + #[command(description = "ban user in chat.")] + Ban { + time: u64, + unit: UnitOfTime, + }, + #[command(description = "mute user in chat.")] + Mute { + time: u64, + unit: UnitOfTime, + }, + Help, +} + +#[derive(Clone)] +enum UnitOfTime { + Seconds, + Minutes, + Hours, +} + +impl FromStr for UnitOfTime { + type Err = &'static str; + fn from_str(s: &str) -> Result::Err> { + match s { + "h" | "hours" => Ok(UnitOfTime::Hours), + "m" | "minutes" => Ok(UnitOfTime::Minutes), + "s" | "seconds" => Ok(UnitOfTime::Seconds), + _ => Err("Allowed units: h, m, s"), + } + } +} + +// Calculates time of user restriction. +fn calc_restrict_time(time: u64, unit: UnitOfTime) -> Duration { + match unit { + UnitOfTime::Hours => Duration::hours(time as i64), + UnitOfTime::Minutes => Duration::minutes(time as i64), + UnitOfTime::Seconds => Duration::seconds(time as i64), + } +} + +type Bot = AutoSend; + +// Kick a user with a replied message. +async fn kick_user(bot: Bot, msg: Message) -> Result<(), Box> { + match msg.reply_to_message() { + Some(replied) => { + // bot.unban_chat_member can also kicks a user from a group chat. + bot.unban_chat_member(msg.chat.id, replied.from().unwrap().id).await?; + } + None => { + bot.send_message(msg.chat.id, "Use this command in reply to another message").await?; + } + } + Ok(()) +} + +// Mute a user with a replied message. +async fn mute_user( + bot: Bot, + msg: Message, + time: Duration, +) -> Result<(), Box> { + match msg.reply_to_message() { + Some(replied) => { + bot.restrict_chat_member( + msg.chat.id, + replied.from().expect("Must be MessageKind::Common").id, + ChatPermissions::empty(), + ) + .until_date(msg.date + time) + .await?; + } + None => { + bot.send_message(msg.chat.id, "Use this command in a reply to another message!") + .await?; + } + } + Ok(()) +} + +// Ban a user with replied message. +async fn ban_user( + bot: Bot, + msg: Message, + time: Duration, +) -> Result<(), Box> { + match msg.reply_to_message() { + Some(replied) => { + bot.kick_chat_member( + msg.chat.id, + replied.from().expect("Must be MessageKind::Common").id, + ) + .until_date(msg.date + time) + .await?; + } + None => { + bot.send_message(msg.chat.id, "Use this command in a reply to another message!") + .await?; + } + } + Ok(()) +} + +async fn action( + bot: Bot, + msg: Message, + command: Command, +) -> Result<(), Box> { + match command { + Command::Help => { + bot.send_message(msg.chat.id, Command::descriptions()).await?; + } + Command::Kick => kick_user(bot, msg).await?, + Command::Ban { time, unit } => ban_user(bot, msg, calc_restrict_time(time, unit)).await?, + Command::Mute { time, unit } => mute_user(bot, msg, calc_restrict_time(time, unit)).await?, + }; + + Ok(()) +} + +#[tokio::main] +async fn main() { + teloxide::enable_logging!(); + log::info!("Starting admin_bot..."); + + let bot = teloxide::Bot::from_env().auto_send(); + + teloxide::repls2::commands_repl(bot, action, Command::ty()).await; +} diff --git a/examples/admin_bot/Cargo.toml b/examples/admin_bot/Cargo.toml deleted file mode 100644 index 52c6f017..00000000 --- a/examples/admin_bot/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "admin_bot" -version = "0.1.0" -authors = ["p0lunin "] -edition = "2018" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -teloxide = { path = "../../", features = ["macros", "auto-send"] } -log = "0.4.8" -pretty_env_logger = "0.4.0" -tokio = { version = "1.3.0", features = ["rt-multi-thread", "macros"] } -chrono = "0.4" - -[profile.release] -lto = true diff --git a/examples/admin_bot/src/main.rs b/examples/admin_bot/src/main.rs deleted file mode 100644 index 904c9369..00000000 --- a/examples/admin_bot/src/main.rs +++ /dev/null @@ -1,157 +0,0 @@ -use std::{error::Error, str::FromStr}; - -use chrono::{DateTime, Duration, NaiveDateTime, Utc}; -use teloxide::{prelude::*, types::{ChatPermissions, Me}, utils::command::BotCommand}; - -// Derive BotCommand to parse text with a command into this enumeration. -// -// 1. rename = "lowercase" turns all the commands into lowercase letters. -// 2. `description = "..."` specifies a text before all the commands. -// -// That is, you can just call Command::descriptions() to get a description of -// your commands in this format: -// %GENERAL-DESCRIPTION% -// %PREFIX%%COMMAND% - %DESCRIPTION% -#[derive(BotCommand)] -#[command( - rename = "lowercase", - description = "Use commands in format /%command% %num% %unit%", - parse_with = "split" -)] -enum Command { - #[command(description = "kick user from chat.")] - Kick, - #[command(description = "ban user in chat.")] - Ban { - time: u64, - unit: UnitOfTime, - }, - #[command(description = "mute user in chat.")] - Mute { - time: u64, - unit: UnitOfTime, - }, - Help, -} - -enum UnitOfTime { - Seconds, - Minutes, - Hours, -} - -impl FromStr for UnitOfTime { - type Err = &'static str; - fn from_str(s: &str) -> Result::Err> { - match s { - "h" | "hours" => Ok(UnitOfTime::Hours), - "m" | "minutes" => Ok(UnitOfTime::Minutes), - "s" | "seconds" => Ok(UnitOfTime::Seconds), - _ => Err("Allowed units: h, m, s"), - } - } -} - -// Calculates time of user restriction. -fn calc_restrict_time(time: u64, unit: UnitOfTime) -> Duration { - match unit { - UnitOfTime::Hours => Duration::hours(time as i64), - UnitOfTime::Minutes => Duration::minutes(time as i64), - UnitOfTime::Seconds => Duration::seconds(time as i64), - } -} - -type Cx = UpdateWithCx, Message>; - -// Mute a user with a replied message. -async fn mute_user(cx: &Cx, time: Duration) -> Result<(), Box> { - match cx.update.reply_to_message() { - Some(msg1) => { - cx.requester - .restrict_chat_member( - cx.update.chat_id(), - msg1.from().expect("Must be MessageKind::Common").id, - ChatPermissions::default(), - ) - .until_date( - DateTime::::from_utc( - NaiveDateTime::from_timestamp(cx.update.date as i64, 0), - Utc, - ) + time, - ) - .await?; - } - None => { - cx.reply_to("Use this command in reply to another message").send().await?; - } - } - Ok(()) -} - -// Kick a user with a replied message. -async fn kick_user(cx: &Cx) -> Result<(), Box> { - match cx.update.reply_to_message() { - Some(mes) => { - // bot.unban_chat_member can also kicks a user from a group chat. - cx.requester - .unban_chat_member(cx.update.chat_id(), mes.from().unwrap().id) - .send() - .await?; - } - None => { - cx.reply_to("Use this command in reply to another message").send().await?; - } - } - Ok(()) -} - -// Ban a user with replied message. -async fn ban_user(cx: &Cx, time: Duration) -> Result<(), Box> { - match cx.update.reply_to_message() { - Some(message) => { - cx.requester - .kick_chat_member( - cx.update.chat_id(), - message.from().expect("Must be MessageKind::Common").id, - ) - .until_date( - DateTime::::from_utc( - NaiveDateTime::from_timestamp(cx.update.date as i64, 0), - Utc, - ) + time, - ) - .await?; - } - None => { - cx.reply_to("Use this command in a reply to another message!").send().await?; - } - } - Ok(()) -} - -async fn action(cx: Cx, command: Command) -> Result<(), Box> { - match command { - Command::Help => cx.answer(Command::descriptions()).send().await.map(|_| ())?, - Command::Kick => kick_user(&cx).await?, - Command::Ban { time, unit } => ban_user(&cx, calc_restrict_time(time, unit)).await?, - Command::Mute { time, unit } => mute_user(&cx, calc_restrict_time(time, unit)).await?, - }; - - Ok(()) -} - -#[tokio::main] -async fn main() { - run().await; -} - -async fn run() { - teloxide::enable_logging!(); - log::info!("Starting admin_bot..."); - - let bot = Bot::from_env().auto_send(); - - let Me { user: bot_user, .. } = bot.get_me().await.unwrap(); - let bot_name = bot_user.username.expect("Bots must have usernames"); - teloxide::commands_repl(bot, bot_name, action).await; -} diff --git a/examples/buttons.rs b/examples/buttons.rs new file mode 100644 index 00000000..3405081e --- /dev/null +++ b/examples/buttons.rs @@ -0,0 +1,132 @@ +use std::error::Error; +use teloxide::{ + payloads::SendMessageSetters, + prelude2::*, + types::{ + InlineKeyboardButton, InlineKeyboardMarkup, InlineQueryResultArticle, InputMessageContent, + InputMessageContentText, + }, + utils::command::BotCommand, +}; + +#[derive(BotCommand)] +#[command(rename = "lowercase", description = "These commands are supported:")] +enum Command { + #[command(description = "Display this text")] + Help, + #[command(description = "Start")] + Start, +} + +/// Creates a keyboard made by buttons in a big column. +fn make_keyboard() -> InlineKeyboardMarkup { + let mut keyboard: Vec> = vec![]; + + let debian_versions = [ + "Buzz", "Rex", "Bo", "Hamm", "Slink", "Potato", "Woody", "Sarge", "Etch", "Lenny", + "Squeeze", "Wheezy", "Jessie", "Stretch", "Buster", "Bullseye", + ]; + + for versions in debian_versions.chunks(3) { + let row = versions + .iter() + .map(|&version| InlineKeyboardButton::callback(version.to_owned(), version.to_owned())) + .collect(); + + keyboard.push(row); + } + + InlineKeyboardMarkup::new(keyboard) +} + +/// Parse the text wrote on Telegram and check if that text is a valid command +/// or not, then match the command. If the command is `/start` it writes a +/// markup with the `InlineKeyboardMarkup`. +async fn message_handler( + m: Message, + bot: AutoSend, +) -> Result<(), Box> { + if let Some(text) = m.text() { + match BotCommand::parse(text, "buttons") { + Ok(Command::Help) => { + // Just send the description of all commands. + bot.send_message(m.chat.id, Command::descriptions()).await?; + } + Ok(Command::Start) => { + // Create a list of buttons and send them. + let keyboard = make_keyboard(); + bot.send_message(m.chat.id, "Debian versions:").reply_markup(keyboard).await?; + } + + Err(_) => { + bot.send_message(m.chat.id, "Command not found!").await?; + } + } + } + + Ok(()) +} + +async fn inline_query_handler( + q: InlineQuery, + bot: AutoSend, +) -> Result<(), Box> { + let choose_debian_version = InlineQueryResultArticle::new( + "0", + "Chose debian version", + InputMessageContent::Text(InputMessageContentText::new("Debian versions:")), + ) + .reply_markup(make_keyboard()); + + bot.answer_inline_query(q.id, vec![choose_debian_version.into()]).await?; + + Ok(()) +} + +/// When it receives a callback from a button it edits the message with all +/// those buttons writing a text with the selected Debian version. +/// +/// **IMPORTANT**: do not send privacy-sensitive data this way!!! +/// Anyone can read data stored in the callback button. +async fn callback_handler( + q: CallbackQuery, + bot: AutoSend, +) -> Result<(), Box> { + if let Some(version) = q.data { + let text = format!("You chose: {}", version); + + match q.message { + Some(Message { id, chat, .. }) => { + bot.edit_message_text(chat.id, id, text).await?; + } + None => { + if let Some(id) = q.inline_message_id { + bot.edit_message_text_inline(id, text).await?; + } + } + } + + log::info!("You chose: {}", version); + } + + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + teloxide::enable_logging!(); + log::info!("Starting bot..."); + + let bot = Bot::from_env().auto_send(); + + let handler = dptree::entry() + .branch(Update::filter_message().endpoint(message_handler)) + .branch(Update::filter_callback_query().endpoint(callback_handler)) + .branch(Update::filter_inline_query().endpoint(inline_query_handler)); + + Dispatcher::builder(bot, handler).build().setup_ctrlc_handler().dispatch().await; + + log::info!("Closing bot... Goodbye!"); + + Ok(()) +} diff --git a/examples/dialogue.rs b/examples/dialogue.rs new file mode 100644 index 00000000..8bb4b294 --- /dev/null +++ b/examples/dialogue.rs @@ -0,0 +1,127 @@ +// This is a bot that asks you three questions, e.g. a simple test. +// +// # Example +// ``` +// - Hey +// - Let's start! What's your full name? +// - Gandalf the Grey +// - How old are you? +// - 223 +// - What's your location? +// - Middle-earth +// - Full name: Gandalf the Grey +// Age: 223 +// Location: Middle-earth +// ``` +use teloxide::{dispatching2::dialogue::InMemStorage, macros::DialogueState, prelude2::*}; + +type MyDialogue = Dialogue>; + +#[derive(DialogueState, Clone)] +#[handler_out(anyhow::Result<()>)] +pub enum State { + #[handler(handle_start)] + Start, + + #[handler(handle_receive_full_name)] + ReceiveFullName, + + #[handler(handle_receive_age)] + ReceiveAge { full_name: String }, + + #[handler(handle_receive_location)] + ReceiveLocation { full_name: String, age: u8 }, +} + +impl Default for State { + fn default() -> Self { + Self::Start + } +} + +#[tokio::main] +async fn main() { + teloxide::enable_logging!(); + log::info!("Starting dialogue_bot..."); + + let bot = Bot::from_env().auto_send(); + + Dispatcher::builder( + bot, + Update::filter_message() + .enter_dialogue::, State>() + .dispatch_by::(), + ) + .dependencies(dptree::deps![InMemStorage::::new()]) + .build() + .setup_ctrlc_handler() + .dispatch() + .await; +} + +async fn handle_start( + bot: AutoSend, + msg: Message, + dialogue: MyDialogue, +) -> anyhow::Result<()> { + bot.send_message(msg.chat.id, "Let's start! What's your full name?").await?; + dialogue.update(State::ReceiveFullName).await?; + Ok(()) +} + +async fn handle_receive_full_name( + bot: AutoSend, + msg: Message, + dialogue: MyDialogue, +) -> anyhow::Result<()> { + match msg.text() { + Some(text) => { + bot.send_message(msg.chat.id, "How old are you?").await?; + dialogue.update(State::ReceiveAge { full_name: text.into() }).await?; + } + None => { + bot.send_message(msg.chat.id, "Send me plain text.").await?; + } + } + + Ok(()) +} + +async fn handle_receive_age( + bot: AutoSend, + msg: Message, + dialogue: MyDialogue, + (full_name,): (String,), // Available from `State::ReceiveAge`. +) -> anyhow::Result<()> { + match msg.text().map(|text| text.parse::()) { + Some(Ok(age)) => { + bot.send_message(msg.chat.id, "What's your location?").await?; + dialogue.update(State::ReceiveLocation { full_name, age }).await?; + } + _ => { + bot.send_message(msg.chat.id, "Send me a number.").await?; + } + } + + Ok(()) +} + +async fn handle_receive_location( + bot: AutoSend, + msg: Message, + dialogue: MyDialogue, + (full_name, age): (String, u8), // Available from `State::ReceiveLocation`. +) -> anyhow::Result<()> { + match msg.text() { + Some(location) => { + let message = format!("Full name: {}\nAge: {}\nLocation: {}", full_name, age, location); + bot.send_message(msg.chat.id, message).await?; + dialogue.exit().await?; + } + None => { + bot.send_message(msg.chat.id, "Send me plain text.").await?; + } + } + + Ok(()) +} diff --git a/examples/dialogue_bot/Cargo.toml b/examples/dialogue_bot/Cargo.toml deleted file mode 100644 index 8d2ea09a..00000000 --- a/examples/dialogue_bot/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "dialogue_bot" -version = "0.1.0" -authors = ["Temirkhan Myrzamadi "] -edition = "2018" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -teloxide = { path = "../../", features = ["frunk", "macros", "auto-send"] } - -futures = "0.3.5" -tokio = { version = "1.3.0", features = ["rt-multi-thread", "macros"] } - -log = "0.4.8" -pretty_env_logger = "0.4.0" -derive_more = "0.99.9" - -frunk = "0.4" -frunk_core = "0.4" - -[profile.release] -lto = true diff --git a/examples/dialogue_bot/src/dialogue/mod.rs b/examples/dialogue_bot/src/dialogue/mod.rs deleted file mode 100644 index 43ad8db3..00000000 --- a/examples/dialogue_bot/src/dialogue/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -mod states; - -use crate::dialogue::states::{ - ReceiveAgeState, ReceiveFullNameState, ReceiveLocationState, StartState, -}; -use derive_more::From; -use teloxide::macros::Transition; - -#[derive(Transition, Clone, From)] -pub enum Dialogue { - Start(StartState), - ReceiveFullName(ReceiveFullNameState), - ReceiveAge(ReceiveAgeState), - ReceiveLocation(ReceiveLocationState), -} - -impl Default for Dialogue { - fn default() -> Self { - Self::Start(StartState) - } -} diff --git a/examples/dialogue_bot/src/dialogue/states/mod.rs b/examples/dialogue_bot/src/dialogue/states/mod.rs deleted file mode 100644 index 4872cc8e..00000000 --- a/examples/dialogue_bot/src/dialogue/states/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -mod receive_age; -mod receive_full_name; -mod receive_location; -mod start; - -pub use receive_age::ReceiveAgeState; -pub use receive_full_name::ReceiveFullNameState; -pub use receive_location::ReceiveLocationState; -pub use start::StartState; diff --git a/examples/dialogue_bot/src/dialogue/states/receive_age.rs b/examples/dialogue_bot/src/dialogue/states/receive_age.rs deleted file mode 100644 index 099b3407..00000000 --- a/examples/dialogue_bot/src/dialogue/states/receive_age.rs +++ /dev/null @@ -1,25 +0,0 @@ -use crate::dialogue::{states::receive_location::ReceiveLocationState, Dialogue}; -use teloxide::prelude::*; - -#[derive(Clone, Generic)] -pub struct ReceiveAgeState { - pub full_name: String, -} - -#[teloxide(subtransition)] -async fn receive_age_state( - state: ReceiveAgeState, - cx: TransitionIn>, - ans: String, -) -> TransitionOut { - match ans.parse::() { - Ok(ans) => { - cx.answer("What's your location?").await?; - next(ReceiveLocationState::up(state, ans)) - } - _ => { - cx.answer("Send me a number.").await?; - next(state) - } - } -} diff --git a/examples/dialogue_bot/src/dialogue/states/receive_full_name.rs b/examples/dialogue_bot/src/dialogue/states/receive_full_name.rs deleted file mode 100644 index 2ea60a1c..00000000 --- a/examples/dialogue_bot/src/dialogue/states/receive_full_name.rs +++ /dev/null @@ -1,15 +0,0 @@ -use crate::dialogue::{states::receive_age::ReceiveAgeState, Dialogue}; -use teloxide::prelude::*; - -#[derive(Clone, Generic)] -pub struct ReceiveFullNameState; - -#[teloxide(subtransition)] -async fn receive_full_name( - state: ReceiveFullNameState, - cx: TransitionIn>, - ans: String, -) -> TransitionOut { - cx.answer("How old are you?").await?; - next(ReceiveAgeState::up(state, ans)) -} diff --git a/examples/dialogue_bot/src/dialogue/states/receive_location.rs b/examples/dialogue_bot/src/dialogue/states/receive_location.rs deleted file mode 100644 index 3c1f6407..00000000 --- a/examples/dialogue_bot/src/dialogue/states/receive_location.rs +++ /dev/null @@ -1,19 +0,0 @@ -use crate::dialogue::Dialogue; -use teloxide::prelude::*; - -#[derive(Clone, Generic)] -pub struct ReceiveLocationState { - pub full_name: String, - pub age: u8, -} - -#[teloxide(subtransition)] -async fn receive_location( - state: ReceiveLocationState, - cx: TransitionIn>, - ans: String, -) -> TransitionOut { - cx.answer(format!("Full name: {}\nAge: {}\nLocation: {}", state.full_name, state.age, ans)) - .await?; - exit() -} diff --git a/examples/dialogue_bot/src/dialogue/states/start.rs b/examples/dialogue_bot/src/dialogue/states/start.rs deleted file mode 100644 index f3f12e0c..00000000 --- a/examples/dialogue_bot/src/dialogue/states/start.rs +++ /dev/null @@ -1,15 +0,0 @@ -use crate::dialogue::{states::ReceiveFullNameState, Dialogue}; -use teloxide::prelude::*; - -#[derive(Clone)] -pub struct StartState; - -#[teloxide(subtransition)] -async fn start( - _state: StartState, - cx: TransitionIn>, - _ans: String, -) -> TransitionOut { - cx.answer("Let's start! What's your full name?").await?; - next(ReceiveFullNameState) -} diff --git a/examples/dialogue_bot/src/main.rs b/examples/dialogue_bot/src/main.rs deleted file mode 100644 index cc2e6e76..00000000 --- a/examples/dialogue_bot/src/main.rs +++ /dev/null @@ -1,56 +0,0 @@ -// This is a bot that asks you three questions, e.g. a simple test. -// -// # Example -// ``` -// - Hey -// - Let's start! What's your full name? -// - Gandalf the Grey -// - How old are you? -// - 223 -// - What's your location? -// - Middle-earth -// - Full name: Gandalf the Grey -// Age: 223 -// Location: Middle-earth -// ``` - -#![allow(clippy::trivial_regex)] -#![allow(dead_code)] - -#[macro_use] -extern crate frunk; - -mod dialogue; - -use crate::dialogue::Dialogue; -use teloxide::prelude::*; - -#[tokio::main] -async fn main() { - run().await; -} - -async fn run() { - teloxide::enable_logging!(); - log::info!("Starting dialogue_bot..."); - - let bot = Bot::from_env().auto_send(); - - teloxide::dialogues_repl(bot, |message, dialogue| async move { - handle_message(message, dialogue).await.expect("Something wrong with the bot!") - }) - .await; -} - -async fn handle_message( - cx: UpdateWithCx, Message>, - dialogue: Dialogue, -) -> TransitionOut { - match cx.update.text().map(ToOwned::to_owned) { - None => { - cx.answer("Send me a text message.").await?; - next(dialogue) - } - Some(ans) => dialogue.react(cx, ans).await, - } -} diff --git a/examples/dices_bot/src/main.rs b/examples/dices.rs similarity index 60% rename from examples/dices_bot/src/main.rs rename to examples/dices.rs index 99cb370e..09b27cdd 100644 --- a/examples/dices_bot/src/main.rs +++ b/examples/dices.rs @@ -1,20 +1,16 @@ // This bot throws a dice on each incoming message. -use teloxide::prelude::*; +use teloxide::prelude2::*; #[tokio::main] async fn main() { - run().await; -} - -async fn run() { teloxide::enable_logging!(); log::info!("Starting dices_bot..."); let bot = Bot::from_env().auto_send(); - teloxide::repl(bot, |message| async move { - message.answer_dice().await?; + teloxide::repls2::repl(bot, |message: Message, bot: AutoSend| async move { + bot.send_dice(message.chat.id).await?; respond(()) }) .await; diff --git a/examples/dices_bot/Cargo.toml b/examples/dices_bot/Cargo.toml deleted file mode 100644 index 763f2c8b..00000000 --- a/examples/dices_bot/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "dices_bot" -version = "0.1.0" -authors = ["Temirkhan Myrzamadi "] -edition = "2018" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -teloxide = { path = "../../", features = ["auto-send"] } -log = "0.4.8" -pretty_env_logger = "0.4.0" -tokio = { version = "1.3.0", features = ["rt-multi-thread", "macros"] } - -[profile.release] -lto = true diff --git a/examples/dispatching2_features.rs b/examples/dispatching2_features.rs new file mode 100644 index 00000000..e99518f0 --- /dev/null +++ b/examples/dispatching2_features.rs @@ -0,0 +1,153 @@ +// This example provide a quick overview of the new features in the +// `dispatching2` module. + +use rand::Rng; + +// You need to import `prelude2` because `prelude` contains items from the old +// dispatching system, which will be deprecated in the future. +use teloxide::{ + prelude2::*, + types::{Dice, Update}, + utils::command::BotCommand, +}; + +#[tokio::main] +async fn main() { + teloxide::enable_logging!(); + log::info!("Starting dispatching2_features_bot..."); + + let bot = Bot::from_env().auto_send(); + + let parameters = ConfigParameters { + bot_maintainer: 268486177, // Paste your ID to run this bot. + maintainer_username: None, + }; + + let handler = Update::filter_message() + // You can use branching to define multiple ways in which an update will be handled. If the + // first branch fails, an update will be passed to the second branch, and so on. + .branch( + // Filtering allow you to filter updates by some condition. + dptree::filter(|msg: Message| msg.chat.is_group() || msg.chat.is_supergroup()) + // An endpoint is the last update handler. + .endpoint(|msg: Message, bot: AutoSend| async move { + log::info!("Received a message from a group chat."); + bot.send_message(msg.chat.id, "This is a group chat.").await?; + respond(()) + }), + ) + .branch( + // There are some extension filtering functions on `Message`. The following filter will + // filter only messages with dices. + Message::filter_dice().endpoint( + |msg: Message, dice: Dice, bot: AutoSend| async move { + bot.send_message(msg.chat.id, format!("Dice value: {}", dice.value)) + .reply_to_message_id(msg.id) + .await?; + Ok(()) + }, + ), + ) + .branch( + dptree::entry() + // Filter commands: the next handlers will receive a parsed `SimpleCommand`. + .filter_command::() + // If a command parsing fails, this handler will not be executed. + .endpoint(simple_commands_handler), + ) + .branch( + // Filter a maintainer by a used ID. + dptree::filter(|msg: Message, cfg: ConfigParameters| { + msg.from().map(|user| user.id == cfg.bot_maintainer).unwrap_or_default() + }) + .filter_command::() + .endpoint( + |msg: Message, bot: AutoSend, cmd: MaintainerCommands| async move { + match cmd { + MaintainerCommands::Rand { from, to } => { + let mut rng = rand::rngs::OsRng::default(); + let value: u64 = rng.gen_range(from..=to); + + bot.send_message(msg.chat.id, value.to_string()).await?; + Ok(()) + } + } + }, + ), + ); + + Dispatcher::builder(bot, handler) + // Here you specify initial dependencies that all handlers will receive; they can be + // database connections, configurations, and other auxiliary arguments. It is similar to + // `actix_web::Extensions`. + .dependencies(dptree::deps![parameters]) + // If no handler succeeded to handle an update, this closure will be called. + .default_handler(|upd| async move { + log::warn!("Unhandled update: {:?}", upd); + }) + // If the dispatcher fails for some reason, execute this handler. + .error_handler(LoggingErrorHandler::with_custom_text( + "An error has occurred in the dispatcher", + )) + .build() + .setup_ctrlc_handler() + .dispatch() + .await; +} + +#[derive(Clone)] +struct ConfigParameters { + bot_maintainer: i64, + maintainer_username: Option, +} + +#[derive(BotCommand, Clone)] +#[command(rename = "lowercase", description = "Simple commands")] +enum SimpleCommand { + #[command(description = "shows this message.")] + Help, + #[command(description = "shows maintainer info.")] + Maintainer, + #[command(description = "shows your ID.")] + MyId, +} + +#[derive(BotCommand, Clone)] +#[command(rename = "lowercase", description = "Maintainer commands")] +enum MaintainerCommands { + #[command(parse_with = "split", description = "generate a number within range")] + Rand { from: u64, to: u64 }, +} + +async fn simple_commands_handler( + msg: Message, + bot: AutoSend, + cmd: SimpleCommand, + cfg: ConfigParameters, +) -> Result<(), teloxide::RequestError> { + let text = match cmd { + SimpleCommand::Help => { + if msg.from().unwrap().id == cfg.bot_maintainer { + format!("{}\n{}", SimpleCommand::descriptions(), MaintainerCommands::descriptions()) + } else { + SimpleCommand::descriptions() + } + } + SimpleCommand::Maintainer => { + if msg.from().unwrap().id == cfg.bot_maintainer { + "Maintainer is you!".into() + } else if let Some(username) = cfg.maintainer_username { + format!("Maintainer is @{}", username) + } else { + format!("Maintainer ID is {}", cfg.bot_maintainer) + } + } + SimpleCommand::MyId => { + format!("{}", msg.from().unwrap().id) + } + }; + + bot.send_message(msg.chat.id, text).await?; + + Ok(()) +} diff --git a/examples/heroku_ping_pong_bot/src/main.rs b/examples/heroku_ping_pong.rs similarity index 67% rename from examples/heroku_ping_pong_bot/src/main.rs rename to examples/heroku_ping_pong.rs index 653fc9f8..909406f2 100644 --- a/examples/heroku_ping_pong_bot/src/main.rs +++ b/examples/heroku_ping_pong.rs @@ -1,7 +1,31 @@ // The version of Heroku ping-pong-bot, which uses a webhook to receive updates // from Telegram, instead of long polling. +// +// You will need to configure the buildpack for heroku. We will be using Heroku +// rust buildpack [1]. Configuration was done by using heroku CLI. +// +// If you're creating a new Heroku application, run this: +// +// ``` +// heroku create --buildpack emk/rust +// ``` +// +// To set buildpack for existing applicaton: +// +// ``` +// heroku buildpacks:set emk/rust +// ``` +// +// [1] https://github.com/emk/heroku-buildpack-rust -use teloxide::{dispatching::{update_listeners::{self, StatefulListener}, stop_token::AsyncStopToken}, prelude::*, types::Update}; +use teloxide::{ + dispatching::{ + stop_token::AsyncStopToken, + update_listeners::{self, StatefulListener}, + }, + prelude2::*, + types::Update, +}; use std::{convert::Infallible, env, net::SocketAddr}; use tokio::sync::mpsc; @@ -12,7 +36,20 @@ use reqwest::{StatusCode, Url}; #[tokio::main] async fn main() { - run().await; + teloxide::enable_logging!(); + log::info!("Starting heroku_ping_pong_bot..."); + + let bot = Bot::from_env().auto_send(); + + teloxide::repls2::repl_with_listener( + bot.clone(), + |msg: Message, bot: AutoSend| async move { + bot.send_message(msg.chat.id, "pong").await?; + respond(()) + }, + webhook(bot).await, + ) + .await; } async fn handle_rejection(error: warp::Rejection) -> Result { @@ -39,10 +76,8 @@ pub async fn webhook(bot: AutoSend) -> impl update_listeners::UpdateListene let server = warp::post() .and(warp::path(path)) .and(warp::body::json()) - .map(move |json: serde_json::Value| { - if let Ok(update) = Update::try_parse(&json) { - tx.send(Ok(update)).expect("Cannot send an incoming update from the webhook") - } + .map(move |update: Update| { + tx.send(Ok(update)).expect("Cannot send an incoming update from the webhook"); StatusCode::OK }) @@ -60,25 +95,11 @@ pub async fn webhook(bot: AutoSend) -> impl update_listeners::UpdateListene tokio::spawn(fut); let stream = UnboundedReceiverStream::new(rx); - fn streamf(state: &mut (S, T)) -> &mut S { &mut state.0 } - - StatefulListener::new((stream, stop_token), streamf, |state: &mut (_, AsyncStopToken)| state.1.clone()) -} - -async fn run() { - teloxide::enable_logging!(); - log::info!("Starting heroku_ping_pong_bot..."); - - let bot = Bot::from_env().auto_send(); - - let cloned_bot = bot.clone(); - teloxide::repl_with_listener( - bot, - |message| async move { - message.answer("pong").await?; - respond(()) - }, - webhook(cloned_bot).await, - ) - .await; + fn streamf(state: &mut (S, T)) -> &mut S { + &mut state.0 + } + + StatefulListener::new((stream, stop_token), streamf, |state: &mut (_, AsyncStopToken)| { + state.1.clone() + }) } diff --git a/examples/heroku_ping_pong_bot/Cargo.toml b/examples/heroku_ping_pong_bot/Cargo.toml deleted file mode 100644 index edcc76f5..00000000 --- a/examples/heroku_ping_pong_bot/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "heroku_ping_pong_bot" -version = "0.1.0" -authors = ["Pedro Lopes "] -edition = "2018" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -teloxide = { path = "../../", features = ["auto-send"] } -log = "0.4.8" -pretty_env_logger = "0.4.0" -tokio = { version = "1.3.0", features = ["rt-multi-thread", "macros"] } -tokio-stream = "0.1.4" - -# Used to setup a webhook -warp = "0.3.0" -reqwest = "0.10.4" -serde_json = "1.0.50" diff --git a/examples/heroku_ping_pong_bot/README.md b/examples/heroku_ping_pong_bot/README.md deleted file mode 100644 index 8869baf0..00000000 --- a/examples/heroku_ping_pong_bot/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Heroku example - -This is an example project on how to deploy `webhook_ping_pong_bot` to heroku. - -You will need to configure the buildpack for heroku. We will be using [Heroku rust buildpack](https://github.com/emk/heroku-buildpack-rust). Configuration was done by using `heroku` CLI. - -If you're creating a new Heroku application, run this command inside example -``` -heroku create --buildpack emk/rust -``` - -To set buildpack for existing applicaton: -``` -heroku buildpacks:set emk/rust -``` diff --git a/examples/inline.rs b/examples/inline.rs new file mode 100644 index 00000000..57049546 --- /dev/null +++ b/examples/inline.rs @@ -0,0 +1,64 @@ +use teloxide::{ + prelude2::*, + types::{ + InlineQueryResult, InlineQueryResultArticle, InputMessageContent, InputMessageContentText, + }, + Bot, +}; + +#[tokio::main] +async fn main() { + teloxide::enable_logging!(); + log::info!("Starting inline_bot..."); + + let bot = Bot::from_env().auto_send(); + + let handler = Update::filter_inline_query().branch(dptree::endpoint( + |query: InlineQuery, bot: AutoSend| async move { + // First, create your actual response + let google_search = InlineQueryResultArticle::new( + // Each item needs a unique ID, as well as the response container for the + // items. These can be whatever, as long as they don't + // conflict. + "01".to_string(), + // What the user will actually see + "Google Search", + // What message will be sent when clicked/tapped + InputMessageContent::Text(InputMessageContentText::new(format!( + "https://www.google.com/search?q={}", + query.query, + ))), + ); + // While constructing them from the struct itself is possible, it is preferred + // to use the builder pattern if you wish to add more + // information to your result. Please refer to the documentation + // for more detailed information about each field. https://docs.rs/teloxide/latest/teloxide/types/struct.InlineQueryResultArticle.html + let ddg_search = InlineQueryResultArticle::new( + "02".to_string(), + "DuckDuckGo Search".to_string(), + InputMessageContent::Text(InputMessageContentText::new(format!( + "https://duckduckgo.com/?q={}", + query.query + ))), + ) + .description("DuckDuckGo Search") + .thumb_url("https://duckduckgo.com/assets/logo_header.v108.png".parse().unwrap()) + .url("https://duckduckgo.com/about".parse().unwrap()); // Note: This is the url that will open if they click the thumbnail + + let results = vec![ + InlineQueryResult::Article(google_search), + InlineQueryResult::Article(ddg_search), + ]; + + // Send it off! One thing to note -- the ID we use here must be of the query + // we're responding to. + let response = bot.answer_inline_query(&query.id, results).send().await; + if let Err(err) = response { + log::error!("Error in handler: {:?}", err); + } + respond(()) + }, + )); + + Dispatcher::builder(bot, handler).build().setup_ctrlc_handler().dispatch().await; +} diff --git a/examples/inline_bot/Cargo.toml b/examples/inline_bot/Cargo.toml deleted file mode 100644 index 40e7281d..00000000 --- a/examples/inline_bot/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "inline_bot" -version = "0.1.0" -authors = ["Colin Diener "] -edition = "2018" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -teloxide = { path = "../../", features = ["macros", "auto-send"] } -log = "0.4.8" -pretty_env_logger = "0.4.0" -tokio = { version = "1.3.0", features = ["rt-multi-thread", "macros"] } -tokio-stream = "0.1.3" diff --git a/examples/inline_bot/src/main.rs b/examples/inline_bot/src/main.rs deleted file mode 100644 index d7c10399..00000000 --- a/examples/inline_bot/src/main.rs +++ /dev/null @@ -1,65 +0,0 @@ -use teloxide::{ - prelude::*, - types::{ - InlineQueryResult, InlineQueryResultArticle, InputMessageContent, InputMessageContentText, - }, - Bot, -}; -use tokio_stream::wrappers::UnboundedReceiverStream; - -#[tokio::main] -async fn main() { - run().await; -} - -async fn run() { - let bot = Bot::from_env().auto_send(); - // Create a new dispatcher to handle incoming queries - Dispatcher::new(bot) - .inline_queries_handler(|rx: DispatcherHandlerRx, InlineQuery>| { - UnboundedReceiverStream::new(rx).for_each_concurrent(None, |query| async move { - // First, create your actual response - let google_search = InlineQueryResultArticle::new( - // Each item needs a unique ID, as well as the response container for the items. - // These can be whatever, as long as they don't conflict. - "01".to_string(), - // What the user will actually see - "Google Search", - // What message will be sent when clicked/tapped - InputMessageContent::Text(InputMessageContentText::new(format!( - "https://www.google.com/search?q={}", - query.update.query, - ))), - ); - // While constructing them from the struct itself is possible, it is preferred to use - // the builder pattern if you wish to add more information to your result. - // Please refer to the documentation for more detailed information about each field. - // https://docs.rs/teloxide/latest/teloxide/types/struct.InlineQueryResultArticle.html - let ddg_search = InlineQueryResultArticle::new( - "02".to_string(), - "DuckDuckGo Search".to_string(), - InputMessageContent::Text(InputMessageContentText::new(format!( - "https://duckduckgo.com/?q={}", - query.update.query.to_string() - ))), - ) - .description("DuckDuckGo Search") - .thumb_url("https://duckduckgo.com/assets/logo_header.v108.png") - .url("https://duckduckgo.com/about"); // Note: This is the url that will open if they click the thumbnail - - let results = vec![ - InlineQueryResult::Article(google_search), - InlineQueryResult::Article(ddg_search), - ]; - - // Send it off! One thing to note -- the ID we use here must be of the query we're responding to. - let response = - query.requester.answer_inline_query(&query.update.id, results).send().await; - if let Err(err) = response { - log::error!("Error in handler: {:?}", err); - } - }) - }) - .dispatch() - .await; -} diff --git a/examples/ngrok_ping_pong_bot/src/main.rs b/examples/ngrok_ping_pong.rs similarity index 66% rename from examples/ngrok_ping_pong_bot/src/main.rs rename to examples/ngrok_ping_pong.rs index c748e0c8..b4159387 100644 --- a/examples/ngrok_ping_pong_bot/src/main.rs +++ b/examples/ngrok_ping_pong.rs @@ -1,7 +1,14 @@ // The version of ngrok ping-pong-bot, which uses a webhook to receive updates // from Telegram, instead of long polling. -use teloxide::{dispatching::{update_listeners::{self, StatefulListener}, stop_token::AsyncStopToken}, prelude::*, types::Update}; +use teloxide::{ + dispatching::{ + stop_token::AsyncStopToken, + update_listeners::{self, StatefulListener}, + }, + prelude2::*, + types::Update, +}; use std::{convert::Infallible, net::SocketAddr}; use tokio::sync::mpsc; @@ -12,7 +19,20 @@ use reqwest::{StatusCode, Url}; #[tokio::main] async fn main() { - run().await; + teloxide::enable_logging!(); + log::info!("Starting heroku_ping_pong_bot..."); + + let bot = Bot::from_env().auto_send(); + + teloxide::repls2::repl_with_listener( + bot.clone(), + |msg: Message, bot: AutoSend| async move { + bot.send_message(msg.chat.id, "pong").await?; + respond(()) + }, + webhook(bot).await, + ) + .await; } async fn handle_rejection(error: warp::Rejection) -> Result { @@ -25,18 +45,14 @@ pub async fn webhook(bot: AutoSend) -> impl update_listeners::UpdateListene // You might want to specify a self-signed certificate via .certificate // method on SetWebhook. - bot.set_webhook(url) - .await - .expect("Cannot setup a webhook"); + bot.set_webhook(url).await.expect("Cannot setup a webhook"); let (tx, rx) = mpsc::unbounded_channel(); let server = warp::post() .and(warp::body::json()) - .map(move |json: serde_json::Value| { - if let Ok(update) = Update::try_parse(&json) { - tx.send(Ok(update)).expect("Cannot send an incoming update from the webhook") - } + .map(move |update: Update| { + tx.send(Ok(update)).expect("Cannot send an incoming update from the webhook"); StatusCode::OK }) @@ -54,25 +70,11 @@ pub async fn webhook(bot: AutoSend) -> impl update_listeners::UpdateListene tokio::spawn(fut); let stream = UnboundedReceiverStream::new(rx); - fn streamf(state: &mut (S, T)) -> &mut S { &mut state.0 } - - StatefulListener::new((stream, stop_token), streamf, |state: &mut (_, AsyncStopToken)| state.1.clone()) -} - -async fn run() { - teloxide::enable_logging!(); - log::info!("Starting ngrok_ping_pong_bot..."); - - let bot = Bot::from_env().auto_send(); - - let cloned_bot = bot.clone(); - teloxide::repl_with_listener( - bot, - |message| async move { - message.answer("pong").await?; - respond(()) - }, - webhook(cloned_bot).await, - ) - .await; + fn streamf(state: &mut (S, T)) -> &mut S { + &mut state.0 + } + + StatefulListener::new((stream, stop_token), streamf, |state: &mut (_, AsyncStopToken)| { + state.1.clone() + }) } diff --git a/examples/ngrok_ping_pong_bot/Cargo.toml b/examples/ngrok_ping_pong_bot/Cargo.toml deleted file mode 100644 index 571fe1ff..00000000 --- a/examples/ngrok_ping_pong_bot/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "webhook_ping_pong_bot" -version = "0.1.0" -authors = ["Temirkhan Myrzamadi "] -edition = "2018" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -teloxide = { path = "../../", features = ["auto-send"] } -log = "0.4.8" -pretty_env_logger = "0.4.0" -tokio = { version = "1.3.0", features = ["rt-multi-thread", "macros"] } -tokio-stream = "0.1.4" - -# Used to setup a webhook -warp = "0.3.0" -reqwest = "0.10.4" -serde_json = "1.0.50" diff --git a/examples/redis_remember.rs b/examples/redis_remember.rs new file mode 100644 index 00000000..0935bcdf --- /dev/null +++ b/examples/redis_remember.rs @@ -0,0 +1,116 @@ +use teloxide::{ + dispatching2::dialogue::{serializer::Bincode, RedisStorage, Storage}, + macros::DialogueState, + prelude2::*, + types::Me, + utils::command::BotCommand, + RequestError, +}; +use thiserror::Error; + +type MyDialogue = Dialogue>; +type StorageError = as Storage>::Error; + +#[derive(Debug, Error)] +enum Error { + #[error("error from Telegram: {0}")] + TelegramError(#[from] RequestError), + + #[error("error from storage: {0}")] + StorageError(#[from] StorageError), +} + +#[derive(DialogueState, Clone, serde::Serialize, serde::Deserialize)] +#[handler_out(anyhow::Result<()>)] +pub enum State { + #[handler(handle_start)] + Start, + + #[handler(handle_got_number)] + GotNumber(i32), +} + +impl Default for State { + fn default() -> Self { + Self::Start + } +} + +#[derive(BotCommand)] +#[command(rename = "lowercase", description = "These commands are supported:")] +pub enum Command { + #[command(description = "get your number.")] + Get, + #[command(description = "reset your number.")] + Reset, +} +#[tokio::main] +async fn main() { + let bot = Bot::from_env().auto_send(); + // You can also choose serializer::JSON or serializer::CBOR + // All serializers but JSON require enabling feature + // "serializer-", e. g. "serializer-cbor" + // or "serializer-bincode" + let storage = RedisStorage::open("redis://127.0.0.1:6379", Bincode).await.unwrap(); + + let handler = Update::filter_message() + .enter_dialogue::, State>() + .dispatch_by::(); + + Dispatcher::builder(bot, handler) + .dependencies(dptree::deps![storage]) + .build() + .setup_ctrlc_handler() + .dispatch() + .await; +} + +async fn handle_start( + bot: AutoSend, + msg: Message, + dialogue: MyDialogue, +) -> anyhow::Result<()> { + match msg.text().unwrap().parse() { + Ok(number) => { + dialogue.update(State::GotNumber(number)).await?; + bot.send_message( + msg.chat.id, + format!("Remembered number {}. Now use /get or /reset", number), + ) + .await?; + } + _ => { + bot.send_message(msg.chat.id, "Please, send me a number").await?; + } + } + + Ok(()) +} + +async fn handle_got_number( + bot: AutoSend, + msg: Message, + dialogue: MyDialogue, + num: i32, + me: Me, +) -> anyhow::Result<()> { + let ans = msg.text().unwrap(); + let bot_name = me.user.username.unwrap(); + + match Command::parse(ans, bot_name) { + Ok(cmd) => match cmd { + Command::Get => { + bot.send_message(msg.chat.id, format!("Here is your number: {}", num)).await?; + } + Command::Reset => { + dialogue.reset().await?; + bot.send_message(msg.chat.id, "Number resetted").await?; + } + }, + Err(_) => { + bot.send_message(msg.chat.id, "Please, send /get or /reset").await?; + } + } + + Ok(()) +} diff --git a/examples/redis_remember_bot/Cargo.toml b/examples/redis_remember_bot/Cargo.toml deleted file mode 100644 index c134f0a4..00000000 --- a/examples/redis_remember_bot/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "redis_remember_bot" -version = "0.1.0" -authors = ["Maximilian Siling "] -edition = "2018" - -[dependencies] -# You can also choose "cbor-serializer" or built-in JSON serializer -teloxide = { path = "../../", features = ["redis-storage", "bincode-serializer", "macros", "auto-send"] } - -log = "0.4.8" -pretty_env_logger = "0.4.0" -tokio = { version = "1.3.0", features = ["rt-multi-thread", "macros"] } - -serde = "1.0.104" -futures = "0.3.5" - -thiserror = "1.0.15" -derive_more = "0.99.9" diff --git a/examples/redis_remember_bot/src/main.rs b/examples/redis_remember_bot/src/main.rs deleted file mode 100644 index 9fc32f22..00000000 --- a/examples/redis_remember_bot/src/main.rs +++ /dev/null @@ -1,62 +0,0 @@ -#[macro_use] -extern crate derive_more; - -mod states; -mod transitions; - -use states::*; - -use teloxide::{ - dispatching::dialogue::{serializer::Bincode, RedisStorage, Storage}, - prelude::*, - RequestError, -}; -use thiserror::Error; - -type StorageError = as Storage>::Error; - -#[derive(Debug, Error)] -enum Error { - #[error("error from Telegram: {0}")] - TelegramError(#[from] RequestError), - #[error("error from storage: {0}")] - StorageError(#[from] StorageError), -} - -type In = DialogueWithCx, Message, Dialogue, StorageError>; - -#[tokio::main] -async fn main() { - run().await; -} - -async fn run() { - let bot = Bot::from_env().auto_send(); - Dispatcher::new(bot) - .messages_handler(DialogueDispatcher::with_storage( - |DialogueWithCx { cx, dialogue }: In| async move { - let dialogue = dialogue.expect("std::convert::Infallible"); - handle_message(cx, dialogue).await.expect("Something wrong with the bot!") - }, - // You can also choose serializer::JSON or serializer::CBOR - // All serializers but JSON require enabling feature - // "serializer-", e. g. "serializer-cbor" - // or "serializer-bincode" - RedisStorage::open("redis://127.0.0.1:6379", Bincode).await.unwrap(), - )) - .dispatch() - .await; -} - -async fn handle_message( - cx: UpdateWithCx, Message>, - dialogue: Dialogue, -) -> TransitionOut { - match cx.update.text().map(ToOwned::to_owned) { - None => { - cx.answer("Send me a text message.").await?; - next(dialogue) - } - Some(ans) => dialogue.react(cx, ans).await, - } -} diff --git a/examples/redis_remember_bot/src/states.rs b/examples/redis_remember_bot/src/states.rs deleted file mode 100644 index 6da08fe8..00000000 --- a/examples/redis_remember_bot/src/states.rs +++ /dev/null @@ -1,23 +0,0 @@ -use teloxide::dispatching::dialogue::Transition; - -use serde::{Deserialize, Serialize}; - -#[derive(Transition, From, Serialize, Deserialize)] -pub enum Dialogue { - Start(StartState), - HaveNumber(HaveNumberState), -} - -impl Default for Dialogue { - fn default() -> Self { - Self::Start(StartState) - } -} - -#[derive(Serialize, Deserialize)] -pub struct StartState; - -#[derive(Serialize, Deserialize)] -pub struct HaveNumberState { - pub number: i32, -} diff --git a/examples/redis_remember_bot/src/transitions.rs b/examples/redis_remember_bot/src/transitions.rs deleted file mode 100644 index 7cfccd13..00000000 --- a/examples/redis_remember_bot/src/transitions.rs +++ /dev/null @@ -1,38 +0,0 @@ -use teloxide::prelude::*; - -use super::states::*; - -#[teloxide(subtransition)] -async fn start( - state: StartState, - cx: TransitionIn>, - ans: String, -) -> TransitionOut { - if let Ok(number) = ans.parse() { - cx.answer(format!("Remembered number {}. Now use /get or /reset", number)).await?; - next(HaveNumberState { number }) - } else { - cx.answer("Please, send me a number").await?; - next(state) - } -} - -#[teloxide(subtransition)] -async fn have_number( - state: HaveNumberState, - cx: TransitionIn>, - ans: String, -) -> TransitionOut { - let num = state.number; - - if ans.starts_with("/get") { - cx.answer(format!("Here is your number: {}", num)).await?; - next(state) - } else if ans.starts_with("/reset") { - cx.answer("Resetted number").await?; - next(StartState) - } else { - cx.answer("Please, send /get or /reset").await?; - next(state) - } -} diff --git a/examples/shared_state.rs b/examples/shared_state.rs new file mode 100644 index 00000000..c7e6f292 --- /dev/null +++ b/examples/shared_state.rs @@ -0,0 +1,27 @@ +// This bot answers how many messages it received in total on every message. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use once_cell::sync::Lazy; +use teloxide::prelude2::*; + +static MESSAGES_TOTAL: Lazy = Lazy::new(AtomicU64::default); + +#[tokio::main] +async fn main() { + teloxide::enable_logging!(); + log::info!("Starting shared_state_bot..."); + + let bot = Bot::from_env().auto_send(); + + let handler = Update::filter_message().branch(dptree::endpoint( + |msg: Message, bot: AutoSend| async move { + let previous = MESSAGES_TOTAL.fetch_add(1, Ordering::Relaxed); + bot.send_message(msg.chat.id, format!("I received {} messages in total.", previous)) + .await?; + respond(()) + }, + )); + + Dispatcher::builder(bot, handler).build().setup_ctrlc_handler().dispatch().await; +} diff --git a/examples/shared_state_bot/Cargo.toml b/examples/shared_state_bot/Cargo.toml deleted file mode 100644 index eb613ba1..00000000 --- a/examples/shared_state_bot/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "shared_state_bot" -version = "0.1.0" -authors = ["Temirkhan Myrzamadi "] -edition = "2018" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -teloxide = { path = "../../", features = ["auto-send"] } -log = "0.4.8" -pretty_env_logger = "0.4.0" -tokio = { version = "1.3.0", features = ["rt-multi-thread", "macros"] } -tokio-stream = "0.1.3" -lazy_static = "1.4.0" diff --git a/examples/shared_state_bot/src/main.rs b/examples/shared_state_bot/src/main.rs deleted file mode 100644 index 123de9dc..00000000 --- a/examples/shared_state_bot/src/main.rs +++ /dev/null @@ -1,38 +0,0 @@ -// This bot answers how many messages it received in total on every message. - -use std::sync::atomic::{AtomicU64, Ordering}; - -use lazy_static::lazy_static; -use teloxide::prelude::*; -use tokio_stream::wrappers::UnboundedReceiverStream; - -lazy_static! { - static ref MESSAGES_TOTAL: AtomicU64 = AtomicU64::new(0); -} - -#[tokio::main] -async fn main() { - run().await; -} - -async fn run() { - teloxide::enable_logging!(); - log::info!("Starting shared_state_bot..."); - - let bot = Bot::from_env().auto_send(); - - Dispatcher::new(bot) - .messages_handler(|rx: DispatcherHandlerRx, Message>| { - UnboundedReceiverStream::new(rx).for_each_concurrent(None, |message| async move { - let previous = MESSAGES_TOTAL.fetch_add(1, Ordering::Relaxed); - - message - .answer(format!("I received {} messages in total.", previous)) - .await - .log_on_error() - .await; - }) - }) - .dispatch() - .await; -} diff --git a/examples/simple_commands_bot/src/main.rs b/examples/simple_commands.rs similarity index 58% rename from examples/simple_commands_bot/src/main.rs rename to examples/simple_commands.rs index 5c459402..f18953e4 100644 --- a/examples/simple_commands_bot/src/main.rs +++ b/examples/simple_commands.rs @@ -1,8 +1,8 @@ -use teloxide::{prelude::*, utils::command::BotCommand}; +use teloxide::{prelude2::*, utils::command::BotCommand}; use std::error::Error; -#[derive(BotCommand)] +#[derive(BotCommand, Clone)] #[command(rename = "lowercase", description = "These commands are supported:")] enum Command { #[command(description = "display this text.")] @@ -14,16 +14,21 @@ enum Command { } async fn answer( - cx: UpdateWithCx, Message>, + bot: AutoSend, + message: Message, command: Command, ) -> Result<(), Box> { match command { - Command::Help => cx.answer(Command::descriptions()).await?, + Command::Help => bot.send_message(message.chat.id, Command::descriptions()).await?, Command::Username(username) => { - cx.answer(format!("Your username is @{}.", username)).await? + bot.send_message(message.chat.id, format!("Your username is @{}.", username)).await? } Command::UsernameAndAge { username, age } => { - cx.answer(format!("Your username is @{} and age is {}.", username, age)).await? + bot.send_message( + message.chat.id, + format!("Your username is @{} and age is {}.", username, age), + ) + .await? } }; @@ -32,15 +37,10 @@ async fn answer( #[tokio::main] async fn main() { - run().await; -} - -async fn run() { teloxide::enable_logging!(); log::info!("Starting simple_commands_bot..."); let bot = Bot::from_env().auto_send(); - let bot_name: String = panic!("Your bot's name here"); - teloxide::commands_repl(bot, bot_name, answer).await; + teloxide::repls2::commands_repl(bot, answer, Command::ty()).await; } diff --git a/examples/simple_commands_bot/Cargo.toml b/examples/simple_commands_bot/Cargo.toml deleted file mode 100644 index 7aef1030..00000000 --- a/examples/simple_commands_bot/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "simple_commands_bot" -version = "0.1.0" -authors = ["Temirkhan Myrzamadi "] -edition = "2018" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -teloxide = { path = "../../", features = ["macros", "auto-send"] } -log = "0.4.8" -pretty_env_logger = "0.4.0" -tokio = { version = "1.3.0", features = ["rt-multi-thread", "macros"] } diff --git a/examples/sqlite_remember.rs b/examples/sqlite_remember.rs new file mode 100644 index 00000000..e7a7fabd --- /dev/null +++ b/examples/sqlite_remember.rs @@ -0,0 +1,113 @@ +use teloxide::{ + dispatching2::dialogue::{serializer::Json, SqliteStorage, Storage}, + macros::DialogueState, + prelude2::*, + types::Me, + utils::command::BotCommand, + RequestError, +}; +use thiserror::Error; + +type MyDialogue = Dialogue>; +type StorageError = as Storage>::Error; + +#[derive(Debug, Error)] +enum Error { + #[error("error from Telegram: {0}")] + TelegramError(#[from] RequestError), + + #[error("error from storage: {0}")] + StorageError(#[from] StorageError), +} + +#[derive(DialogueState, Clone, serde::Serialize, serde::Deserialize)] +#[handler_out(anyhow::Result<()>)] +pub enum State { + #[handler(handle_start)] + Start, + + #[handler(handle_got_number)] + GotNumber(i32), +} + +impl Default for State { + fn default() -> Self { + Self::Start + } +} + +#[derive(BotCommand)] +#[command(rename = "lowercase", description = "These commands are supported:")] +pub enum Command { + #[command(description = "get your number.")] + Get, + #[command(description = "reset your number.")] + Reset, +} + +#[tokio::main] +async fn main() { + let bot = Bot::from_env().auto_send(); + let storage = SqliteStorage::open("db.sqlite", Json).await.unwrap(); + + let handler = Update::filter_message() + .enter_dialogue::, State>() + .dispatch_by::(); + + Dispatcher::builder(bot, handler) + .dependencies(dptree::deps![storage]) + .build() + .setup_ctrlc_handler() + .dispatch() + .await; +} + +async fn handle_start( + bot: AutoSend, + msg: Message, + dialogue: MyDialogue, +) -> anyhow::Result<()> { + match msg.text().unwrap().parse() { + Ok(number) => { + dialogue.update(State::GotNumber(number)).await?; + bot.send_message( + msg.chat.id, + format!("Remembered number {}. Now use /get or /reset", number), + ) + .await?; + } + _ => { + bot.send_message(msg.chat.id, "Please, send me a number").await?; + } + } + + Ok(()) +} + +async fn handle_got_number( + bot: AutoSend, + msg: Message, + dialogue: MyDialogue, + num: i32, + me: Me, +) -> anyhow::Result<()> { + let ans = msg.text().unwrap(); + let bot_name = me.user.username.unwrap(); + + match Command::parse(ans, bot_name) { + Ok(cmd) => match cmd { + Command::Get => { + bot.send_message(msg.chat.id, format!("Here is your number: {}", num)).await?; + } + Command::Reset => { + dialogue.reset().await?; + bot.send_message(msg.chat.id, "Number resetted").await?; + } + }, + Err(_) => { + bot.send_message(msg.chat.id, "Please, send /get or /reset").await?; + } + } + + Ok(()) +} diff --git a/examples/sqlite_remember_bot/Cargo.toml b/examples/sqlite_remember_bot/Cargo.toml deleted file mode 100644 index d224d528..00000000 --- a/examples/sqlite_remember_bot/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "sqlite_remember_bot" -version = "0.1.0" -authors = ["Maximilian Siling ", "Sergey Levitin "] -edition = "2018" - -[dependencies] -# You can also choose "cbor-serializer" or built-in JSON serializer -teloxide = { path = "../../", features = ["sqlite-storage", "bincode-serializer", "redis-storage", "macros", "auto-send"] } - -log = "0.4.8" -pretty_env_logger = "0.4.0" -tokio = { version = "1.3.0", features = ["rt-multi-thread", "macros"] } - -serde = "1.0.104" -futures = "0.3.5" - -thiserror = "1.0.15" -derive_more = "0.99.9" diff --git a/examples/sqlite_remember_bot/src/main.rs b/examples/sqlite_remember_bot/src/main.rs deleted file mode 100644 index c8980765..00000000 --- a/examples/sqlite_remember_bot/src/main.rs +++ /dev/null @@ -1,55 +0,0 @@ -#[macro_use] -extern crate derive_more; - -mod states; -mod transitions; - -use states::*; - -use teloxide::{ - dispatching::dialogue::{serializer::Json, SqliteStorage, Storage}, - prelude::*, - RequestError, -}; -use thiserror::Error; - -type StorageError = as Storage>::Error; - -#[derive(Debug, Error)] -enum Error { - #[error("error from Telegram: {0}")] - TelegramError(#[from] RequestError), - #[error("error from storage: {0}")] - StorageError(#[from] StorageError), -} - -type In = DialogueWithCx, Message, Dialogue, StorageError>; - -async fn handle_message( - cx: UpdateWithCx, Message>, - dialogue: Dialogue, -) -> TransitionOut { - match cx.update.text().map(ToOwned::to_owned) { - None => { - cx.answer("Send me a text message.").await?; - next(dialogue) - } - Some(ans) => dialogue.react(cx, ans).await, - } -} - -#[tokio::main] -async fn main() { - let bot = Bot::from_env().auto_send(); - - Dispatcher::new(bot) - .messages_handler(DialogueDispatcher::with_storage( - |DialogueWithCx { cx, dialogue }: In| async move { - let dialogue = dialogue.expect("std::convert::Infallible"); - handle_message(cx, dialogue).await.expect("Something wrong with the bot!") - }, - SqliteStorage::open("db.sqlite", Json).await.unwrap(), - )) - .dispatch() - .await; -} diff --git a/examples/sqlite_remember_bot/src/states.rs b/examples/sqlite_remember_bot/src/states.rs deleted file mode 100644 index 1c007b5a..00000000 --- a/examples/sqlite_remember_bot/src/states.rs +++ /dev/null @@ -1,23 +0,0 @@ -use teloxide::macros::Transition; - -use serde::{Deserialize, Serialize}; - -#[derive(Transition, From, Serialize, Deserialize)] -pub enum Dialogue { - Start(StartState), - HaveNumber(HaveNumberState), -} - -impl Default for Dialogue { - fn default() -> Self { - Self::Start(StartState) - } -} - -#[derive(Serialize, Deserialize)] -pub struct StartState; - -#[derive(Serialize, Deserialize)] -pub struct HaveNumberState { - pub number: i32, -} diff --git a/examples/sqlite_remember_bot/src/transitions.rs b/examples/sqlite_remember_bot/src/transitions.rs deleted file mode 100644 index 2606e203..00000000 --- a/examples/sqlite_remember_bot/src/transitions.rs +++ /dev/null @@ -1,39 +0,0 @@ -use teloxide::prelude::*; -use teloxide::macros::teloxide; - -use super::states::*; - -#[teloxide(subtransition)] -async fn start( - state: StartState, - cx: TransitionIn>, - ans: String, -) -> TransitionOut { - if let Ok(number) = ans.parse() { - cx.answer(format!("Remembered number {}. Now use /get or /reset", number)).await?; - next(HaveNumberState { number }) - } else { - cx.answer("Please, send me a number").await?; - next(state) - } -} - -#[teloxide(subtransition)] -async fn have_number( - state: HaveNumberState, - cx: TransitionIn>, - ans: String, -) -> TransitionOut { - let num = state.number; - - if ans.starts_with("/get") { - cx.answer(format!("Here is your number: {}", num)).await?; - next(state) - } else if ans.starts_with("/reset") { - cx.answer("Resetted number").await?; - next(StartState) - } else { - cx.answer("Please, send /get or /reset").await?; - next(state) - } -} diff --git a/examples/test_examples.sh b/examples/test_examples.sh deleted file mode 100644 index 4b1969ec..00000000 --- a/examples/test_examples.sh +++ /dev/null @@ -1,10 +0,0 @@ -##!/bin/sh - -for example in */; do - echo Testing $example... - cd $example - cargo check & - cd .. -done - -wait diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000..7158d75f --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "nightly-2022-02-02" +components = ["rustfmt", "clippy"] +profile = "minimal" diff --git a/src/dispatching/dialogue/dialogue_dispatcher.rs b/src/dispatching/dialogue/dialogue_dispatcher.rs index 3232e633..6c9703e9 100644 --- a/src/dispatching/dialogue/dialogue_dispatcher.rs +++ b/src/dispatching/dialogue/dialogue_dispatcher.rs @@ -30,6 +30,7 @@ use tokio_stream::wrappers::UnboundedReceiverStream; /// /// [`Dispatcher`]: crate::dispatching::Dispatcher /// [`DispatcherHandler`]: crate::dispatching::DispatcherHandler +#[deprecated(note = "Use dispatching2 instead")] pub struct DialogueDispatcher { storage: Arc, handler: Arc, diff --git a/src/dispatching/dialogue/dialogue_dispatcher_handler.rs b/src/dispatching/dialogue/dialogue_dispatcher_handler.rs index 827809ea..d3bd92c9 100644 --- a/src/dispatching/dialogue/dialogue_dispatcher_handler.rs +++ b/src/dispatching/dialogue/dialogue_dispatcher_handler.rs @@ -1,4 +1,4 @@ -use crate::prelude::{DialogueStage, DialogueWithCx}; +use crate::dispatching::dialogue::{DialogueStage, DialogueWithCx}; use futures::future::BoxFuture; use std::{future::Future, sync::Arc}; @@ -8,6 +8,7 @@ use std::{future::Future, sync::Arc}; /// overview](crate::dispatching::dialogue). /// /// [`DialogueDispatcher`]: crate::dispatching::dialogue::DialogueDispatcher +#[deprecated(note = "Use dispatching2 instead")] pub trait DialogueDispatcherHandler { #[must_use] fn handle( diff --git a/src/dispatching/dialogue/dialogue_stage.rs b/src/dispatching/dialogue/dialogue_stage.rs index ad731e74..c4a29744 100644 --- a/src/dispatching/dialogue/dialogue_stage.rs +++ b/src/dispatching/dialogue/dialogue_stage.rs @@ -5,6 +5,7 @@ use crate::dispatching::dialogue::TransitionOut; /// See [the module-level documentation for the design /// overview](crate::dispatching::dialogue). #[derive(Debug, Copy, Clone, Eq, Hash, PartialEq)] +#[deprecated(note = "Use dispatching2 instead")] pub enum DialogueStage { Next(D), Exit, @@ -21,6 +22,7 @@ pub enum DialogueStage { /// /// [`From`]: std::convert::From /// [derive-more]: https://crates.io/crates/derive_more +#[deprecated(note = "Use dispatching2 instead")] pub fn next(new_state: State) -> TransitionOut where Dialogue: From, @@ -32,6 +34,7 @@ where /// /// See [the module-level documentation for the design /// overview](crate::dispatching::dialogue). +#[deprecated(note = "Use dispatching2 instead")] pub fn exit() -> TransitionOut { Ok(DialogueStage::Exit) } diff --git a/src/dispatching/dialogue/dialogue_with_cx.rs b/src/dispatching/dialogue/dialogue_with_cx.rs index 8b718122..19240bdb 100644 --- a/src/dispatching/dialogue/dialogue_with_cx.rs +++ b/src/dispatching/dialogue/dialogue_with_cx.rs @@ -9,6 +9,7 @@ use teloxide_core::requests::Requester; /// /// [`DialogueDispatcher`]: crate::dispatching::dialogue::DialogueDispatcher #[derive(Debug)] +#[deprecated(note = "Use dispatching2 instead")] pub struct DialogueWithCx { pub cx: UpdateWithCx, pub dialogue: Result, diff --git a/src/dispatching/dialogue/get_chat_id.rs b/src/dispatching/dialogue/get_chat_id.rs index 9d492a43..72014f8e 100644 --- a/src/dispatching/dialogue/get_chat_id.rs +++ b/src/dispatching/dialogue/get_chat_id.rs @@ -1,6 +1,7 @@ use teloxide_core::types::Message; /// Something that has a chat ID. +#[deprecated(note = "Use dispatching2 instead")] pub trait GetChatId { #[must_use] fn chat_id(&self) -> i64; diff --git a/src/dispatching/dialogue/mod.rs b/src/dispatching/dialogue/mod.rs index 097c7180..962c27f6 100644 --- a/src/dispatching/dialogue/mod.rs +++ b/src/dispatching/dialogue/mod.rs @@ -80,10 +80,6 @@ //! //! #[tokio::main] //! async fn main() { -//! run().await; -//! } -//! -//! async fn run() { //! teloxide::enable_logging!(); //! log::info!("Starting dialogue_bot!"); //! diff --git a/src/dispatching/dialogue/transition.rs b/src/dispatching/dialogue/transition.rs index 5674ae3c..31f8f167 100644 --- a/src/dispatching/dialogue/transition.rs +++ b/src/dispatching/dialogue/transition.rs @@ -3,6 +3,7 @@ use futures::future::BoxFuture; use teloxide_core::types::Message; /// Represents a transition function of a dialogue FSM. +#[deprecated(note = "Use dispatching2 instead")] pub trait Transition: Sized { type Aux; type Error; @@ -21,6 +22,7 @@ pub trait Transition: Sized { /// Like [`Transition`], but from `StateN` -> `Dialogue`. /// /// [`Transition`]: crate::dispatching::dialogue::Transition +#[deprecated(note = "Use dispatching2 instead")] pub trait Subtransition where Self::Dialogue: Transition, @@ -45,6 +47,7 @@ where /// /// Now it is used only inside `#[teloxide(subtransition)]` for type inference. #[doc(hidden)] +#[deprecated(note = "Use dispatching2 instead")] pub trait SubtransitionOutputType { type Output; type Error; @@ -56,7 +59,9 @@ impl SubtransitionOutputType for TransitionOut { } /// An input passed into a FSM (sub)transition function. +#[deprecated(note = "Use dispatching2 instead")] pub type TransitionIn = UpdateWithCx; /// A type returned from a FSM (sub)transition function. +#[deprecated(note = "Use dispatching2 instead")] pub type TransitionOut = Result, E>; diff --git a/src/dispatching/dispatcher.rs b/src/dispatching/dispatcher.rs index 77133764..ec486abb 100644 --- a/src/dispatching/dispatcher.rs +++ b/src/dispatching/dispatcher.rs @@ -1,11 +1,4 @@ -use std::{ - fmt::{self, Debug}, - sync::{ - atomic::{AtomicU8, Ordering}, - Arc, - }, - time::Duration, -}; +use std::{fmt::Debug, sync::Arc}; use crate::{ dispatching::{ @@ -14,21 +7,21 @@ use crate::{ DispatcherHandler, UpdateWithCx, }, error_handlers::{ErrorHandler, LoggingErrorHandler}, + utils::shutdown_token::shutdown_check_timeout_for, }; -use futures::{stream::FuturesUnordered, Future, StreamExt}; +use futures::{stream::FuturesUnordered, StreamExt}; use teloxide_core::{ requests::Requester, types::{ - AllowedUpdate, CallbackQuery, ChatMemberUpdated, ChosenInlineResult, InlineQuery, Message, - Poll, PollAnswer, PreCheckoutQuery, ShippingQuery, Update, UpdateKind, + AllowedUpdate, CallbackQuery, ChatJoinRequest, ChatMemberUpdated, ChosenInlineResult, + InlineQuery, Message, Poll, PollAnswer, PreCheckoutQuery, ShippingQuery, Update, + UpdateKind, }, }; -use tokio::{ - sync::{mpsc, Notify}, - task::JoinHandle, - time::timeout, -}; +use tokio::{sync::mpsc, task::JoinHandle, time::timeout}; + +use crate::utils::shutdown_token::ShutdownToken; type Tx = Option>>; @@ -36,6 +29,7 @@ type Tx = Option>>; /// /// See the [module-level documentation](crate::dispatching) for the design /// overview. +#[deprecated(note = "Use dispatching2 instead")] pub struct Dispatcher { requester: R, @@ -52,11 +46,11 @@ pub struct Dispatcher { poll_answers_queue: Tx, my_chat_members_queue: Tx, chat_members_queue: Tx, + chat_join_requests_queue: Tx, running_handlers: FuturesUnordered>, - state: Arc, - shutdown_notify_back: Arc, + state: ShutdownToken, } impl Dispatcher @@ -81,9 +75,9 @@ where poll_answers_queue: None, my_chat_members_queue: None, chat_members_queue: None, + chat_join_requests_queue: None, running_handlers: FuturesUnordered::new(), - state: <_>::default(), - shutdown_notify_back: <_>::default(), + state: ShutdownToken::new(), } } @@ -106,22 +100,21 @@ where /// /// [`shutdown`]: ShutdownToken::shutdown #[cfg(feature = "ctrlc_handler")] - #[cfg_attr(docsrs, doc(cfg(feature = "ctrlc_handler")))] + #[cfg_attr(all(docsrs, feature = "nightly"), doc(cfg(feature = "ctrlc_handler")))] + #[must_use] pub fn setup_ctrlc_handler(self) -> Self { - let state = Arc::clone(&self.state); + let token = self.state.clone(); tokio::spawn(async move { loop { tokio::signal::ctrl_c().await.expect("Failed to listen for ^C"); - match shutdown_inner(&state) { - Ok(()) => log::info!("^C received, trying to shutdown the dispatcher..."), - Err(Ok(AlreadyShuttingDown)) => { - log::info!( - "^C received, the dispatcher is already shutting down, ignoring the \ - signal" - ) + match token.shutdown() { + Ok(f) => { + log::info!("^C received, trying to shutdown the dispatcher..."); + f.await; + log::info!("dispatcher is shutdown..."); } - Err(Err(IdleShutdownError)) => { + Err(_) => { log::info!("^C received, the dispatcher isn't running, ignoring the signal") } } @@ -263,7 +256,7 @@ where pub async fn dispatch(&mut self) where R: Requester + Clone, - ::GetUpdatesFaultTolerant: Send, + ::GetUpdates: Send, { let listener = update_listeners::polling_default(self.requester.clone()).await; let error_handler = @@ -292,19 +285,12 @@ where ListenerE: Debug, R: Requester + Clone, { - use ShutdownState::*; - self.hint_allowed_updates(&mut update_listener); let shutdown_check_timeout = shutdown_check_timeout_for(&update_listener); let mut stop_token = Some(update_listener.stop_token()); - if let Err(actual) = self.state.compare_exchange(Idle, Running) { - unreachable!( - "Dispatching is already running: expected `{:?}` state, found `{:?}`", - Idle, actual - ); - } + self.state.start_dispatching(); { let stream = update_listener.as_stream(); @@ -320,7 +306,7 @@ where } } - if let ShuttingDown = self.state.load() { + if self.state.is_shutting_down() { if let Some(token) = stop_token.take() { log::debug!("Start shutting down dispatching..."); token.stop(); @@ -330,27 +316,13 @@ where } self.wait_for_handlers().await; - - if let ShuttingDown = self.state.load() { - // Stopped because of a `shutdown` call. - - // Notify `shutdown`s that we finished - self.shutdown_notify_back.notify_waiters(); - log::info!("Dispatching has been shut down."); - } else { - log::info!("Dispatching has been stopped (listener returned `None`)."); - } - - self.state.store(Idle); + self.state.done(); } /// Returns a shutdown token, which can later be used to shutdown /// dispatching. pub fn shutdown_token(&self) -> ShutdownToken { - ShutdownToken { - dispatcher_state: Arc::clone(&self.state), - shutdown_notify_back: Arc::clone(&self.shutdown_notify_back), - } + self.state.clone() } async fn process_update( @@ -446,6 +418,20 @@ where chat_member_updated, "UpdateKind::MyChatMember", ), + UpdateKind::ChatJoinRequest(chat_join_request) => send( + &self.requester, + &self.chat_join_requests_queue, + chat_join_request, + "UpdateKind::ChatJoinRequest", + ), + UpdateKind::Error(err) => { + log::error!( + "Cannot parse an update.\nError: {:?}\n\ + This is a bug in teloxide-core, please open an issue here: \ + https://github.com/teloxide/teloxide-core/issues.", + err, + ); + } } } } @@ -528,127 +514,6 @@ where } } -/// This error is returned from [`ShutdownToken::shutdown`] when trying to -/// shutdown an idle [`Dispatcher`]. -#[derive(Debug)] -pub struct IdleShutdownError; - -impl fmt::Display for IdleShutdownError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Dispatcher was idle and as such couldn't be shut down") - } -} - -impl std::error::Error for IdleShutdownError {} - -/// A token which used to shutdown [`Dispatcher`]. -#[derive(Clone)] -pub struct ShutdownToken { - dispatcher_state: Arc, - shutdown_notify_back: Arc, -} - -impl ShutdownToken { - /// Tries to shutdown dispatching. - /// - /// Returns an error if the dispatcher is idle at the moment. - /// - /// If you don't need to wait for shutdown, the returned future can be - /// ignored. - pub fn shutdown(&self) -> Result + '_, IdleShutdownError> { - match shutdown_inner(&self.dispatcher_state) { - Ok(()) | Err(Ok(AlreadyShuttingDown)) => Ok(async move { - log::info!("Trying to shutdown the dispatcher..."); - self.shutdown_notify_back.notified().await - }), - Err(Err(err)) => Err(err), - } - } -} - -struct DispatcherState { - inner: AtomicU8, -} - -impl DispatcherState { - fn load(&self) -> ShutdownState { - ShutdownState::from_u8(self.inner.load(Ordering::SeqCst)) - } - - fn store(&self, new: ShutdownState) { - self.inner.store(new as _, Ordering::SeqCst) - } - - fn compare_exchange( - &self, - current: ShutdownState, - new: ShutdownState, - ) -> Result { - self.inner - .compare_exchange(current as _, new as _, Ordering::SeqCst, Ordering::SeqCst) - .map(ShutdownState::from_u8) - .map_err(ShutdownState::from_u8) - } -} - -impl Default for DispatcherState { - fn default() -> Self { - Self { inner: AtomicU8::new(ShutdownState::Idle as _) } - } -} - -#[repr(u8)] -#[derive(Debug)] -enum ShutdownState { - Running, - ShuttingDown, - Idle, -} - -impl ShutdownState { - fn from_u8(n: u8) -> Self { - const RUNNING: u8 = ShutdownState::Running as u8; - const SHUTTING_DOWN: u8 = ShutdownState::ShuttingDown as u8; - const IDLE: u8 = ShutdownState::Idle as u8; - - match n { - RUNNING => ShutdownState::Running, - SHUTTING_DOWN => ShutdownState::ShuttingDown, - IDLE => ShutdownState::Idle, - _ => unreachable!(), - } - } -} - -fn shutdown_check_timeout_for(update_listener: &impl UpdateListener) -> Duration { - const MIN_SHUTDOWN_CHECK_TIMEOUT: Duration = Duration::from_secs(1); - - // FIXME: replace this by just Duration::ZERO once 1.53 will be released - const DZERO: Duration = Duration::from_secs(0); - - let shutdown_check_timeout = update_listener.timeout_hint().unwrap_or(DZERO); - - // FIXME: replace this by just saturating_add once 1.53 will be released - shutdown_check_timeout.checked_add(MIN_SHUTDOWN_CHECK_TIMEOUT).unwrap_or(shutdown_check_timeout) -} - -struct AlreadyShuttingDown; - -fn shutdown_inner( - state: &DispatcherState, -) -> Result<(), Result> { - use ShutdownState::*; - - let res = state.compare_exchange(Running, ShuttingDown); - - match res { - Ok(_) => Ok(()), - Err(ShuttingDown) => Err(Ok(AlreadyShuttingDown)), - Err(Idle) => Err(Err(IdleShutdownError)), - Err(Running) => unreachable!(), - } -} - fn send<'a, R, Upd>(requester: &'a R, tx: &'a Tx, update: Upd, variant: &'static str) where Upd: Debug, diff --git a/src/dispatching/dispatcher_handler.rs b/src/dispatching/dispatcher_handler.rs index cd05f3cd..8751d080 100644 --- a/src/dispatching/dispatcher_handler.rs +++ b/src/dispatching/dispatcher_handler.rs @@ -9,6 +9,7 @@ use futures::future::BoxFuture; /// overview. /// /// [`Dispatcher`]: crate::dispatching::Dispatcher +#[deprecated(note = "Use dispatching2 instead")] pub trait DispatcherHandler { #[must_use] fn handle(self, updates: DispatcherHandlerRx) -> BoxFuture<'static, ()> diff --git a/src/dispatching/dispatcher_handler_rx_ext.rs b/src/dispatching/dispatcher_handler_rx_ext.rs index d9616645..9270826f 100644 --- a/src/dispatching/dispatcher_handler_rx_ext.rs +++ b/src/dispatching/dispatcher_handler_rx_ext.rs @@ -8,6 +8,7 @@ use teloxide_core::types::Message; /// overview. /// /// [`DispatcherHandlerRx`]: crate::dispatching::DispatcherHandlerRx +#[deprecated(note = "Use dispatching2 instead")] pub trait DispatcherHandlerRxExt { /// Extracts only text messages from this stream of arbitrary messages. fn text_messages(self) -> BoxStream<'static, (UpdateWithCx, String)> diff --git a/src/dispatching/mod.rs b/src/dispatching/mod.rs index 98eec830..78a29dd5 100644 --- a/src/dispatching/mod.rs +++ b/src/dispatching/mod.rs @@ -1,4 +1,5 @@ -//! Updates dispatching. +//! Old updates dispatching (**DEPRECATED**: use [`crate::dispatching2`] +//! instead). //! //! The key type here is [`Dispatcher`]. It encapsulates [`Bot`] and handlers //! for [all the update kinds]. @@ -45,11 +46,12 @@ //! [`tokio::sync::mpsc::UnboundedReceiver`]: https://docs.rs/tokio/0.2.11/tokio/sync/mpsc/struct.UnboundedReceiver.html //! [examples/dialogue_bot]: https://github.com/teloxide/teloxide/tree/master/examples/dialogue_bot +#![allow(deprecated)] + pub mod dialogue; pub mod stop_token; pub mod update_listeners; -#[cfg(feature = "ctrlc_handler")] pub(crate) mod repls; mod dispatcher; @@ -57,7 +59,8 @@ mod dispatcher_handler; mod dispatcher_handler_rx_ext; mod update_with_cx; -pub use dispatcher::{Dispatcher, IdleShutdownError, ShutdownToken}; +pub use crate::utils::shutdown_token::{IdleShutdownError, ShutdownToken}; +pub use dispatcher::Dispatcher; pub use dispatcher_handler::DispatcherHandler; pub use dispatcher_handler_rx_ext::DispatcherHandlerRxExt; use tokio::sync::mpsc::UnboundedReceiver; @@ -66,4 +69,5 @@ pub use update_with_cx::{UpdateWithCx, UpdateWithCxRequesterType}; /// A type of a stream, consumed by [`Dispatcher`]'s handlers. /// /// [`Dispatcher`]: crate::dispatching::Dispatcher +#[deprecated(note = "Use dispatching2 instead")] pub type DispatcherHandlerRx = UnboundedReceiver>; diff --git a/src/dispatching/repls/commands_repl.rs b/src/dispatching/repls/commands_repl.rs index c1799d58..09fc1c4a 100644 --- a/src/dispatching/repls/commands_repl.rs +++ b/src/dispatching/repls/commands_repl.rs @@ -31,7 +31,7 @@ where HandlerE: Debug + Send, N: Into + Send + 'static, R: Requester + Send + Clone + 'static, - ::GetUpdatesFaultTolerant: Send, + ::GetUpdates: Send, { let cloned_requester = requester.clone(); diff --git a/src/dispatching/repls/dialogues_repl.rs b/src/dispatching/repls/dialogues_repl.rs index 345c6bcb..f0819183 100644 --- a/src/dispatching/repls/dialogues_repl.rs +++ b/src/dispatching/repls/dialogues_repl.rs @@ -29,7 +29,7 @@ where D: Clone + Default + Send + 'static, Fut: Future> + Send + 'static, R: Requester + Send + Clone + 'static, - ::GetUpdatesFaultTolerant: Send, + ::GetUpdates: Send, { let cloned_requester = requester.clone(); diff --git a/src/dispatching/repls/repl.rs b/src/dispatching/repls/repl.rs index 635a030c..90f01289 100644 --- a/src/dispatching/repls/repl.rs +++ b/src/dispatching/repls/repl.rs @@ -28,7 +28,7 @@ where Result<(), E>: OnError, E: Debug + Send, R: Requester + Send + Clone + 'static, - ::GetUpdatesFaultTolerant: Send, + ::GetUpdates: Send, { let cloned_requester = requester.clone(); repl_with_listener( @@ -83,3 +83,12 @@ pub async fn repl_with_listener<'a, R, H, Fut, E, L, ListenerE>( ) .await; } + +#[test] +fn repl_is_send() { + let bot = crate::Bot::new(""); + let repl = crate::repl(bot, |_| async { crate::respond(()) }); + assert_send(&repl); + + fn assert_send(_: &impl Send) {} +} diff --git a/src/dispatching/update_listeners.rs b/src/dispatching/update_listeners.rs index a0d9edca..18d294d1 100644 --- a/src/dispatching/update_listeners.rs +++ b/src/dispatching/update_listeners.rs @@ -1,107 +1,27 @@ //! Receiving updates from Telegram. //! -//! The key trait here is [`UpdateListener`]. You can get it by these functions: +//! The key trait here is [`UpdateListener`]. You can get its implementation +//! using one these functions: //! -//! - [`polling_default`], which returns a default long polling listener. -//! - [`polling`], which returns a long/short polling listener with your -//! configuration. +//! - [`polling_default`], which returns a default long polling listener. +//! - [`polling`], which returns a long polling listener with your +//! configuration. //! -//! And then you can extract updates from it and pass them directly to a -//! dispatcher. +//! And then you can extract updates from it or pass them directly to a +//! [`Dispatcher`]. //! -//! Telegram supports two ways of [getting updates]: [long]/[short] polling and -//! [webhook]. -//! -//! # Long Polling -//! -//! In long polling, you just call [`Box::get_updates`] every N seconds. -//! -//! ## Example -//! -//! -//! -//! ^1 A timeout can be even 0 -//! (this is also called short polling), -//! but you should use it **only** for testing purposes. -//! -//! ^2 Large delays will cause in bot lags, -//! so delay shouldn't exceed second. -//! -//! ^3 Note that if Telegram already have updates for -//! you it will answer you **without** waiting for a timeout. -//! -//! ^4 `offset = N` means that we've already received -//! updates `0..=N`. -//! -//! # Webhooks -//! See the [README FAQ about webhooks](https://github.com/teloxide/teloxide/blob/master/README.md#faq). +//! Telegram supports two ways of [getting updates]: [long polling] and +//! [webhooks]. Currently, only the former one is implemented (see [`polling()`] +//! and [`polling_default`]). See also [README FAQ about webhooks](https://github.com/teloxide/teloxide/blob/master/README.md#faq). //! //! [`UpdateListener`]: UpdateListener //! [`polling_default`]: polling_default //! [`polling`]: polling() +//! [`Dispatcher`]: crate::dispatching::Dispatcher //! [`Box::get_updates`]: crate::requests::Requester::get_updates //! [getting updates]: https://core.telegram.org/bots/api#getting-updates -//! [long]: https://en.wikipedia.org/wiki/Push_technology#Long_polling -//! [short]: https://en.wikipedia.org/wiki/Polling_(computer_science) -//! [webhook]: https://en.wikipedia.org/wiki/Webhook +//! [long polling]: https://en.wikipedia.org/wiki/Push_technology#Long_polling +//! [webhooks]: https://en.wikipedia.org/wiki/Webhook use futures::Stream; @@ -122,19 +42,15 @@ pub use self::{ /// An update listener. /// -/// Implementors of this trait allow getting updates from Telegram. -/// -/// Currently Telegram has 2 ways of getting updates -- [polling] and -/// [webhooks]. Currently, only the former one is implemented (see [`polling()`] -/// and [`polling_default`]) +/// Implementors of this trait allow getting updates from Telegram. See +/// [module-level documentation] for more. /// /// Some functions of this trait are located in the supertrait /// ([`AsUpdateStream`]), see also: /// - [`AsUpdateStream::Stream`] /// - [`AsUpdateStream::as_stream`] /// -/// [polling]: self#long-polling -/// [webhooks]: self#webhooks +/// [module-level documentation]: mod@self pub trait UpdateListener: for<'a> AsUpdateStream<'a, E> { /// The type of token which allows to stop this listener. type StopToken: StopToken; @@ -150,8 +66,8 @@ pub trait UpdateListener: for<'a> AsUpdateStream<'a, E> { /// Implementors of this function are encouraged to stop listening for /// updates as soon as possible and return `None` from the update stream as /// soon as all cached updates are returned. - #[must_use = "This function doesn't stop listening, to stop listening you need to call stop on \ - the returned token"] + #[must_use = "This function doesn't stop listening, to stop listening you need to call `stop` \ + on the returned token"] fn stop_token(&mut self) -> Self::StopToken; /// Hint which updates should the listener listen for. @@ -188,7 +104,14 @@ pub trait UpdateListener: for<'a> AsUpdateStream<'a, E> { /// This trait is a workaround to not require GAT. pub trait AsUpdateStream<'a, E> { /// The stream of updates from Telegram. - type Stream: Stream> + 'a; + // HACK: There is currently no way to write something like + // `-> impl for<'a> AsUpdateStream<'a, E, Stream: Send>`. Since we return + // `impl UpdateListener` from `polling`, we need to have `Send` bound here, + // to make the stream `Send`. + // + // Without this it's, for example, impossible to spawn a tokio task with + // teloxide polling. + type Stream: Stream> + Send + 'a; /// Creates the update [`Stream`]. /// diff --git a/src/dispatching/update_listeners/polling.rs b/src/dispatching/update_listeners/polling.rs index 5440fd6a..acc21e12 100644 --- a/src/dispatching/update_listeners/polling.rs +++ b/src/dispatching/update_listeners/polling.rs @@ -7,12 +7,12 @@ use futures::{ use crate::{ dispatching::{ - stop_token::{AsyncStopFlag, AsyncStopToken}, + stop_token::{AsyncStopFlag, AsyncStopToken, StopToken}, update_listeners::{stateful_listener::StatefulListener, UpdateListener}, }, - payloads::GetUpdates, + payloads::{GetUpdates, GetUpdatesSetters as _}, requests::{HasPayload, Request, Requester}, - types::{AllowedUpdate, SemiparsedVec, Update}, + types::{AllowedUpdate, Update}, }; /// Returns a long polling update listener with `timeout` of 10 seconds. @@ -22,36 +22,118 @@ use crate::{ /// ## Notes /// /// This function will automatically delete a webhook if it was set up. -pub async fn polling_default(requester: R) -> impl UpdateListener +pub async fn polling_default( + requester: R, +) -> impl UpdateListener where - R: Requester + 'static, - ::GetUpdatesFaultTolerant: Send, + R: Requester + Send + 'static, + ::GetUpdates: Send, { delete_webhook_if_setup(&requester).await; polling(requester, Some(Duration::from_secs(10)), None, None) } -/// Returns a long/short polling update listener with some additional options. +#[cfg_attr(doc, aquamarine::aquamarine)] +/// Returns a long polling update listener with some additional options. /// /// - `bot`: Using this bot, the returned update listener will receive updates. -/// - `timeout`: A timeout for polling. +/// - `timeout`: A timeout in seconds for polling. /// - `limit`: Limits the number of updates to be retrieved at once. Values /// between 1—100 are accepted. /// - `allowed_updates`: A list the types of updates you want to receive. +/// /// See [`GetUpdates`] for defaults. /// /// See also: [`polling_default`](polling_default). /// -/// [`GetUpdates`]: crate::payloads::GetUpdates +/// ## Notes +/// +/// - `timeout` should not be bigger than http client timeout, see +/// [`default_reqwest_settings`] for default http client settings. +/// - [`repl`]s and [`Dispatcher`] use [`hint_allowed_updates`] to set +/// `allowed_updates`, so you rarely need to pass `allowed_updates` +/// explicitly. +/// +/// [`default_reqwest_settings`]: teloxide::net::default_reqwest_settings +/// [`repl`]: fn@crate::repl +/// [`Dispatcher`]: crate::dispatching::Dispatcher +/// [`hint_allowed_updates`]: +/// crate::dispatching::update_listeners::UpdateListener::hint_allowed_updates +/// +/// ## How it works +/// +/// Long polling works by repeatedly calling [`Bot::get_updates`][get_updates]. +/// If telegram has any updates, it returns them immediately, otherwise it waits +/// until either it has any updates or `timeout` expires. +/// +/// Each [`get_updates`][get_updates] call includes an `offset` parameter equal +/// to the latest update id + one, that allows to only receive updates that has +/// not been received before. +/// +/// When telegram receives a [`get_updates`][get_updates] request with `offset = +/// N` it forgets any updates with id < `N`. When `polling` listener is stopped, +/// it sends [`get_updates`][get_updates] with `timeout = 0, limit = 1` and +/// appropriate `offset`, so future bot restarts won't see updates that were +/// already seen. +/// +/// Consumers of a `polling` update listener then need to repeatedly call +/// [`futures::StreamExt::next`] to get the updates. +/// +/// Here is an example diagram that shows these interactions between consumers +/// like [`Dispatcher`], `polling` update listener and telegram. +/// +/// ```mermaid +/// sequenceDiagram +/// participant C as Consumer +/// participant P as polling +/// participant T as Telegram +/// +/// link C: Dispatcher @ ../struct.Dispatcher.html +/// link C: repl @ ../../fn.repl.html +/// +/// C->>P: next +/// +/// P->>+T: Updates? (offset = 0) +/// Note right of T: timeout +/// T->>-P: None +/// +/// P->>+T: Updates? (offset = 0) +/// Note right of T: <= timeout +/// T->>-P: updates with ids [3, 4] +/// +/// P->>C: update(3) +/// +/// C->>P: next +/// P->>C: update(4) +/// +/// C->>P: next +/// +/// P->>+T: Updates? (offset = 5) +/// Note right of T: <= timeout +/// T->>-P: updates with ids [5] +/// +/// C->>P: stop signal +/// +/// P->>C: update(5) +/// +/// C->>P: next +/// +/// P->>T: *Acknolegment of update(5)* +/// T->>P: ok +/// +/// P->>C: None +/// ``` +/// +/// [get_updates]: crate::requests::Requester::get_updates pub fn polling( - requester: R, + bot: R, timeout: Option, limit: Option, allowed_updates: Option>, -) -> impl UpdateListener +) -> impl UpdateListener where - R: Requester + 'static, - ::GetUpdatesFaultTolerant: Send, + R: Requester + Send + 'static, + ::GetUpdates: Send, { struct State { bot: B, @@ -61,73 +143,57 @@ where offset: i32, flag: AsyncStopFlag, token: AsyncStopToken, + force_stop: bool, } - fn stream(st: &mut State) -> impl Stream> + '_ + fn stream(st: &mut State) -> impl Stream> + Send + '_ where - B: Requester, + B: Requester + Send, + ::GetUpdates: Send, { stream::unfold(st, move |state| async move { - let State { timeout, limit, allowed_updates, bot, offset, flag, .. } = &mut *state; + let State { timeout, limit, allowed_updates, bot, offset, flag, force_stop, .. } = + &mut *state; + + if *force_stop { + return None; + } if flag.is_stopped() { - let mut req = bot.get_updates_fault_tolerant(); - - req.payload_mut().0 = GetUpdates { - offset: Some(*offset), - timeout: Some(0), - limit: Some(1), - allowed_updates: allowed_updates.take(), - }; + let mut req = bot.get_updates().offset(*offset).timeout(0).limit(1); + req.payload_mut().allowed_updates = allowed_updates.take(); return match req.send().await { Ok(_) => None, - Err(err) => Some((Either::Left(stream::once(ready(Err(err)))), state)), + Err(err) => { + // Prevents infinite retries, see https://github.com/teloxide/teloxide/issues/496 + *force_stop = true; + + Some((Either::Left(stream::once(ready(Err(err)))), state)) + } }; } - let mut req = bot.get_updates_fault_tolerant(); - req.payload_mut().0 = GetUpdates { + let mut req = bot.get_updates(); + *req.payload_mut() = GetUpdates { offset: Some(*offset), timeout: *timeout, limit: *limit, allowed_updates: allowed_updates.take(), }; - let updates = match req.send().await { - Err(err) => return Some((Either::Left(stream::once(ready(Err(err)))), state)), - Ok(SemiparsedVec(updates)) => { + match req.send().await { + Ok(updates) => { // Set offset to the last update's id + 1 if let Some(upd) = updates.last() { - let id: i32 = match upd { - Ok(ok) => ok.id, - Err((value, _)) => value["update_id"] - .as_i64() - .expect("The 'update_id' field must always exist in Update") - .try_into() - .expect("update_id must be i32"), - }; - - *offset = id + 1; + *offset = upd.id + 1; } - for update in &updates { - if let Err((value, e)) = update { - log::error!( - "Cannot parse an update.\nError: {:?}\nValue: {}\n\ - This is a bug in teloxide-core, please open an issue here: \ - https://github.com/teloxide/teloxide-core/issues.", - e, - value - ); - } - } - - updates.into_iter().filter_map(Result::ok).map(Ok) + let updates = updates.into_iter().map(Ok); + Some((Either::Right(stream::iter(updates)), state)) } - }; - - Some((Either::Right(stream::iter(updates)), state)) + Err(err) => Some((Either::Left(stream::once(ready(Err(err)))), state)), + } }) .flatten() } @@ -135,13 +201,14 @@ where let (token, flag) = AsyncStopToken::new_pair(); let state = State { - bot: requester, + bot, timeout: timeout.map(|t| t.as_secs().try_into().expect("timeout is too big")), limit, allowed_updates, offset: 0, flag, token, + force_stop: false, }; let stop_token = |st: &mut State<_>| st.token.clone(); @@ -169,7 +236,7 @@ where } }; - let is_webhook_setup = !webhook_info.url.is_empty(); + let is_webhook_setup = webhook_info.url.is_some(); if is_webhook_setup { if let Err(e) = requester.delete_webhook().send().await { @@ -177,3 +244,17 @@ where } } } + +#[test] +fn polling_is_send() { + use crate::dispatching::update_listeners::AsUpdateStream; + + let bot = crate::Bot::new("TOKEN"); + let mut polling = polling(bot, None, None, None); + + assert_send(&polling); + assert_send(&polling.as_stream()); + assert_send(&polling.stop_token()); + + fn assert_send(_: &impl Send) {} +} diff --git a/src/dispatching/update_listeners/stateful_listener.rs b/src/dispatching/update_listeners/stateful_listener.rs index a9c26576..df32361c 100644 --- a/src/dispatching/update_listeners/stateful_listener.rs +++ b/src/dispatching/update_listeners/stateful_listener.rs @@ -25,25 +25,23 @@ pub struct StatefulListener { /// The function used as [`AsUpdateStream::as_stream`]. /// - /// Must be of type `for<'a> &'a mut St -> impl Stream + 'a` and callable by - /// `&mut`. + /// Must implement `for<'a> FnMut(&'a mut St) -> impl Stream + 'a`. pub stream: Assf, /// The function used as [`UpdateListener::stop_token`]. /// - /// Must be of type `for<'a> &'a mut St -> impl StopToken`. + /// Must implement `FnMut(&mut St) -> impl StopToken`. pub stop_token: Sf, /// The function used as [`UpdateListener::hint_allowed_updates`]. /// - /// Must be of type `for<'a, 'b> &'a mut St, &'b mut dyn Iterator -> ()`. + /// Must implement `FnMut(&mut St, &mut dyn Iterator)`. pub hint_allowed_updates: Option, /// The function used as [`UpdateListener::timeout_hint`]. /// - /// Must be of type `for<'a> &'a St -> Option` and callable by - /// `&`. + /// Must implement `Fn(&St) -> Option`. pub timeout_hint: Option, } @@ -79,7 +77,7 @@ impl Thfn, > where - S: Stream> + Unpin + 'static, + S: Stream> + Unpin + Send + 'static, { /// Creates a new update listener from a stream of updates which ignores /// stop signals. @@ -109,6 +107,7 @@ impl<'a, St, Assf, Sf, Hauf, Thf, Strm, E> AsUpdateStream<'a, E> for StatefulListener where (St, Strm): 'a, + Strm: Send, Assf: FnMut(&'a mut St) -> Strm, Strm: Stream>, { diff --git a/src/dispatching/update_with_cx.rs b/src/dispatching/update_with_cx.rs index c0b60722..ecdc40a6 100644 --- a/src/dispatching/update_with_cx.rs +++ b/src/dispatching/update_with_cx.rs @@ -17,6 +17,7 @@ use teloxide_core::{ /// /// [`Dispatcher`]: crate::dispatching::Dispatcher #[derive(Debug)] +#[deprecated(note = "Use dispatching2 instead")] pub struct UpdateWithCx { pub requester: R, pub update: Upd, diff --git a/src/dispatching2/dialogue/get_chat_id.rs b/src/dispatching2/dialogue/get_chat_id.rs new file mode 100644 index 00000000..eb2a1bf1 --- /dev/null +++ b/src/dispatching2/dialogue/get_chat_id.rs @@ -0,0 +1,20 @@ +use crate::types::CallbackQuery; +use teloxide_core::types::Message; + +/// Something that may has a chat ID. +pub trait GetChatId { + #[must_use] + fn chat_id(&self) -> Option; +} + +impl GetChatId for Message { + fn chat_id(&self) -> Option { + Some(self.chat.id) + } +} + +impl GetChatId for CallbackQuery { + fn chat_id(&self) -> Option { + self.message.as_ref().map(|mes| mes.chat.id) + } +} diff --git a/src/dispatching2/dialogue/mod.rs b/src/dispatching2/dialogue/mod.rs new file mode 100644 index 00000000..5222b1a7 --- /dev/null +++ b/src/dispatching2/dialogue/mod.rs @@ -0,0 +1,177 @@ +//! Support for user dialogues. +//! +//! The main type is (surprise!) [`Dialogue`]. Under the hood, it is just a +//! wrapper over [`Storage`] and a chat ID. All it does is provides convenient +//! method for manipulating the dialogue state. [`Storage`] is where all +//! dialogue states are stored; it can be either [`InMemStorage`], which is a +//! simple hash map, or database wrappers such as [`SqliteStorage`]. In the +//! latter case, your dialogues are _persistent_, meaning that you can safely +//! restart your bot and all dialogues will remain in the database -- this is a +//! preferred method for production bots. +//! +//! [`examples/dialogue.rs`] clearly demonstrates the typical usage of +//! dialogues. Your dialogue state can be represented as an enumeration: +//! +//! ```ignore +//! #[derive(DialogueState, Clone)] +//! #[handler_out(anyhow::Result<()>)] +//! pub enum State { +//! #[handler(handle_start)] +//! Start, +//! +//! #[handler(handle_receive_full_name)] +//! ReceiveFullName, +//! +//! #[handler(handle_receive_age)] +//! ReceiveAge { full_name: String }, +//! +//! #[handler(handle_receive_location)] +//! ReceiveLocation { full_name: String, age: u8 }, +//! } +//! ``` +//! +//! Each state is associated with its respective handler: e.g., when a dialogue +//! state is `ReceiveAge`, `handle_receive_age` is invoked: +//! +//! ```ignore +//! async fn handle_receive_age( +//! bot: AutoSend, +//! msg: Message, +//! dialogue: MyDialogue, +//! (full_name,): (String,), // Available from `State::ReceiveAge`. +//! ) -> anyhow::Result<()> { +//! match msg.text().map(|text| text.parse::()) { +//! Some(Ok(age)) => { +//! bot.send_message(msg.chat.id, "What's your location?").await?; +//! dialogue.update(State::ReceiveLocation { full_name, age }).await?; +//! } +//! _ => { +//! bot.send_message(msg.chat.id, "Send me a number.").await?; +//! } +//! } +//! +//! Ok(()) +//! } +//! ``` +//! +//! Variant's fields are passed to state handlers as tuples: `(full_name,): +//! (String,)`. Using [`Dialogue::update`], you can update the dialogue with a +//! new state, in our case -- `State::ReceiveLocation { full_name, age }`. To +//! exit the dialogue, just call [`Dialogue::exit`] and it will be removed from +//! the inner storage: +//! +//! ```ignore +//! async fn handle_receive_location( +//! bot: AutoSend, +//! msg: Message, +//! dialogue: MyDialogue, +//! (full_name, age): (String, u8), // Available from `State::ReceiveLocation`. +//! ) -> anyhow::Result<()> { +//! match msg.text() { +//! Some(location) => { +//! let message = +//! format!("Full name: {}\nAge: {}\nLocation: {}", full_name, age, location); +//! bot.send_message(msg.chat.id, message).await?; +//! dialogue.exit().await?; +//! } +//! None => { +//! bot.send_message(msg.chat.id, "Send me a text message.").await?; +//! } +//! } +//! +//! Ok(()) +//! } +//! ``` +//! +//! [`examples/dialogue.rs`]: https://github.com/teloxide/teloxide/blob/master/examples/dialogue.rs + +#[cfg(feature = "redis-storage")] +#[cfg_attr(all(docsrs, feature = "nightly"), doc(cfg(feature = "redis-storage")))] +pub use crate::dispatching::dialogue::{RedisStorage, RedisStorageError}; + +#[cfg(feature = "sqlite-storage")] +pub use crate::dispatching::dialogue::{SqliteStorage, SqliteStorageError}; + +pub use crate::dispatching::dialogue::{ + serializer, InMemStorage, InMemStorageError, Serializer, Storage, TraceStorage, +}; +pub use get_chat_id::GetChatId; + +use std::{marker::PhantomData, sync::Arc}; + +mod get_chat_id; + +/// A handle for controlling dialogue state. +#[derive(Debug)] +pub struct Dialogue { + storage: Arc, + chat_id: i64, + _phantom: PhantomData, +} + +// `#[derive]` requires generics to implement `Clone`, but `S` is wrapped around +// `Arc`, and `D` is wrapped around PhantomData. +impl Clone for Dialogue { + fn clone(&self) -> Self { + Dialogue { storage: self.storage.clone(), chat_id: self.chat_id, _phantom: PhantomData } + } +} + +impl Dialogue +where + D: Send + 'static, + S: Storage, +{ + /// Constructs a new dialogue with `storage` (where dialogues are stored) + /// and `chat_id` of a current dialogue. + pub fn new(storage: Arc, chat_id: i64) -> Self { + Self { storage, chat_id, _phantom: PhantomData } + } + + /// Retrieves the current state of the dialogue or `None` if there is no + /// dialogue. + pub async fn get(&self) -> Result, S::Error> { + self.storage.clone().get_dialogue(self.chat_id).await + } + + /// Like [`Dialogue::get`] but returns a default value if there is no + /// dialogue. + pub async fn get_or_default(&self) -> Result + where + D: Default, + { + match self.get().await? { + Some(d) => Ok(d), + None => { + self.storage.clone().update_dialogue(self.chat_id, D::default()).await?; + Ok(D::default()) + } + } + } + + /// Updates the dialogue state. + /// + /// The dialogue type `D` must implement `From` to allow implicit + /// conversion from `State` to `D`. + pub async fn update(&self, state: State) -> Result<(), S::Error> + where + D: From, + { + let new_dialogue = state.into(); + self.storage.clone().update_dialogue(self.chat_id, new_dialogue).await?; + Ok(()) + } + + /// Updates the dialogue with a default value. + pub async fn reset(&self) -> Result<(), S::Error> + where + D: Default, + { + self.update(D::default()).await + } + + /// Removes the dialogue from the storage provided to [`Dialogue::new`]. + pub async fn exit(&self) -> Result<(), S::Error> { + self.storage.clone().remove_dialogue(self.chat_id).await + } +} diff --git a/src/dispatching2/dispatcher.rs b/src/dispatching2/dispatcher.rs new file mode 100644 index 00000000..5e885101 --- /dev/null +++ b/src/dispatching2/dispatcher.rs @@ -0,0 +1,274 @@ +use crate::{ + adaptors::CacheMe, + dispatching::{ + stop_token::StopToken, update_listeners, update_listeners::UpdateListener, ShutdownToken, + }, + error_handlers::{ErrorHandler, LoggingErrorHandler}, + requests::Requester, + types::{AllowedUpdate, Update}, + utils::shutdown_token::shutdown_check_timeout_for, +}; +use dptree::di::{DependencyMap, DependencySupplier}; +use futures::{future::BoxFuture, StreamExt}; +use std::{collections::HashSet, fmt::Debug, ops::ControlFlow, sync::Arc}; +use teloxide_core::requests::{Request, RequesterExt}; +use tokio::time::timeout; + +use std::future::Future; + +/// The builder for [`Dispatcher`]. +pub struct DispatcherBuilder { + bot: R, + dependencies: DependencyMap, + handler: UpdateHandler, + default_handler: DefaultHandler, + error_handler: Arc>, +} + +impl DispatcherBuilder +where + R: Clone + Requester + Clone + Send + Sync + 'static, + Err: Debug + Send + Sync + 'static, +{ + /// Specifies a handler that will be called for an unhandled update. + /// + /// By default, it is a mere [`log::warn`]. + #[must_use] + pub fn default_handler(self, handler: H) -> Self + where + H: Fn(Arc) -> Fut + 'static, + Fut: Future + Send + 'static, + { + let handler = Arc::new(handler); + + Self { + default_handler: Box::new(move |upd| { + let handler = Arc::clone(&handler); + Box::pin(handler(upd)) + }), + ..self + } + } + + /// Specifies a handler that will be called on a handler error. + /// + /// By default, it is [`LoggingErrorHandler`]. + #[must_use] + pub fn error_handler(self, handler: Arc>) -> Self { + Self { error_handler: handler, ..self } + } + + /// Specifies dependencies that can be used inside of handlers. + /// + /// By default, there is no dependencies. + #[must_use] + pub fn dependencies(self, dependencies: DependencyMap) -> Self { + Self { dependencies, ..self } + } + + /// Constructs [`Dispatcher`]. + #[must_use] + pub fn build(self) -> Dispatcher { + Dispatcher { + bot: self.bot.clone(), + cache_me_bot: self.bot.cache_me(), + dependencies: self.dependencies, + handler: self.handler, + default_handler: self.default_handler, + error_handler: self.error_handler, + allowed_updates: Default::default(), + state: ShutdownToken::new(), + } + } +} + +/// The base for update dispatching. +pub struct Dispatcher { + bot: R, + cache_me_bot: CacheMe, + dependencies: DependencyMap, + + handler: UpdateHandler, + default_handler: DefaultHandler, + error_handler: Arc>, + // TODO: respect allowed_udpates + allowed_updates: HashSet, + + state: ShutdownToken, +} + +// TODO: it is allowed to return message as response on telegram request in +// webhooks, so we can allow this too. See more there: https://core.telegram.org/bots/api#making-requests-when-getting-updates + +/// A handler that processes updates from Telegram. +pub type UpdateHandler = dptree::Handler<'static, DependencyMap, Result<(), Err>>; + +type DefaultHandler = Box) -> BoxFuture<'static, ()>>; + +impl Dispatcher +where + R: Requester + Clone + Send + Sync + 'static, + Err: Send + Sync + 'static, +{ + /// Constructs a new [`DispatcherBuilder`] with `bot` and `handler`. + #[must_use] + pub fn builder(bot: R, handler: UpdateHandler) -> DispatcherBuilder + where + Err: Debug, + { + DispatcherBuilder { + bot, + dependencies: DependencyMap::new(), + handler, + default_handler: Box::new(|upd| { + log::warn!("Unhandled update: {:?}", upd); + Box::pin(async {}) + }), + error_handler: LoggingErrorHandler::new(), + } + } + + /// Starts your bot with the default parameters. + /// + /// The default parameters are a long polling update listener and log all + /// errors produced by this listener. + /// + /// Each time a handler is invoked, [`Dispatcher`] adds the following + /// dependencies (in addition to those passed to + /// [`DispatcherBuilder::dependencies`]): + /// + /// - Your bot passed to [`Dispatcher::builder`]; + /// - An update from Telegram; + /// - [`crate::types::Me`] (can be used in [`HandlerExt::filter_command`]). + /// + /// [`shutdown`]: ShutdownToken::shutdown + /// [a ctrlc signal]: Dispatcher::setup_ctrlc_handler + /// [`HandlerExt::filter_command`]: crate::dispatching2::HandlerExt::filter_command + pub async fn dispatch(&mut self) + where + R: Requester + Clone, + ::GetUpdates: Send, + { + let listener = update_listeners::polling_default(self.bot.clone()).await; + let error_handler = + LoggingErrorHandler::with_custom_text("An error from the update listener"); + + self.dispatch_with_listener(listener, error_handler).await; + } + + /// Starts your bot with custom `update_listener` and + /// `update_listener_error_handler`. + /// + /// This method adds the same dependencies as [`Dispatcher::dispatch`]. + /// + /// [`shutdown`]: ShutdownToken::shutdown + /// [a ctrlc signal]: Dispatcher::setup_ctrlc_handler + pub async fn dispatch_with_listener<'a, UListener, ListenerE, Eh>( + &'a mut self, + mut update_listener: UListener, + update_listener_error_handler: Arc, + ) where + UListener: UpdateListener + 'a, + Eh: ErrorHandler + 'a, + ListenerE: Debug, + { + update_listener.hint_allowed_updates(&mut self.allowed_updates.clone().into_iter()); + + let shutdown_check_timeout = shutdown_check_timeout_for(&update_listener); + let mut stop_token = Some(update_listener.stop_token()); + + self.state.start_dispatching(); + + { + let stream = update_listener.as_stream(); + tokio::pin!(stream); + + loop { + // False positive + #[allow(clippy::collapsible_match)] + if let Ok(upd) = timeout(shutdown_check_timeout, stream.next()).await { + match upd { + None => break, + Some(upd) => self.process_update(upd, &update_listener_error_handler).await, + } + } + + if self.state.is_shutting_down() { + if let Some(token) = stop_token.take() { + log::debug!("Start shutting down dispatching..."); + token.stop(); + break; + } + } + } + } + + // TODO: wait for executing handlers? + + self.state.done(); + } + + async fn process_update( + &self, + update: Result, + err_handler: &Arc, + ) where + LErrHandler: ErrorHandler, + { + match update { + Ok(upd) => { + let mut deps = self.dependencies.clone(); + deps.insert(upd); + deps.insert(self.bot.clone()); + deps.insert( + self.cache_me_bot.get_me().send().await.expect("Failed to retrieve 'me'"), + ); + + match self.handler.dispatch(deps).await { + ControlFlow::Break(Ok(())) => {} + ControlFlow::Break(Err(err)) => { + self.error_handler.clone().handle_error(err).await + } + ControlFlow::Continue(deps) => { + let upd = deps.get(); + (self.default_handler)(upd).await; + } + } + } + Err(err) => err_handler.clone().handle_error(err).await, + } + } + + /// Setups the `^C` handler that [`shutdown`]s dispatching. + /// + /// [`shutdown`]: ShutdownToken::shutdown + #[cfg(feature = "ctrlc_handler")] + #[cfg_attr(docsrs, doc(cfg(feature = "ctrlc_handler")))] + pub fn setup_ctrlc_handler(&mut self) -> &mut Self { + let token = self.state.clone(); + tokio::spawn(async move { + loop { + tokio::signal::ctrl_c().await.expect("Failed to listen for ^C"); + + match token.shutdown() { + Ok(f) => { + log::info!("^C received, trying to shutdown the dispatcher..."); + f.await; + log::info!("dispatcher is shutdown..."); + } + Err(_) => { + log::info!("^C received, the dispatcher isn't running, ignoring the signal") + } + } + } + }); + + self + } + + /// Returns a shutdown token, which can later be used to shutdown + /// dispatching. + pub fn shutdown_token(&self) -> ShutdownToken { + self.state.clone() + } +} diff --git a/src/dispatching2/filter_ext.rs b/src/dispatching2/filter_ext.rs new file mode 100644 index 00000000..5f37f2a0 --- /dev/null +++ b/src/dispatching2/filter_ext.rs @@ -0,0 +1,108 @@ +#![allow(clippy::redundant_closure_call)] + +use dptree::{di::DependencyMap, Handler}; +use teloxide_core::types::{Message, Update, UpdateKind}; + +macro_rules! define_ext { + ($ext_name:ident, $for_ty:ty => $( ($func:ident, $proj_fn:expr, $fn_doc:expr) ,)*) => { + #[doc = concat!("Filter methods for [`", stringify!($for_ty), "`].")] + pub trait $ext_name: private::Sealed { + $( define_ext!(@sig $func, $fn_doc); )* + } + + impl $ext_name for $for_ty + where + Out: Send + Sync + 'static, + { + $( define_ext!(@impl $for_ty, $func, $proj_fn); )* + } + }; + + (@sig $func:ident, $fn_doc:expr) => { + #[doc = $fn_doc] + fn $func() -> Handler<'static, DependencyMap, Out>; + }; + + (@impl $for_ty:ty, $func:ident, $proj_fn:expr) => { + fn $func() -> Handler<'static, DependencyMap, Out> { + dptree::filter_map(move |input: $for_ty| { + $proj_fn(input) + }) + } + }; +} + +mod private { + use teloxide_core::types::{Message, Update}; + + pub trait Sealed {} + + impl Sealed for Update {} + impl Sealed for Message {} +} + +macro_rules! define_message_ext { + ($( ($func:ident, $fn_name:path) ,)*) => { + define_ext! { + MessageFilterExt, crate::types::Message => + $(( + $func, + (|x| $fn_name(&x).map(ToOwned::to_owned)), + concat!("Applies the [`crate::types::", stringify!($fn_name), "`] filter.") + ),)* + } + } +} + +// May be expanded in the future. +define_message_ext! { + (filter_from, Message::from), + (filter_animation, Message::animation), + (filter_audio, Message::audio), + (filter_contact, Message::contact), + (filter_document, Message::document), + (filter_location, Message::location), + (filter_photo, Message::photo), + (filter_poll, Message::poll), + (filter_sticker, Message::sticker), + (filter_text, Message::text), + (filter_reply_to_message, Message::reply_to_message), + (filter_forward_from, Message::forward_from), + (filter_new_chat_members, Message::new_chat_members), + (filter_left_chat_member, Message::left_chat_member), + (filter_pinned, Message::pinned_message), + (filter_dice, Message::dice), +} + +macro_rules! define_update_ext { + ($( ($func:ident, $kind:path) ,)*) => { + define_ext! { + UpdateFilterExt, crate::types::Update => + $(( + $func, + |update: Update| match update.kind { + $kind(x) => Some(x), + _ => None, + }, + concat!("Filters out [`crate::types::", stringify!($kind), "`] objects.") + ),)* + } + } +} + +// May be expanded in the future. +define_update_ext! { + (filter_message, UpdateKind::Message), + (filter_edited_message, UpdateKind::EditedMessage), + (filter_channel_post, UpdateKind::ChannelPost), + (filter_edited_channel_post, UpdateKind::EditedChannelPost), + (filter_inline_query, UpdateKind::InlineQuery), + (filter_chosen_inline_result, UpdateKind::ChosenInlineResult), + (filter_callback_query, UpdateKind::CallbackQuery), + (filter_shipping_query, UpdateKind::ShippingQuery), + (filter_pre_checkout_query, UpdateKind::PreCheckoutQuery), + (filter_poll, UpdateKind::Poll), + (filter_poll_answer, UpdateKind::PollAnswer), + (filter_my_chat_member, UpdateKind::MyChatMember), + (filter_chat_member, UpdateKind::ChatMember), +} diff --git a/src/dispatching2/handler_ext.rs b/src/dispatching2/handler_ext.rs new file mode 100644 index 00000000..7c3db7ec --- /dev/null +++ b/src/dispatching2/handler_ext.rs @@ -0,0 +1,101 @@ +use std::sync::Arc; + +use crate::{ + dispatching2::{ + dialogue::{Dialogue, GetChatId, Storage}, + HandlerFactory, + }, + types::{Me, Message}, + utils::command::BotCommand, +}; +use dptree::{di::DependencyMap, Handler}; + +use std::fmt::Debug; + +/// Extension methods for working with `dptree` handlers. +pub trait HandlerExt { + /// Returns a handler that accepts a parsed command `C`. + /// + /// ## Dependency requirements + /// + /// - [`crate::types::Message`] + /// - [`crate::types::Me`] + #[must_use] + fn filter_command(self) -> Self + where + C: BotCommand + Send + Sync + 'static; + + /// Passes [`Dialogue`] and `D` as handler dependencies. + /// + /// It does so by the following steps: + /// + /// 1. If an incoming update has no chat ID ([`GetChatId::chat_id`] returns + /// `None`), the rest of the chain will not be executed. Otherwise, passes + /// `Dialogue::new(storage, chat_id)` forwards. + /// 2. If [`Dialogue::get_or_default`] on the passed dialogue returns `Ok`, + /// passes the dialogue state forwards. Otherwise, logs an error and the + /// rest of the chain is not executed. + /// + /// ## Dependency requirements + /// + /// - `Arc` + /// - `Upd` + /// + /// [`Dialogue`]: Dialogue + #[must_use] + fn enter_dialogue(self) -> Self + where + S: Storage + Send + Sync + 'static, + >::Error: Debug + Send, + D: Default + Send + Sync + 'static, + Upd: GetChatId + Clone + Send + Sync + 'static; + + #[must_use] + fn dispatch_by(self) -> Self + where + F: HandlerFactory; +} + +impl HandlerExt for Handler<'static, DependencyMap, Output> +where + Output: Send + Sync + 'static, +{ + fn filter_command(self) -> Self + where + C: BotCommand + Send + Sync + 'static, + { + self.chain(dptree::filter_map(move |message: Message, me: Me| { + let bot_name = me.user.username.expect("Bots must have a username"); + message.text().and_then(|text| C::parse(text, bot_name).ok()) + })) + } + + fn enter_dialogue(self) -> Self + where + S: Storage + Send + Sync + 'static, + >::Error: Debug + Send, + D: Default + Send + Sync + 'static, + Upd: GetChatId + Clone + Send + Sync + 'static, + { + self.chain(dptree::filter_map(|storage: Arc, upd: Upd| { + let chat_id = upd.chat_id()?; + Some(Dialogue::new(storage, chat_id)) + })) + .chain(dptree::filter_map_async(|dialogue: Dialogue| async move { + match dialogue.get_or_default().await { + Ok(dialogue) => Some(dialogue), + Err(err) => { + log::error!("dialogue.get_or_default() failed: {:?}", err); + None + } + } + })) + } + + fn dispatch_by(self) -> Self + where + F: HandlerFactory, + { + self.chain(F::handler()) + } +} diff --git a/src/dispatching2/handler_factory.rs b/src/dispatching2/handler_factory.rs new file mode 100644 index 00000000..b561fea8 --- /dev/null +++ b/src/dispatching2/handler_factory.rs @@ -0,0 +1,8 @@ +use dptree::{di::DependencyMap, Handler}; + +/// Something that can construct a handler. +pub trait HandlerFactory { + type Out; + + fn handler() -> Handler<'static, DependencyMap, Self::Out>; +} diff --git a/src/dispatching2/mod.rs b/src/dispatching2/mod.rs new file mode 100644 index 00000000..5bbc270e --- /dev/null +++ b/src/dispatching2/mod.rs @@ -0,0 +1,109 @@ +//! A new dispatching model based on [`dptree`]. +//! +//! In teloxide, updates are dispatched by a pipleine. The central type is +//! [`dptree::Handler`] -- it represents a handler of an update; since the API +//! is highly declarative, you can combine handlers with each other via such +//! methods as [`dptree::Handler::chain`] and [`dptree::Handler::branch`]. The +//! former method pipes one handler to another one, whilst the latter creates a +//! new node, as communicated by the name. For more information, please refer to +//! the documentation of [`dptree`]. +//! +//! The pattern itself is called [chain of responsibility], a well-known design +//! technique across OOP developers. But unlike typical object-oriented design, +//! we employ declarative FP-style functions like [`dptree::filter`], +//! [`dptree::filter_map`], and [`dptree::endpoint`]; these functions create +//! special forms of [`dptree::Handler`]; for more information, please refer to +//! their respective documentation. Each of these higher-order functions accept +//! a closure that is made into a handler -- this closure can take any +//! additional parameters, which must be supplied while creating [`Dispatcher`] +//! (see [`DispatcherBuilder::dependencies`]). +//! +//! The [`Dispatcher`] type puts all these things together: it only provides +//! [`Dispatcher::dispatch`] and a handful of other methods. Once you call +//! `.dispatch()`, it will retrieve updates from the Telegram server and pass +//! them to your handler, which is a parameter of [`Dispatcher::builder`]. +//! +//! Let us look at a simple example: +//! +//! +//! ([Full](https://github.com/teloxide/teloxide/blob/master/examples/shared_state.rs)) +//! +//! ```no_run +//! use std::sync::atomic::{AtomicU64, Ordering}; +//! +//! use once_cell::sync::Lazy; +//! use teloxide::prelude2::*; +//! +//! static MESSAGES_TOTAL: Lazy = Lazy::new(AtomicU64::default); +//! +//! # #[tokio::main] +//! # async fn main() { +//! teloxide::enable_logging!(); +//! log::info!("Starting shared_state_bot..."); +//! +//! let bot = Bot::from_env().auto_send(); +//! +//! let handler = Update::filter_message().branch(dptree::endpoint( +//! |msg: Message, bot: AutoSend| async move { +//! let previous = MESSAGES_TOTAL.fetch_add(1, Ordering::Relaxed); +//! bot.send_message(msg.chat.id, format!("I received {} messages in total.", previous)) +//! .await?; +//! respond(()) +//! }, +//! )); +//! +//! Dispatcher::builder(bot, handler).build().setup_ctrlc_handler().dispatch().await; +//! # } +//! ``` +//! +//! 1. First, we create the bot: `let bot = Bot::from_env().auto_send()`. +//! 2. Then we construct an update handler. While it is possible to handle all +//! kinds of [`crate::types::Update`], here we are only interested in +//! [`crate::types::Message`]: [`UpdateFilterExt::filter_message`] create a +//! handler object which filters all messages out of a generic update. +//! 3. By doing `.branch(dptree::endpoint(...))`, we set up a custom handling +//! closure that receives `msg: Message` and `bot: AutoSend`. There are +//! called dependencies: `msg` is supplied by +//! [`UpdateFilterExt::filter_message`], while `bot` is supplied by +//! [`Dispatcher`]. +//! +//! That being said, if we receive a message, the dispatcher will call our +//! handler, but if we receive something other than a message (e.g., a channel +//! post), you will see an unhandled update notice in your terminal. +//! +//! This is a very limited example of update pipelining facilities. In more +//! involved scenarios, there are multiple branches and chains; if one element +//! of a chain fails to handle an update, the update will be passed forwards; if +//! no handler succeeds at handling the update, [`Dispatcher`] will invoke a +//! default handler set up via [`DispatcherBuilder::default_handler`]. +//! +//! Update pipelining provides several advantages over the typical `match +//! (update.kind) { ... }` approach: +//! +//! 1. It supports _extension_: e.g., you +//! can define extension filters or some other handlers and then combine them in +//! a single place, thus facilitating loose coupling. +//! 2. Pipelining exhibits a natural syntax for expressing message processing. +//! 3. Lastly, it provides a primitive form of [dependency injection (DI)], +//! which allows you to deal with such objects as a bot and various update types +//! easily. +//! +//! For a more involved example, see [`examples/dispatching2_features.rs`](https://github.com/teloxide/teloxide/blob/master/examples/dispatching2_features.rs). +//! +//! TODO: explain a more involved example with multiple branches. +//! +//! [chain of responsibility]: https://en.wikipedia.org/wiki/Chain-of-responsibility_pattern +//! [dependency injection (DI)]: https://en.wikipedia.org/wiki/Dependency_injection + +pub mod repls; + +pub mod dialogue; +mod dispatcher; +mod filter_ext; +mod handler_ext; +mod handler_factory; + +pub use dispatcher::{Dispatcher, DispatcherBuilder, UpdateHandler}; +pub use filter_ext::{MessageFilterExt, UpdateFilterExt}; +pub use handler_ext::HandlerExt; +pub use handler_factory::HandlerFactory; diff --git a/src/dispatching2/repls/commands_repl.rs b/src/dispatching2/repls/commands_repl.rs new file mode 100644 index 00000000..320381fa --- /dev/null +++ b/src/dispatching2/repls/commands_repl.rs @@ -0,0 +1,97 @@ +use crate::{ + dispatching::{update_listeners, update_listeners::UpdateListener}, + dispatching2::{HandlerExt, UpdateFilterExt}, + error_handlers::LoggingErrorHandler, + types::Update, + utils::command::BotCommand, +}; +use dptree::di::{DependencyMap, Injectable}; +use std::{fmt::Debug, marker::PhantomData}; +use teloxide_core::requests::Requester; + +/// A [REPL] for commands. +/// +/// All errors from an update listener and handler will be logged. +/// +/// ## Caution +/// **DO NOT** use this function together with [`Dispatcher`] and other REPLs, +/// because Telegram disallow multiple requests at the same time from the same +/// bot. +/// +/// ## Dependency requirements +/// +/// - Those of [`HandlerExt::filter_command`]. +/// +/// [REPL]: https://en.wikipedia.org/wiki/Read-eval-print_loop +/// [`Dispatcher`]: crate::dispatching::Dispatcher +#[cfg(feature = "ctrlc_handler")] +pub async fn commands_repl<'a, R, Cmd, H, E, Args>(bot: R, handler: H, cmd: PhantomData) +where + Cmd: BotCommand + Send + Sync + 'static, + H: Injectable, Args> + Send + Sync + 'static, + R: Requester + Clone + Send + Sync + 'static, + ::GetUpdates: Send, + E: Debug + Send + Sync + 'static, +{ + let cloned_bot = bot.clone(); + + commands_repl_with_listener( + bot, + handler, + update_listeners::polling_default(cloned_bot).await, + cmd, + ) + .await; +} + +/// Like [`commands_repl`], but with a custom [`UpdateListener`]. +/// +/// All errors from an update listener and handler will be logged. +/// +/// ## Caution +/// **DO NOT** use this function together with [`Dispatcher`] and other REPLs, +/// because Telegram disallow multiple requests at the same time from the same +/// bot. +/// +/// ## Dependency requirements +/// +/// - Those of [`HandlerExt::filter_command`]. +/// +/// [`Dispatcher`]: crate::dispatching::Dispatcher +/// [`commands_repl`]: crate::dispatching::repls::commands_repl() +/// [`UpdateListener`]: crate::dispatching::update_listeners::UpdateListener +#[cfg(feature = "ctrlc_handler")] +pub async fn commands_repl_with_listener<'a, R, Cmd, H, L, ListenerE, E, Args>( + bot: R, + handler: H, + listener: L, + _cmd: PhantomData, +) where + Cmd: BotCommand + Send + Sync + 'static, + H: Injectable, Args> + Send + Sync + 'static, + L: UpdateListener + Send + 'a, + ListenerE: Debug + Send + 'a, + R: Requester + Clone + Send + Sync + 'static, + E: Debug + Send + Sync + 'static, +{ + use crate::dispatching2::Dispatcher; + + let mut dispatcher = Dispatcher::builder( + bot, + Update::filter_message().filter_command::().branch(dptree::endpoint(handler)), + ) + .build(); + + #[cfg(feature = "ctrlc_handler")] + dispatcher.setup_ctrlc_handler(); + + // To make mutable var from immutable. + let mut dispatcher = dispatcher; + + dispatcher + .dispatch_with_listener( + listener, + LoggingErrorHandler::with_custom_text("An error from the update listener"), + ) + .await; +} diff --git a/src/dispatching2/repls/dialogues_repl.rs b/src/dispatching2/repls/dialogues_repl.rs new file mode 100644 index 00000000..755d4dd3 --- /dev/null +++ b/src/dispatching2/repls/dialogues_repl.rs @@ -0,0 +1,96 @@ +use crate::{ + dispatching::{ + dialogue::{DialogueDispatcher, DialogueStage, DialogueWithCx, InMemStorageError}, + update_listeners, + update_listeners::UpdateListener, + Dispatcher, UpdateWithCx, + }, + error_handlers::LoggingErrorHandler, +}; +use std::{fmt::Debug, future::Future, sync::Arc}; +use teloxide_core::{requests::Requester, types::Message}; + +/// A [REPL] for dialogues. +/// +/// All errors from an update listener and handler will be logged. This function +/// uses [`InMemStorage`]. +/// +/// # Caution +/// **DO NOT** use this function together with [`Dispatcher`] and other REPLs, +/// because Telegram disallow multiple requests at the same time from the same +/// bot. +/// +/// [REPL]: https://en.wikipedia.org/wiki/Read-eval-print_loop +/// [`Dispatcher`]: crate::dispatching::Dispatcher +/// [`InMemStorage`]: crate::dispatching::dialogue::InMemStorage +#[cfg(feature = "ctrlc_handler")] +pub async fn dialogues_repl<'a, R, H, D, Fut>(requester: R, handler: H) +where + H: Fn(UpdateWithCx, D) -> Fut + Send + Sync + 'static, + D: Clone + Default + Send + 'static, + Fut: Future> + Send + 'static, + R: Requester + Send + Clone + 'static, + ::GetUpdatesFaultTolerant: Send, +{ + let cloned_requester = requester.clone(); + + dialogues_repl_with_listener( + requester, + handler, + update_listeners::polling_default(cloned_requester).await, + ) + .await; +} + +/// Like [`dialogues_repl`], but with a custom [`UpdateListener`]. +/// +/// All errors from an update listener and handler will be logged. This function +/// uses [`InMemStorage`]. +/// +/// # Caution +/// **DO NOT** use this function together with [`Dispatcher`] and other REPLs, +/// because Telegram disallow multiple requests at the same time from the same +/// bot. +/// +/// [`Dispatcher`]: crate::dispatching::Dispatcher +/// [`dialogues_repl`]: crate::dispatching::repls::dialogues_repl() +/// [`UpdateListener`]: crate::dispatching::update_listeners::UpdateListener +/// [`InMemStorage`]: crate::dispatching::dialogue::InMemStorage +#[cfg(feature = "ctrlc_handler")] +pub async fn dialogues_repl_with_listener<'a, R, H, D, Fut, L, ListenerE>( + requester: R, + handler: H, + listener: L, +) where + H: Fn(UpdateWithCx, D) -> Fut + Send + Sync + 'static, + D: Clone + Default + Send + 'static, + Fut: Future> + Send + 'static, + L: UpdateListener + Send + 'a, + ListenerE: Debug + Send + 'a, + R: Requester + Send + Clone + 'static, +{ + let handler = Arc::new(handler); + + Dispatcher::new(requester) + .messages_handler(DialogueDispatcher::new( + move |DialogueWithCx { cx, dialogue }: DialogueWithCx< + R, + Message, + D, + InMemStorageError, + >| { + let handler = Arc::clone(&handler); + + async move { + let dialogue = dialogue.expect("std::convert::Infallible"); + handler(cx, dialogue).await + } + }, + )) + .setup_ctrlc_handler() + .dispatch_with_listener( + listener, + LoggingErrorHandler::with_custom_text("An error from the update listener"), + ) + .await; +} diff --git a/src/dispatching2/repls/mod.rs b/src/dispatching2/repls/mod.rs new file mode 100644 index 00000000..ca41a0d2 --- /dev/null +++ b/src/dispatching2/repls/mod.rs @@ -0,0 +1,9 @@ +//! REPLs for dispatching updates. + +//mod dialogues_repl; +mod commands_repl; +mod repl; + +pub use commands_repl::{commands_repl, commands_repl_with_listener}; +//pub use dialogues_repl::{dialogues_repl, dialogues_repl_with_listener}; +pub use repl::{repl, repl_with_listener}; diff --git a/src/dispatching2/repls/repl.rs b/src/dispatching2/repls/repl.rs new file mode 100644 index 00000000..6a047b98 --- /dev/null +++ b/src/dispatching2/repls/repl.rs @@ -0,0 +1,73 @@ +use crate::{ + dispatching::{update_listeners, update_listeners::UpdateListener}, + dispatching2::UpdateFilterExt, + error_handlers::{LoggingErrorHandler, OnError}, + types::Update, +}; +use dptree::di::{DependencyMap, Injectable}; +use std::fmt::Debug; +use teloxide_core::requests::Requester; + +/// A [REPL] for messages. +/// +/// All errors from an update listener and a handler will be logged. +/// +/// # Caution +/// **DO NOT** use this function together with [`Dispatcher`] and other REPLs, +/// because Telegram disallow multiple requests at the same time from the same +/// bot. +/// +/// [REPL]: https://en.wikipedia.org/wiki/Read-eval-print_loop +/// [`Dispatcher`]: crate::dispatching::Dispatcher +#[cfg(feature = "ctrlc_handler")] +pub async fn repl(bot: R, handler: H) +where + H: Injectable, Args> + Send + Sync + 'static, + Result<(), E>: OnError, + E: Debug + Send + Sync + 'static, + R: Requester + Send + Sync + Clone + 'static, + ::GetUpdates: Send, +{ + let cloned_bot = bot.clone(); + repl_with_listener(bot, handler, update_listeners::polling_default(cloned_bot).await).await; +} + +/// Like [`repl`], but with a custom [`UpdateListener`]. +/// +/// All errors from an update listener and handler will be logged. +/// +/// # Caution +/// **DO NOT** use this function together with [`Dispatcher`] and other REPLs, +/// because Telegram disallow multiple requests at the same time from the same +/// bot. +/// +/// [`Dispatcher`]: crate::dispatching::Dispatcher +/// [`repl`]: crate::dispatching::repls::repl() +/// [`UpdateListener`]: crate::dispatching::update_listeners::UpdateListener +#[cfg(feature = "ctrlc_handler")] +pub async fn repl_with_listener<'a, R, H, E, L, ListenerE, Args>(bot: R, handler: H, listener: L) +where + H: Injectable, Args> + Send + Sync + 'static, + L: UpdateListener + Send + 'a, + ListenerE: Debug, + Result<(), E>: OnError, + E: Debug + Send + Sync + 'static, + R: Requester + Clone + Send + Sync + 'static, +{ + use crate::dispatching2::Dispatcher; + + #[allow(unused_mut)] + let mut dispatcher = + Dispatcher::builder(bot, Update::filter_message().branch(dptree::endpoint(handler))) + .build(); + + #[cfg(feature = "ctrlc_handler")] + dispatcher.setup_ctrlc_handler(); + + dispatcher + .dispatch_with_listener( + listener, + LoggingErrorHandler::with_custom_text("An error from the update listener"), + ) + .await; +} diff --git a/src/features.txt b/src/features.txt index f6ef21ba..7edd2f39 100644 --- a/src/features.txt +++ b/src/features.txt @@ -10,8 +10,11 @@ | `native-tls` | Enables the [`native-tls`] TLS implementation (enabled by default). | | `rustls` | Enables the [`rustls`] TLS implementation. | | `ctrlc_handler` | Enables the [`Dispatcher::setup_ctrlc_handler`](dispatching::Dispatcher::setup_ctrlc_handler) function. | -| `auto-send` | Enables the `AutoSend` bot adaptor. | -| `cache-me` | Enables the `CacheMe` bot adaptor. | +| `auto-send` | Enables the [`AutoSend`](adaptors::AutoSend) bot adaptor. | +| `throttle` | Enables the [`Throttle`](adaptors::Throttle) bot adaptor. | +| `cache-me` | Enables the [`CacheMe`](adaptors::CacheMe) bot adaptor. | +| `trace-adaptor` | Enables the [`Trace`](adaptors::Trace) bot adaptor. | +| `erased` | Enables the [`ErasedRequester`](adaptors::ErasedRequester) bot adaptor. | | `frunk` | Enables [`teloxide::utils::UpState`]. | | `full` | Enables all the features except `nightly`. | | `nightly` | Enables nightly-only features (see the [teloxide-core features]). | diff --git a/src/lib.rs b/src/lib.rs index 9114d239..8c3ea617 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,17 +6,17 @@ //! //! ([Full](https://github.com/teloxide/teloxide/blob/master/examples/dices_bot/src/main.rs)) //! ```no_run -//! use teloxide::prelude::*; +//! use teloxide::prelude2::*; //! //! # #[tokio::main] -//! # async fn main_() { +//! # async fn main() { //! teloxide::enable_logging!(); //! log::info!("Starting dices_bot..."); //! //! let bot = Bot::from_env().auto_send(); //! -//! teloxide::repl(bot, |message| async move { -//! message.answer_dice().await?; +//! teloxide::repls2::repl(bot, |message: Message, bot: AutoSend| async move { +//! bot.send_dice(message.chat.id).await?; //! respond(()) //! }) //! .await; @@ -44,8 +44,6 @@ html_logo_url = "https://github.com/teloxide/teloxide/raw/master/ICON.png", html_favicon_url = "https://github.com/teloxide/teloxide/raw/master/ICON.png" )] -#![allow(clippy::match_bool)] -#![forbid(unsafe_code)] // We pass "--cfg docsrs" when building docs to add `This is supported on // feature="..." only.` // @@ -56,21 +54,33 @@ // $ RUSTFLAGS="--cfg dep_docsrs" RUSTDOCFLAGS="--cfg docsrs -Znormalize-docs" cargo +nightly doc --open --all-features // ``` #![cfg_attr(all(docsrs, feature = "nightly"), feature(doc_cfg))] +#![forbid(unsafe_code)] +#![warn(rustdoc::broken_intra_doc_links)] +#![allow(clippy::match_bool)] #![allow(clippy::redundant_pattern_matching)] // https://github.com/rust-lang/rust-clippy/issues/7422 #![allow(clippy::nonstandard_macro_braces)] -#[cfg(feature = "ctrlc_handler")] pub use dispatching::repls::{ commands_repl, commands_repl_with_listener, dialogues_repl, dialogues_repl_with_listener, repl, repl_with_listener, }; +#[cfg(feature = "dispatching2")] +pub use dispatching2::repls as repls2; + mod logging; +// Things from this module is also used for the dispatching2 module. pub mod dispatching; +#[cfg(feature = "dispatching2")] +#[cfg_attr(all(docsrs, feature = "nightly"), doc(cfg(feature = "dispatching2")))] +pub mod dispatching2; pub mod error_handlers; pub mod prelude; +#[cfg(feature = "dispatching2")] +#[cfg_attr(all(docsrs, feature = "nightly"), doc(cfg(feature = "dispatching2")))] +pub mod prelude2; pub mod utils; #[doc(inline)] @@ -80,6 +90,9 @@ pub use teloxide_core::*; #[cfg_attr(all(docsrs, feature = "nightly"), doc(cfg(feature = "macros")))] pub use teloxide_macros as macros; +#[cfg(feature = "dispatching2")] +#[cfg_attr(all(docsrs, feature = "nightly"), doc(cfg(feature = "dispatching2")))] +pub use dptree; #[cfg_attr(all(docsrs, feature = "nightly"), doc(cfg(feature = "macros")))] #[cfg(feature = "macros")] pub use teloxide_macros::teloxide; diff --git a/src/logging.rs b/src/logging.rs index fd7e5192..f39f66f5 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -3,12 +3,8 @@ /// A logger will **only** print errors, warnings, and general information from /// teloxide and **all** logs from your program. /// -/// # Example -/// ```no_compile -/// teloxide::enable_logging!(); -/// ``` -/// /// # Note +/// /// Calling this macro **is not mandatory**; you can setup if your own logger if /// you want. /// @@ -27,6 +23,7 @@ macro_rules! enable_logging { /// teloxide and restrict logs from your program by the specified filter. /// /// # Example +/// /// Allow printing all logs from your program up to [`LevelFilter::Debug`] (i.e. /// do not print traces): /// @@ -35,6 +32,7 @@ macro_rules! enable_logging { /// ``` /// /// # Note +/// /// Calling this macro **is not mandatory**; you can setup if your own logger if /// you want. /// @@ -45,7 +43,7 @@ macro_rules! enable_logging_with_filter { ($filter:expr) => { pretty_env_logger::formatted_builder() .write_style(pretty_env_logger::env_logger::WriteStyle::Auto) - .filter(Some(&env!("CARGO_PKG_NAME").replace("-", "_")), $filter) + .filter(Some(&env!("CARGO_CRATE_NAME").replace("-", "_")), $filter) .filter(Some("teloxide"), log::LevelFilter::Info) .init(); }; diff --git a/src/prelude.rs b/src/prelude.rs index b7f9a040..9fa5ffb6 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -1,17 +1,21 @@ //! Commonly used items. +#![deprecated(note = "Use dispatching2 instead")] +#![allow(deprecated)] + pub use crate::{ - dispatching::{ - dialogue::{ - exit, next, DialogueDispatcher, DialogueStage, DialogueWithCx, GetChatId, Transition, - TransitionIn, TransitionOut, - }, - Dispatcher, DispatcherHandlerRx, DispatcherHandlerRxExt, UpdateWithCx, - }, error_handlers::{LoggingErrorHandler, OnError}, respond, }; +pub use crate::dispatching::{ + dialogue::{ + exit, next, DialogueDispatcher, DialogueStage, DialogueWithCx, GetChatId, Transition, + TransitionIn, TransitionOut, + }, + Dispatcher, DispatcherHandlerRx, DispatcherHandlerRxExt, UpdateWithCx, +}; + #[cfg_attr(all(docsrs, feature = "nightly"), doc(cfg(feature = "macros")))] #[cfg(feature = "macros")] pub use crate::teloxide; diff --git a/src/prelude2.rs b/src/prelude2.rs new file mode 100644 index 00000000..79aadf51 --- /dev/null +++ b/src/prelude2.rs @@ -0,0 +1,27 @@ +//! Commonly used items (`dispatching2`). + +pub use crate::{ + error_handlers::{LoggingErrorHandler, OnError}, + respond, +}; + +pub use crate::dispatching2::{ + dialogue::Dialogue, Dispatcher, HandlerExt as _, MessageFilterExt as _, UpdateFilterExt as _, +}; + +#[cfg_attr(all(docsrs, feature = "nightly"), doc(cfg(feature = "macros")))] +#[cfg(feature = "macros")] +pub use crate::teloxide; + +pub use teloxide_core::types::{ + CallbackQuery, ChatMemberUpdated, ChosenInlineResult, InlineQuery, Message, Poll, PollAnswer, + PreCheckoutQuery, ShippingQuery, Update, +}; + +#[cfg(feature = "auto-send")] +pub use crate::adaptors::AutoSend; + +#[doc(no_inline)] +pub use teloxide_core::prelude::*; + +pub use dptree::{self, prelude::*}; diff --git a/src/utils/command.rs b/src/utils/command.rs index 5dd3f852..35023099 100644 --- a/src/utils/command.rs +++ b/src/utils/command.rs @@ -51,6 +51,7 @@ use std::{ fmt::{Display, Formatter}, }; +use std::marker::PhantomData; #[cfg(feature = "macros")] #[cfg_attr(all(docsrs, feature = "nightly"), doc(cfg(feature = "macros")))] pub use teloxide_macros::BotCommand; @@ -208,6 +209,10 @@ pub trait BotCommand: Sized { fn parse(s: &str, bot_name: N) -> Result where N: Into; + fn ty() -> PhantomData { + PhantomData + } + fn bot_commands() -> Vec; } pub type PrefixedBotCommand = String; diff --git a/src/utils/html.rs b/src/utils/html.rs index dc26a299..d61c84c2 100644 --- a/src/utils/html.rs +++ b/src/utils/html.rs @@ -61,7 +61,7 @@ pub fn code_block(code: &str) -> String { pub fn code_block_with_lang(code: &str, lang: &str) -> String { format!( "
{}
", - escape(lang).replace("\"", """), + escape(lang).replace('"', """), escape(code) ) } @@ -81,7 +81,7 @@ pub fn code_inline(s: &str) -> String { /// /// [spec]: https://core.telegram.org/bots/api#html-style pub fn escape(s: &str) -> String { - s.replace("&", "&").replace("<", "<").replace(">", ">") + s.replace('&', "&").replace('<', "<").replace('>', ">") } pub fn user_mention_or_link(user: &User) -> String { diff --git a/src/utils/markdown.rs b/src/utils/markdown.rs index d778aee2..6ee5f451 100644 --- a/src/utils/markdown.rs +++ b/src/utils/markdown.rs @@ -89,36 +89,36 @@ pub fn code_inline(s: &str) -> String { /// /// [spec]: https://core.telegram.org/bots/api#html-style pub fn escape(s: &str) -> String { - s.replace("_", r"\_") - .replace("*", r"\*") - .replace("[", r"\[") - .replace("]", r"\]") - .replace("(", r"\(") - .replace(")", r"\)") - .replace("~", r"\~") - .replace("`", r"\`") - .replace(">", r"\>") - .replace("#", r"\#") - .replace("+", r"\+") - .replace("-", r"\-") - .replace("=", r"\=") - .replace("|", r"\|") - .replace("{", r"\{") - .replace("}", r"\}") - .replace(".", r"\.") - .replace("!", r"\!") + s.replace('_', r"\_") + .replace('*', r"\*") + .replace('[', r"\[") + .replace(']', r"\]") + .replace('(', r"\(") + .replace(')', r"\)") + .replace('~', r"\~") + .replace('`', r"\`") + .replace('>', r"\>") + .replace('#', r"\#") + .replace('+', r"\+") + .replace('-', r"\-") + .replace('=', r"\=") + .replace('|', r"\|") + .replace('{', r"\{") + .replace('}', r"\}") + .replace('.', r"\.") + .replace('!', r"\!") } /// Escapes all markdown special characters specific for the inline link URL /// (``` and `)`). pub fn escape_link_url(s: &str) -> String { - s.replace("`", r"\`").replace(")", r"\)") + s.replace('`', r"\`").replace(')', r"\)") } /// Escapes all markdown special characters specific for the code block (``` and /// `\`). pub fn escape_code(s: &str) -> String { - s.replace(r"\", r"\\").replace("`", r"\`") + s.replace('\\', r"\\").replace('`', r"\`") } pub fn user_mention_or_link(user: &User) -> String { diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 6e29632d..928fe6fe 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -3,6 +3,7 @@ pub mod command; pub mod html; pub mod markdown; +pub(crate) mod shutdown_token; mod up_state; pub use teloxide_core::net::client_from_env; diff --git a/src/utils/shutdown_token.rs b/src/utils/shutdown_token.rs new file mode 100644 index 00000000..3c11c812 --- /dev/null +++ b/src/utils/shutdown_token.rs @@ -0,0 +1,165 @@ +use std::{ + fmt, + future::Future, + sync::{ + atomic::{AtomicU8, Ordering}, + Arc, + }, + time::Duration, +}; + +use tokio::sync::Notify; + +use crate::dispatching::update_listeners::UpdateListener; + +/// A token which used to shutdown [`Dispatcher`]. +#[derive(Clone)] +pub struct ShutdownToken { + dispatcher_state: Arc, + shutdown_notify_back: Arc, +} + +/// This error is returned from [`ShutdownToken::shutdown`] when trying to +/// shutdown an idle [`Dispatcher`]. +#[derive(Debug)] +pub struct IdleShutdownError; + +impl ShutdownToken { + /// Tries to shutdown dispatching. + /// + /// Returns an error if the dispatcher is idle at the moment. + /// + /// If you don't need to wait for shutdown, the returned future can be + /// ignored. + pub fn shutdown(&self) -> Result + '_, IdleShutdownError> { + match shutdown_inner(&self.dispatcher_state) { + Ok(()) | Err(Ok(AlreadyShuttingDown)) => Ok(async move { + log::info!("Trying to shutdown the dispatcher..."); + self.shutdown_notify_back.notified().await + }), + Err(Err(err)) => Err(err), + } + } + + pub(crate) fn new() -> Self { + Self { + dispatcher_state: Arc::new(DispatcherState { + inner: AtomicU8::new(ShutdownState::Idle as _), + }), + shutdown_notify_back: <_>::default(), + } + } + + pub(crate) fn start_dispatching(&self) { + if let Err(actual) = + self.dispatcher_state.compare_exchange(ShutdownState::Idle, ShutdownState::Running) + { + panic!( + "Dispatching is already running: expected `{:?}` state, found `{:?}`", + ShutdownState::Idle, + actual + ); + } + } + + pub(crate) fn is_shutting_down(&self) -> bool { + matches!(self.dispatcher_state.load(), ShutdownState::ShuttingDown) + } + + pub(crate) fn done(&self) { + if self.is_shutting_down() { + // Stopped because of a `shutdown` call. + + // Notify `shutdown`s that we finished + self.shutdown_notify_back.notify_waiters(); + log::info!("Dispatching has been shut down."); + } else { + log::info!("Dispatching has been stopped (listener returned `None`)."); + } + + self.dispatcher_state.store(ShutdownState::Idle); + } +} + +impl fmt::Display for IdleShutdownError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Dispatcher was idle and as such couldn't be shut down") + } +} + +impl std::error::Error for IdleShutdownError {} + +pub(crate) fn shutdown_check_timeout_for(update_listener: &impl UpdateListener) -> Duration { + const MIN_SHUTDOWN_CHECK_TIMEOUT: Duration = Duration::from_secs(1); + const DZERO: Duration = Duration::ZERO; + + let shutdown_check_timeout = update_listener.timeout_hint().unwrap_or(DZERO); + shutdown_check_timeout.saturating_add(MIN_SHUTDOWN_CHECK_TIMEOUT) +} + +struct DispatcherState { + inner: AtomicU8, +} + +impl DispatcherState { + // Ordering::Relaxed: only one atomic variable, nothing to synchronize. + + fn load(&self) -> ShutdownState { + ShutdownState::from_u8(self.inner.load(Ordering::Relaxed)) + } + + fn store(&self, new: ShutdownState) { + self.inner.store(new as _, Ordering::Relaxed) + } + + fn compare_exchange( + &self, + current: ShutdownState, + new: ShutdownState, + ) -> Result { + self.inner + .compare_exchange(current as _, new as _, Ordering::Relaxed, Ordering::Relaxed) + .map(ShutdownState::from_u8) + .map_err(ShutdownState::from_u8) + } +} + +#[repr(u8)] +#[derive(Debug)] +enum ShutdownState { + Running, + ShuttingDown, + Idle, +} + +impl ShutdownState { + fn from_u8(n: u8) -> Self { + const RUNNING: u8 = ShutdownState::Running as u8; + const SHUTTING_DOWN: u8 = ShutdownState::ShuttingDown as u8; + const IDLE: u8 = ShutdownState::Idle as u8; + + match n { + RUNNING => ShutdownState::Running, + SHUTTING_DOWN => ShutdownState::ShuttingDown, + IDLE => ShutdownState::Idle, + _ => unreachable!(), + } + } +} + +struct AlreadyShuttingDown; + +fn shutdown_inner( + state: &DispatcherState, +) -> Result<(), Result> { + use ShutdownState::*; + + let res = state.compare_exchange(Running, ShuttingDown); + + match res { + Ok(_) => Ok(()), + Err(ShuttingDown) => Err(Ok(AlreadyShuttingDown)), + Err(Idle) => Err(Err(IdleShutdownError)), + Err(Running) => unreachable!(), + } +} diff --git a/tests/dialogue_state.rs b/tests/dialogue_state.rs new file mode 100644 index 00000000..d1fd7d4c --- /dev/null +++ b/tests/dialogue_state.rs @@ -0,0 +1,62 @@ +#[cfg(feature = "macros")] +use teloxide::macros::DialogueState; +// We put tests here because macro expand in unit tests in the crate was a +// failure + +#[test] +#[cfg(feature = "macros")] +fn compile_test() { + #[allow(dead_code)] + #[derive(DialogueState, Clone)] + #[handler_out(Result<(), teloxide::RequestError>)] + enum State { + #[handler(handle_start)] + Start, + + #[handler(handle_have_data)] + HaveData(String), + } + + impl Default for State { + fn default() -> Self { + Self::Start + } + } + + async fn handle_start() -> Result<(), teloxide::RequestError> { + Ok(()) + } + + async fn handle_have_data() -> Result<(), teloxide::RequestError> { + Ok(()) + } +} + +#[test] +#[cfg(feature = "macros")] +fn compile_test_generics() { + #[allow(dead_code)] + #[derive(DialogueState, Clone)] + #[handler_out(Result<(), teloxide::RequestError>)] + enum State { + #[handler(handle_start)] + Start, + + #[handler(handle_have_data)] + HaveData(X), + } + + impl Default for State { + fn default() -> Self { + Self::Start + } + } + + async fn handle_start() -> Result<(), teloxide::RequestError> { + Ok(()) + } + + async fn handle_have_data() -> Result<(), teloxide::RequestError> { + Ok(()) + } +}
-//!     tg                           bot
-//!      |                            |
-//!      |<---------------------------| Updates? (Bot::get_updates call)
-//!      ↑                            ↑
-//!      |          timeout^1         |
-//!      ↓                            ↓
-//! Nope |--------------------------->|
-//!      ↑                            ↑
-//!      | delay between Bot::get_updates^2 |
-//!      ↓                            ↓
-//!      |<---------------------------| Updates?
-//!      ↑                            ↑
-//!      |          timeout^3         |
-//!      ↓                            ↓
-//! Yes  |-------[updates 0, 1]------>|
-//!      ↑                            ↑
-//!      |           delay            |
-//!      ↓                            ↓
-//!      |<-------[offset = 1]--------| Updates?^4
-//!      ↑                            ↑
-//!      |           timeout          |
-//!      ↓                            ↓
-//! Yes  |---------[update 2]-------->|
-//!      ↑                            ↑
-//!      |           delay            |
-//!      ↓                            ↓
-//!      |<-------[offset = 2]--------| Updates?
-//!      ↑                            ↑
-//!      |           timeout          |
-//!      ↓                            ↓
-//! Nope |--------------------------->|
-//!      ↑                            ↑
-//!      |           delay            |
-//!      ↓                            ↓
-//!      |<-------[offset = 2]--------| Updates?
-//!      ↑                            ↑
-//!      |           timeout          |
-//!      ↓                            ↓
-//! Nope |--------------------------->|
-//!      ↑                            ↑
-//!      |           delay            |
-//!      ↓                            ↓
-//!      |<-------[offset = 2]--------| Updates?
-//!      ↑                            ↑
-//!      |           timeout          |
-//!      ↓                            ↓
-//! Yes  |-------[updates 2..5]------>|
-//!      ↑                            ↑
-//!      |           delay            |
-//!      ↓                            ↓
-//!      |<-------[offset = 5]--------| Updates?
-//!      ↑                            ↑
-//!      |           timeout          |
-//!      ↓                            ↓
-//! Nope |--------------------------->|
-//!      |                            |
-//!      ~    and so on, and so on    ~
-//!