Merge branch 'master' into add_string_to_api_error_kind_other

This commit is contained in:
Dmytro Polunin 2020-07-06 16:03:25 +03:00 committed by GitHub
commit 66569cafa2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 722 additions and 317 deletions

View file

@ -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:

View file

@ -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<T>. 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)).

View file

@ -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"

View file

@ -35,18 +35,34 @@
## Features
<h3 align="center">Type safety</h3>
<h3 align="center">Functional reactive design</h3>
<p align="center">
All the API <a href="https://docs.rs/teloxide/latest/teloxide/types/index.html">types</a> and <a href="https://docs.rs/teloxide/0.2.0/teloxide/requests/index.html">methods</a> are implemented with heavy use of <a href="https://en.wikipedia.org/wiki/Algebraic_data_type"><strong>ADT</strong>s</a> to enforce type safety and tight integration with IDEs. Bot&#39;s commands <a href="https://github.com/teloxide/teloxide#commands">have precise types too</a>, thereby serving as a self-documenting code and respecting the <a href="https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/">parse, don&#39;t validate</a> programming idiom.
teloxide has <a href="https://en.wikipedia.org/wiki/Functional_reactive_programming">functional reactive design</a>, allowing you to declaratively manipulate streams of updates from Telegram using filters, maps, folds, zips, and a lot of <a href="https://docs.rs/futures/latest/futures/stream/trait.StreamExt.html">other adaptors</a>.
</p>
<hr>
<h3 align="center">API types as ADTs</h3>
<p align="center">
All the API <a href="https://docs.rs/teloxide/latest/teloxide/types/index.html">types</a> and <a href="https://docs.rs/teloxide/latest/teloxide/requests/index.html">methods</a> are hand-written, with heavy use of <a href="https://en.wikipedia.org/wiki/Algebraic_data_type"><strong>ADT</strong>s</a> (algebraic data types) to enforce type safety and tight integration with IDEs. As few <code>Option</code>s as possible.
</p>
<hr>
<h3 align="center">Persistence</h3>
<p align="center">
Dialogues management is independent of how/where they are stored: just replace one line and make them <a href="https://en.wikipedia.org/wiki/Persistence_(computer_science)">persistent</a> (for example, store on a disk, transmit through a network), without affecting the actual <a href="https://en.wikipedia.org/wiki/Finite-state_machine">FSM</a> algorithm. By default, teloxide stores all user dialogues in RAM. Default database implementations <a href="https://github.com/teloxide/teloxide/issues/183">are coming</a>!
Dialogues management is independent of how/where dialogues are stored: you can just replace one line and make them <a href="https://en.wikipedia.org/wiki/Persistence_(computer_science)">persistent</a>. Out-of-the-box storages include <a href="https://redis.io/">Redis</a>.
</p>
<hr>
<h3 align="center">Strongly typed bot commands</h3>
<p align="center">
You can describe bot commands as enumerations, and then they'll be automatically constructed from strings. Just like you describe JSON structures in <a href="https://github.com/serde-rs/json">serde-json</a> and command-line arguments in <a href="https://github.com/TeXitoi/structopt">structopt</a>.
</p>
<hr>
## 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`.

View file

@ -45,9 +45,9 @@ async fn run() {
Dispatcher::new(bot)
.messages_handler(DialogueDispatcher::new(
|cx: DialogueWithCx<Message, Dialogue, Infallible>| async move {
|input: TransitionIn<Dialogue, Infallible>| 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!")
},

View file

@ -0,0 +1,16 @@
[package]
name = "redis_remember_bot"
version = "0.1.0"
authors = ["Maximilian Siling <mouse-art@ya.ru>"]
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"

View file

@ -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 = <RedisStorage<Bincode> as Storage<Dialogue>>::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<Dialogue, StorageError>;
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-<name>", e. g. "serializer-cbor"
// or "serializer-bincode"
RedisStorage::open("redis://127.0.0.1:6379", Bincode)
.await
.unwrap(),
))
.dispatch()
.await;
}

View file

@ -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),
}

View file

