diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ee91e26..9d67b41f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,4 @@ + on: push: branches: [ master ] @@ -35,6 +36,12 @@ jobs: profile: minimal toolchain: stable override: true + - name: Setup redis + run: | + sudo apt install redis-server + redis-server --port 7777 > /dev/null & + redis-server --port 7778 > /dev/null & + redis-server --port 7779 > /dev/null & - name: Cargo test run: cargo test --all-features build-example: diff --git a/CHANGELOG.md b/CHANGELOG.md index bfb68427..60c78953 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ 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). +## [0.3.0] - ??? +### Changed + - 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)) + ## [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)). diff --git a/Cargo.toml b/Cargo.toml index 0235fc94..1a975c17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,10 @@ authors = [ [badges] maintenance = { status = "actively-developed" } -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[features] +redis-storage = ["redis"] +cbor-serializer = ["serde_cbor"] +bincode-serializer = ["bincode"] [dependencies] serde_json = "1.0.44" @@ -45,7 +48,11 @@ futures = "0.3.1" pin-project = "0.4.6" serde_with_macros = "1.0.1" -teloxide-macros = "0.3.0" +redis = { version = "0.15.1", optional = true } +serde_cbor = { version = "0.11.1", optional = true } +bincode = { version = "1.2.1", optional = true } + +teloxide-macros = "0.3.1" [dev-dependencies] smart-default = "0.6.0" diff --git a/README.md b/README.md index 038b8329..6ad32d8d 100644 --- a/README.md +++ b/README.md @@ -35,18 +35,34 @@ ## Features -

Type safety

+

Functional reactive design

-All the API types and methods are implemented with heavy use of ADTs to enforce type safety and tight integration with IDEs. Bot's commands have precise types too, thereby serving as a self-documenting code and respecting the parse, don't validate programming idiom. +teloxide has functional reactive design, allowing you to declaratively manipulate streams of updates from Telegram using filters, maps, folds, zips, and a lot of other adaptors. +

+ +
+ +

API types as ADTs

+

+All the API types and methods are hand-written, with heavy use of ADTs (algebraic data types) to enforce type safety and tight integration with IDEs. As few Options as possible.


Persistence

-Dialogues management is independent of how/where they are stored: just replace one line and make them persistent (for example, store on a disk, transmit through a network), without affecting the actual FSM algorithm. By default, teloxide stores all user dialogues in RAM. Default database implementations are coming! +Dialogues management is independent of how/where dialogues are stored: you can just replace one line and make them persistent. Out-of-the-box storages include Redis.

+
+ +

Strongly typed bot commands

+

+You can describe bot commands as enumerations, and then they'll be automatically constructed from strings. Just like you describe JSON structures in serde-json and command-line arguments in structopt. +

