diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 387c6070..9b60d7eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,7 @@ jobs: - stable - beta - nightly + - msrv include: - rust: stable @@ -66,6 +67,9 @@ jobs: - rust: nightly toolchain: nightly-2022-01-17 features: "--all-features" + - rust: msrv + toolchain: "1.58.0" + features: "--features full" steps: - uses: actions/checkout@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index cf9d13d6..d2886288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## unreleased +## 0.8.1 - 2022-04-24 + +### Added + + - Implement `GetChatId` for `Update`. + - The `dialogue::enter()` function as a shortcut for `dptree::entry().enter_dialogue()`. + ## 0.8.0 - 2022-04-18 ### Removed diff --git a/Cargo.toml b/Cargo.toml index 1c211294..e48440a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "teloxide" -version = "0.8.0" +version = "0.8.1" edition = "2021" description = "An elegant Telegram bots framework for Rust" repository = "https://github.com/teloxide/teloxide" @@ -152,3 +152,7 @@ required-features = ["macros"] [[example]] name = "ngrok_ping_pong" required-features = ["webhooks-axum"] + +[[example]] +name = "purchase" +required-features = ["macros"] diff --git a/README.md b/README.md index 437e2633..fdb8f3bc 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ $ set TELOXIDE_TOKEN= $ $env:TELOXIDE_TOKEN= ``` - 4. Make sure that your Rust compiler is up to date: + 4. Make sure that your Rust compiler is up to date (teloxide currently requires rustc at least version 1.58): ```bash # If you're using stable $ rustup update stable @@ -334,6 +334,10 @@ Associated links: - [Marvin's Marvellous Guide to All Things Webhook](https://core.telegram.org/bots/webhooks) - [Using self-signed certificates](https://core.telegram.org/bots/self-signed) +**Q: Can I handle both callback queries and messages within a single dialogue?** + +A: Yes, see [`examples/purchase.rs`](examples/purchase.rs). + ## Community bots Feel free to propose your own bot to our collection! diff --git a/examples/purchase.rs b/examples/purchase.rs new file mode 100644 index 00000000..6f4af6af --- /dev/null +++ b/examples/purchase.rs @@ -0,0 +1,144 @@ +// This example demonstrates how to deal with messages and callback queries +// within a single dialogue. +// +// # Example +// ``` +// - /start +// - Let's start! What's your full name? +// - John Doe +// - Select a product: +// [Apple, Banana, Orange, Potato] +// - +// - John Doe, product 'Banana' has been purchased successfully! +// ``` + +use teloxide::{ + dispatching::dialogue::{self, GetChatId, InMemStorage}, + prelude::*, + types::{InlineKeyboardButton, InlineKeyboardMarkup}, + utils::command::BotCommands, +}; + +type MyDialogue = Dialogue>; +type HandlerResult = Result<(), Box>; + +#[derive(Clone)] +pub enum State { + Start, + ReceiveFullName, + ReceiveProductChoice { full_name: String }, +} + +impl Default for State { + fn default() -> Self { + Self::Start + } +} + +#[derive(BotCommands, Clone)] +#[command(rename = "lowercase", description = "These commands are supported:")] +enum Command { + #[command(description = "display this text.")] + Help, + #[command(description = "start the purchase procedure.")] + Start, +} + +#[tokio::main] +async fn main() { + pretty_env_logger::init(); + log::info!("Starting dialogue_bot..."); + + let bot = Bot::from_env().auto_send(); + + Dispatcher::builder( + bot, + dialogue::enter::, State, _>() + .branch( + Update::filter_message() + .branch(teloxide::handler![State::ReceiveFullName].endpoint(receive_full_name)) + .branch(dptree::entry().filter_command::().endpoint(handle_command)) + .branch(dptree::endpoint(invalid_state)), + ) + .branch( + Update::filter_callback_query().chain( + teloxide::handler![State::ReceiveProductChoice { full_name }] + .endpoint(receive_product_selection), + ), + ), + ) + .dependencies(dptree::deps![InMemStorage::::new()]) + .build() + .setup_ctrlc_handler() + .dispatch() + .await; +} + +async fn handle_command( + bot: AutoSend, + msg: Message, + cmd: Command, + dialogue: MyDialogue, +) -> HandlerResult { + match cmd { + Command::Help => { + bot.send_message(msg.chat.id, Command::descriptions().to_string()).await?; + } + Command::Start => { + bot.send_message(msg.chat.id, "Let's start! What's your full name?").await?; + dialogue.update(State::ReceiveFullName).await?; + } + } + + Ok(()) +} + +async fn receive_full_name( + bot: AutoSend, + msg: Message, + dialogue: MyDialogue, +) -> HandlerResult { + match msg.text().map(ToOwned::to_owned) { + Some(full_name) => { + let products = InlineKeyboardMarkup::default().append_row( + vec!["Apple", "Banana", "Orange", "Potato"].into_iter().map(|product| { + InlineKeyboardButton::callback(product.to_owned(), product.to_owned()) + }), + ); + + bot.send_message(msg.chat.id, "Select a product:").reply_markup(products).await?; + dialogue.update(State::ReceiveProductChoice { full_name }).await?; + } + None => { + bot.send_message(msg.chat.id, "Please, send me your full name.").await?; + } + } + + Ok(()) +} + +async fn receive_product_selection( + bot: AutoSend, + q: CallbackQuery, + dialogue: MyDialogue, + full_name: String, +) -> HandlerResult { + if let Some(product) = &q.data { + if let Some(chat_id) = q.chat_id() { + bot.send_message( + chat_id, + format!("{full_name}, product '{product}' has been purchased successfully!"), + ) + .await?; + dialogue.exit().await?; + } + } + + Ok(()) +} + +async fn invalid_state(bot: AutoSend, msg: Message) -> HandlerResult { + bot.send_message(msg.chat.id, "Unable to handle the message. Type /help to see the usage.") + .await?; + Ok(()) +} diff --git a/src/dispatching/dialogue/get_chat_id.rs b/src/dispatching/dialogue/get_chat_id.rs index d84d73fd..a0e72f8b 100644 --- a/src/dispatching/dialogue/get_chat_id.rs +++ b/src/dispatching/dialogue/get_chat_id.rs @@ -1,5 +1,4 @@ -use crate::types::CallbackQuery; -use teloxide_core::types::{ChatId, Message}; +use crate::types::{CallbackQuery, ChatId, Message, Update}; /// Something that may has a chat ID. pub trait GetChatId { @@ -18,3 +17,9 @@ impl GetChatId for CallbackQuery { self.message.as_ref().map(|mes| mes.chat.id) } } + +impl GetChatId for Update { + fn chat_id(&self) -> Option { + self.chat().map(|chat| chat.id) + } +} diff --git a/src/dispatching/dialogue/mod.rs b/src/dispatching/dialogue/mod.rs index 920e17d1..98f2887d 100644 --- a/src/dispatching/dialogue/mod.rs +++ b/src/dispatching/dialogue/mod.rs @@ -83,11 +83,14 @@ pub use crate::dispatching::dialogue::{RedisStorage, RedisStorageError}; #[cfg(feature = "sqlite-storage")] pub use crate::dispatching::dialogue::{SqliteStorage, SqliteStorageError}; +use dptree::{prelude::DependencyMap, Handler}; pub use get_chat_id::GetChatId; pub use storage::*; use teloxide_core::types::ChatId; -use std::{marker::PhantomData, sync::Arc}; +use std::{fmt::Debug, marker::PhantomData, sync::Arc}; + +use super::DpHandlerDescription; mod get_chat_id; mod storage; @@ -180,6 +183,37 @@ where } } +/// Enters a dialogue context. +/// +/// A call to this function is the same as `dptree::entry().enter_dialogue()`. +/// +/// See [`HandlerExt::enter_dialogue`]. +/// +/// [`HandlerExt::enter_dialogue`]: super::HandlerExt::enter_dialogue +pub fn enter() -> Handler<'static, DependencyMap, Output, DpHandlerDescription> +where + S: Storage + ?Sized + Send + Sync + 'static, + >::Error: Debug + Send, + D: Default + Send + Sync + 'static, + Upd: GetChatId + Clone + Send + Sync + 'static, + Output: Send + Sync + 'static, +{ + dptree::entry() + .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 + } + } + })) +} + /// Perform a dialogue FSM transition. /// /// This macro expands to a [`dptree::Handler`] that filters your dialogue @@ -203,8 +237,8 @@ where /// - For `State::MyVariant(param,)` and `State::MyVariant { param, }`, the /// payload is `(param,)`. /// - For `State::MyVariant(param1, ..., paramN)` and `State::MyVariant { -/// param1, ..., paramN }`, the payload is `(param1, ..., paramN)` (where `N` -/// > 1). +/// param1, ..., paramN }`, the payload is `(param1, ..., paramN)` (where +/// `N`>1). /// /// ## Dependency requirements /// diff --git a/src/dispatching/handler_ext.rs b/src/dispatching/handler_ext.rs index a495e9d2..3e698140 100644 --- a/src/dispatching/handler_ext.rs +++ b/src/dispatching/handler_ext.rs @@ -1,8 +1,6 @@ -use std::sync::Arc; - use crate::{ dispatching::{ - dialogue::{Dialogue, GetChatId, Storage}, + dialogue::{GetChatId, Storage}, DpHandlerDescription, }, types::{Me, Message}, @@ -82,19 +80,7 @@ where 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 - } - } - })) + self.chain(super::dialogue::enter::()) } #[allow(deprecated)]