@ -0,0 +1,42 @@
use teloxide::prelude::*;
use super::states::*;
pub type Cx = UpdateWithCx<Message>;
pub type Out = TransitionOut<Dialogue>;
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,
}
}

View file

@ -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.
///

View file

@ -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.

View file

@ -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<SE>
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<S> {
conn: Mutex<redis::aio::Connection>,
serializer: S,
}
impl<S> RedisStorage<S> {
pub async fn open(
url: impl IntoConnectionInfo,
serializer: S,
) -> Result<Arc<Self>, RedisStorageError<Infallible>> {
Ok(Arc::new(Self {
conn: Mutex::new(
redis::Client::open(url)?.get_async_connection().await?,
),
serializer,
}))
}
}
impl<S, D> Storage<D> for RedisStorage<S>
where
S: Send + Sync + Serializer<D> + 'static,
D: Send + Serialize + DeserializeOwned + 'static,
<S as Serializer<D>>::Error: Debug + Display,
{
type Error = RedisStorageError<<S as Serializer<D>>::Error>;
// `.del().ignore()` is much more readable than `.del()\n.ignore()`
#[rustfmt::skip]
fn remove_dialogue(
self: Arc<Self>,
chat_id: i64,
) -> BoxFuture<'static, Result<Option<D>, 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::<Vec<u8>>::from_redis_value(&bulk[0])?
.map(|v| {
self.serializer
.deserialize(&v)
.map_err(RedisStorageError::SerdeError)
})
.transpose()?)
}
_ => unreachable!(),
}
})
}
fn update_dialogue(
self: Arc<Self>,
chat_id: i64,
dialogue: D,
) -> BoxFuture<'static, Result<Option<D>, Self::Error>> {
Box::pin(async move {
let dialogue = self
.serializer
.serialize(&dialogue)
.map_err(RedisStorageError::SerdeError)?;
Ok(self
.conn
.lock()
.await
.getset::<_, Vec<u8>, Option<Vec<u8>>>(chat_id, dialogue)
.await?
.map(|d| {
self.serializer
.deserialize(&d)
.map_err(RedisStorageError::SerdeError)
})
.transpose()?)
})
}
}

View file

@ -0,0 +1,68 @@
/// Various serializers for memory storages.
use serde::{de::DeserializeOwned, ser::Serialize};
/// A serializer for memory storages.
pub trait Serializer<D> {
type Error;
fn serialize(&self, val: &D) -> Result<Vec<u8>, Self::Error>;
fn deserialize(&self, data: &[u8]) -> Result<D, Self::Error>;
}
/// The JSON serializer for memory storages.
pub struct JSON;
impl<D> Serializer<D> for JSON
where
D: Serialize + DeserializeOwned,
{
type Error = serde_json::Error;
fn serialize(&self, val: &D) -> Result<Vec<u8>, Self::Error> {
serde_json::to_vec(val)
}
fn deserialize(&self, data: &[u8]) -> Result<D, Self::Error> {
serde_json::from_slice(data)
}
}
/// The CBOR serializer for memory storages.
#[cfg(feature = "cbor-serializer")]
pub struct CBOR;
#[cfg(feature = "cbor-serializer")]
impl<D> Serializer<D> for CBOR
where
D: Serialize + DeserializeOwned,
{
type Error = serde_cbor::Error;
fn serialize(&self, val: &D) -> Result<Vec<u8>, Self::Error> {
serde_cbor::to_vec(val)
}
fn deserialize(&self, data: &[u8]) -> Result<D, Self::Error> {
serde_cbor::from_slice(data)
}
}
/// The Bincode serializer for memory storages.
#[cfg(feature = "bincode-serializer")]
pub struct Bincode;
#[cfg(feature = "bincode-serializer")]
impl<D> Serializer<D> for Bincode
where
D: Serialize + DeserializeOwned,
{
type Error = bincode::Error;
fn serialize(&self, val: &D) -> Result<Vec<u8>, Self::Error> {
bincode::serialize(val)
}
fn deserialize(&self, data: &[u8]) -> Result<D, Self::Error> {
bincode::deserialize(data)
}
}

View file

@ -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<True> {
net::request_multipart(
async fn send(&self) -> tokio::io::Result<ResponseResult<True>> {
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)
}
}