+ +
+ ## 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`. diff --git a/examples/dialogue_bot/src/main.rs b/examples/dialogue_bot/src/main.rs index e0447004..f9b9683d 100644 --- a/examples/dialogue_bot/src/main.rs +++ b/examples/dialogue_bot/src/main.rs @@ -45,9 +45,9 @@ async fn run() { Dispatcher::new(bot) .messages_handler(DialogueDispatcher::new( - |cx: DialogueWithCx| async move { + |input: TransitionIn| async move { // Unwrap without panic because of std::convert::Infallible. - dispatch(cx.cx, cx.dialogue.unwrap()) + dispatch(input.cx, input.dialogue.unwrap()) .await .expect("Something wrong with the bot!") }, diff --git a/examples/redis_remember_bot/Cargo.toml b/examples/redis_remember_bot/Cargo.toml new file mode 100644 index 00000000..6a91b292 --- /dev/null +++ b/examples/redis_remember_bot/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "redis_remember_bot" +version = "0.1.0" +authors = ["Maximilian Siling "] +edition = "2018" + +[dependencies] +tokio = "0.2.9" + +# You can also choose "cbor-serializer" or built-in JSON serializer +teloxide = { path = "../../", features = ["redis-storage", "bincode-serializer"] } +serde = "1.0.104" + +thiserror = "1.0.15" +smart-default = "0.6.0" +derive_more = "0.99.9" \ No newline at end of file diff --git a/examples/redis_remember_bot/src/main.rs b/examples/redis_remember_bot/src/main.rs new file mode 100644 index 00000000..bffaa8aa --- /dev/null +++ b/examples/redis_remember_bot/src/main.rs @@ -0,0 +1,66 @@ +#[macro_use] +extern crate smart_default; +#[macro_use] +extern crate derive_more; + +mod states; +mod transitions; + +use states::*; +use transitions::*; + +use teloxide::{ + dispatching::dialogue::{serializer::Bincode, RedisStorage, Storage}, + prelude::*, +}; +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 = TransitionIn; + +async fn handle_message(input: In) -> Out { + let (cx, dialogue) = input.unpack(); + + match cx.update.text_owned() { + Some(text) => dispatch(cx, dialogue, &text).await, + None => { + cx.answer_str("Please, send me a text message").await?; + next(StartState) + } + } +} + +#[tokio::main] +async fn main() { + run().await; +} + +async fn run() { + let bot = Bot::from_env(); + Dispatcher::new(bot) + .messages_handler(DialogueDispatcher::with_storage( + |cx| async move { + handle_message(cx) + .await + .expect("Something is 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; +} diff --git a/examples/redis_remember_bot/src/states.rs b/examples/redis_remember_bot/src/states.rs new file mode 100644 index 00000000..142e823a --- /dev/null +++ b/examples/redis_remember_bot/src/states.rs @@ -0,0 +1,23 @@ +use teloxide::prelude::*; + +use serde::{Deserialize, Serialize}; + +#[derive(Default, Serialize, Deserialize)] +pub struct StartState; + +#[derive(Serialize, Deserialize)] +pub struct HaveNumberState { + rest: StartState, + pub number: i32, +} + +up!( + StartState + [number: i32] -> HaveNumberState, +); + +#[derive(SmartDefault, From, Serialize, Deserialize)] +pub enum Dialogue { + #[default] + Start(StartState), + HaveNumber(HaveNumberState), +} diff --git a/examples/redis_remember_bot/src/transitions.rs b/examples/redis_remember_bot/src/transitions.rs new file mode 100644 index 00000000..594471e1 --- /dev/null +++ b/examples/redis_remember_bot/src/transitions.rs @@ -0,0 +1,42 @@ +use teloxide::prelude::*; + +use super::states::*; + +pub type Cx = UpdateWithCx; +pub type Out = TransitionOut; + +async fn start(cx: Cx, state: StartState, text: &str) -> Out { + if let Ok(number) = text.parse() { + cx.answer_str(format!( + "Remembered number {}. Now use /get or /reset", + number + )) + .await?; + next(state.up(number)) + } else { + cx.answer_str("Please, send me a number").await?; + next(state) + } +} + +async fn have_number(cx: Cx, state: HaveNumberState, text: &str) -> Out { + let num = state.number; + + if text.starts_with("/get") { + cx.answer_str(format!("Here is your number: {}", num)).await?; + next(state) + } else if text.starts_with("/reset") { + cx.answer_str("Resetted number").await?; + next(StartState) + } else { + cx.answer_str("Please, send /get or /reset").await?; + next(state) + } +} + +pub async fn dispatch(cx: Cx, dialogue: Dialogue, text: &str) -> Out { + match dialogue { + Dialogue::Start(state) => start(cx, state, text).await, + Dialogue::HaveNumber(state) => have_number(cx, state, text).await, + } +} diff --git a/src/dispatching/dialogue/mod.rs b/src/dispatching/dialogue/mod.rs index 20efc346..09d753fd 100644 --- a/src/dispatching/dialogue/mod.rs +++ b/src/dispatching/dialogue/mod.rs @@ -55,7 +55,11 @@ pub use dialogue_dispatcher_handler::DialogueDispatcherHandler; pub use dialogue_stage::{exit, next, DialogueStage}; pub use dialogue_with_cx::DialogueWithCx; pub use get_chat_id::GetChatId; -pub use storage::{InMemStorage, Storage}; + +#[cfg(feature = "redis-storage")] +pub use storage::{RedisStorage, RedisStorageError}; + +pub use storage::{serializer, InMemStorage, Serializer, Storage}; /// Generates `.up(field)` methods for dialogue states. /// diff --git a/src/dispatching/dialogue/storage/mod.rs b/src/dispatching/dialogue/storage/mod.rs index acbd9888..ed5319c8 100644 --- a/src/dispatching/dialogue/storage/mod.rs +++ b/src/dispatching/dialogue/storage/mod.rs @@ -1,7 +1,16 @@ +pub mod serializer; + mod in_mem_storage; +#[cfg(feature = "redis-storage")] +mod redis_storage; + use futures::future::BoxFuture; + pub use in_mem_storage::InMemStorage; +#[cfg(feature = "redis-storage")] +pub use redis_storage::{RedisStorage, RedisStorageError}; +pub use serializer::Serializer; use std::sync::Arc; /// A storage of dialogues. diff --git a/src/dispatching/dialogue/storage/redis_storage.rs b/src/dispatching/dialogue/storage/redis_storage.rs new file mode 100644 index 00000000..37c5fd07 --- /dev/null +++ b/src/dispatching/dialogue/storage/redis_storage.rs @@ -0,0 +1,112 @@ +use super::{serializer::Serializer, Storage}; +use futures::future::BoxFuture; +use redis::{AsyncCommands, FromRedisValue, IntoConnectionInfo}; +use serde::{de::DeserializeOwned, Serialize}; +use std::{ + convert::Infallible, + fmt::{Debug, Display}, + ops::DerefMut, + sync::Arc, +}; +use thiserror::Error; +use tokio::sync::Mutex; + +/// An error returned from [`RedisStorage`]. +/// +/// [`RedisStorage`]: struct.RedisStorage.html +#[derive(Debug, Error)] +pub enum RedisStorageError +where + SE: Debug + Display, +{ + #[error("parsing/serializing error: {0}")] + SerdeError(SE), + #[error("error from Redis: {0}")] + RedisError(#[from] redis::RedisError), +} + +/// A memory storage based on [Redis](https://redis.io/). +pub struct RedisStorage { + conn: Mutex, + serializer: S, +} + +impl RedisStorage { + pub async fn open( + url: impl IntoConnectionInfo, + serializer: S, + ) -> Result, RedisStorageError> { + Ok(Arc::new(Self { + conn: Mutex::new( + redis::Client::open(url)?.get_async_connection().await?, + ), + serializer, + })) + } +} + +impl Storage for RedisStorage +where + S: Send + Sync + Serializer + 'static, + D: Send + Serialize + DeserializeOwned + 'static, + >::Error: Debug + Display, +{ + type Error = RedisStorageError<>::Error>; + + // `.del().ignore()` is much more readable than `.del()\n.ignore()` + #[rustfmt::skip] + fn remove_dialogue( + self: Arc, + chat_id: i64, + ) -> BoxFuture<'static, Result, Self::Error>> { + Box::pin(async move { + let res = redis::pipe() + .atomic() + .get(chat_id) + .del(chat_id).ignore() + .query_async::<_, redis::Value>( + self.conn.lock().await.deref_mut(), + ) + .await?; + // We're expecting `.pipe()` to return us an exactly one result in + // bulk, so all other branches should be unreachable + match res { + redis::Value::Bulk(bulk) if bulk.len() == 1 => { + Ok(Option::>::from_redis_value(&bulk[0])? + .map(|v| { + self.serializer + .deserialize(&v) + .map_err(RedisStorageError::SerdeError) + }) + .transpose()?) + } + _ => unreachable!(), + } + }) + } + + fn update_dialogue( + self: Arc, + chat_id: i64, + dialogue: D, + ) -> BoxFuture<'static, Result, Self::Error>> { + Box::pin(async move { + let dialogue = self + .serializer + .serialize(&dialogue) + .map_err(RedisStorageError::SerdeError)?; + Ok(self + .conn + .lock() + .await + .getset::<_, Vec, Option>>(chat_id, dialogue) + .await? + .map(|d| { + self.serializer + .deserialize(&d) + .map_err(RedisStorageError::SerdeError) + }) + .transpose()?) + }) + } +} diff --git a/src/dispatching/dialogue/storage/serializer.rs b/src/dispatching/dialogue/storage/serializer.rs new file mode 100644 index 00000000..f31724a4 --- /dev/null +++ b/src/dispatching/dialogue/storage/serializer.rs @@ -0,0 +1,68 @@ +/// Various serializers for memory storages. +use serde::{de::DeserializeOwned, ser::Serialize}; + +/// A serializer for memory storages. +pub trait Serializer { + type Error; + + fn serialize(&self, val: &D) -> Result, Self::Error>; + fn deserialize(&self, data: &[u8]) -> Result; +} + +/// The JSON serializer for memory storages. +pub struct JSON; + +impl Serializer for JSON +where + D: Serialize + DeserializeOwned, +{ + type Error = serde_json::Error; + + fn serialize(&self, val: &D) -> Result, Self::Error> { + serde_json::to_vec(val) + } + + fn deserialize(&self, data: &[u8]) -> Result { + serde_json::from_slice(data) + } +} + +/// The CBOR serializer for memory storages. +#[cfg(feature = "cbor-serializer")] +pub struct CBOR; + +#[cfg(feature = "cbor-serializer")] +impl Serializer for CBOR +where + D: Serialize + DeserializeOwned, +{ + type Error = serde_cbor::Error; + + fn serialize(&self, val: &D) -> Result, Self::Error> { + serde_cbor::to_vec(val) + } + + fn deserialize(&self, data: &[u8]) -> Result { + serde_cbor::from_slice(data) + } +} + +/// The Bincode serializer for memory storages. +#[cfg(feature = "bincode-serializer")] +pub struct Bincode; + +#[cfg(feature = "bincode-serializer")] +impl Serializer for Bincode +where + D: Serialize + DeserializeOwned, +{ + type Error = bincode::Error; + + fn serialize(&self, val: &D) -> Result, Self::Error> { + bincode::serialize(val) + } + + fn deserialize(&self, data: &[u8]) -> Result { + bincode::deserialize(data) + } +} diff --git a/src/requests/all/add_sticker_to_set.rs b/src/requests/all/add_sticker_to_set.rs index 119bd7e1..23facb90 100644 --- a/src/requests/all/add_sticker_to_set.rs +++ b/src/requests/all/add_sticker_to_set.rs @@ -5,7 +5,7 @@ use crate::{ Bot, }; -use crate::requests::{Request, ResponseResult}; +use crate::requests::{RequestWithFile, ResponseResult}; use std::sync::Arc; /// Use this method to add a new sticker to a set created by the bot. @@ -22,28 +22,24 @@ pub struct AddStickerToSet { } #[async_trait::async_trait] -impl Request for AddStickerToSet { +impl RequestWithFile for AddStickerToSet { type Output = True; - async fn send(&self) -> ResponseResult { - net::request_multipart( + async fn send(&self) -> tokio::io::Result> { + Ok(net::request_multipart( self.bot.client(), self.bot.token(), "addStickerToSet", FormBuilder::new() - .add("user_id", &self.user_id) - .await - .add("name", &self.name) - .await - .add("png_sticker", &self.png_sticker) - .await - .add("emojis", &self.emojis) - .await - .add("mask_position", &self.mask_position) - .await + .add_text("user_id", &self.user_id) + .add_text("name", &self.name) + .add_input_file("png_sticker", &self.png_sticker) + .await? + .add_text("emojis", &self.emojis) + .add_text("mask_position", &self.mask_position) .build(), ) - .await + .await) } } diff --git a/src/requests/all/create_new_sticker_set.rs b/src/requests/all/create_new_sticker_set.rs index cfb98634..7c80840b 100644 --- a/src/requests/all/create_new_sticker_set.rs +++ b/src/requests/all/create_new_sticker_set.rs @@ -1,6 +1,6 @@ use crate::{ net, - requests::{form_builder::FormBuilder, Request, ResponseResult}, + requests::{form_builder::FormBuilder, RequestWithFile, ResponseResult}, types::{InputFile, MaskPosition, True}, Bot, }; @@ -23,32 +23,26 @@ pub struct CreateNewStickerSet { } #[async_trait::async_trait] -impl Request for CreateNewStickerSet { +impl RequestWithFile for CreateNewStickerSet { type Output = True; - async fn send(&self) -> ResponseResult { - net::request_multipart( + async fn send(&self) -> tokio::io::Result> { + Ok(net::request_multipart( self.bot.client(), self.bot.token(), "createNewStickerSet", FormBuilder::new() - .add("user_id", &self.user_id) - .await - .add("name", &self.name) - .await - .add("title", &self.title) - .await - .add("png_sticker", &self.png_sticker) - .await - .add("emojis", &self.emojis) - .await - .add("contains_masks", &self.contains_masks) - .await - .add("mask_position", &self.mask_position) - .await + .add_text("user_id", &self.user_id) + .add_text("name", &self.name) + .add_text("title", &self.title) + .add_input_file("png_sticker", &self.png_sticker) + .await? + .add_text("emojis", &self.emojis) + .add_text("contains_masks", &self.contains_masks) + .add_text("mask_position", &self.mask_position) .build(), ) - .await + .await) } } diff --git a/src/requests/all/edit_message_media.rs b/src/requests/all/edit_message_media.rs index e2d365b0..27cb1dd0 100644 --- a/src/requests/all/edit_message_media.rs +++ b/src/requests/all/edit_message_media.rs @@ -38,14 +38,12 @@ impl Request for EditMessageMedia { match &self.chat_or_inline_message { ChatOrInlineMessage::Chat { chat_id, message_id } => { params = params - .add("chat_id", chat_id) - .await - .add("message_id", message_id) - .await; + .add_text("chat_id", chat_id) + .add_text("message_id", message_id); } ChatOrInlineMessage::Inline { inline_message_id } => { params = - params.add("inline_message_id", inline_message_id).await; + params.add_text("inline_message_id", inline_message_id); } } @@ -54,10 +52,8 @@ impl Request for EditMessageMedia { self.bot.token(), "editMessageMedia", params - .add("media", &self.media) - .await - .add("reply_markup", &self.reply_markup) - .await + .add_text("media", &self.media) + .add_text("reply_markup", &self.reply_markup) .build(), ) .await diff --git a/src/requests/all/send_animation.rs b/src/requests/all/send_animation.rs index cbd40485..ee5f2d39 100644 --- a/src/requests/all/send_animation.rs +++ b/src/requests/all/send_animation.rs @@ -1,6 +1,6 @@ use crate::{ net, - requests::{form_builder::FormBuilder, Request, ResponseResult}, + requests::{form_builder::FormBuilder, RequestWithFile, ResponseResult}, types::{ChatId, InputFile, Message, ParseMode, ReplyMarkup}, Bot, }; @@ -30,40 +30,32 @@ pub struct SendAnimation { } #[async_trait::async_trait] -impl Request for SendAnimation { +impl RequestWithFile for SendAnimation { type Output = Message; - async fn send(&self) -> ResponseResult { - net::request_multipart( + async fn send(&self) -> tokio::io::Result> { + let mut builder = FormBuilder::new() + .add_text("chat_id", &self.chat_id) + .add_input_file("animation", &self.animation) + .await? + .add_text("duration", &self.duration) + .add_text("width", &self.width) + .add_text("height", &self.height) + .add_text("caption", &self.caption) + .add_text("parse_mode", &self.parse_mode) + .add_text("disable_notification", &self.disable_notification) + .add_text("reply_to_message_id", &self.reply_to_message_id) + .add_text("reply_markup", &self.reply_markup); + if let Some(thumb) = self.thumb.as_ref() { + builder = builder.add_input_file("thumb", thumb).await?; + } + Ok(net::request_multipart( self.bot.client(), self.bot.token(), "sendAnimation", - FormBuilder::new() - .add("chat_id", &self.chat_id) - .await - .add("animation", &self.animation) - .await - .add("duration", &self.duration) - .await - .add("width", &self.width) - .await - .add("height", &self.height) - .await - .add("thumb", &self.thumb) - .await - .add("caption", &self.caption) - .await - .add("parse_mode", &self.parse_mode) - .await - .add("disable_notification", &self.disable_notification) - .await - .add("reply_to_message_id", &self.reply_to_message_id) - .await - .add("reply_markup", &self.reply_markup) - .await - .build(), + builder.build(), ) - .await + .await) } } diff --git a/src/requests/all/send_audio.rs b/src/requests/all/send_audio.rs index 829e41f9..f7f45f27 100644 --- a/src/requests/all/send_audio.rs +++ b/src/requests/all/send_audio.rs @@ -1,6 +1,6 @@ use crate::{ net, - requests::{form_builder::FormBuilder, Request, ResponseResult}, + requests::{form_builder::FormBuilder, RequestWithFile, ResponseResult}, types::{ChatId, InputFile, Message, ParseMode, ReplyMarkup}, Bot, }; @@ -34,40 +34,32 @@ pub struct SendAudio { } #[async_trait::async_trait] -impl Request for SendAudio { +impl RequestWithFile for SendAudio { type Output = Message; - async fn send(&self) -> ResponseResult { - net::request_multipart( + async fn send(&self) -> tokio::io::Result> { + let mut builder = FormBuilder::new() + .add_text("chat_id", &self.chat_id) + .add_input_file("audio", &self.audio) + .await? + .add_text("caption", &self.caption) + .add_text("parse_mode", &self.parse_mode) + .add_text("duration", &self.duration) + .add_text("performer", &self.performer) + .add_text("title", &self.title) + .add_text("disable_notification", &self.disable_notification) + .add_text("reply_to_message_id", &self.reply_to_message_id) + .add_text("reply_markup", &self.reply_markup); + if let Some(thumb) = self.thumb.as_ref() { + builder = builder.add_input_file("thumb", thumb).await?; + } + Ok(net::request_multipart( self.bot.client(), self.bot.token(), "sendAudio", - FormBuilder::new() - .add("chat_id", &self.chat_id) - .await - .add("audio", &self.audio) - .await - .add("caption", &self.caption) - .await - .add("parse_mode", &self.parse_mode) - .await - .add("duration", &self.duration) - .await - .add("performer", &self.performer) - .await - .add("title", &self.title) - .await - .add("thumb", &self.thumb) - .await - .add("disable_notification", &self.disable_notification) - .await - .add("reply_to_message_id", &self.reply_to_message_id) - .await - .add("reply_markup", &self.reply_markup) - .await - .build(), + builder.build(), ) - .await + .await) } } diff --git a/src/requests/all/send_document.rs b/src/requests/all/send_document.rs index 3e2cfd46..bd97fb91 100644 --- a/src/requests/all/send_document.rs +++ b/src/requests/all/send_document.rs @@ -1,6 +1,6 @@ use crate::{ net, - requests::{form_builder::FormBuilder, Request, ResponseResult}, + requests::{form_builder::FormBuilder, RequestWithFile, ResponseResult}, types::{ChatId, InputFile, Message, ParseMode, ReplyMarkup}, Bot, }; @@ -26,34 +26,29 @@ pub struct SendDocument { } #[async_trait::async_trait] -impl Request for SendDocument { +impl RequestWithFile for SendDocument { type Output = Message; - async fn send(&self) -> ResponseResult { - net::request_multipart( + async fn send(&self) -> tokio::io::Result> { + let mut builder = FormBuilder::new() + .add_text("chat_id", &self.chat_id) + .add_input_file("document", &self.document) + .await? + .add_text("caption", &self.caption) + .add_text("parse_mode", &self.parse_mode) + .add_text("disable_notification", &self.disable_notification) + .add_text("reply_to_message_id", &self.reply_to_message_id) + .add_text("reply_markup", &self.reply_markup); + if let Some(thumb) = self.thumb.as_ref() { + builder = builder.add_input_file("thumb", thumb).await?; + } + Ok(net::request_multipart( self.bot.client(), self.bot.token(), "sendDocument", - FormBuilder::new() - .add("chat_id", &self.chat_id) - .await - .add("document", &self.document) - .await - .add("thumb", &self.thumb) - .await - .add("caption", &self.caption) - .await - .add("parse_mode", &self.parse_mode) - .await - .add("disable_notification", &self.disable_notification) - .await - .add("reply_to_message_id", &self.reply_to_message_id) - .await - .add("reply_markup", &self.reply_markup) - .await - .build(), + builder.build(), ) - .await + .await) } } diff --git a/src/requests/all/send_media_group.rs b/src/requests/all/send_media_group.rs index cae6604d..b6f2d9c3 100644 --- a/src/requests/all/send_media_group.rs +++ b/src/requests/all/send_media_group.rs @@ -28,14 +28,10 @@ impl Request for SendMediaGroup { self.bot.token(), "sendMediaGroup", FormBuilder::new() - .add("chat_id", &self.chat_id) - .await - .add("media", &self.media) - .await - .add("disable_notification", &self.disable_notification) - .await - .add("reply_to_message_id", &self.reply_to_message_id) - .await + .add_text("chat_id", &self.chat_id) + .add_text("media", &self.media) + .add_text("disable_notification", &self.disable_notification) + .add_text("reply_to_message_id", &self.reply_to_message_id) .build(), ) .await diff --git a/src/requests/all/send_photo.rs b/src/requests/all/send_photo.rs index ea603eca..247257b1 100644 --- a/src/requests/all/send_photo.rs +++ b/src/requests/all/send_photo.rs @@ -1,6 +1,6 @@ use crate::{ net, - requests::{form_builder::FormBuilder, Request, ResponseResult}, + requests::{form_builder::FormBuilder, RequestWithFile, ResponseResult}, types::{ChatId, InputFile, Message, ParseMode, ReplyMarkup}, Bot, }; @@ -22,32 +22,26 @@ pub struct SendPhoto { } #[async_trait::async_trait] -impl Request for SendPhoto { +impl RequestWithFile for SendPhoto { type Output = Message; - async fn send(&self) -> ResponseResult { - net::request_multipart( + async fn send(&self) -> tokio::io::Result> { + Ok(net::request_multipart( self.bot.client(), self.bot.token(), "sendPhoto", FormBuilder::new() - .add("chat_id", &self.chat_id) - .await - .add("photo", &self.photo) - .await - .add("caption", &self.caption) - .await - .add("parse_mode", &self.parse_mode) - .await - .add("disable_notification", &self.disable_notification) - .await - .add("reply_to_message_id", &self.reply_to_message_id) - .await - .add("reply_markup", &self.reply_markup) - .await + .add_text("chat_id", &self.chat_id) + .add_input_file("photo", &self.photo) + .await? + .add_text("caption", &self.caption) + .add_text("parse_mode", &self.parse_mode) + .add_text("disable_notification", &self.disable_notification) + .add_text("reply_to_message_id", &self.reply_to_message_id) + .add_text("reply_markup", &self.reply_markup) .build(), ) - .await + .await) } } diff --git a/src/requests/all/send_sticker.rs b/src/requests/all/send_sticker.rs index e44dbb80..37ca4769 100644 --- a/src/requests/all/send_sticker.rs +++ b/src/requests/all/send_sticker.rs @@ -1,6 +1,6 @@ use crate::{ net, - requests::{form_builder::FormBuilder, Request, ResponseResult}, + requests::{form_builder::FormBuilder, RequestWithFile, ResponseResult}, types::{ChatId, InputFile, Message, ReplyMarkup}, Bot, }; @@ -22,28 +22,24 @@ pub struct SendSticker { } #[async_trait::async_trait] -impl Request for SendSticker { +impl RequestWithFile for SendSticker { type Output = Message; - async fn send(&self) -> ResponseResult { - net::request_multipart( + async fn send(&self) -> tokio::io::Result> { + Ok(net::request_multipart( self.bot.client(), self.bot.token(), "sendSticker", FormBuilder::new() - .add("chat_id", &self.chat_id) - .await - .add("sticker", &self.sticker) - .await - .add("disable_notification", &self.disable_notification) - .await - .add("reply_to_message_id", &self.reply_to_message_id) - .await - .add("reply_markup", &self.reply_markup) - .await + .add_text("chat_id", &self.chat_id) + .add_input_file("sticker", &self.sticker) + .await? + .add_text("disable_notification", &self.disable_notification) + .add_text("reply_to_message_id", &self.reply_to_message_id) + .add_text("reply_markup", &self.reply_markup) .build(), ) - .await + .await) } } diff --git a/src/requests/all/send_video.rs b/src/requests/all/send_video.rs index 8e89ef2e..b908fa5e 100644 --- a/src/requests/all/send_video.rs +++ b/src/requests/all/send_video.rs @@ -1,6 +1,6 @@ use crate::{ net, - requests::{form_builder::FormBuilder, Request, ResponseResult}, + requests::{form_builder::FormBuilder, RequestWithFile, ResponseResult}, types::{ChatId, InputFile, Message, ParseMode, ReplyMarkup}, Bot, }; @@ -31,42 +31,33 @@ pub struct SendVideo { } #[async_trait::async_trait] -impl Request for SendVideo { +impl RequestWithFile for SendVideo { type Output = Message; - async fn send(&self) -> ResponseResult { - net::request_multipart( + async fn send(&self) -> tokio::io::Result> { + let mut builder = FormBuilder::new() + .add_text("chat_id", &self.chat_id) + .add_input_file("video", &self.video) + .await? + .add_text("duration", &self.duration) + .add_text("width", &self.width) + .add_text("height", &self.height) + .add_text("caption", &self.caption) + .add_text("parse_mode", &self.parse_mode) + .add_text("supports_streaming", &self.supports_streaming) + .add_text("disable_notification", &self.disable_notification) + .add_text("reply_to_message_id", &self.reply_to_message_id) + .add_text("reply_markup", &self.reply_markup); + if let Some(thumb) = self.thumb.as_ref() { + builder = builder.add_input_file("thumb", thumb).await?; + } + Ok(net::request_multipart( self.bot.client(), self.bot.token(), "sendVideo", - FormBuilder::new() - .add("chat_id", &self.chat_id) - .await - .add("video", &self.video) - .await - .add("duration", &self.duration) - .await - .add("width", &self.width) - .await - .add("height", &self.height) - .await - .add("thumb", &self.thumb) - .await - .add("caption", &self.caption) - .await - .add("parse_mode", &self.parse_mode) - .await - .add("supports_streaming", &self.supports_streaming) - .await - .add("disable_notification", &self.disable_notification) - .await - .add("reply_to_message_id", &self.reply_to_message_id) - .await - .add("reply_markup", &self.reply_markup) - .await - .build(), + builder.build(), ) - .await + .await) } } diff --git a/src/requests/all/send_video_note.rs b/src/requests/all/send_video_note.rs index e3099c35..e06f804b 100644 --- a/src/requests/all/send_video_note.rs +++ b/src/requests/all/send_video_note.rs @@ -1,6 +1,6 @@ use crate::{ net, - requests::{form_builder::FormBuilder, Request, ResponseResult}, + requests::{form_builder::FormBuilder, RequestWithFile, ResponseResult}, types::{ChatId, InputFile, Message, ReplyMarkup}, Bot, }; @@ -26,34 +26,29 @@ pub struct SendVideoNote { } #[async_trait::async_trait] -impl Request for SendVideoNote { +impl RequestWithFile for SendVideoNote { type Output = Message; - async fn send(&self) -> ResponseResult { - net::request_multipart( + async fn send(&self) -> tokio::io::Result> { + let mut builder = FormBuilder::new() + .add_text("chat_id", &self.chat_id) + .add_input_file("video_note", &self.video_note) + .await? + .add_text("duration", &self.duration) + .add_text("length", &self.length) + .add_text("disable_notification", &self.disable_notification) + .add_text("reply_to_message_id", &self.reply_to_message_id) + .add_text("reply_markup", &self.reply_markup); + if let Some(thumb) = self.thumb.as_ref() { + builder = builder.add_input_file("thumb", thumb).await?; + } + Ok(net::request_multipart( self.bot.client(), self.bot.token(), "sendVideoNote", - FormBuilder::new() - .add("chat_id", &self.chat_id) - .await - .add("video_note", &self.video_note) - .await - .add("duration", &self.duration) - .await - .add("length", &self.length) - .await - .add("thumb", &self.thumb) - .await - .add("disable_notification", &self.disable_notification) - .await - .add("reply_to_message_id", &self.reply_to_message_id) - .await - .add("reply_markup", &self.reply_markup) - .await - .build(), + builder.build(), ) - .await + .await) } } diff --git a/src/requests/all/send_voice.rs b/src/requests/all/send_voice.rs index b5ef7b5d..44f69fc7 100644 --- a/src/requests/all/send_voice.rs +++ b/src/requests/all/send_voice.rs @@ -1,6 +1,6 @@ use crate::{ net, - requests::{form_builder::FormBuilder, Request, ResponseResult}, + requests::{form_builder::FormBuilder, RequestWithFile, ResponseResult}, types::{ChatId, InputFile, Message, ParseMode, ReplyMarkup}, Bot, }; @@ -32,34 +32,27 @@ pub struct SendVoice { } #[async_trait::async_trait] -impl Request for SendVoice { +impl RequestWithFile for SendVoice { type Output = Message; - async fn send(&self) -> ResponseResult { - net::request_multipart( + async fn send(&self) -> tokio::io::Result> { + Ok(net::request_multipart( self.bot.client(), self.bot.token(), "sendVoice", FormBuilder::new() - .add("chat_id", &self.chat_id) - .await - .add("voice", &self.voice) - .await - .add("caption", &self.caption) - .await - .add("parse_mode", &self.parse_mode) - .await - .add("duration", &self.duration) - .await - .add("disable_notification", &self.disable_notification) - .await - .add("reply_to_message_id", &self.reply_to_message_id) - .await - .add("reply_markup", &self.reply_markup) - .await + .add_text("chat_id", &self.chat_id) + .add_input_file("voice", &self.voice) + .await? + .add_text("caption", &self.caption) + .add_text("parse_mode", &self.parse_mode) + .add_text("duration", &self.duration) + .add_text("disable_notification", &self.disable_notification) + .add_text("reply_to_message_id", &self.reply_to_message_id) + .add_text("reply_markup", &self.reply_markup) .build(), ) - .await + .await) } } diff --git a/src/requests/form_builder.rs b/src/requests/form_builder.rs index 1f433035..e94edaaa 100644 --- a/src/requests/form_builder.rs +++ b/src/requests/form_builder.rs @@ -21,36 +21,52 @@ impl FormBuilder { Self { form: Form::new() } } - /// Add the supplied key-value pair to this `FormBuilder`. - pub async fn add<'a, T, N>(self, name: N, value: &T) -> Self + pub fn add_text<'a, T, N>(self, name: N, value: &T) -> Self where N: Into>, - T: IntoFormValue, + T: IntoFormText, { - let name = name.into().into_owned(); - match value.into_form_value() { - Some(FormValue::Str(string)) => { - Self { form: self.form.text(name, string) } - } - Some(FormValue::File(path)) => self.add_file(name, path).await, - Some(FormValue::Memory { file_name, data }) => { - self.add_file_from_memory(name, file_name, data) + match value.into_form_text() { + Some(val) => { + Self { form: self.form.text(name.into().into_owned(), val) } } None => self, } } - // used in SendMediaGroup - pub async fn add_file<'a, N>(self, name: N, path_to_file: PathBuf) -> Self + pub async fn add_input_file<'a, N>( + self, + name: N, + value: &InputFile, + ) -> tokio::io::Result where N: Into>, { - Self { + Ok(match value { + InputFile::File(path) => self.add_file(name, path.clone()).await?, + InputFile::Memory { file_name, data } => { + self.add_file_from_memory(name, file_name.clone(), data.clone()) + } + InputFile::Url(url) => self.add_text(name, url), + InputFile::FileId(file_id) => self.add_text(name, file_id), + }) + } + + // used in SendMediaGroup + pub async fn add_file<'a, N>( + self, + name: N, + path_to_file: PathBuf, + ) -> tokio::io::Result + where + N: Into>, + { + Ok(Self { form: self.form.part( name.into().into_owned(), - file_to_part(path_to_file).await, + file_to_part(path_to_file).await?, ), - } + }) } fn add_file_from_memory<'a, N>( @@ -75,24 +91,18 @@ impl FormBuilder { } } -pub(crate) enum FormValue { - File(PathBuf), - Memory { file_name: String, data: Cow<'static, [u8]> }, - Str(String), -} - -pub(crate) trait IntoFormValue { - fn into_form_value(&self) -> Option; +pub(crate) trait IntoFormText { + fn into_form_text(&self) -> Option; } macro_rules! impl_for_struct { ($($name:ty),*) => { $( - impl IntoFormValue for $name { - fn into_form_value(&self) -> Option { + impl IntoFormText for $name { + fn into_form_text(&self) -> Option { let json = serde_json::to_string(self) .expect("serde_json::to_string failed"); - Some(FormValue::Str(json)) + Some(json) } } )* @@ -109,77 +119,63 @@ impl_for_struct!( MaskPosition ); -impl IntoFormValue for Option +impl IntoFormText for Option where - T: IntoFormValue, + T: IntoFormText, { - fn into_form_value(&self) -> Option { - self.as_ref().and_then(IntoFormValue::into_form_value) + fn into_form_text(&self) -> Option { + self.as_ref().and_then(IntoFormText::into_form_text) } } // TODO: fix InputMedia implementation of IntoFormValue (for now it doesn't // encode files :|) -impl IntoFormValue for Vec { - fn into_form_value(&self) -> Option { +impl IntoFormText for Vec { + fn into_form_text(&self) -> Option { let json = serde_json::to_string(self).expect("serde_json::to_string failed"); - Some(FormValue::Str(json)) + Some(json) } } -impl IntoFormValue for InputMedia { - fn into_form_value(&self) -> Option { +impl IntoFormText for InputMedia { + fn into_form_text(&self) -> Option { let json = serde_json::to_string(self).expect("serde_json::to_string failed"); - Some(FormValue::Str(json)) + Some(json) } } -impl IntoFormValue for str { - fn into_form_value(&self) -> Option { - Some(FormValue::Str(self.to_owned())) +impl IntoFormText for str { + fn into_form_text(&self) -> Option { + Some(self.to_owned()) } } -impl IntoFormValue for ParseMode { - fn into_form_value(&self) -> Option { +impl IntoFormText for ParseMode { + fn into_form_text(&self) -> Option { let string = match self { ParseMode::MarkdownV2 => String::from("MarkdownV2"), ParseMode::HTML => String::from("HTML"), #[allow(deprecated)] ParseMode::Markdown => String::from("Markdown"), }; - Some(FormValue::Str(string)) + Some(string) } } -impl IntoFormValue for ChatId { - fn into_form_value(&self) -> Option { +impl IntoFormText for ChatId { + fn into_form_text(&self) -> Option { let string = match self { ChatId::Id(id) => id.to_string(), ChatId::ChannelUsername(username) => username.clone(), }; - Some(FormValue::Str(string)) + Some(string) } } -impl IntoFormValue for String { - fn into_form_value(&self) -> Option { - Some(FormValue::Str(self.clone())) - } -} - -impl IntoFormValue for InputFile { - fn into_form_value(&self) -> Option { - match self { - InputFile::File(path) => Some(FormValue::File(path.clone())), - InputFile::Memory { file_name, data } => Some(FormValue::Memory { - file_name: file_name.clone(), - data: data.clone(), - }), - InputFile::Url(url) => Some(FormValue::Str(url.clone())), - InputFile::FileId(file_id) => Some(FormValue::Str(file_id.clone())), - } +impl IntoFormText for String { + fn into_form_text(&self) -> Option { + Some(self.clone()) } } diff --git a/src/requests/mod.rs b/src/requests/mod.rs index 7f121d29..09f2af6c 100644 --- a/src/requests/mod.rs +++ b/src/requests/mod.rs @@ -18,3 +18,15 @@ pub trait Request { /// Asynchronously sends this request to Telegram and returns the result. async fn send(&self) -> ResponseResult; } + +/// Designates an API request with possibly sending file. +#[async_trait::async_trait] +pub trait RequestWithFile { + /// A data structure returned if success. + type Output; + + /// Asynchronously sends this request to Telegram and returns the result. + /// Returns `tokio::io::Result::Err` when trying to send file which does not + /// exists. + async fn send(&self) -> tokio::io::Result>; +} diff --git a/src/requests/utils.rs b/src/requests/utils.rs index 25cc5926..399c8cd1 100644 --- a/src/requests/utils.rs +++ b/src/requests/utils.rs @@ -21,18 +21,16 @@ impl Decoder for FileDecoder { } } -pub async fn file_to_part(path_to_file: PathBuf) -> Part { +pub async fn file_to_part(path_to_file: PathBuf) -> std::io::Result { let file_name = path_to_file.file_name().unwrap().to_string_lossy().into_owned(); let file = FramedRead::new( - tokio::fs::File::open(path_to_file).await.unwrap(), /* TODO: this - * can - * cause panics */ + tokio::fs::File::open(path_to_file).await?, FileDecoder, ); - Part::stream(Body::wrap_stream(file)).file_name(file_name) + Ok(Part::stream(Body::wrap_stream(file)).file_name(file_name)) } pub fn file_from_memory_to_part( diff --git a/tests/command.rs b/tests/command.rs index eadb214d..47b3555f 100644 --- a/tests/command.rs +++ b/tests/command.rs @@ -171,3 +171,16 @@ fn parse_named_fields() { DefaultCommands::parse("/start 10 hello", "").unwrap() ); } + +#[test] +fn descriptions_off() { + #[command(rename = "lowercase")] + #[derive(BotCommand, Debug, PartialEq)] + enum DefaultCommands { + #[command(description = "off")] + Start, + Help, + } + + assert_eq!(DefaultCommands::descriptions(), "/help\n".to_owned()); +} diff --git a/tests/redis.rs b/tests/redis.rs new file mode 100644 index 00000000..61dd6ad1 --- /dev/null +++ b/tests/redis.rs @@ -0,0 +1,82 @@ +use std::{ + fmt::{Debug, Display}, + future::Future, + sync::Arc, +}; +use teloxide::dispatching::dialogue::{ + serializer::{Bincode, CBOR, JSON}, + RedisStorage, Serializer, Storage, +}; + +#[tokio::test] +async fn test_redis_json() { + let storage = + RedisStorage::open("redis://127.0.0.1:7777", JSON).await.unwrap(); + test_redis(storage).await; +} + +#[tokio::test] +async fn test_redis_bincode() { + let storage = + RedisStorage::open("redis://127.0.0.1:7778", Bincode).await.unwrap(); + test_redis(storage).await; +} + +#[tokio::test] +async fn test_redis_cbor() { + let storage = + RedisStorage::open("redis://127.0.0.1:7779", CBOR).await.unwrap(); + test_redis(storage).await; +} + +type Dialogue = String; + +async fn test_redis(storage: Arc>) +where + S: Send + Sync + Serializer + 'static, + >::Error: Debug + Display, +{ + check_dialogue( + None, + Arc::clone(&storage).update_dialogue(1, "ABC".to_owned()), + ) + .await; + check_dialogue( + None, + Arc::clone(&storage).update_dialogue(11, "DEF".to_owned()), + ) + .await; + check_dialogue( + None, + Arc::clone(&storage).update_dialogue(256, "GHI".to_owned()), + ) + .await; + + // 1 - ABC, 11 - DEF, 256 - GHI + + check_dialogue( + "ABC", + Arc::clone(&storage).update_dialogue(1, "JKL".to_owned()), + ) + .await; + check_dialogue( + "GHI", + Arc::clone(&storage).update_dialogue(256, "MNO".to_owned()), + ) + .await; + + // 1 - GKL, 11 - DEF, 256 - MNO + + check_dialogue("JKL", Arc::clone(&storage).remove_dialogue(1)).await; + check_dialogue("DEF", Arc::clone(&storage).remove_dialogue(11)).await; + check_dialogue("MNO", Arc::clone(&storage).remove_dialogue(256)).await; +} + +async fn check_dialogue( + expected: impl Into>, + actual: impl Future, E>>, +) where + E: Debug, +{ + assert_eq!(expected.into().map(ToOwned::to_owned), actual.await.unwrap()) +}