View file

@ -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<True> {
net::request_multipart(
async fn send(&self) -> tokio::io::Result<ResponseResult<True>> {
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)
}
}

View file

@ -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

View file

@ -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<Message> {
net::request_multipart(
async fn send(&self) -> tokio::io::Result<ResponseResult<Message>> {
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)
}
}

View file

@ -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<Message> {
net::request_multipart(
async fn send(&self) -> tokio::io::Result<ResponseResult<Message>> {
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)
}
}

View file

@ -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<Message> {
net::request_multipart(
async fn send(&self) -> tokio::io::Result<ResponseResult<Message>> {
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)
}
}

View file

@ -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

View file

@ -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<Message> {
net::request_multipart(
async fn send(&self) -> tokio::io::Result<ResponseResult<Message>> {
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)
}
}

View file

@ -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<Message> {
net::request_multipart(
async fn send(&self) -> tokio::io::Result<ResponseResult<Message>> {
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)
}
}

View file

@ -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<Message> {
net::request_multipart(
async fn send(&self) -> tokio::io::Result<ResponseResult<Message>> {
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)
}
}

View file

@ -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<Message> {
net::request_multipart(
async fn send(&self) -> tokio::io::Result<ResponseResult<Message>> {
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)
}
}

View file

@ -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<Message> {
net::request_multipart(
async fn send(&self) -> tokio::io::Result<ResponseResult<Message>> {
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)
}
}

View file

@ -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<Cow<'a, str>>,
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<Self>
where
N: Into<Cow<'a, str>>,
{
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<Self>
where
N: Into<Cow<'a, str>>,
{
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<FormValue>;
pub(crate) trait IntoFormText {
fn into_form_text(&self) -> Option<String>;
}
macro_rules! impl_for_struct {
($($name:ty),*) => {
$(
impl IntoFormValue for $name {
fn into_form_value(&self) -> Option<FormValue> {
impl IntoFormText for $name {
fn into_form_text(&self) -> Option<String> {
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<T> IntoFormValue for Option<T>
impl<T> IntoFormText for Option<T>
where
T: IntoFormValue,
T: IntoFormText,
{
fn into_form_value(&self) -> Option<FormValue> {
self.as_ref().and_then(IntoFormValue::into_form_value)
fn into_form_text(&self) -> Option<String> {
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<InputMedia> {
fn into_form_value(&self) -> Option<FormValue> {
impl IntoFormText for Vec<InputMedia> {
fn into_form_text(&self) -> Option<String> {
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<FormValue> {
impl IntoFormText for InputMedia {
fn into_form_text(&self) -> Option<String> {
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<FormValue> {
Some(FormValue::Str(self.to_owned()))
impl IntoFormText for str {
fn into_form_text(&self) -> Option<String> {
Some(self.to_owned())
}
}
impl IntoFormValue for ParseMode {
fn into_form_value(&self) -> Option<FormValue> {
impl IntoFormText for ParseMode {
fn into_form_text(&self) -> Option<String> {
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<FormValue> {
impl IntoFormText for ChatId {
fn into_form_text(&self) -> Option<String> {
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<FormValue> {
Some(FormValue::Str(self.clone()))
}
}
impl IntoFormValue for InputFile {
fn into_form_value(&self) -> Option<FormValue> {
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<String> {
Some(self.clone())
}
}

View file

@ -18,3 +18,15 @@ pub trait Request {
/// Asynchronously sends this request to Telegram and returns the result.
async fn send(&self) -> ResponseResult<Self::Output>;
}
/// 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<ResponseResult<Self::Output>>;
}

View file

@ -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<Part> {
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(

View file

@ -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());
}

82
tests/redis.rs Normal file
View file

@ -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<S>(storage: Arc<RedisStorage<S>>)
where
S: Send + Sync + Serializer<Dialogue> + 'static,
<S as Serializer<Dialogue>>::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<E>(
expected: impl Into<Option<&str>>,
actual: impl Future<Output = Result<Option<Dialogue>, E>>,
) where
E: Debug,
{
assert_eq!(expected.into().map(ToOwned::to_owned), actual.await.unwrap())
}