diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..0ec2a488 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,72 @@ +on: [push, pull_request] + +name: Continuous integration + +jobs: + ci: + runs-on: ubuntu-latest + strategy: + matrix: + rust: + - stable + - beta + - nightly + + steps: + - uses: actions/checkout@v1 + + - uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: ${{ matrix.rust }} + override: true + components: rustfmt, clippy + + - name: stable/beta build + uses: actions-rs/cargo@v1 + if: matrix.rust == 'stable' || matrix.rust == 'beta' + with: + command: build + args: --verbose --features "" + + - name: nightly build + uses: actions-rs/cargo@v1 + if: matrix.rust == 'nightly' + with: + command: build + args: --verbose --all-features + + - name: stable/beta test + uses: actions-rs/cargo@v1 + if: matrix.rust == 'stable' || matrix.rust == 'beta' + with: + command: test + args: --verbose --features "" + + - name: nightly test + uses: actions-rs/cargo@v1 + if: matrix.rust == 'nightly' + with: + command: test + args: --verbose --all-features + + - name: fmt + uses: actions-rs/cargo@v1 + if: matrix.rust == 'nightly' + with: + command: fmt + args: --all -- --check + + - name: stable/beta clippy + uses: actions-rs/cargo@v1 + if: matrix.rust == 'stable' || matrix.rust == 'beta' + with: + command: clippy + args: --all-targets --features "" -- -D warnings + + - name: nightly clippy + uses: actions-rs/cargo@v1 + if: matrix.rust == 'nightly' + with: + command: clippy + args: --all-targets --all-features -- -D warnings diff --git a/.gitignore b/.gitignore index 0668c27c..f2d88a0e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,11 @@ /target **/*.rs.bk Cargo.lock -.idea/ \ No newline at end of file +.idea/ +.vscode/ +examples/ping_pong_bot/target +examples/dialogue_bot/target +examples/multiple_handlers_bot/target +examples/admin_bot/target +examples/guess_a_number_bot/target +examples/simple_commands_bot/target \ No newline at end of file diff --git a/CODE_STYLE.md b/CODE_STYLE.md new file mode 100644 index 00000000..384d7fa4 --- /dev/null +++ b/CODE_STYLE.md @@ -0,0 +1,124 @@ +# Code style +This is a description of a coding style that every contributor must follow. Please, read the whole document before you start pushing code. + +## Generics +Generics are always written with `where`. + +Bad: + +```rust + pub fn new, + T: Into, + P: Into, + E: Into> + (user_id: i32, name: N, title: T, png_sticker: P, emojis: E) -> Self { ... } +``` + +Good: + +```rust + pub fn new(user_id: i32, name: N, title: T, png_sticker: P, emojis: E) -> Self + where + N: Into, + T: Into, + P: Into, + E: Into { ... } +``` + +## Comments + 1. Comments must describe what your code does and mustn't describe how your code does it and bla-bla-bla. Be sure that your comments follow the grammar, including punctuation, the first capital letter and so on. + +Bad: + +```rust +/// this function make request to telegram +pub fn make_request(url: &str) -> String { ... } +``` + +Good: + +```rust +/// This function makes a request to Telegram. +pub fn make_request(url: &str) -> String { ... } +``` + + 2. Also, link resources in your comments when possible: + +```rust +/// Download a file from Telegram. +/// +/// `path` can be obtained from the [`Bot::get_file`]. +/// +/// To download into [`AsyncWrite`] (e.g. [`tokio::fs::File`]), see +/// [`Bot::download_file`]. +/// +/// [`Bot::get_file`]: crate::bot::Bot::get_file +/// [`AsyncWrite`]: tokio::io::AsyncWrite +/// [`tokio::fs::File`]: tokio::fs::File +/// [`Bot::download_file`]: crate::Bot::download_file +#[cfg(feature = "unstable-stream")] +pub async fn download_file_stream( + &self, + path: &str, +) -> Result>, reqwest::Error> +{ + download_file_stream(&self.client, &self.token, path).await +} +``` + +## Use Self where possible +Bad: + +```rust +impl ErrorKind { + fn print(&self) { + ErrorKind::Io => println!("Io"), + ErrorKind::Network => println!("Network"), + ErrorKind::Json => println!("Json"), + } +} +``` + +Good: +```rust +impl ErrorKind { + fn print(&self) { + Self::Io => println!("Io"), + Self::Network => println!("Network"), + Self::Json => println!("Json"), + } +} +``` + +
+ More examples + +Bad: + +```rust +impl<'a> AnswerCallbackQuery<'a> { + pub(crate) fn new(bot: &'a Bot, callback_query_id: C) -> AnswerCallbackQuery<'a> + where +C: Into, { ... } +``` + +Good: + +```rust +impl<'a> AnswerCallbackQuery<'a> { + pub(crate) fn new(bot: &'a Bot, callback_query_id: C) -> Self + where +C: Into, { ... } +``` +
+ +## Naming + 1. Avoid unnecessary duplication (`Message::message_id` -> `Message::id` using `#[serde(rename = "message_id")]`). + 2. Use a generic parameter name `S` for streams, `Fut` for futures, `F` for functions (where possible). + +## Deriving + 1. Derive `Copy`, `Eq`, `Hash`, `PartialEq`, `Clone`, `Debug` for public types when possible (note: if the default `Debug` implementation is weird, you should manually implement it by yourself). + 2. Derive `Default` when there is an algorithm to get a default value for your type. + +## Misc + 1. Use `Into<...>` only where there exists at least one conversion **and** it will be logically to use. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..f8bf24e7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,13 @@ +# Contributing +Before contributing, please read our [the code style](https://github.com/teloxide/teloxide/blob/dev/CODE_STYLE.md). + +To change the source code, fork this repository and work inside your own branch. Then send us a PR and wait for the CI to check everything. However, you'd better check changes first locally: + +``` +cargo clippy --all --all-features --all-targets +cargo test --all +cargo doc --open +cargo fmt --all -- --check +``` + +To report a bug, suggest new functionality, or ask a question, go to [Issues](https://github.com/teloxide/teloxide/issues). Try to make MRE (**M**inimal **R**eproducible **E**xample) and specify your teloxide version to let others help you. diff --git a/Cargo.toml b/Cargo.toml index f8fa400b..05cefbae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,42 @@ [package] -name = "async-telegram-bot" +name = "teloxide" version = "0.1.0" edition = "2018" +description = "An elegant Telegram bots framework for Rust" +repository = "https://github.com/teloxide/teloxide" +documentation = "https://docs.rs/teloxide/" +readme = "README.md" +keywords = ["teloxide", "telegram", "telegram-bot-framework", "telegram-bot-api"] +license = "MIT" + +[badges] +maintenance = { status = "actively-developed" } # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -futures-preview = { version = "0.3.0-alpha.14", features = ["compat"] } -reqwest = "0.9.20" -serde_json = "1.0.39" -serde = {version = "1.0.92", features = ["derive"] } -lazy_static = "1.3" \ No newline at end of file +serde_json = "1.0.44" +serde = { version = "1.0.101", features = ["derive"] } + +tokio = { version = "0.2.6", features = ["full"] } +tokio-util = { version = "0.2.0", features = ["full"] } + +reqwest = { version = "0.10", features = ["json", "stream", "native-tls-vendored"] } +log = "0.4.8" +bytes = "0.5.3" +mime = "0.3.16" + +derive_more = "0.99.2" +thiserror = "1.0.9" +async-trait = "0.1.22" +futures = "0.3.1" +pin-project = "0.4.6" +serde_with_macros = "1.0.1" +either = "1.5.3" + +teloxide-macros = { path = "teloxide-macros" } + +[dev-dependencies] +smart-default = "0.6.0" +rand = "0.7.3" +pretty_env_logger = "0.4.0" diff --git a/ICON.png b/ICON.png new file mode 100644 index 00000000..c4ac3cec Binary files /dev/null and b/ICON.png differ diff --git a/LICENSE b/LICENSE index 75a50a58..aa510ea3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 async-telegram-bot +Copyright (c) 2019 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/README.md b/README.md index 71107c03..cd2367c7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,244 @@ -# async-telegram-bot -An asynchronous full-featured Telegram bot framework for Rust +
+ +

teloxide

+ + + + + + + + + + + + A full-featured framework that empowers you to easily build [Telegram bots](https://telegram.org/blog/bot-revolution) using the [`async`/`.await`](https://rust-lang.github.io/async-book/01_getting_started/01_chapter.html) syntax in [Rust](https://www.rust-lang.org/). It handles all the difficult stuff so you can focus only on your business logic. +
+ +## Features + - **Type-safe.** teloxide leverages the Rust's type system with two serious implications: resistance to human mistakes and tight integration with IDEs. Write fast, avoid debugging as possible. + + - **Persistency.** By default, teloxide stores all user dialogues in RAM, but you can store them somewhere else (for example, in DB) just by implementing 2 functions. + + - **Convenient dialogues system.** Define a type-safe [finite automaton](https://en.wikipedia.org/wiki/Finite-state_machine) + and transition functions to drive a user dialogue with ease (see the examples below). + + - **Convenient API.** Automatic conversions are used to avoid boilerplate. For example, functions accept `Into`, rather than `&str` or `String`, so you can call them without `.to_string()`/`.as_str()`/etc. + +## Getting started + 1. Create a new bot using [@Botfather](https://t.me/botfather) to get a token in the format `123456789:blablabla`. + 2. Initialise the `TELOXIDE_TOKEN` environmental variable to your token: +```bash +# Unix +$ export TELOXIDE_TOKEN=MyAwesomeToken + +# Windows +$ set TELOXITE_TOKEN=MyAwesomeToken +``` + 3. Be sure that you are up to date: +```bash +$ rustup update stable +``` + + 4. Execute `cargo new my_bot`, enter the directory and put these lines into your `Cargo.toml`: +```toml +[dependencies] +teloxide = "0.1.0" +log = "0.4.8" +tokio = "0.2.11" +pretty_env_logger = "0.4.0" +``` + +## The ping-pong bot +This bot has a single message handler, which answers "pong" to each incoming message: + +([Full](https://github.com/teloxide/teloxide/blob/dev/examples/ping_pong_bot/src/main.rs)) +```rust +use teloxide::prelude::*; + +#[tokio::main] +async fn main() { + teloxide::enable_logging!(); + log::info!("Starting the ping-pong bot!"); + + let bot = Bot::from_env(); + + Dispatcher::::new(bot) + .message_handler(&|ctx: DispatcherHandlerCtx| async move { + ctx.answer("pong").send().await?; + Ok(()) + }) + .dispatch() + .await; +} +``` + +## Commands +Commands are defined similar to how we define CLI using [structopt](https://docs.rs/structopt/0.3.9/structopt/). This bot says "I am a cat! Meow!" on `/meow`, generates a random number within [0; 1) on `/generate`, and shows the usage guide on `/help`: + +([Full](https://github.com/teloxide/teloxide/blob/dev/examples/simple_commands_bot/src/main.rs)) +```rust +// Imports are omitted... + +#[derive(BotCommand)] +#[command(rename = "lowercase", description = "These commands are supported:")] +enum Command { + #[command(description = "display this text.")] + Help, + #[command(description = "be a cat.")] + Meow, + #[command(description = "generate a random number within [0; 1).")] + Generate, +} + +async fn handle_command( + ctx: DispatcherHandlerCtx, +) -> Result<(), RequestError> { + let text = match ctx.update.text() { + Some(text) => text, + None => { + log::info!("Received a message, but not text."); + return Ok(()); + } + }; + + let command = match Command::parse(text) { + Some((command, _)) => command, + None => { + log::info!("Received a text message, but not a command."); + return Ok(()); + } + }; + + match command { + Command::Help => ctx.answer(Command::descriptions()).send().await?, + Command::Generate => { + ctx.answer(thread_rng().gen_range(0.0, 1.0).to_string()) + .send() + .await? + } + Command::Meow => ctx.answer("I am a cat! Meow!").send().await?, + }; + + Ok(()) +} + +#[tokio::main] +async fn main() { + // Setup is omitted... +} + +``` + +## Guess a number +Wanna see more? This is a bot, which starts a game on each incoming message. You must guess a number from 1 to 10 (inclusively): + +([Full](https://github.com/teloxide/teloxide/blob/dev/examples/guess_a_number_bot/src/main.rs)) +```rust +// Imports are omitted... + +#[derive(SmartDefault)] +enum Dialogue { + #[default] + Start, + ReceiveAttempt(u8), +} + +async fn handle_message( + ctx: DialogueHandlerCtx, +) -> Result, RequestError> { + match ctx.dialogue { + Dialogue::Start => { + ctx.answer( + "Let's play a game! Guess a number from 1 to 10 (inclusively).", + ) + .send() + .await?; + next(Dialogue::ReceiveAttempt(thread_rng().gen_range(1, 11))) + } + Dialogue::ReceiveAttempt(secret) => match ctx.update.text() { + None => { + ctx.answer("Oh, please, send me a text message!") + .send() + .await?; + next(ctx.dialogue) + } + Some(text) => match text.parse::() { + Ok(attempt) => match attempt { + x if !(1..=10).contains(&x) => { + ctx.answer( + "Oh, please, send me a number in the range [1; \ + 10]!", + ) + .send() + .await?; + next(ctx.dialogue) + } + x if x == secret => { + ctx.answer("Congratulations! You won!").send().await?; + exit() + } + _ => { + ctx.answer("No.").send().await?; + next(ctx.dialogue) + } + }, + Err(_) => { + ctx.answer( + "Oh, please, send me a number in the range [1; 10]!", + ) + .send() + .await?; + next(ctx.dialogue) + } + }, + }, + } +} + +#[tokio::main] +async fn main() { + // Setup is omitted... + + Dispatcher::new(bot) + .message_handler(&DialogueDispatcher::new(|ctx| async move { + handle_message(ctx) + .await + .expect("Something wrong with the bot!") + })) + .dispatch() + .await; +} +``` + +Our [finite automaton](https://en.wikipedia.org/wiki/Finite-state_machine), designating a user dialogue, cannot be in an invalid state. See [examples/dialogue_bot](https://github.com/teloxide/teloxide/blob/dev/examples/dialogue_bot/src/main.rs) to see a bit more complicated bot with dialogues. + +[See more examples](https://github.com/teloxide/teloxide/tree/dev/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 because of the `#[tokio::main]` macro. However, the examples in this README use the second variant for brevity. + +## Contributing +See [CONRIBUTING.md](https://github.com/teloxide/teloxide/blob/dev/CONTRIBUTING.md). diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..7102117e --- /dev/null +++ b/examples/README.md @@ -0,0 +1,9 @@ +# 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. + + - [ping_pong_bot](ping_pong_bot) - Answers "pong" to each incoming message. + - [simple_commands_bot](simple_commands_bot) - Shows how to deal with bot's commands. + - [guess_a_number_bot](guess_a_number_bot) - The "guess a number" game. + - [dialogue_bot](dialogue_bot) - Drive a dialogue with a user using a type-safe finite automaton. + - [admin_bot](admin_bot) - A bot, which can ban, kick, and mute on a command. + - [multiple_handlers_bot](multiple_handlers_bot) - Shows how multiple dispatcher's handlers relate to each other. \ No newline at end of file diff --git a/examples/admin_bot/Cargo.toml b/examples/admin_bot/Cargo.toml new file mode 100644 index 00000000..c9470f3d --- /dev/null +++ b/examples/admin_bot/Cargo.toml @@ -0,0 +1,16 @@ +[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] +log = "0.4.8" +tokio = "0.2.9" +pretty_env_logger = "0.4.0" +teloxide = { path = "../../" } + +[profile.release] +lto = true \ No newline at end of file diff --git a/examples/admin_bot/src/main.rs b/examples/admin_bot/src/main.rs new file mode 100644 index 00000000..9a891a5f --- /dev/null +++ b/examples/admin_bot/src/main.rs @@ -0,0 +1,203 @@ +// TODO: simplify this and use typed command variants (see https://github.com/teloxide/teloxide/issues/152). + +use teloxide::{ + prelude::*, 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)] +#[command( + rename = "lowercase", + description = "Use commands in format /%command% %num% %unit%" +)] +enum Command { + #[command(description = "kick user from chat.")] + Kick, + #[command(description = "ban user in chat.")] + Ban, + #[command(description = "mute user in chat.")] + Mute, + + Help, +} + +// Calculates time of user restriction. +fn calc_restrict_time(num: i32, unit: &str) -> Result { + match unit { + "h" | "hours" => Ok(num * 3600), + "m" | "minutes" => Ok(num * 60), + "s" | "seconds" => Ok(num), + _ => Err("Allowed units: h, m, s"), + } +} + +// Parse arguments after a command. +fn parse_args(args: Vec<&str>) -> Result<(i32, &str), &str> { + let num = match args.get(0) { + Some(s) => s, + None => return Err("Use command in format /%command% %num% %unit%"), + }; + let unit = match args.get(1) { + Some(s) => s, + None => return Err("Use command in format /%command% %num% %unit%"), + }; + + match num.parse::() { + Ok(n) => Ok((n, unit)), + Err(_) => Err("input positive number!"), + } +} + +// Parse arguments into a user restriction duration. +fn parse_time_restrict(args: Vec<&str>) -> Result { + let (num, unit) = parse_args(args)?; + calc_restrict_time(num, unit) +} + +type Ctx = DispatcherHandlerCtx; + +// Mute a user with a replied message. +async fn mute_user(ctx: &Ctx, args: Vec<&str>) -> Result<(), RequestError> { + match ctx.update.reply_to_message() { + Some(msg1) => match parse_time_restrict(args) { + // Mute user temporarily... + Ok(time) => { + ctx.bot + .restrict_chat_member( + ctx.update.chat_id(), + msg1.from().expect("Must be MessageKind::Common").id, + ChatPermissions::default(), + ) + .until_date(ctx.update.date + time) + .send() + .await?; + } + // ...or permanently + Err(_) => { + ctx.bot + .restrict_chat_member( + ctx.update.chat_id(), + msg1.from().unwrap().id, + ChatPermissions::default(), + ) + .send() + .await?; + } + }, + None => { + ctx.reply_to("Use this command in reply to another message") + .send() + .await?; + } + } + Ok(()) +} + +// Kick a user with a replied message. +async fn kick_user(ctx: &Ctx) -> Result<(), RequestError> { + match ctx.update.reply_to_message() { + Some(mes) => { + // bot.unban_chat_member can also kicks a user from a group chat. + ctx.bot + .unban_chat_member(ctx.update.chat_id(), mes.from().unwrap().id) + .send() + .await?; + } + None => { + ctx.reply_to("Use this command in reply to another message") + .send() + .await?; + } + } + Ok(()) +} + +// Ban a user with replied message. +async fn ban_user(ctx: &Ctx, args: Vec<&str>) -> Result<(), RequestError> { + match ctx.update.reply_to_message() { + Some(message) => match parse_time_restrict(args) { + // Mute user temporarily... + Ok(time) => { + ctx.bot + .kick_chat_member( + ctx.update.chat_id(), + message.from().expect("Must be MessageKind::Common").id, + ) + .until_date(ctx.update.date + time) + .send() + .await?; + } + // ...or permanently + Err(_) => { + ctx.bot + .kick_chat_member( + ctx.update.chat_id(), + message.from().unwrap().id, + ) + .send() + .await?; + } + }, + None => { + ctx.reply_to("Use this command in a reply to another message!") + .send() + .await?; + } + } + Ok(()) +} + +// Handle all messages. +async fn handle_command(ctx: Ctx) -> Result<(), RequestError> { + if ctx.update.chat.is_group() { + // The same as DispatcherHandlerResult::exit(Ok(())). If you have more + // handlers, use DispatcherHandlerResult::next(...) + return Ok(()); + } + + if let Some(text) = ctx.update.text() { + // Parse text into a command with args. + let (command, args): (Command, Vec<&str>) = match Command::parse(text) { + Some(tuple) => tuple, + None => return Ok(()), + }; + + match command { + Command::Help => { + ctx.answer(Command::descriptions()).send().await?; + } + Command::Kick => { + kick_user(&ctx).await?; + } + Command::Ban => { + ban_user(&ctx, args).await?; + } + Command::Mute => { + mute_user(&ctx, args).await?; + } + }; + } + + Ok(()) +} + +#[tokio::main] +async fn main() { + teloxide::enable_logging!(); + log::info!("Starting admin_bot!"); + + let bot = Bot::from_env(); + + Dispatcher::new(bot) + .message_handler(&handle_command) + .dispatch() + .await +} diff --git a/examples/dialogue_bot/Cargo.toml b/examples/dialogue_bot/Cargo.toml new file mode 100644 index 00000000..947470f0 --- /dev/null +++ b/examples/dialogue_bot/Cargo.toml @@ -0,0 +1,18 @@ +[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] +log = "0.4.8" +tokio = "0.2.9" +pretty_env_logger = "0.4.0" +smart-default = "0.6.0" +parse-display = "0.1.1" +teloxide = { path = "../../" } + +[profile.release] +lto = true \ No newline at end of file diff --git a/examples/dialogue_bot/src/main.rs b/examples/dialogue_bot/src/main.rs new file mode 100644 index 00000000..6a75d68c --- /dev/null +++ b/examples/dialogue_bot/src/main.rs @@ -0,0 +1,209 @@ +// This is a bot that asks your full name, your age, your favourite kind of +// music and sends all the gathered information back. +// +// # Example +// ``` +// - Let's start! First, what's your full name? +// - Luke Skywalker +// - What a wonderful name! Your age? +// - 26 +// - Good. Now choose your favourite music +// *A keyboard of music kinds is displayed* +// *You select Metal* +// - Metal +// - Fine. Your full name: Luke Skywalker, your age: 26, your favourite music: Metal +// ``` + +#![allow(clippy::trivial_regex)] + +#[macro_use] +extern crate smart_default; + +use teloxide::{ + prelude::*, + types::{KeyboardButton, ReplyKeyboardMarkup}, +}; + +use parse_display::{Display, FromStr}; + +// ============================================================================ +// [Favourite music kinds] +// ============================================================================ + +#[derive(Copy, Clone, Display, FromStr)] +enum FavouriteMusic { + Rock, + Metal, + Pop, + Other, +} + +impl FavouriteMusic { + fn markup() -> ReplyKeyboardMarkup { + ReplyKeyboardMarkup::default().append_row(vec![ + KeyboardButton::new("Rock"), + KeyboardButton::new("Metal"), + KeyboardButton::new("Pop"), + KeyboardButton::new("Other"), + ]) + } +} + +// ============================================================================ +// [A type-safe finite automaton] +// ============================================================================ + +#[derive(Clone)] +struct ReceiveAgeState { + full_name: String, +} + +#[derive(Clone)] +struct ReceiveFavouriteMusicState { + data: ReceiveAgeState, + age: u8, +} + +#[derive(Display)] +#[display( + "Your full name: {data.data.full_name}, your age: {data.age}, your \ + favourite music: {favourite_music}" +)] +struct ExitState { + data: ReceiveFavouriteMusicState, + favourite_music: FavouriteMusic, +} + +#[derive(SmartDefault)] +enum Dialogue { + #[default] + Start, + ReceiveFullName, + ReceiveAge(ReceiveAgeState), + ReceiveFavouriteMusic(ReceiveFavouriteMusicState), +} + +// ============================================================================ +// [Control a dialogue] +// ============================================================================ + +type Ctx = DialogueHandlerCtx; +type Res = Result, RequestError>; + +async fn start(ctx: Ctx<()>) -> Res { + ctx.answer("Let's start! First, what's your full name?") + .send() + .await?; + next(Dialogue::ReceiveFullName) +} + +async fn full_name(ctx: Ctx<()>) -> Res { + match ctx.update.text() { + None => { + ctx.answer("Please, send me a text message!").send().await?; + next(Dialogue::ReceiveFullName) + } + Some(full_name) => { + ctx.answer("What a wonderful name! Your age?") + .send() + .await?; + next(Dialogue::ReceiveAge(ReceiveAgeState { + full_name: full_name.to_owned(), + })) + } + } +} + +async fn age(ctx: Ctx) -> Res { + match ctx.update.text().unwrap().parse() { + Ok(age) => { + ctx.answer("Good. Now choose your favourite music:") + .reply_markup(FavouriteMusic::markup()) + .send() + .await?; + next(Dialogue::ReceiveFavouriteMusic( + ReceiveFavouriteMusicState { + data: ctx.dialogue, + age, + }, + )) + } + Err(_) => { + ctx.answer("Oh, please, enter a number!").send().await?; + next(Dialogue::ReceiveAge(ctx.dialogue)) + } + } +} + +async fn favourite_music(ctx: Ctx) -> Res { + match ctx.update.text().unwrap().parse() { + Ok(favourite_music) => { + ctx.answer(format!( + "Fine. {}", + ExitState { + data: ctx.dialogue.clone(), + favourite_music + } + )) + .send() + .await?; + exit() + } + Err(_) => { + ctx.answer("Oh, please, enter from the keyboard!") + .send() + .await?; + next(Dialogue::ReceiveFavouriteMusic(ctx.dialogue)) + } + } +} + +async fn handle_message(ctx: Ctx) -> Res { + match ctx { + DialogueHandlerCtx { + bot, + update, + dialogue: Dialogue::Start, + } => start(DialogueHandlerCtx::new(bot, update, ())).await, + DialogueHandlerCtx { + bot, + update, + dialogue: Dialogue::ReceiveFullName, + } => full_name(DialogueHandlerCtx::new(bot, update, ())).await, + DialogueHandlerCtx { + bot, + update, + dialogue: Dialogue::ReceiveAge(s), + } => age(DialogueHandlerCtx::new(bot, update, s)).await, + DialogueHandlerCtx { + bot, + update, + dialogue: Dialogue::ReceiveFavouriteMusic(s), + } => favourite_music(DialogueHandlerCtx::new(bot, update, s)).await, + } +} + +// ============================================================================ +// [Run!] +// ============================================================================ + +#[tokio::main] +async fn main() { + run().await; +} + +async fn run() { + teloxide::enable_logging!(); + log::info!("Starting dialogue_bot!"); + + let bot = Bot::from_env(); + + Dispatcher::new(bot) + .message_handler(&DialogueDispatcher::new(|ctx| async move { + handle_message(ctx) + .await + .expect("Something wrong with the bot!") + })) + .dispatch() + .await; +} diff --git a/examples/guess_a_number_bot/Cargo.toml b/examples/guess_a_number_bot/Cargo.toml new file mode 100644 index 00000000..c81ef20f --- /dev/null +++ b/examples/guess_a_number_bot/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "guess_a_number_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] +log = "0.4.8" +tokio = "0.2.9" +smart-default = "0.6.0" +rand = "0.7.3" +pretty_env_logger = "0.4.0" +teloxide = { path = "../../" } \ No newline at end of file diff --git a/examples/guess_a_number_bot/src/main.rs b/examples/guess_a_number_bot/src/main.rs new file mode 100644 index 00000000..2e9d573c --- /dev/null +++ b/examples/guess_a_number_bot/src/main.rs @@ -0,0 +1,116 @@ +// This is a guess-a-number game! +// +// # Example +// ``` +// - Hello +// - Let's play a game! Guess a number from 1 to 10 (inclusively). +// - 4 +// - No. +// - 3 +// - No. +// - Blablabla +// - Oh, please, send me a text message! +// - 111 +// - Oh, please, send me a number in the range [1; 10]! +// - 5 +// - Congratulations! You won! +// ``` + +#[macro_use] +extern crate smart_default; + +use teloxide::prelude::*; + +use rand::{thread_rng, Rng}; + +// ============================================================================ +// [A type-safe finite automaton] +// ============================================================================ + +#[derive(SmartDefault)] +enum Dialogue { + #[default] + Start, + ReceiveAttempt(u8), +} + +// ============================================================================ +// [Control a dialogue] +// ============================================================================ + +async fn handle_message( + ctx: DialogueHandlerCtx, +) -> Result, RequestError> { + match ctx.dialogue { + Dialogue::Start => { + ctx.answer( + "Let's play a game! Guess a number from 1 to 10 (inclusively).", + ) + .send() + .await?; + next(Dialogue::ReceiveAttempt(thread_rng().gen_range(1, 11))) + } + Dialogue::ReceiveAttempt(secret) => match ctx.update.text() { + None => { + ctx.answer("Oh, please, send me a text message!") + .send() + .await?; + next(ctx.dialogue) + } + Some(text) => match text.parse::() { + Ok(attempt) => match attempt { + x if !(1..=10).contains(&x) => { + ctx.answer( + "Oh, please, send me a number in the range [1; \ + 10]!", + ) + .send() + .await?; + next(ctx.dialogue) + } + x if x == secret => { + ctx.answer("Congratulations! You won!").send().await?; + exit() + } + _ => { + ctx.answer("No.").send().await?; + next(ctx.dialogue) + } + }, + Err(_) => { + ctx.answer( + "Oh, please, send me a number in the range [1; 10]!", + ) + .send() + .await?; + next(ctx.dialogue) + } + }, + }, + } +} + +// ============================================================================ +// [Run!] +// ============================================================================ + +#[tokio::main] +async fn main() { + run().await; +} + +async fn run() { + teloxide::enable_logging!(); + log::info!("Starting guess_a_number_bot!"); + + let bot = Bot::from_env(); + + Dispatcher::new(bot) + .message_handler(&DialogueDispatcher::new(|ctx| async move { + handle_message(ctx) + .await + .expect("Something wrong with the bot!") + })) + .dispatch() + .await; +} diff --git a/examples/multiple_handlers_bot/Cargo.toml b/examples/multiple_handlers_bot/Cargo.toml new file mode 100644 index 00000000..89a1b61a --- /dev/null +++ b/examples/multiple_handlers_bot/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "multiple_handlers_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] +log = "0.4.8" +tokio = "0.2.9" +pretty_env_logger = "0.4.0" +teloxide = { path = "../../" } \ No newline at end of file diff --git a/examples/multiple_handlers_bot/src/main.rs b/examples/multiple_handlers_bot/src/main.rs new file mode 100644 index 00000000..c09353e5 --- /dev/null +++ b/examples/multiple_handlers_bot/src/main.rs @@ -0,0 +1,49 @@ +// This example demonstrates the ability of Dispatcher to deal with multiple +// handlers. + +use teloxide::prelude::*; + +#[tokio::main] +async fn main() { + run().await; +} + +async fn run() { + teloxide::enable_logging!(); + log::info!("Starting multiple_handlers_bot!"); + + let bot = Bot::from_env(); + + // Create a dispatcher with multiple handlers of different types. This will + // print One! and Two! on every incoming UpdateKind::Message. + Dispatcher::::new(bot) + // This is the first UpdateKind::Message handler, which will be called + // after the Update handler below. + .message_handler(&|ctx: DispatcherHandlerCtx| async move { + log::info!("Two!"); + DispatcherHandlerResult::next(ctx.update, Ok(())) + }) + // Remember: handler of Update are called first. + .update_handler(&|ctx: DispatcherHandlerCtx| async move { + log::info!("One!"); + DispatcherHandlerResult::next(ctx.update, Ok(())) + }) + // This handler will be called right after the first UpdateKind::Message + // handler, because it is registered after. + .message_handler(&|_ctx: DispatcherHandlerCtx| async move { + // The same as DispatcherHandlerResult::exit(Ok(())) + Ok(()) + }) + // This handler will never be called, because the UpdateKind::Message + // handler above terminates the pipeline. + .message_handler(&|ctx: DispatcherHandlerCtx| async move { + log::info!("This will never be printed!"); + DispatcherHandlerResult::next(ctx.update, Ok(())) + }) + .dispatch() + .await; + + // Note: if this bot receive, for example, UpdateKind::ChannelPost, it will + // only print "One!", because the UpdateKind::Message handlers will not be + // called. +} diff --git a/examples/ping_pong_bot/Cargo.toml b/examples/ping_pong_bot/Cargo.toml new file mode 100644 index 00000000..73a9d9ed --- /dev/null +++ b/examples/ping_pong_bot/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "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] +log = "0.4.8" +tokio = "0.2.9" +pretty_env_logger = "0.4.0" +teloxide = { path = "../../" } + +[profile.release] +lto = true \ No newline at end of file diff --git a/examples/ping_pong_bot/src/main.rs b/examples/ping_pong_bot/src/main.rs new file mode 100644 index 00000000..e731200c --- /dev/null +++ b/examples/ping_pong_bot/src/main.rs @@ -0,0 +1,23 @@ +use teloxide::prelude::*; + +#[tokio::main] +async fn main() { + run().await; +} + +async fn run() { + teloxide::enable_logging!(); + log::info!("Starting ping_pong_bot!"); + + let bot = Bot::from_env(); + + // Create a dispatcher with a single message handler that answers "pong" to + // each incoming message. + Dispatcher::::new(bot) + .message_handler(&|ctx: DispatcherHandlerCtx| async move { + ctx.answer("pong").send().await?; + Ok(()) + }) + .dispatch() + .await; +} diff --git a/examples/simple_commands_bot/Cargo.toml b/examples/simple_commands_bot/Cargo.toml new file mode 100644 index 00000000..6baf6eb8 --- /dev/null +++ b/examples/simple_commands_bot/Cargo.toml @@ -0,0 +1,14 @@ +[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] +log = "0.4.8" +tokio = "0.2.9" +rand = "0.7.3" +pretty_env_logger = "0.4.0" +teloxide = { path = "../../" } diff --git a/examples/simple_commands_bot/src/main.rs b/examples/simple_commands_bot/src/main.rs new file mode 100644 index 00000000..3d6525c5 --- /dev/null +++ b/examples/simple_commands_bot/src/main.rs @@ -0,0 +1,63 @@ +use teloxide::{prelude::*, utils::command::BotCommand}; + +use rand::{thread_rng, Rng}; + +#[derive(BotCommand)] +#[command(rename = "lowercase", description = "These commands are supported:")] +enum Command { + #[command(description = "display this text.")] + Help, + #[command(description = "be a cat.")] + Meow, + #[command(description = "generate a random number within [0; 1).")] + Generate, +} + +async fn handle_command( + ctx: DispatcherHandlerCtx, +) -> Result<(), RequestError> { + let text = match ctx.update.text() { + Some(text) => text, + None => { + log::info!("Received a message, but not text."); + return Ok(()); + } + }; + + let command = match Command::parse(text) { + Some((command, _)) => command, + None => { + log::info!("Received a text message, but not a command."); + return Ok(()); + } + }; + + match command { + Command::Help => ctx.answer(Command::descriptions()).send().await?, + Command::Generate => { + ctx.answer(thread_rng().gen_range(0.0, 1.0).to_string()) + .send() + .await? + } + Command::Meow => ctx.answer("I am a cat! Meow!").send().await?, + }; + + Ok(()) +} + +#[tokio::main] +async fn main() { + run().await; +} + +async fn run() { + teloxide::enable_logging!(); + log::info!("Starting simple_commands_bot!"); + + let bot = Bot::from_env(); + + Dispatcher::::new(bot) + .message_handler(&handle_command) + .dispatch() + .await; +} diff --git a/logo.svg b/logo.svg new file mode 100644 index 00000000..652bca3d --- /dev/null +++ b/logo.svg @@ -0,0 +1,405 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/media/FILTER_DP_FLOWCHART.png b/media/FILTER_DP_FLOWCHART.png new file mode 100644 index 00000000..e1ceb327 Binary files /dev/null and b/media/FILTER_DP_FLOWCHART.png differ diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 00000000..cee9b586 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,5 @@ +format_code_in_doc_comments = true +wrap_comments = true +format_strings = true +max_width = 80 +merge_imports = true \ No newline at end of file diff --git a/src/bot/api.rs b/src/bot/api.rs new file mode 100644 index 00000000..a1058d49 --- /dev/null +++ b/src/bot/api.rs @@ -0,0 +1,1590 @@ +use crate::{ + requests::{ + AddStickerToSet, AnswerCallbackQuery, AnswerInlineQuery, + AnswerPreCheckoutQuery, AnswerShippingQuery, CreateNewStickerSet, + DeleteChatPhoto, DeleteChatStickerSet, DeleteMessage, + DeleteStickerFromSet, DeleteWebhook, EditMessageCaption, + EditMessageLiveLocation, EditMessageMedia, EditMessageReplyMarkup, + EditMessageText, ExportChatInviteLink, ForwardMessage, GetChat, + GetChatAdministrators, GetChatMember, GetChatMembersCount, GetFile, + GetGameHighScores, GetMe, GetStickerSet, GetUpdates, + GetUserProfilePhotos, GetWebhookInfo, KickChatMember, LeaveChat, + PinChatMessage, PromoteChatMember, RestrictChatMember, SendAnimation, + SendAudio, SendChatAction, SendChatActionKind, SendContact, + SendDocument, SendGame, SendInvoice, SendLocation, SendMediaGroup, + SendMessage, SendPhoto, SendPoll, SendSticker, SendVenue, SendVideo, + SendVideoNote, SendVoice, SetChatAdministratorCustomTitle, + SetChatDescription, SetChatPermissions, SetChatPhoto, + SetChatStickerSet, SetChatTitle, SetGameScore, SetStickerPositionInSet, + SetWebhook, StopMessageLiveLocation, StopPoll, UnbanChatMember, + UnpinChatMessage, UploadStickerFile, + }, + types::{ + ChatId, ChatOrInlineMessage, ChatPermissions, InlineQueryResult, + InputFile, InputMedia, LabeledPrice, + }, + Bot, +}; +use std::sync::Arc; + +impl Bot { + /// Use this method to receive incoming updates using long polling ([wiki]). + /// + /// **Notes:** + /// 1. This method will not work if an outgoing webhook is set up. + /// 2. In order to avoid getting duplicate updates, + /// recalculate offset after each server response. + /// + /// [The official docs](https://core.telegram.org/bots/api#getupdates). + /// + /// [wiki]: https://en.wikipedia.org/wiki/Push_technology#Long_polling + pub fn get_updates(self: &Arc) -> GetUpdates { + GetUpdates::new(Arc::clone(self)) + } + + /// Use this method to specify a url and receive incoming updates via an + /// outgoing webhook. + /// + /// Whenever there is an update for the bot, we will send an + /// HTTPS POST request to the specified url, containing a JSON-serialized + /// [`Update`]. In case of an unsuccessful request, we will give up after a + /// reasonable amount of attempts. + /// + /// If you'd like to make sure that the Webhook request comes from Telegram, + /// we recommend using a secret path in the URL, e.g. + /// `https://www.example.com/`. Since nobody else knows your bot‘s + /// token, you can be pretty sure it’s us. + /// + /// [The official docs](https://core.telegram.org/bots/api#setwebhook). + /// + /// # Params + /// - `url`: HTTPS url to send updates to. + /// + /// Use an empty string to remove webhook integration. + /// + /// [`Update`]: crate::types::Update + pub fn set_webhook(self: &Arc, url: U) -> SetWebhook + where + U: Into, + { + SetWebhook::new(Arc::clone(self), url) + } + + /// Use this method to remove webhook integration if you decide to switch + /// back to [Bot::get_updates]. + /// + /// [The official docs](https://core.telegram.org/bots/api#deletewebhook). + /// + /// [Bot::get_updates]: crate::Bot::get_updates + pub fn delete_webhook(self: &Arc) -> DeleteWebhook { + DeleteWebhook::new(Arc::clone(self)) + } + + /// Use this method to get current webhook status. + /// + /// If the bot is using [`Bot::get_updates`], will return an object with the + /// url field empty. + /// + /// [The official docs](https://core.telegram.org/bots/api#getwebhookinfo). + /// + /// [`Bot::get_updates`]: crate::Bot::get_updates + pub fn get_webhook_info(self: &Arc) -> GetWebhookInfo { + GetWebhookInfo::new(Arc::clone(self)) + } + + /// A simple method for testing your bot's auth token. Requires no + /// parameters. + /// + /// [The official docs](https://core.telegram.org/bots/api#getme). + pub fn get_me(self: &Arc) -> GetMe { + GetMe::new(Arc::clone(self)) + } + + /// Use this method to send text messages. + /// + /// [The official docs](https://core.telegram.org/bots/api#sendmessage). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `text`: Text of the message to be sent. + pub fn send_message( + self: &Arc, + chat_id: C, + text: T, + ) -> SendMessage + where + C: Into, + T: Into, + { + SendMessage::new(Arc::clone(self), chat_id, text) + } + + /// Use this method to forward messages of any kind. + /// + /// [`The official docs`](https://core.telegram.org/bots/api#forwardmessage). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `from_chat_id`: Unique identifier for the chat where the original + /// message was sent (or channel username in the format + /// `@channelusername`). + /// - `message_id`: Message identifier in the chat specified in + /// [`from_chat_id`]. + /// + /// [`from_chat_id`]: ForwardMessage::from_chat_id + pub fn forward_message( + self: &Arc, + chat_id: C, + from_chat_id: F, + message_id: i32, + ) -> ForwardMessage + where + C: Into, + F: Into, + { + ForwardMessage::new(Arc::clone(self), chat_id, from_chat_id, message_id) + } + + /// Use this method to send photos. + /// + /// [The official docs](https://core.telegram.org/bots/api#sendphoto). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `photo`: Photo to send. + /// + /// Pass [`InputFile::File`] to send a photo that exists on + /// the Telegram servers (recommended), pass an [`InputFile::Url`] for + /// Telegram to get a .webp file from the Internet, or upload a new one + /// using [`InputFile::FileId`]. [More info on Sending Files »]. + /// + /// [`InputFile::File`]: crate::types::InputFile::File + /// [`InputFile::Url`]: crate::types::InputFile::Url + /// [`InputFile::FileId`]: crate::types::InputFile::FileId + /// + /// [More info on Sending Files »]: https://core.telegram.org/bots/api#sending-files + pub fn send_photo( + self: &Arc, + chat_id: C, + photo: InputFile, + ) -> SendPhoto + where + C: Into, + { + SendPhoto::new(Arc::clone(self), chat_id, photo) + } + + /// + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + pub fn send_audio( + self: &Arc, + chat_id: C, + audio: InputFile, + ) -> SendAudio + where + C: Into, + { + SendAudio::new(Arc::clone(self), chat_id, audio) + } + + /// Use this method to send general files. + /// + /// Bots can currently send files of any type of up to 50 MB in size, this + /// limit may be changed in the future. + /// + /// [The official docs](https://core.telegram.org/bots/api#senddocument). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `document`: File to send. + /// + /// Pass a file_id as String to send a file that exists on the + /// Telegram servers (recommended), pass an HTTP URL as a String for + /// Telegram to get a file from the Internet, or upload a new one using + /// `multipart/form-data`. [More info on Sending Files »]. + /// + /// [More info on Sending Files »]: https://core.telegram.org/bots/api#sending-files + pub fn send_document( + self: &Arc, + chat_id: C, + document: InputFile, + ) -> SendDocument + where + C: Into, + { + SendDocument::new(Arc::clone(self), chat_id, document) + } + + /// Use this method to send video files, Telegram clients support mp4 videos + /// (other formats may be sent as Document). + /// + /// Bots can currently send video files of up to 50 MB in size, this + /// limit may be changed in the future. + /// + /// [The official docs](https://core.telegram.org/bots/api#sendvideo). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `video`: Video to sent. + /// + /// Pass [`InputFile::File`] to send a file that exists on + /// the Telegram servers (recommended), pass an [`InputFile::Url`] for + /// Telegram to get a .webp file from the Internet, or upload a new one + /// using [`InputFile::FileId`]. [More info on Sending Files »]. + /// + /// [`InputFile::File`]: crate::types::InputFile::File + /// [`InputFile::Url`]: crate::types::InputFile::Url + /// [`InputFile::FileId`]: crate::types::InputFile::FileId + pub fn send_video( + self: &Arc, + chat_id: C, + video: InputFile, + ) -> SendVideo + where + C: Into, + { + SendVideo::new(Arc::clone(self), chat_id, video) + } + + /// Use this method to send animation files (GIF or H.264/MPEG-4 AVC video + /// without sound). + /// + /// Bots can currently send animation files of up to 50 MB in size, this + /// limit may be changed in the future. + /// + /// [The official docs](https://core.telegram.org/bots/api#sendanimation). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `animation`: Animation to send. + pub fn send_animation( + self: &Arc, + chat_id: C, + animation: InputFile, + ) -> SendAnimation + where + C: Into, + { + SendAnimation::new(Arc::clone(self), chat_id, animation) + } + + /// Use this method to send audio files, if you want Telegram clients to + /// display the file as a playable voice message. + /// + /// For this to work, your audio must be in an .ogg file encoded with OPUS + /// (other formats may be sent as [`Audio`] or [`Document`]). Bots can + /// currently send voice messages of up to 50 MB in size, this limit may + /// be changed in the future. + /// + /// [The official docs](https://core.telegram.org/bots/api#sendvoice). + /// + /// [`Audio`]: crate::types::Audio + /// [`Document`]: crate::types::Document + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `voice`: Audio file to send. + /// + /// Pass [`InputFile::File`] to send a file that exists on + /// the Telegram servers (recommended), pass an [`InputFile::Url`] for + /// Telegram to get a .webp file from the Internet, or upload a new one + /// using [`InputFile::FileId`]. [More info on Sending Files »]. + /// + /// [`InputFile::File`]: crate::types::InputFile::File + /// [`InputFile::Url`]: crate::types::InputFile::Url + /// [`InputFile::FileId`]: crate::types::InputFile::FileId + /// [More info on Sending Files »]: https://core.telegram.org/bots/api#sending-files + pub fn send_voice( + self: &Arc, + chat_id: C, + voice: InputFile, + ) -> SendVoice + where + C: Into, + { + SendVoice::new(Arc::clone(self), chat_id, voice) + } + + /// As of [v.4.0], Telegram clients support rounded square mp4 videos of up + /// to 1 minute long. Use this method to send video messages. + /// + /// [The official docs](https://core.telegram.org/bots/api#sendvideonote). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `video_note`: Video note to send. + /// + /// Pass [`InputFile::File`] to send a file that exists on the Telegram + /// servers (recommended), pass an [`InputFile::Url`] for Telegram to get a + /// .webp file from the Internet, or upload a new one using + /// [`InputFile::FileId`]. [More info on Sending Files »]. + /// + /// [v.4.0]: https://telegram.org/blog/video-messages-and-telescope + /// [`InputFile::File`]: crate::types::InputFile::File + /// [`InputFile::Url`]: crate::types::InputFile::Url + /// [`InputFile::FileId`]: crate::types::InputFile::FileId + /// [More info on Sending Files »]: https://core.telegram.org/bots/api#sending-files + pub fn send_video_note( + self: &Arc, + chat_id: C, + video_note: InputFile, + ) -> SendVideoNote + where + C: Into, + { + SendVideoNote::new(Arc::clone(self), chat_id, video_note) + } + + /// Use this method to send a group of photos or videos as an album. + /// + /// [The official docs](https://core.telegram.org/bots/api#sendmediagroup). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `media`: A JSON-serialized array describing photos and videos to be + /// sent, must include 2–10 items. + pub fn send_media_group( + self: &Arc, + chat_id: C, + media: M, + ) -> SendMediaGroup + where + C: Into, + M: Into>, + { + SendMediaGroup::new(Arc::clone(self), chat_id, media) + } + + /// Use this method to send point on the map. + /// + /// [The official docs](https://core.telegram.org/bots/api#sendlocation). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `latitude`: Latitude of the location. + /// - `longitude`: Latitude of the location. + pub fn send_location( + self: &Arc, + chat_id: C, + latitude: f32, + longitude: f32, + ) -> SendLocation + where + C: Into, + { + SendLocation::new(Arc::clone(self), chat_id, latitude, longitude) + } + + /// Use this method to edit live location messages. + /// + /// A location can be edited until its live_period expires or editing is + /// explicitly disabled by a call to stopMessageLiveLocation. On success, if + /// the edited message was sent by the bot, the edited [`Message`] is + /// returned, otherwise [`True`] is returned. + /// + /// [The official docs](https://core.telegram.org/bots/api#editmessagelivelocation). + /// + /// # Params + /// - `latitude`: Latitude of new location. + /// - `longitude`: Longitude of new location. + /// + /// [`Message`]: crate::types::Message + /// [`True`]: crate::types::True + pub fn edit_message_live_location( + self: &Arc, + chat_or_inline_message: ChatOrInlineMessage, + latitude: f32, + longitude: f32, + ) -> EditMessageLiveLocation { + EditMessageLiveLocation::new( + Arc::clone(self), + chat_or_inline_message, + latitude, + longitude, + ) + } + + /// Use this method to stop updating a live location message before + /// `live_period` expires. + /// + /// On success, if the message was sent by the bot, the sent [`Message`] is + /// returned, otherwise [`True`] is returned. + /// + /// [The official docs](https://core.telegram.org/bots/api#stopmessagelivelocation). + /// + /// [`Message`]: crate::types::Message + /// [`True`]: crate::types::True + pub fn stop_message_live_location( + self: &Arc, + chat_or_inline_message: ChatOrInlineMessage, + ) -> StopMessageLiveLocation { + StopMessageLiveLocation::new(Arc::clone(self), chat_or_inline_message) + } + + /// Use this method to send information about a venue. + /// + /// [The official docs](https://core.telegram.org/bots/api#sendvenue). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `latitude`: Latitude of the venue. + /// - `longitude`: Longitude of the venue. + /// - `title`: Name of the venue. + /// - `address`: Address of the venue. + pub fn send_venue( + self: &Arc, + chat_id: C, + latitude: f32, + longitude: f32, + title: T, + address: A, + ) -> SendVenue + where + C: Into, + T: Into, + A: Into, + { + SendVenue::new( + Arc::clone(self), + chat_id, + latitude, + longitude, + title, + address, + ) + } + + /// Use this method to send phone contacts. + /// + /// [The official docs](https://core.telegram.org/bots/api#sendcontact). + /// + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `phone_number`: Contact's phone number. + /// - `first_name`: Contact's first name. + pub fn send_contact( + self: &Arc, + chat_id: C, + phone_number: P, + first_name: F, + ) -> SendContact + where + C: Into, + P: Into, + F: Into, + { + SendContact::new(Arc::clone(self), chat_id, phone_number, first_name) + } + + /// Use this method to send a native poll. A native poll can't be sent to a + /// private chat. + /// + /// [The official docs](https://core.telegram.org/bots/api#sendpoll). + /// + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `question`: Poll question, 1-255 characters. + /// - `options`: List of answer options, 2-10 strings 1-100 characters + /// each. + pub fn send_poll( + self: &Arc, + chat_id: C, + question: Q, + options: O, + ) -> SendPoll + where + C: Into, + Q: Into, + O: Into>, + { + SendPoll::new(Arc::clone(self), chat_id, question, options) + } + + /// Use this method when you need to tell the user that something is + /// happening on the bot's side. + /// + /// The status is set for 5 seconds or less (when a message arrives from + /// your bot, Telegram clients clear its typing status). + /// + /// ## Note + /// Example: The [ImageBot] needs some time to process a request and upload + /// the image. Instead of sending a text message along the lines of + /// “Retrieving image, please wait…”, the bot may use + /// [`Bot::send_chat_action`] with `action = upload_photo`. The user + /// will see a `sending photo` status for the bot. + /// + /// We only recommend using this method when a response from the bot will + /// take a **noticeable** amount of time to arrive. + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// + /// [ImageBot]: https://t.me/imagebot + /// [`Bot::send_chat_action`]: crate::Bot::send_chat_action + pub fn send_chat_action( + self: &Arc, + chat_id: C, + action: SendChatActionKind, + ) -> SendChatAction + where + C: Into, + { + SendChatAction::new(Arc::clone(self), chat_id, action) + } + + /// Use this method to get a list of profile pictures for a user. + /// + /// [The official docs](https://core.telegram.org/bots/api#getuserprofilephotos). + /// + /// # Params + /// - `user_id`: Unique identifier of the target user. + pub fn get_user_profile_photos( + self: &Arc, + user_id: i32, + ) -> GetUserProfilePhotos { + GetUserProfilePhotos::new(Arc::clone(self), user_id) + } + + /// Use this method to get basic info about a file and prepare it for + /// downloading. + /// + /// For the moment, bots can download files of up to `20MB` in size. + /// + /// The file can then be downloaded via the link + /// `https://api.telegram.org/file/bot/`, where `` + /// is taken from the response. It is guaranteed that the link will be valid + /// for at least `1` hour. When the link expires, a new one can be requested + /// by calling [`GetFile`] again. + /// + /// **Note**: This function may not preserve the original file name and MIME + /// type. You should save the file's MIME type and name (if available) when + /// the [`File`] object is received. + /// + /// [The official docs](https://core.telegram.org/bots/api#getfile). + /// + /// # Params + /// - `file_id`: File identifier to get info about. + /// + /// [`File`]: crate::types::file + /// [`GetFile`]: self::GetFile + pub fn get_file(self: &Arc, file_id: F) -> GetFile + where + F: Into, + { + GetFile::new(Arc::clone(self), file_id) + } + + /// Use this method to kick a user from a group, a supergroup or a channel. + /// + /// In the case of supergroups and channels, the user will not be able to + /// return to the group on their own using invite links, etc., unless + /// [unbanned] first. The bot must be an administrator in the chat for + /// this to work and must have the appropriate admin rights. + /// + /// [The official docs](https://core.telegram.org/bots/api#kickchatmember). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `user_id`: Unique identifier of the target user. + /// + /// [unbanned]: crate::Bot::unban_chat_member + pub fn kick_chat_member( + self: &Arc, + chat_id: C, + user_id: i32, + ) -> KickChatMember + where + C: Into, + { + KickChatMember::new(Arc::clone(self), chat_id, user_id) + } + + /// Use this method to unban a previously kicked user in a supergroup or + /// channel. The user will **not** return to the group or channel + /// automatically, but will be able to join via link, etc. The bot must + /// be an administrator for this to work. + /// + /// [The official docs](https://core.telegram.org/bots/api#unbanchatmember). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `user_id`: Unique identifier of the target user. + pub fn unban_chat_member( + self: &Arc, + chat_id: C, + user_id: i32, + ) -> UnbanChatMember + where + C: Into, + { + UnbanChatMember::new(Arc::clone(self), chat_id, user_id) + } + + /// Use this method to restrict a user in a supergroup. + /// + /// The bot must be an administrator in the supergroup for this to work and + /// must have the appropriate admin rights. Pass `true` for all + /// permissions to lift restrictions from a user. + /// + /// [The official docs](https://core.telegram.org/bots/api#restrictchatmember). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `user_id`: Unique identifier of the target user. + /// - `permissions`: New user permissions. + pub fn restrict_chat_member( + self: &Arc, + chat_id: C, + user_id: i32, + permissions: ChatPermissions, + ) -> RestrictChatMember + where + C: Into, + { + RestrictChatMember::new(Arc::clone(self), chat_id, user_id, permissions) + } + + /// Use this method to promote or demote a user in a supergroup or a + /// channel. + /// + /// The bot must be an administrator in the chat for this to work and must + /// have the appropriate admin rights. Pass False for all boolean + /// parameters to demote a user. + /// + /// [The official docs](https://core.telegram.org/bots/api#promotechatmember). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `user_id`: Unique identifier of the target user. + pub fn promote_chat_member( + self: &Arc, + chat_id: C, + user_id: i32, + ) -> PromoteChatMember + where + C: Into, + { + PromoteChatMember::new(Arc::clone(self), chat_id, user_id) + } + + /// Use this method to set default chat permissions for all members. + /// + /// The bot must be an administrator in the group or a supergroup for this + /// to work and must have the can_restrict_members admin rights. + /// + /// [The official docs](https://core.telegram.org/bots/api#setchatpermissions). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `permissions`: New default chat permissions. + pub fn set_chat_permissions( + self: &Arc, + chat_id: C, + permissions: ChatPermissions, + ) -> SetChatPermissions + where + C: Into, + { + SetChatPermissions::new(Arc::clone(self), chat_id, permissions) + } + + /// Use this method to generate a new invite link for a chat; any previously + /// generated link is revoked. + /// + /// The bot must be an administrator in the chat for this to work and must + /// have the appropriate admin rights. + /// + /// # Note + /// Each administrator in a chat generates their own invite links. Bots + /// can't use invite links generated by other administrators. If you + /// want your bot to work with invite links, it will need to generate + /// its own link using [`Bot::export_chat_invite_link`] – after this the + /// link will become available to the bot via the [`Bot::get_chat`] + /// method. If your bot needs to generate a new invite link replacing + /// its previous one, use [`Bot::export_chat_invite_link`] again. + /// + /// [The official docs](https://core.telegram.org/bots/api#exportchatinvitelink). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// + /// [`Bot::export_chat_invite_link`]: crate::Bot::export_chat_invite_link + /// [`Bot::get_chat`]: crate::Bot::get_chat + pub fn export_chat_invite_link( + self: &Arc, + chat_id: C, + ) -> ExportChatInviteLink + where + C: Into, + { + ExportChatInviteLink::new(Arc::clone(self), chat_id) + } + + /// Use this method to set a new profile photo for the chat. + /// + /// Photos can't be changed for private chats. The bot must be an + /// administrator in the chat for this to work and must have the + /// appropriate admin rights. + /// + /// [The official docs](https://core.telegram.org/bots/api#setchatphoto). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `photo`: New chat photo, uploaded using `multipart/form-data`. + pub fn set_chat_photo( + self: &Arc, + chat_id: C, + photo: InputFile, + ) -> SetChatPhoto + where + C: Into, + { + SetChatPhoto::new(Arc::clone(self), chat_id, photo) + } + + /// Use this method to delete a chat photo. Photos can't be changed for + /// private chats. The bot must be an administrator in the chat for this + /// to work and must have the appropriate admin rights. + /// + /// [The official docs](https://core.telegram.org/bots/api#deletechatphoto). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + pub fn delete_chat_photo(self: &Arc, chat_id: C) -> DeleteChatPhoto + where + C: Into, + { + DeleteChatPhoto::new(Arc::clone(self), chat_id) + } + + /// Use this method to change the title of a chat. + /// + /// Titles can't be changed for private chats. The bot must be an + /// administrator in the chat for this to work and must have the + /// appropriate admin rights. + /// + /// [The official docs](https://core.telegram.org/bots/api#setchattitle). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `title`: New chat title, 1-255 characters. + pub fn set_chat_title( + self: &Arc, + chat_id: C, + title: T, + ) -> SetChatTitle + where + C: Into, + T: Into, + { + SetChatTitle::new(Arc::clone(self), chat_id, title) + } + + /// Use this method to change the description of a group, a supergroup or a + /// channel. + /// + /// The bot must be an administrator in the chat for this to work and must + /// have the appropriate admin rights. + /// + /// [The official docs](https://core.telegram.org/bots/api#setchatdescription). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + pub fn set_chat_description( + self: &Arc, + chat_id: C, + ) -> SetChatDescription + where + C: Into, + { + SetChatDescription::new(Arc::clone(self), chat_id) + } + + /// Use this method to pin a message in a group, a supergroup, or a channel. + /// + /// The bot must be an administrator in the chat for this to work and must + /// have the `can_pin_messages` admin right in the supergroup or + /// `can_edit_messages` admin right in the channel. + /// + /// [The official docs](https://core.telegram.org/bots/api#pinchatmessage). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `message_id`: Identifier of a message to pin. + pub fn pin_chat_message( + self: &Arc, + chat_id: C, + message_id: i32, + ) -> PinChatMessage + where + C: Into, + { + PinChatMessage::new(Arc::clone(self), chat_id, message_id) + } + + /// Use this method to unpin a message in a group, a supergroup, or a + /// channel. + /// + /// The bot must be an administrator in the chat for this to work and must + /// have the `can_pin_messages` admin right in the supergroup or + /// `can_edit_messages` admin right in the channel. + /// + /// [The official docs](https://core.telegram.org/bots/api#unpinchatmessage). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + pub fn unpin_chat_message( + self: &Arc, + chat_id: C, + ) -> UnpinChatMessage + where + C: Into, + { + UnpinChatMessage::new(Arc::clone(self), chat_id) + } + + /// Use this method for your bot to leave a group, supergroup or channel. + /// + /// [The official docs](https://core.telegram.org/bots/api#leavechat). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + pub fn leave_chat(self: &Arc, chat_id: C) -> LeaveChat + where + C: Into, + { + LeaveChat::new(Arc::clone(self), chat_id) + } + + /// Use this method to get up to date information about the chat (current + /// name of the user for one-on-one conversations, current username of a + /// user, group or channel, etc.). + /// + /// [The official docs](https://core.telegram.org/bots/api#getchat). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + pub fn get_chat(self: &Arc, chat_id: C) -> GetChat + where + C: Into, + { + GetChat::new(Arc::clone(self), chat_id) + } + + /// Use this method to get a list of administrators in a chat. + /// + /// If the chat is a group or a supergroup and no administrators were + /// appointed, only the creator will be returned. + /// + /// [The official docs](https://core.telegram.org/bots/api#getchatadministrators). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + pub fn get_chat_administrators( + self: &Arc, + chat_id: C, + ) -> GetChatAdministrators + where + C: Into, + { + GetChatAdministrators::new(Arc::clone(self), chat_id) + } + + /// Use this method to get the number of members in a chat. + /// + /// [The official docs](https://core.telegram.org/bots/api#getchatmemberscount). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + pub fn get_chat_members_count( + self: &Arc, + chat_id: C, + ) -> GetChatMembersCount + where + C: Into, + { + GetChatMembersCount::new(Arc::clone(self), chat_id) + } + + /// Use this method to get information about a member of a chat. + /// + /// [The official docs](https://core.telegram.org/bots/api#getchatmember). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup or channel (in the format `@channelusername`). + /// - `user_id`: Unique identifier of the target user. + pub fn get_chat_member( + self: &Arc, + chat_id: C, + user_id: i32, + ) -> GetChatMember + where + C: Into, + { + GetChatMember::new(Arc::clone(self), chat_id, user_id) + } + + /// Use this method to set a new group sticker set for a supergroup. + /// + /// The bot must be an administrator in the chat for this to work and must + /// have the appropriate admin rights. Use the field can_set_sticker_set + /// optionally returned in getChat requests to check if the bot can use + /// this method. + /// + /// [The official docs](https://core.telegram.org/bots/api#setchatstickerset). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup (in the format `@supergroupusername`). + /// - `sticker_set_name`: Name of the sticker set to be set as the group + /// sticker set. + pub fn set_chat_sticker_set( + self: &Arc, + chat_id: C, + sticker_set_name: S, + ) -> SetChatStickerSet + where + C: Into, + S: Into, + { + SetChatStickerSet::new(Arc::clone(self), chat_id, sticker_set_name) + } + + /// Use this method to delete a group sticker set from a supergroup. + /// + /// The bot must be an administrator in the chat for this to work and must + /// have the appropriate admin rights. Use the field + /// `can_set_sticker_set` optionally returned in [`Bot::get_chat`] + /// requests to check if the bot can use this method. + /// + /// [The official docs](https://core.telegram.org/bots/api#deletechatstickerset). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target supergroup (in the format `@supergroupusername`). + /// + /// [`Bot::get_chat`]: crate::Bot::get_chat + pub fn delete_chat_sticker_set( + self: &Arc, + chat_id: C, + ) -> DeleteChatStickerSet + where + C: Into, + { + DeleteChatStickerSet::new(Arc::clone(self), chat_id) + } + + /// Use this method to send answers to callback queries sent from [inline + /// keyboards]. + /// + /// The answer will be displayed to the user as a notification at + /// the top of the chat screen or as an alert. + /// + /// [The official docs](https://core.telegram.org/bots/api#answercallbackquery). + /// + /// # Params + /// - `callback_query_id`: Unique identifier for the query to be answered. + /// + /// [inline keyboards]: https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating + pub fn answer_callback_query( + self: &Arc, + callback_query_id: C, + ) -> AnswerCallbackQuery + where + C: Into, + { + AnswerCallbackQuery::new(Arc::clone(self), callback_query_id) + } + + /// Use this method to edit text and game messages. + /// + /// On success, if edited message is sent by the bot, the edited [`Message`] + /// is returned, otherwise [`True`] is returned. + /// + /// [The official docs](https://core.telegram.org/bots/api#editmessagetext). + /// + /// # Params + /// - New text of the message. + /// + /// [`Message`]: crate::types::Message + /// [`True`]: crate::types::True + pub fn edit_message_text( + self: &Arc, + chat_or_inline_message: ChatOrInlineMessage, + text: T, + ) -> EditMessageText + where + T: Into, + { + EditMessageText::new(Arc::clone(self), chat_or_inline_message, text) + } + + /// Use this method to edit captions of messages. + /// + /// On success, if edited message is sent by the bot, the edited [`Message`] + /// is returned, otherwise [`True`] is returned. + /// + /// [The official docs](https://core.telegram.org/bots/api#editmessagecaption). + /// + /// [`Message`]: crate::types::Message + /// [`True`]: crate::types::True + pub fn edit_message_caption( + self: &Arc, + chat_or_inline_message: ChatOrInlineMessage, + ) -> EditMessageCaption { + EditMessageCaption::new(Arc::clone(self), chat_or_inline_message) + } + + /// Use this method to edit animation, audio, document, photo, or video + /// messages. + /// + /// If a message is a part of a message album, then it can be edited only to + /// a photo or a video. Otherwise, message type can be changed + /// arbitrarily. When inline message is edited, new file can't be + /// uploaded. Use previously uploaded file via its `file_id` or specify + /// a URL. On success, if the edited message was sent by the bot, the + /// edited [`Message`] is returned, otherwise [`True`] is returned. + /// + /// [The official docs](https://core.telegram.org/bots/api#editmessagemedia). + /// + /// [`Message`]: crate::types::Message + /// [`True`]: crate::types::True + pub fn edit_message_media( + self: &Arc, + chat_or_inline_message: ChatOrInlineMessage, + media: InputMedia, + ) -> EditMessageMedia { + EditMessageMedia::new(Arc::clone(self), chat_or_inline_message, media) + } + + /// Use this method to edit only the reply markup of messages. + /// + /// On success, if edited message is sent by the bot, the edited [`Message`] + /// is returned, otherwise [`True`] is returned. + /// + /// [The official docs](https://core.telegram.org/bots/api#editmessagereplymarkup). + /// + /// [`Message`]: crate::types::Message + /// [`True`]: crate::types::True + pub fn edit_message_reply_markup( + self: &Arc, + chat_or_inline_message: ChatOrInlineMessage, + ) -> EditMessageReplyMarkup { + EditMessageReplyMarkup::new(Arc::clone(self), chat_or_inline_message) + } + + /// Use this method to stop a poll which was sent by the bot. + /// + /// [The official docs](https://core.telegram.org/bots/api#stoppoll). + /// + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target channel (in the format `@channelusername`). + /// - `message_id`: Identifier of the original message with the poll. + pub fn stop_poll( + self: &Arc, + chat_id: C, + message_id: i32, + ) -> StopPoll + where + C: Into, + { + StopPoll::new(Arc::clone(self), chat_id, message_id) + } + + /// Use this method to delete a message, including service messages. + /// + /// The limitations are: + /// - A message can only be deleted if it was sent less than 48 hours ago. + /// - Bots can delete outgoing messages in private chats, groups, and + /// supergroups. + /// - Bots can delete incoming messages in private chats. + /// - Bots granted can_post_messages permissions can delete outgoing + /// messages in channels. + /// - If the bot is an administrator of a group, it can delete any message + /// there. + /// - If the bot has can_delete_messages permission in a supergroup or a + /// channel, it can delete any message there. + /// + /// [The official docs](https://core.telegram.org/bots/api#deletemessage). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target channel (in the format `@channelusername`). + /// - `message_id`: Identifier of the message to delete. + pub fn delete_message( + self: &Arc, + chat_id: C, + message_id: i32, + ) -> DeleteMessage + where + C: Into, + { + DeleteMessage::new(Arc::clone(self), chat_id, message_id) + } + + /// Use this method to send static .WEBP or [animated] .TGS stickers. + /// + /// [The official docs](https://core.telegram.org/bots/api#sendsticker). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target channel (in the format `@channelusername`). + /// - `sticker`: Sticker to send. + /// + /// Pass [`InputFile::File`] to send a file that exists on the Telegram + /// servers (recommended), pass an [`InputFile::Url`] for Telegram to get a + /// .webp file from the Internet, or upload a new one using + /// [`InputFile::FileId`]. [More info on Sending Files »]. + /// + /// [animated]: https://telegram.org/blog/animated-stickers + /// [`InputFile::File`]: crate::types::InputFile::File + /// [`InputFile::Url`]: crate::types::InputFile::Url + /// [`InputFile::FileId`]: crate::types::InputFile::FileId + /// [More info on Sending Files »]: https://core.telegram.org/bots/api#sending-files + pub fn send_sticker( + self: &Arc, + chat_id: C, + sticker: InputFile, + ) -> SendSticker + where + C: Into, + { + SendSticker::new(Arc::clone(self), chat_id, sticker) + } + + /// Use this method to get a sticker set. + /// + /// [The official docs](https://core.telegram.org/bots/api#getstickerset). + /// + /// # Params + /// - `name`: Name of the sticker set. + pub fn get_sticker_set(self: &Arc, name: N) -> GetStickerSet + where + N: Into, + { + GetStickerSet::new(Arc::clone(self), name) + } + + /// Use this method to upload a .png file with a sticker for later use in + /// [`Bot::create_new_sticker_set`] and [`Bot::add_sticker_to_set`] methods + /// (can be used multiple times). + /// + /// [The official docs](https://core.telegram.org/bots/api#uploadstickerfile). + /// + /// # Params + /// - `user_id`: User identifier of sticker file owner. + /// - `png_sticker`: **Png** image with the sticker, must be up to 512 + /// kilobytes in size, dimensions must not exceed 512px, and either + /// width or height must be exactly 512px. [More info on Sending Files + /// »]. + /// + /// [More info on Sending Files »]: https://core.telegram.org/bots/api#sending-files + /// [`Bot::create_new_sticker_set`]: crate::Bot::create_new_sticker_set + /// [`Bot::add_sticker_to_set`]: crate::Bot::add_sticker_to_set + pub fn upload_sticker_file( + self: &Arc, + user_id: i32, + png_sticker: InputFile, + ) -> UploadStickerFile { + UploadStickerFile::new(Arc::clone(self), user_id, png_sticker) + } + + /// Use this method to create new sticker set owned by a user. The bot will + /// be able to edit the created sticker set. + /// + /// [The official docs](https://core.telegram.org/bots/api#createnewstickerset). + /// + /// # Params + /// - `user_id`: User identifier of created sticker set owner. + /// - `name`: Short name of sticker set, to be used in `t.me/addstickers/` + /// URLs (e.g., animals). Can contain only english letters, digits and + /// underscores. + /// + /// Must begin with a letter, can't contain consecutive underscores and must + /// end in `_by_`. `` is case insensitive. 1-64 + /// characters. + /// - `title`: Sticker set title, 1-64 characters. + /// - `png_sticker`: **Png** image with the sticker, must be up to 512 + /// kilobytes in size, dimensions must not exceed 512px, and either + /// width or height must be exactly 512px. + /// + /// Pass [`InputFile::File`] to send a file that exists on the Telegram + /// servers (recommended), pass an [`InputFile::Url`] for Telegram to get a + /// .webp file from the Internet, or upload a new one using + /// [`InputFile::FileId`]. [More info on Sending Files »]. + /// - `emojis`: One or more emoji corresponding to the sticker. + /// + /// [`InputFile::File`]: crate::types::InputFile::File + /// [`InputFile::Url`]: crate::types::InputFile::Url + /// [`InputFile::FileId`]: crate::types::InputFile::FileId + pub fn create_new_sticker_set( + self: &Arc, + user_id: i32, + name: N, + title: T, + png_sticker: InputFile, + emojis: E, + ) -> CreateNewStickerSet + where + N: Into, + T: Into, + E: Into, + { + CreateNewStickerSet::new( + Arc::clone(self), + user_id, + name, + title, + png_sticker, + emojis, + ) + } + + /// Use this method to add a new sticker to a set created by the bot. + /// + /// [The official docs](https://core.telegram.org/bots/api#addstickertoset). + /// + /// # Params + /// - `user_id`: User identifier of sticker set owner. + /// - `name`: Sticker set name. + /// - `png_sticker`: **Png** image with the sticker, must be up to 512 + /// kilobytes in size, dimensions must not exceed 512px, and either + /// width or height must be exactly 512px. + /// + /// Pass [`InputFile::File`] to send a file that exists on the Telegram + /// servers (recommended), pass an [`InputFile::Url`] for Telegram to get a + /// .webp file from the Internet, or upload a new one using [`InputFile: + /// :FileId`]. [More info on Sending Files »]. + /// - `emojis`: One or more emoji corresponding to the sticker. + /// + /// [`InputFile::File`]: crate::types::InputFile::File + /// [`InputFile::Url`]: crate::types::InputFile::Url + /// [`InputFile::FileId`]: crate::types::InputFile::FileId + pub fn add_sticker_to_set( + self: &Arc, + user_id: i32, + name: N, + png_sticker: InputFile, + emojis: E, + ) -> AddStickerToSet + where + N: Into, + E: Into, + { + AddStickerToSet::new( + Arc::clone(self), + user_id, + name, + png_sticker, + emojis, + ) + } + + /// Use this method to move a sticker in a set created by the bot to a + /// specific position. + /// + /// [The official docs](https://core.telegram.org/bots/api#setstickerpositioninset). + /// + /// # Params + /// - `sticker`: File identifier of the sticker. + /// - `position`: New sticker position in the set, zero-based. + pub fn set_sticker_position_in_set( + self: &Arc, + sticker: S, + position: i32, + ) -> SetStickerPositionInSet + where + S: Into, + { + SetStickerPositionInSet::new(Arc::clone(self), sticker, position) + } + + /// Use this method to delete a sticker from a set created by the bot. + /// + /// [The official docs](https://core.telegram.org/bots/api#deletestickerfromset). + /// + /// # Params + /// - `sticker`: File identifier of the sticker. + pub fn delete_sticker_from_set( + self: &Arc, + sticker: S, + ) -> DeleteStickerFromSet + where + S: Into, + { + DeleteStickerFromSet::new(Arc::clone(self), sticker) + } + + /// Use this method to send answers to an inline query. + /// + /// No more than **50** results per query are allowed. + /// + /// [The official docs](https://core.telegram.org/bots/api#answerinlinequery). + /// + /// # Params + /// - `inline_query_id`: Unique identifier for the answered query. + /// - `results`: A JSON-serialized array of results for the inline query. + pub fn answer_inline_query( + self: &Arc, + inline_query_id: I, + results: R, + ) -> AnswerInlineQuery + where + I: Into, + R: Into>, + { + AnswerInlineQuery::new(Arc::clone(self), inline_query_id, results) + } + + /// Use this method to send invoices. + /// + /// [The official docs](https://core.telegram.org/bots/api#sendinvoice). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target private chat. + /// - `title`: Product name, 1-32 characters. + /// - `description`: Product description, 1-255 characters. + /// - `payload`: Bot-defined invoice payload, 1-128 bytes. This will not + /// be displayed to the user, use for your internal processes. + /// - `provider_token`: Payments provider token, obtained via + /// [@Botfather]. + /// - `start_parameter`: Unique deep-linking parameter that can be used to + /// generate this invoice when used as a start parameter. + /// - `currency`: Three-letter ISO 4217 currency code, see [more on + /// currencies]. + /// - `prices`: Price breakdown, a list of components (e.g. product price, + /// tax, discount, delivery cost, delivery tax, bonus, etc.). + /// + /// [more on currencies]: https://core.telegram.org/bots/payments#supported-currencies + /// [@Botfather]: https://t.me/botfather + #[allow(clippy::too_many_arguments)] + pub fn send_invoice( + self: &Arc, + chat_id: i32, + title: T, + description: D, + payload: Pl, + provider_token: Pt, + start_parameter: S, + currency: C, + prices: Pr, + ) -> SendInvoice + where + T: Into, + D: Into, + Pl: Into, + Pt: Into, + S: Into, + C: Into, + Pr: Into>, + { + SendInvoice::new( + Arc::clone(self), + chat_id, + title, + description, + payload, + provider_token, + start_parameter, + currency, + prices, + ) + } + + /// Once the user has confirmed their payment and shipping details, the Bot + /// API sends the final confirmation in the form of an [`Update`] with + /// the field `pre_checkout_query`. Use this method to respond to such + /// pre-checkout queries. Note: The Bot API must receive an answer + /// within 10 seconds after the pre-checkout query was sent. + /// + /// [The official docs](https://core.telegram.org/bots/api#answerprecheckoutquery). + /// + /// # Params + /// - `shipping_query_id`: Unique identifier for the query to be answered. + /// - `ok`: Specify `true` if delivery to the specified address is + /// possible and `false` if there are any problems (for example, if + /// delivery to the specified address is not possible). + /// + /// [`Update`]: crate::types::Update + pub fn answer_shipping_query( + self: &Arc, + shipping_query_id: S, + ok: bool, + ) -> AnswerShippingQuery + where + S: Into, + { + AnswerShippingQuery::new(Arc::clone(self), shipping_query_id, ok) + } + + /// Once the user has confirmed their payment and shipping details, the Bot + /// API sends the final confirmation in the form of an [`Update`] with + /// the field `pre_checkout_query`. Use this method to respond to such + /// pre-checkout queries. Note: The Bot API must receive an answer + /// within 10 seconds after the pre-checkout query was sent. + /// + /// [The official docs](https://core.telegram.org/bots/api#answerprecheckoutquery). + /// + /// # Params + /// - `pre_checkout_query_id`: Unique identifier for the query to be + /// answered. + /// - `ok`: Specify `true` if everything is alright (goods are available, + /// etc.) and the bot is ready to proceed with the order. Use False if + /// there are any problems. + /// + /// [`Update`]: crate::types::Update + pub fn answer_pre_checkout_query

( + self: &Arc, + pre_checkout_query_id: P, + ok: bool, + ) -> AnswerPreCheckoutQuery + where + P: Into, + { + AnswerPreCheckoutQuery::new(Arc::clone(self), pre_checkout_query_id, ok) + } + + /// Use this method to send a game. + /// + /// [The official docs](https://core.telegram.org/bots/api#sendgame). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat. + /// - `game_short_name`: Short name of the game, serves as the unique + /// identifier for the game. Set up your games via [@Botfather]. + /// + /// [@Botfather]: https://t.me/botfather + pub fn send_game( + self: &Arc, + chat_id: i32, + game_short_name: G, + ) -> SendGame + where + G: Into, + { + SendGame::new(Arc::clone(self), chat_id, game_short_name) + } + + /// Use this method to set the score of the specified user in a game. + /// + /// On success, if the message was sent by the bot, returns the edited + /// [`Message`], otherwise returns [`True`]. Returns an error, if the new + /// score is not greater than the user's current score in the chat and + /// force is `false`. + /// + /// [The official docs](https://core.telegram.org/bots/api#setgamescore). + /// + /// # Params + /// - `user_id`: User identifier. + /// - `score`: New score, must be non-negative. + /// + /// [`Message`]: crate::types::Message + /// [`True`]: crate::types::True + pub fn set_game_score( + self: &Arc, + chat_or_inline_message: ChatOrInlineMessage, + user_id: i32, + score: i32, + ) -> SetGameScore { + SetGameScore::new( + Arc::clone(self), + chat_or_inline_message, + user_id, + score, + ) + } + + /// Use this method to get data for high score tables. + /// + /// Will return the score of the specified user and several of his neighbors + /// in a game. + /// + /// # Note + /// This method will currently return scores for the target user, plus two + /// of his closest neighbors on each side. Will also return the top + /// three users if the user and his neighbors are not among them. Please + /// note that this behavior is subject to change. + /// + /// [The official docs](https://core.telegram.org/bots/api#getgamehighscores). + /// + /// # Params + /// - `user_id`: Target user id. + pub fn get_game_high_scores( + self: &Arc, + chat_or_inline_message: ChatOrInlineMessage, + user_id: i32, + ) -> GetGameHighScores { + GetGameHighScores::new( + Arc::clone(self), + chat_or_inline_message, + user_id, + ) + } + + /// Use this method to set a custom title for an administrator in a + /// supergroup promoted by the bot. + /// + /// [The official docs](https://core.telegram.org/bots/api#setchatadministratorcustomtitle). + /// + /// # Params + /// - `chat_id`: Unique identifier for the target chat or username of the + /// target channel (in the format `@channelusername`). + /// - `user_id`: Unique identifier of the target user. + /// - `custom_title`: New custom title for the administrator; 0-16 + /// characters, emoji are not allowed. + pub fn set_chat_administrator_custom_title( + self: &Arc, + chat_id: C, + user_id: i32, + custom_title: CT, + ) -> SetChatAdministratorCustomTitle + where + C: Into, + CT: Into, + { + SetChatAdministratorCustomTitle::new( + Arc::clone(self), + chat_id, + user_id, + custom_title, + ) + } +} diff --git a/src/bot/download.rs b/src/bot/download.rs new file mode 100644 index 00000000..985e36e4 --- /dev/null +++ b/src/bot/download.rs @@ -0,0 +1,66 @@ +use tokio::io::AsyncWrite; + +#[cfg(feature = "unstable-stream")] +use ::{bytes::Bytes, tokio::stream::Stream}; + +#[cfg(feature = "unstable-stream")] +use crate::net::download_file_stream; +use crate::{bot::Bot, net::download_file, DownloadError}; + +impl Bot { + /// Download a file from Telegram into `destination`. + /// + /// `path` can be obtained from [`Bot::get_file`]. + /// + /// To download as a stream of chunks, see [`Bot::download_file_stream`]. + /// + /// ## Examples + /// + /// ```no_run + /// use teloxide::types::File as TgFile; + /// use tokio::fs::File; + /// # use teloxide::RequestError; + /// use teloxide::{requests::Request, Bot}; + /// + /// # async fn run() -> Result<(), Box> { + /// let bot = Bot::new("TOKEN"); + /// let mut file = File::create("/home/waffle/Pictures/test.png").await?; + /// + /// let TgFile { file_path, .. } = bot.get_file("*file_id*").send().await?; + /// bot.download_file(&file_path, &mut file).await?; + /// # Ok(()) } + /// ``` + /// + /// [`Bot::get_file`]: crate::Bot::get_file + /// [`Bot::download_file_stream`]: crate::Bot::download_file_stream + pub async fn download_file( + &self, + path: &str, + destination: &mut D, + ) -> Result<(), DownloadError> + where + D: AsyncWrite + Unpin, + { + download_file(&self.client, &self.token, path, destination).await + } + + /// Download a file from Telegram. + /// + /// `path` can be obtained from the [`Bot::get_file`]. + /// + /// To download into [`AsyncWrite`] (e.g. [`tokio::fs::File`]), see + /// [`Bot::download_file`]. + /// + /// [`Bot::get_file`]: crate::bot::Bot::get_file + /// [`AsyncWrite`]: tokio::io::AsyncWrite + /// [`tokio::fs::File`]: tokio::fs::File + /// [`Bot::download_file`]: crate::Bot::download_file + #[cfg(feature = "unstable-stream")] + pub async fn download_file_stream( + &self, + path: &str, + ) -> Result>, reqwest::Error> + { + download_file_stream(&self.client, &self.token, path).await + } +} diff --git a/src/bot/mod.rs b/src/bot/mod.rs new file mode 100644 index 00000000..942caae1 --- /dev/null +++ b/src/bot/mod.rs @@ -0,0 +1,80 @@ +use reqwest::Client; +use std::sync::Arc; + +mod api; +mod download; + +/// A Telegram bot used to send requests. +#[derive(Debug, Clone)] +pub struct Bot { + token: String, + client: Client, +} + +impl Bot { + /// Creates a new `Bot` with the `TELOXIDE_TOKEN` environmental variable (a + /// bot's token) and the default [`reqwest::Client`]. + /// + /// # Panics + /// If cannot get the `TELOXIDE_TOKEN` environmental variable. + /// + /// [`reqwest::Client`]: https://docs.rs/reqwest/0.10.1/reqwest/struct.Client.html + pub fn from_env() -> Arc { + Self::new( + std::env::var("TELOXIDE_TOKEN") + .expect("Cannot get the TELOXIDE_TOKEN env variable"), + ) + } + + /// Creates a new `Bot` with the `TELOXIDE_TOKEN` environmental variable (a + /// bot's token) and your [`reqwest::Client`]. + /// + /// # Panics + /// If cannot get the `TELOXIDE_TOKEN` environmental variable. + /// + /// [`reqwest::Client`]: https://docs.rs/reqwest/0.10.1/reqwest/struct.Client.html + pub fn from_env_with_client(client: Client) -> Arc { + Self::with_client( + std::env::var("TELOXIDE_TOKEN") + .expect("Cannot get the TELOXIDE_TOKEN env variable"), + client, + ) + } + + /// Creates a new `Bot` with the specified token and the default + /// [`reqwest::Client`]. + /// + /// [`reqwest::Client`]: https://docs.rs/reqwest/0.10.1/reqwest/struct.Client.html + pub fn new(token: S) -> Arc + where + S: Into, + { + Self::with_client(token, Client::new()) + } + + /// Creates a new `Bot` with the specified token and your + /// [`reqwest::Client`]. + /// + /// [`reqwest::Client`]: https://docs.rs/reqwest/0.10.1/reqwest/struct.Client.html + pub fn with_client(token: S, client: Client) -> Arc + where + S: Into, + { + Arc::new(Self { + token: token.into(), + client, + }) + } +} + +impl Bot { + // TODO: const fn + pub fn token(&self) -> &str { + &self.token + } + + // TODO: const fn + pub fn client(&self) -> &Client { + &self.client + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs deleted file mode 100644 index 32bd3ec3..00000000 --- a/src/core/mod.rs +++ /dev/null @@ -1,64 +0,0 @@ -use futures::compat::Future01CompatExt; -use reqwest::r#async::Client; -use reqwest::StatusCode; -use serde::Deserialize; -use serde_json::Value; - -lazy_static! { - static ref REQWEST_CLIENT: Client = Client::new(); -} - -const TELEGRAM_URL_START: &str = "https://api.telegram.org/bot"; - -#[derive(Debug)] -pub enum Error { - Api { - status_code: StatusCode, - description: Option, - }, - Send(reqwest::Error), - InvalidJson(reqwest::Error), -} - -pub type Response = Result; - -#[derive(Debug, Deserialize)] -pub struct User { - id: i64, - is_bot: bool, - first_name: String, - last_name: Option, - username: Option, - language_code: Option, -} - -pub async fn get_me(bot_token: &str) -> Response { - let mut response = REQWEST_CLIENT - .get(&format!( - "{}{bot_token}/getMe", - TELEGRAM_URL_START, - bot_token = bot_token - )) - .send() - .compat() - .await - .map_err(Error::Send)?; - - let response_json = response - .json::() - .compat() - .await - .map_err(Error::InvalidJson)?; - - if response_json["ok"] == "false" { - return Err(Error::Api { - status_code: response.status(), - description: match response_json.get("description") { - None => None, - Some(description) => Some(description.to_string()), - }, - }); - } - - Ok(serde_json::from_value(response_json["result"].clone()).unwrap()) -} diff --git a/src/dispatching/ctx_handlers.rs b/src/dispatching/ctx_handlers.rs new file mode 100644 index 00000000..ba6e22bc --- /dev/null +++ b/src/dispatching/ctx_handlers.rs @@ -0,0 +1,31 @@ +use std::{future::Future, pin::Pin}; + +/// An asynchronous handler of a context. +/// +/// See [the module-level documentation for the design +/// overview](crate::dispatching). +pub trait CtxHandler { + #[must_use] + fn handle_ctx<'a>( + &'a self, + ctx: Ctx, + ) -> Pin + 'a>> + where + Ctx: 'a; +} + +impl CtxHandler for F +where + F: Fn(Ctx) -> Fut, + Fut: Future, +{ + fn handle_ctx<'a>( + &'a self, + ctx: Ctx, + ) -> Pin + 'a>> + where + Ctx: 'a, + { + Box::pin(async move { self(ctx).await }) + } +} diff --git a/src/dispatching/dialogue/dialogue_dispatcher.rs b/src/dispatching/dialogue/dialogue_dispatcher.rs new file mode 100644 index 00000000..4b5fea97 --- /dev/null +++ b/src/dispatching/dialogue/dialogue_dispatcher.rs @@ -0,0 +1,97 @@ +use crate::dispatching::{ + dialogue::{ + DialogueHandlerCtx, DialogueStage, GetChatId, InMemStorage, Storage, + }, + CtxHandler, DispatcherHandlerCtx, +}; +use std::{future::Future, pin::Pin}; + +/// A dispatcher of dialogues. +/// +/// Note that `DialogueDispatcher` implements `CtxHandler`, so you can just put +/// an instance of this dispatcher into the [`Dispatcher`]'s methods. +/// +/// [`Dispatcher`]: crate::dispatching::Dispatcher +pub struct DialogueDispatcher<'a, D, H> { + storage: Box + 'a>, + handler: H, +} + +impl<'a, D, H> DialogueDispatcher<'a, D, H> +where + D: Default + 'a, +{ + /// Creates a dispatcher with the specified `handler` and [`InMemStorage`] + /// (a default storage). + /// + /// [`InMemStorage`]: crate::dispatching::dialogue::InMemStorage + #[must_use] + pub fn new(handler: H) -> Self { + Self { + storage: Box::new(InMemStorage::default()), + handler, + } + } + + /// Creates a dispatcher with the specified `handler` and `storage`. + #[must_use] + pub fn with_storage(handler: H, storage: Stg) -> Self + where + Stg: Storage + 'a, + { + Self { + storage: Box::new(storage), + handler, + } + } +} + +impl<'a, D, H, Upd> CtxHandler, Result<(), ()>> + for DialogueDispatcher<'a, D, H> +where + H: CtxHandler, DialogueStage>, + Upd: GetChatId, + D: Default, +{ + fn handle_ctx<'b>( + &'b self, + ctx: DispatcherHandlerCtx, + ) -> Pin> + 'b>> + where + Upd: 'b, + { + Box::pin(async move { + let chat_id = ctx.update.chat_id(); + + let dialogue = self + .storage + .remove_dialogue(chat_id) + .await + .unwrap_or_default(); + + if let DialogueStage::Next(new_dialogue) = self + .handler + .handle_ctx(DialogueHandlerCtx { + bot: ctx.bot, + update: ctx.update, + dialogue, + }) + .await + { + if self + .storage + .update_dialogue(chat_id, new_dialogue) + .await + .is_some() + { + panic!( + "We previously storage.remove_dialogue() so \ + storage.update_dialogue() must return None" + ); + } + } + + Ok(()) + }) + } +} diff --git a/src/dispatching/dialogue/dialogue_handler_ctx.rs b/src/dispatching/dialogue/dialogue_handler_ctx.rs new file mode 100644 index 00000000..257744aa --- /dev/null +++ b/src/dispatching/dialogue/dialogue_handler_ctx.rs @@ -0,0 +1,190 @@ +use crate::{ + dispatching::dialogue::GetChatId, + requests::{ + DeleteMessage, EditMessageCaption, EditMessageText, ForwardMessage, + PinChatMessage, SendAnimation, SendAudio, SendContact, SendDocument, + SendLocation, SendMediaGroup, SendMessage, SendPhoto, SendSticker, + SendVenue, SendVideo, SendVideoNote, SendVoice, + }, + types::{ChatId, ChatOrInlineMessage, InputFile, InputMedia, Message}, + Bot, +}; +use std::sync::Arc; + +/// A context of a [`DialogueDispatcher`]'s message handler. +/// +/// [`DialogueDispatcher`]: crate::dispatching::dialogue::DialogueDispatcher +pub struct DialogueHandlerCtx { + pub bot: Arc, + pub update: Upd, + pub dialogue: D, +} + +impl DialogueHandlerCtx { + /// Creates a new instance with the provided fields. + pub fn new(bot: Arc, update: Upd, dialogue: D) -> Self { + Self { + bot, + update, + dialogue, + } + } + + /// Creates a new instance by substituting a dialogue and preserving + /// `self.bot` and `self.update`. + pub fn with_new_dialogue( + self, + new_dialogue: Nd, + ) -> DialogueHandlerCtx { + DialogueHandlerCtx { + bot: self.bot, + update: self.update, + dialogue: new_dialogue, + } + } +} + +impl GetChatId for DialogueHandlerCtx +where + Upd: GetChatId, +{ + fn chat_id(&self) -> i64 { + self.update.chat_id() + } +} + +impl DialogueHandlerCtx { + pub fn answer(&self, text: T) -> SendMessage + where + T: Into, + { + self.bot.send_message(self.chat_id(), text) + } + + pub fn reply_to(&self, text: T) -> SendMessage + where + T: Into, + { + self.bot + .send_message(self.chat_id(), text) + .reply_to_message_id(self.update.id) + } + + pub fn answer_photo(&self, photo: InputFile) -> SendPhoto { + self.bot.send_photo(self.update.chat.id, photo) + } + + pub fn answer_audio(&self, audio: InputFile) -> SendAudio { + self.bot.send_audio(self.update.chat.id, audio) + } + + pub fn answer_animation(&self, animation: InputFile) -> SendAnimation { + self.bot.send_animation(self.update.chat.id, animation) + } + + pub fn answer_document(&self, document: InputFile) -> SendDocument { + self.bot.send_document(self.update.chat.id, document) + } + + pub fn answer_video(&self, video: InputFile) -> SendVideo { + self.bot.send_video(self.update.chat.id, video) + } + + pub fn answer_voice(&self, voice: InputFile) -> SendVoice { + self.bot.send_voice(self.update.chat.id, voice) + } + + pub fn answer_media_group(&self, media_group: T) -> SendMediaGroup + where + T: Into>, + { + self.bot.send_media_group(self.update.chat.id, media_group) + } + + pub fn answer_location( + &self, + latitude: f32, + longitude: f32, + ) -> SendLocation { + self.bot + .send_location(self.update.chat.id, latitude, longitude) + } + + pub fn answer_venue( + &self, + latitude: f32, + longitude: f32, + title: T, + address: U, + ) -> SendVenue + where + T: Into, + U: Into, + { + self.bot.send_venue( + self.update.chat.id, + latitude, + longitude, + title, + address, + ) + } + + pub fn answer_video_note(&self, video_note: InputFile) -> SendVideoNote { + self.bot.send_video_note(self.update.chat.id, video_note) + } + + pub fn answer_contact( + &self, + phone_number: T, + first_name: U, + ) -> SendContact + where + T: Into, + U: Into, + { + self.bot + .send_contact(self.chat_id(), phone_number, first_name) + } + + pub fn answer_sticker(&self, sticker: InputFile) -> SendSticker { + self.bot.send_sticker(self.update.chat.id, sticker) + } + + pub fn forward_to(&self, chat_id: T) -> ForwardMessage + where + T: Into, + { + self.bot + .forward_message(chat_id, self.update.chat.id, self.update.id) + } + + pub fn edit_message_text(&self, text: T) -> EditMessageText + where + T: Into, + { + self.bot.edit_message_text( + ChatOrInlineMessage::Chat { + chat_id: self.update.chat.id.into(), + message_id: self.update.id, + }, + text, + ) + } + + pub fn edit_message_caption(&self) -> EditMessageCaption { + self.bot.edit_message_caption(ChatOrInlineMessage::Chat { + chat_id: self.update.chat.id.into(), + message_id: self.update.id, + }) + } + + pub fn delete_message(&self) -> DeleteMessage { + self.bot.delete_message(self.update.chat.id, self.update.id) + } + + pub fn pin_message(&self) -> PinChatMessage { + self.bot + .pin_chat_message(self.update.chat.id, self.update.id) + } +} diff --git a/src/dispatching/dialogue/dialogue_stage.rs b/src/dispatching/dialogue/dialogue_stage.rs new file mode 100644 index 00000000..afb5a31c --- /dev/null +++ b/src/dispatching/dialogue/dialogue_stage.rs @@ -0,0 +1,16 @@ +/// Continue or terminate a dialogue. +#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq)] +pub enum DialogueStage { + Next(D), + Exit, +} + +/// A shortcut for `Ok(DialogueStage::Next(dialogue))`. +pub fn next(dialogue: D) -> Result, E> { + Ok(DialogueStage::Next(dialogue)) +} + +/// A shortcut for `Ok(DialogueStage::Exit)`. +pub fn exit() -> Result, E> { + Ok(DialogueStage::Exit) +} diff --git a/src/dispatching/dialogue/get_chat_id.rs b/src/dispatching/dialogue/get_chat_id.rs new file mode 100644 index 00000000..d7e64206 --- /dev/null +++ b/src/dispatching/dialogue/get_chat_id.rs @@ -0,0 +1,13 @@ +use crate::types::Message; + +/// Something that has a chat ID. +pub trait GetChatId { + #[must_use] + fn chat_id(&self) -> i64; +} + +impl GetChatId for Message { + fn chat_id(&self) -> i64 { + self.chat.id + } +} diff --git a/src/dispatching/dialogue/mod.rs b/src/dispatching/dialogue/mod.rs new file mode 100644 index 00000000..33fd28b6 --- /dev/null +++ b/src/dispatching/dialogue/mod.rs @@ -0,0 +1,48 @@ +//! Dealing with dialogues. +//! +//! There are four main components: +//! +//! 1. Your type `D`, which designates a dialogue state at the current +//! moment. +//! 2. [`Storage`], which encapsulates all the dialogues. +//! 3. Your handler, which receives an update and turns your dialogue into the +//! next state. +//! 4. [`DialogueDispatcher`], which encapsulates your handler, [`Storage`], +//! and implements [`CtxHandler`]. +//! +//! You supply [`DialogueDispatcher`] into [`Dispatcher`]. Every time +//! [`Dispatcher`] calls `DialogueDispatcher::handle_ctx(...)`, the following +//! steps are executed: +//! +//! 1. If a storage doesn't contain a dialogue from this chat, supply +//! `D::default()` into you handler, otherwise, supply the saved session +//! from this chat. +//! 2. If a handler has returned [`DialogueStage::Exit`], remove the session +//! from the storage, otherwise ([`DialogueStage::Next`]) force the storage to +//! update the session. +//! +//! Please, see [examples/dialogue_bot] as an example. +//! +//! [`Storage`]: crate::dispatching::dialogue::Storage +//! [`DialogueDispatcher`]: crate::dispatching::dialogue::DialogueDispatcher +//! [`DialogueStage::Exit`]: +//! crate::dispatching::dialogue::DialogueStage::Exit +//! [`DialogueStage::Next`]: crate::dispatching::dialogue::DialogueStage::Next +//! [`CtxHandler`]: crate::dispatching::CtxHandler +//! [`Dispatcher`]: crate::dispatching::Dispatcher +//! [examples/dialogue_bot]: https://github.com/teloxide/teloxide/tree/dev/examples/dialogue_bot + +#![allow(clippy::module_inception)] +#![allow(clippy::type_complexity)] + +mod dialogue_dispatcher; +mod dialogue_handler_ctx; +mod dialogue_stage; +mod get_chat_id; +mod storage; + +pub use dialogue_dispatcher::DialogueDispatcher; +pub use dialogue_handler_ctx::DialogueHandlerCtx; +pub use dialogue_stage::{exit, next, DialogueStage}; +pub use get_chat_id::GetChatId; +pub use storage::{InMemStorage, Storage}; diff --git a/src/dispatching/dialogue/storage/in_mem_storage.rs b/src/dispatching/dialogue/storage/in_mem_storage.rs new file mode 100644 index 00000000..bfc1d033 --- /dev/null +++ b/src/dispatching/dialogue/storage/in_mem_storage.rs @@ -0,0 +1,29 @@ +use async_trait::async_trait; + +use super::Storage; +use std::collections::HashMap; +use tokio::sync::Mutex; + +/// A memory storage based on a hash map. Stores all the dialogues directly in +/// RAM. +/// +/// ## Note +/// All the dialogues will be lost after you restart your bot. If you need to +/// store them somewhere on a drive, you need to implement a storage +/// communicating with a DB. +#[derive(Debug, Default)] +pub struct InMemStorage { + map: Mutex>, +} + +#[async_trait(?Send)] +#[async_trait] +impl Storage for InMemStorage { + async fn remove_dialogue(&self, chat_id: i64) -> Option { + self.map.lock().await.remove(&chat_id) + } + + async fn update_dialogue(&self, chat_id: i64, dialogue: D) -> Option { + self.map.lock().await.insert(chat_id, dialogue) + } +} diff --git a/src/dispatching/dialogue/storage/mod.rs b/src/dispatching/dialogue/storage/mod.rs new file mode 100644 index 00000000..f06fbf49 --- /dev/null +++ b/src/dispatching/dialogue/storage/mod.rs @@ -0,0 +1,28 @@ +mod in_mem_storage; + +use async_trait::async_trait; +pub use in_mem_storage::InMemStorage; + +/// A storage of dialogues. +/// +/// You can implement this trait for a structure that communicates with a DB and +/// be sure that after you restart your bot, all the dialogues won't be lost. +/// +/// For a storage based on a simple hash map, see [`InMemStorage`]. +/// +/// [`InMemStorage`]: crate::dispatching::dialogue::InMemStorage +#[async_trait(?Send)] +#[async_trait] +pub trait Storage { + /// Removes a dialogue with the specified `chat_id`. + /// + /// Returns `None` if there wasn't such a dialogue, `Some(dialogue)` if a + /// `dialogue` was deleted. + async fn remove_dialogue(&self, chat_id: i64) -> Option; + + /// Updates a dialogue with the specified `chat_id`. + /// + /// Returns `None` if there wasn't such a dialogue, `Some(dialogue)` if a + /// `dialogue` was updated. + async fn update_dialogue(&self, chat_id: i64, dialogue: D) -> Option; +} diff --git a/src/dispatching/dispatcher.rs b/src/dispatching/dispatcher.rs new file mode 100644 index 00000000..82f16b2d --- /dev/null +++ b/src/dispatching/dispatcher.rs @@ -0,0 +1,381 @@ +use crate::{ + dispatching::{ + error_handlers::ErrorHandler, update_listeners, + update_listeners::UpdateListener, CtxHandler, DispatcherHandlerCtx, + DispatcherHandlerResult, LoggingErrorHandler, + }, + types::{ + CallbackQuery, ChosenInlineResult, InlineQuery, Message, Poll, + PollAnswer, PreCheckoutQuery, ShippingQuery, Update, UpdateKind, + }, + Bot, RequestError, +}; +use futures::{stream, StreamExt}; +use std::{fmt::Debug, future::Future, sync::Arc}; + +type Handlers<'a, Upd, HandlerE> = Vec< + Box< + dyn CtxHandler< + DispatcherHandlerCtx, + DispatcherHandlerResult, + > + 'a, + >, +>; + +/// One dispatcher to rule them all. +/// +/// See [the module-level documentation for the design +/// overview](crate::dispatching). +// HandlerE=RequestError doesn't work now, because of very poor type inference. +// See https://github.com/rust-lang/rust/issues/27336 for more details. +pub struct Dispatcher<'a, HandlerE = RequestError> { + bot: Arc, + + handlers_error_handler: Box + 'a>, + + update_handlers: Handlers<'a, Update, HandlerE>, + message_handlers: Handlers<'a, Message, HandlerE>, + edited_message_handlers: Handlers<'a, Message, HandlerE>, + channel_post_handlers: Handlers<'a, Message, HandlerE>, + edited_channel_post_handlers: Handlers<'a, Message, HandlerE>, + inline_query_handlers: Handlers<'a, InlineQuery, HandlerE>, + chosen_inline_result_handlers: Handlers<'a, ChosenInlineResult, HandlerE>, + callback_query_handlers: Handlers<'a, CallbackQuery, HandlerE>, + shipping_query_handlers: Handlers<'a, ShippingQuery, HandlerE>, + pre_checkout_query_handlers: Handlers<'a, PreCheckoutQuery, HandlerE>, + poll_handlers: Handlers<'a, Poll, HandlerE>, + poll_answer_handlers: Handlers<'a, PollAnswer, HandlerE>, +} + +impl<'a, HandlerE> Dispatcher<'a, HandlerE> +where + HandlerE: Debug + 'a, +{ + /// Constructs a new dispatcher with this `bot`. + #[must_use] + pub fn new(bot: Arc) -> Self { + Self { + bot, + handlers_error_handler: Box::new(LoggingErrorHandler::new( + "An error from a Dispatcher's handler", + )), + update_handlers: Vec::new(), + message_handlers: Vec::new(), + edited_message_handlers: Vec::new(), + channel_post_handlers: Vec::new(), + edited_channel_post_handlers: Vec::new(), + inline_query_handlers: Vec::new(), + chosen_inline_result_handlers: Vec::new(), + callback_query_handlers: Vec::new(), + shipping_query_handlers: Vec::new(), + pre_checkout_query_handlers: Vec::new(), + poll_handlers: Vec::new(), + poll_answer_handlers: Vec::new(), + } + } + + /// Registers a handler of errors, produced by other handlers. + #[must_use] + pub fn handlers_error_handler(mut self, val: T) -> Self + where + T: ErrorHandler + 'a, + { + self.handlers_error_handler = Box::new(val); + self + } + + #[must_use] + pub fn update_handler(mut self, h: &'a H) -> Self + where + H: CtxHandler, I> + 'a, + I: Into> + 'a, + { + self.update_handlers = register_handler(self.update_handlers, h); + self + } + + #[must_use] + pub fn message_handler(mut self, h: &'a H) -> Self + where + H: CtxHandler, I> + 'a, + I: Into> + 'a, + { + self.message_handlers = register_handler(self.message_handlers, h); + self + } + + #[must_use] + pub fn edited_message_handler(mut self, h: &'a H) -> Self + where + H: CtxHandler, I> + 'a, + I: Into> + 'a, + { + self.edited_message_handlers = + register_handler(self.edited_message_handlers, h); + self + } + + #[must_use] + pub fn channel_post_handler(mut self, h: &'a H) -> Self + where + H: CtxHandler, I> + 'a, + I: Into> + 'a, + { + self.channel_post_handlers = + register_handler(self.channel_post_handlers, h); + self + } + + #[must_use] + pub fn edited_channel_post_handler(mut self, h: &'a H) -> Self + where + H: CtxHandler, I> + 'a, + I: Into> + 'a, + { + self.edited_channel_post_handlers = + register_handler(self.edited_channel_post_handlers, h); + self + } + + #[must_use] + pub fn inline_query_handler(mut self, h: &'a H) -> Self + where + H: CtxHandler, I> + 'a, + I: Into> + 'a, + { + self.inline_query_handlers = + register_handler(self.inline_query_handlers, h); + self + } + + #[must_use] + pub fn chosen_inline_result_handler(mut self, h: &'a H) -> Self + where + H: CtxHandler, I> + 'a, + I: Into> + 'a, + { + self.chosen_inline_result_handlers = + register_handler(self.chosen_inline_result_handlers, h); + self + } + + #[must_use] + pub fn callback_query_handler(mut self, h: &'a H) -> Self + where + H: CtxHandler, I> + 'a, + I: Into> + 'a, + { + self.callback_query_handlers = + register_handler(self.callback_query_handlers, h); + self + } + + #[must_use] + pub fn shipping_query_handler(mut self, h: &'a H) -> Self + where + H: CtxHandler, I> + 'a, + I: Into> + 'a, + { + self.shipping_query_handlers = + register_handler(self.shipping_query_handlers, h); + self + } + + #[must_use] + pub fn pre_checkout_query_handler(mut self, h: &'a H) -> Self + where + H: CtxHandler, I> + 'a, + I: Into> + 'a, + { + self.pre_checkout_query_handlers = + register_handler(self.pre_checkout_query_handlers, h); + self + } + + #[must_use] + pub fn poll_handler(mut self, h: &'a H) -> Self + where + H: CtxHandler, I> + 'a, + I: Into> + 'a, + { + self.poll_handlers = register_handler(self.poll_handlers, h); + self + } + + #[must_use] + pub fn poll_answer_handler(mut self, h: &'a H) -> Self + where + H: CtxHandler, I> + 'a, + I: Into> + 'a, + { + self.poll_answer_handlers = + register_handler(self.poll_answer_handlers, h); + self + } + + /// Starts your bot with the default parameters. + /// + /// The default parameters are a long polling update listener and log all + /// errors produced by this listener). + pub async fn dispatch(&'a self) { + self.dispatch_with_listener( + update_listeners::polling_default(Arc::clone(&self.bot)), + &LoggingErrorHandler::new("An error from the update listener"), + ) + .await; + } + + /// Starts your bot with custom `update_listener` and + /// `update_listener_error_handler`. + pub async fn dispatch_with_listener( + &'a self, + update_listener: UListener, + update_listener_error_handler: &'a Eh, + ) where + UListener: UpdateListener + 'a, + Eh: ErrorHandler + 'a, + ListenerE: Debug, + { + let update_listener = Box::pin(update_listener); + + update_listener + .for_each_concurrent(None, move |update| async move { + log::trace!("Dispatcher received an update: {:?}", update); + + let update = match update { + Ok(update) => update, + Err(error) => { + update_listener_error_handler.handle_error(error).await; + return; + } + }; + + let update = + match self.handle(&self.update_handlers, update).await { + Some(update) => update, + None => return, + }; + + match update.kind { + UpdateKind::Message(message) => { + self.handle(&self.message_handlers, message).await; + } + UpdateKind::EditedMessage(message) => { + self.handle(&self.edited_message_handlers, message) + .await; + } + UpdateKind::ChannelPost(post) => { + self.handle(&self.channel_post_handlers, post).await; + } + UpdateKind::EditedChannelPost(post) => { + self.handle(&self.edited_channel_post_handlers, post) + .await; + } + UpdateKind::InlineQuery(query) => { + self.handle(&self.inline_query_handlers, query).await; + } + UpdateKind::ChosenInlineResult(result) => { + self.handle( + &self.chosen_inline_result_handlers, + result, + ) + .await; + } + UpdateKind::CallbackQuery(query) => { + self.handle(&self.callback_query_handlers, query).await; + } + UpdateKind::ShippingQuery(query) => { + self.handle(&self.shipping_query_handlers, query).await; + } + UpdateKind::PreCheckoutQuery(query) => { + self.handle(&self.pre_checkout_query_handlers, query) + .await; + } + UpdateKind::Poll(poll) => { + self.handle(&self.poll_handlers, poll).await; + } + UpdateKind::PollAnswer(answer) => { + self.handle(&self.poll_answer_handlers, answer).await; + } + } + }) + .await + } + + // Handles a single update. + #[allow(clippy::ptr_arg)] + async fn handle( + &self, + handlers: &Handlers<'a, Upd, HandlerE>, + update: Upd, + ) -> Option { + stream::iter(handlers) + .fold(Some(update), |acc, handler| { + async move { + // Option::and_then is not working here, because + // Middleware::handle is asynchronous. + match acc { + Some(update) => { + let DispatcherHandlerResult { next, result } = + handler + .handle_ctx(DispatcherHandlerCtx { + bot: Arc::clone(&self.bot), + update, + }) + .await; + + if let Err(error) = result { + self.handlers_error_handler + .handle_error(error) + .await + } + + next + } + None => None, + } + } + }) + .await + } +} + +/// Transforms Future into Future by applying an Into +/// conversion. +async fn intermediate_fut0(fut: impl Future) -> U +where + T: Into, +{ + fut.await.into() +} + +/// Transforms CtxHandler with Into> as a return +/// value into CtxHandler with DispatcherHandlerResult return value. +fn intermediate_fut1<'a, Upd, HandlerE, H, I>( + h: &'a H, +) -> impl CtxHandler< + DispatcherHandlerCtx, + DispatcherHandlerResult, +> + 'a +where + H: CtxHandler, I> + 'a, + I: Into> + 'a, + Upd: 'a, +{ + move |ctx| intermediate_fut0(h.handle_ctx(ctx)) +} + +/// Registers a single handler. +fn register_handler<'a, Upd, H, I, HandlerE>( + mut handlers: Handlers<'a, Upd, HandlerE>, + h: &'a H, +) -> Handlers<'a, Upd, HandlerE> +where + H: CtxHandler, I> + 'a, + I: Into> + 'a, + HandlerE: 'a, + Upd: 'a, +{ + handlers.push(Box::new(intermediate_fut1(h))); + handlers +} diff --git a/src/dispatching/dispatcher_handler_ctx.rs b/src/dispatching/dispatcher_handler_ctx.rs new file mode 100644 index 00000000..ccda15ff --- /dev/null +++ b/src/dispatching/dispatcher_handler_ctx.rs @@ -0,0 +1,168 @@ +use crate::{ + dispatching::dialogue::GetChatId, + requests::{ + DeleteMessage, EditMessageCaption, EditMessageText, ForwardMessage, + PinChatMessage, SendAnimation, SendAudio, SendContact, SendDocument, + SendLocation, SendMediaGroup, SendMessage, SendPhoto, SendSticker, + SendVenue, SendVideo, SendVideoNote, SendVoice, + }, + types::{ChatId, ChatOrInlineMessage, InputFile, InputMedia, Message}, + Bot, +}; +use std::sync::Arc; + +/// A [`Dispatcher`]'s handler's context of a bot and an update. +/// +/// See [the module-level documentation for the design +/// overview](crate::dispatching). +/// +/// [`Dispatcher`]: crate::dispatching::Dispatcher +pub struct DispatcherHandlerCtx { + pub bot: Arc, + pub update: Upd, +} + +impl GetChatId for DispatcherHandlerCtx +where + Upd: GetChatId, +{ + fn chat_id(&self) -> i64 { + self.update.chat_id() + } +} + +impl DispatcherHandlerCtx { + pub fn answer(&self, text: T) -> SendMessage + where + T: Into, + { + self.bot.send_message(self.chat_id(), text) + } + + pub fn reply_to(&self, text: T) -> SendMessage + where + T: Into, + { + self.bot + .send_message(self.chat_id(), text) + .reply_to_message_id(self.update.id) + } + + pub fn answer_photo(&self, photo: InputFile) -> SendPhoto { + self.bot.send_photo(self.update.chat.id, photo) + } + + pub fn answer_audio(&self, audio: InputFile) -> SendAudio { + self.bot.send_audio(self.update.chat.id, audio) + } + + pub fn answer_animation(&self, animation: InputFile) -> SendAnimation { + self.bot.send_animation(self.update.chat.id, animation) + } + + pub fn answer_document(&self, document: InputFile) -> SendDocument { + self.bot.send_document(self.update.chat.id, document) + } + + pub fn answer_video(&self, video: InputFile) -> SendVideo { + self.bot.send_video(self.update.chat.id, video) + } + + pub fn answer_voice(&self, voice: InputFile) -> SendVoice { + self.bot.send_voice(self.update.chat.id, voice) + } + + pub fn answer_media_group(&self, media_group: T) -> SendMediaGroup + where + T: Into>, + { + self.bot.send_media_group(self.update.chat.id, media_group) + } + + pub fn answer_location( + &self, + latitude: f32, + longitude: f32, + ) -> SendLocation { + self.bot + .send_location(self.update.chat.id, latitude, longitude) + } + + pub fn answer_venue( + &self, + latitude: f32, + longitude: f32, + title: T, + address: U, + ) -> SendVenue + where + T: Into, + U: Into, + { + self.bot.send_venue( + self.update.chat.id, + latitude, + longitude, + title, + address, + ) + } + + pub fn answer_video_note(&self, video_note: InputFile) -> SendVideoNote { + self.bot.send_video_note(self.update.chat.id, video_note) + } + + pub fn answer_contact( + &self, + phone_number: T, + first_name: U, + ) -> SendContact + where + T: Into, + U: Into, + { + self.bot + .send_contact(self.chat_id(), phone_number, first_name) + } + + pub fn answer_sticker(&self, sticker: InputFile) -> SendSticker { + self.bot.send_sticker(self.update.chat.id, sticker) + } + + pub fn forward_to(&self, chat_id: T) -> ForwardMessage + where + T: Into, + { + self.bot + .forward_message(chat_id, self.update.chat.id, self.update.id) + } + + pub fn edit_message_text(&self, text: T) -> EditMessageText + where + T: Into, + { + self.bot.edit_message_text( + ChatOrInlineMessage::Chat { + chat_id: self.update.chat.id.into(), + message_id: self.update.id, + }, + text, + ) + } + + pub fn edit_message_caption(&self) -> EditMessageCaption { + self.bot.edit_message_caption(ChatOrInlineMessage::Chat { + chat_id: self.update.chat.id.into(), + message_id: self.update.id, + }) + } + + pub fn delete_message(&self) -> DeleteMessage { + self.bot.delete_message(self.update.chat.id, self.update.id) + } + + pub fn pin_message(&self) -> PinChatMessage { + self.bot + .pin_chat_message(self.update.chat.id, self.update.id) + } +} diff --git a/src/dispatching/dispatcher_handler_result.rs b/src/dispatching/dispatcher_handler_result.rs new file mode 100644 index 00000000..d7cacf22 --- /dev/null +++ b/src/dispatching/dispatcher_handler_result.rs @@ -0,0 +1,31 @@ +/// A result of a handler in [`Dispatcher`]. +/// +/// See [the module-level documentation for the design +/// overview](crate::dispatching). +/// +/// [`Dispatcher`]: crate::dispatching::Dispatcher +pub struct DispatcherHandlerResult { + pub next: Option, + pub result: Result<(), E>, +} + +impl DispatcherHandlerResult { + /// Creates new `DispatcherHandlerResult` that continues the pipeline. + pub fn next(update: Upd, result: Result<(), E>) -> Self { + Self { + next: Some(update), + result, + } + } + + /// Creates new `DispatcherHandlerResult` that terminates the pipeline. + pub fn exit(result: Result<(), E>) -> Self { + Self { next: None, result } + } +} + +impl From> for DispatcherHandlerResult { + fn from(result: Result<(), E>) -> Self { + Self::exit(result) + } +} diff --git a/src/dispatching/error_handlers.rs b/src/dispatching/error_handlers.rs new file mode 100644 index 00000000..c9e1aa44 --- /dev/null +++ b/src/dispatching/error_handlers.rs @@ -0,0 +1,151 @@ +use std::{convert::Infallible, fmt::Debug, future::Future, pin::Pin}; + +/// An asynchronous handler of an error. +/// +/// See [the module-level documentation for the design +/// overview](crate::dispatching). +pub trait ErrorHandler { + #[must_use] + fn handle_error<'a>( + &'a self, + error: E, + ) -> Pin + 'a>> + where + E: 'a; +} + +impl ErrorHandler for F +where + F: Fn(E) -> Fut, + Fut: Future, +{ + fn handle_error<'a>( + &'a self, + error: E, + ) -> Pin + 'a>> + where + E: 'a, + { + Box::pin(async move { self(error).await }) + } +} + +/// A handler that silently ignores all errors. +/// +/// ## Example +/// ``` +/// # #[tokio::main] +/// # async fn main_() { +/// use teloxide::dispatching::{ErrorHandler, IgnoringErrorHandler}; +/// +/// IgnoringErrorHandler.handle_error(()).await; +/// IgnoringErrorHandler.handle_error(404).await; +/// IgnoringErrorHandler.handle_error("error").await; +/// # } +/// ``` +pub struct IgnoringErrorHandler; + +impl ErrorHandler for IgnoringErrorHandler { + fn handle_error<'a>( + &'a self, + _: E, + ) -> Pin + 'a>> + where + E: 'a, + { + Box::pin(async {}) + } +} + +/// A handler that silently ignores all errors that can never happen (e.g.: +/// [`!`] or [`Infallible`]). +/// +/// ## Examples +/// ``` +/// # #[tokio::main] +/// # async fn main_() { +/// use std::convert::{Infallible, TryInto}; +/// +/// use teloxide::dispatching::{ErrorHandler, IgnoringErrorHandlerSafe}; +/// +/// let result: Result = "str".try_into(); +/// match result { +/// Ok(string) => println!("{}", string), +/// Err(inf) => IgnoringErrorHandlerSafe.handle_error(inf).await, +/// } +/// +/// IgnoringErrorHandlerSafe.handle_error(return).await; // return type of `return` is `!` (aka never) +/// # } +/// ``` +/// +/// ```compile_fail +/// use teloxide::dispatching::{ErrorHandler, IgnoringErrorHandlerSafe}; +/// +/// IgnoringErrorHandlerSafe.handle_error(0); +/// ``` +/// +/// [`!`]: https://doc.rust-lang.org/std/primitive.never.html +/// [`Infallible`]: std::convert::Infallible +pub struct IgnoringErrorHandlerSafe; + +#[allow(unreachable_code)] +impl ErrorHandler for IgnoringErrorHandlerSafe { + fn handle_error<'a>( + &'a self, + _: Infallible, + ) -> Pin + 'a>> + where + Infallible: 'a, + { + Box::pin(async {}) + } +} + +/// A handler that log all errors passed into it. +/// +/// ## Example +/// ``` +/// # #[tokio::main] +/// # async fn main_() { +/// use teloxide::dispatching::{ErrorHandler, LoggingErrorHandler}; +/// +/// LoggingErrorHandler::default().handle_error(()).await; +/// LoggingErrorHandler::new("error").handle_error(404).await; +/// LoggingErrorHandler::new("error") +/// .handle_error("Invalid data type!") +/// .await; +/// # } +/// ``` +#[derive(Default)] +pub struct LoggingErrorHandler { + text: String, +} + +impl LoggingErrorHandler { + /// Creates `LoggingErrorHandler` with a meta text before a log. + /// + /// The logs will be printed in this format: `{text}: {:?}`. + #[must_use] + pub fn new(text: T) -> Self + where + T: Into, + { + Self { text: text.into() } + } +} + +impl ErrorHandler for LoggingErrorHandler +where + E: Debug, +{ + fn handle_error<'a>( + &'a self, + error: E, + ) -> Pin + 'a>> + where + E: 'a, + { + log::error!("{text}: {:?}", error, text = self.text); + Box::pin(async {}) + } +} diff --git a/src/dispatching/mod.rs b/src/dispatching/mod.rs new file mode 100644 index 00000000..9f248b42 --- /dev/null +++ b/src/dispatching/mod.rs @@ -0,0 +1,120 @@ +//! Updates dispatching. +//! +//! The key type here is [`Dispatcher`]. It encapsulates [`Bot`], handlers for +//! [11 update kinds] (+ for [`Update`]) and [`ErrorHandler`] for them. When +//! [`Update`] is received from Telegram, the following steps are executed: +//! +//! 1. It is supplied into an appropriate handler (the first ones is those who +//! accept [`Update`]). +//! 2. If a handler failed, invoke [`ErrorHandler`] with the corresponding +//! error. +//! 3. If a handler has returned [`DispatcherHandlerResult`] with `None`, +//! terminate the pipeline, otherwise supply an update into the next handler +//! (back to step 1). +//! +//! The pipeline is executed until either all the registered handlers were +//! executed, or one of handlers has terminated the pipeline. That's simple! +//! +//! 1. Note that handlers implement [`CtxHandler`], which means that you are +//! able to supply [`DialogueDispatcher`] as a handler, since it implements +//! [`CtxHandler`] too! +//! 2. Note that you don't always need to return [`DispatcherHandlerResult`] +//! explicitly, because of automatic conversions. Just return `Result<(), E>` if +//! you want to terminate the pipeline (see the example below). +//! +//! # Examples +//! ### The ping-pong bot +//! +//! ```no_run +//! # #[tokio::main] +//! # async fn main_() { +//! use teloxide::prelude::*; +//! +//! // Setup logging here... +//! +//! // Create a dispatcher with a single message handler that answers "pong" +//! // to each incoming message. +//! Dispatcher::::new(Bot::from_env()) +//! .message_handler(&|ctx: DispatcherHandlerCtx| async move { +//! ctx.answer("pong").send().await?; +//! Ok(()) +//! }) +//! .dispatch() +//! .await; +//! # } +//! ``` +//! +//! [Full](https://github.com/teloxide/teloxide/blob/dev/examples/ping_pong_bot/) +//! +//! ### Multiple handlers +//! +//! ```no_run +//! # #[tokio::main] +//! # async fn main_() { +//! use teloxide::prelude::*; +//! +//! // Create a dispatcher with multiple handlers of different types. This will +//! // print One! and Two! on every incoming UpdateKind::Message. +//! Dispatcher::::new(Bot::from_env()) +//! // This is the first UpdateKind::Message handler, which will be called +//! // after the Update handler below. +//! .message_handler(&|ctx: DispatcherHandlerCtx| async move { +//! log::info!("Two!"); +//! DispatcherHandlerResult::next(ctx.update, Ok(())) +//! }) +//! // Remember: handler of Update are called first. +//! .update_handler(&|ctx: DispatcherHandlerCtx| async move { +//! log::info!("One!"); +//! DispatcherHandlerResult::next(ctx.update, Ok(())) +//! }) +//! // This handler will be called right after the first UpdateKind::Message +//! // handler, because it is registered after. +//! .message_handler(&|_ctx: DispatcherHandlerCtx| async move { +//! // The same as DispatcherHandlerResult::exit(Ok(())) +//! Ok(()) +//! }) +//! // This handler will never be called, because the UpdateKind::Message +//! // handler above terminates the pipeline. +//! .message_handler(&|ctx: DispatcherHandlerCtx| async move { +//! log::info!("This will never be printed!"); +//! DispatcherHandlerResult::next(ctx.update, Ok(())) +//! }) +//! .dispatch() +//! .await; +//! +//! // Note: if this bot receive, for example, UpdateKind::ChannelPost, it will +//! // only print "One!", because the UpdateKind::Message handlers will not be +//! // called. +//! # } +//! ``` +//! +//! [Full](https://github.com/teloxide/teloxide/blob/dev/examples/miltiple_handlers_bot/) +//! +//! For a bit more complicated example, please see [examples/dialogue_bot]. +//! +//! [`Dispatcher`]: crate::dispatching::Dispatcher +//! [11 update kinds]: crate::types::UpdateKind +//! [`Update`]: crate::types::Update +//! [`ErrorHandler`]: crate::dispatching::ErrorHandler +//! [`CtxHandler`]: crate::dispatching::CtxHandler +//! [`DialogueDispatcher`]: crate::dispatching::dialogue::DialogueDispatcher +//! [`DispatcherHandlerResult`]: crate::dispatching::DispatcherHandlerResult +//! [`Bot`]: crate::Bot +//! [examples/dialogue_bot]: https://github.com/teloxide/teloxide/tree/dev/examples/dialogue_bot + +mod ctx_handlers; +pub mod dialogue; +mod dispatcher; +mod dispatcher_handler_ctx; +mod dispatcher_handler_result; +mod error_handlers; +pub mod update_listeners; + +pub use ctx_handlers::CtxHandler; +pub use dispatcher::Dispatcher; +pub use dispatcher_handler_ctx::DispatcherHandlerCtx; +pub use dispatcher_handler_result::DispatcherHandlerResult; +pub use error_handlers::{ + ErrorHandler, IgnoringErrorHandler, IgnoringErrorHandlerSafe, + LoggingErrorHandler, +}; diff --git a/src/dispatching/update_listeners.rs b/src/dispatching/update_listeners.rs new file mode 100644 index 00000000..0b1190e3 --- /dev/null +++ b/src/dispatching/update_listeners.rs @@ -0,0 +1,204 @@ +//! Receiving updates from Telegram. +//! +//! The key trait here is [`UpdateListener`]. You can get it by these functions: +//! +//! - [`polling_default`], which returns a default long polling listener. +//! - [`polling`], which returns a long/short polling listener with your +//! configuration. +//! +//! And then you can extract updates from it and 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 +//! +//!

+//!     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    ~
+//! 
+//! +//! ^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`. +//! +//! [`UpdateListener`]: UpdateListener +//! [`polling_default`]: polling_default +//! [`polling`]: polling +//! [`Box::get_updates`]: crate::Bot::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 + +use futures::{stream, Stream, StreamExt}; + +use crate::{ + bot::Bot, + requests::Request, + types::{AllowedUpdate, Update}, + RequestError, +}; +use std::{convert::TryInto, sync::Arc, time::Duration}; + +/// A generic update listener. +pub trait UpdateListener: Stream> { + // TODO: add some methods here (.shutdown(), etc). +} +impl UpdateListener for S where S: Stream> {} + +/// Returns a long polling update listener with the default configuration. +/// +/// See also: [`polling`](polling). +pub fn polling_default(bot: Arc) -> impl UpdateListener { + polling(bot, None, None, None) +} + +/// Returns a long/short polling update listener with some additional options. +/// +/// - `bot`: Using this bot, the returned update listener will receive updates. +/// - `timeout`: A timeout 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::requests::GetUpdates +pub fn polling( + bot: Arc, + timeout: Option, + limit: Option, + allowed_updates: Option>, +) -> impl UpdateListener { + let timeout = + timeout.map(|t| t.as_secs().try_into().expect("timeout is too big")); + + stream::unfold( + (allowed_updates, bot, 0), + move |(mut allowed_updates, bot, mut offset)| async move { + let mut req = bot.get_updates().offset(offset); + req.timeout = timeout; + req.limit = limit; + req.allowed_updates = allowed_updates.take(); + + let updates = match req.send().await { + Err(err) => vec![Err(err)], + 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; + } + + let updates = updates + .into_iter() + .filter(|update| match update { + Err((value, error)) => { + log::error!("Cannot parse an update.\nError: {:?}\nValue: {}\n\ + This is a bug in teloxide, please open an issue here: \ + https://github.com/teloxide/teloxide/issues.", error, value); + false + } + Ok(_) => true, + }) + .map(|update| { + update.expect("See the previous .filter() call") + }) + .collect::>(); + + updates.into_iter().map(Ok).collect::>() + } + }; + + Some((stream::iter(updates), (allowed_updates, bot, offset))) + }, + ) + .flatten() +} + +// TODO implement webhook (this actually require webserver and probably we +// should add cargo feature that adds webhook) +//pub fn webhook<'a>(bot: &'a cfg: WebhookConfig) -> Updater> + 'a> {} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 00000000..d64d073b --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,519 @@ +use derive_more::From; +use reqwest::StatusCode; +use serde::Deserialize; +use thiserror::Error; + +// +/// An error occurred after downloading a file. +#[derive(Debug, Error, From)] +pub enum DownloadError { + #[error("A network error: {0}")] + NetworkError(#[source] reqwest::Error), + + #[error("An I/O error: {0}")] + Io(#[source] std::io::Error), +} + +// + +// +/// An error occurred after making a request to Telegram. +#[derive(Debug, Error)] +pub enum RequestError { + #[error("A Telegram's error #{status_code}: {kind:?}")] + ApiError { + status_code: StatusCode, + kind: ApiErrorKind, + }, + + /// The group has been migrated to a supergroup with the specified + /// identifier. + #[error("The group has been migrated to a supergroup with ID #{0}")] + MigrateToChatId(i64), + + /// In case of exceeding flood control, the number of seconds left to wait + /// before the request can be repeated. + #[error("Retry after {0} seconds")] + RetryAfter(i32), + + #[error("A network error: {0}")] + NetworkError(#[source] reqwest::Error), + + #[error("An error while parsing JSON: {0}")] + InvalidJson(#[source] serde_json::Error), +} + +// + +/// A kind of an API error returned from Telegram. +#[derive(Debug, Deserialize, PartialEq, Copy, Hash, Eq, Clone)] +pub enum ApiErrorKind { + /// Occurs when the bot tries to send message to user who blocked the bot. + #[serde(rename = "Forbidden: bot was blocked by the user")] + BotBlocked, + + /// Occurs when bot tries to modify a message without modification content. + /// + /// May happen in methods: + /// 1. [`EditMessageText`] + /// + /// [`EditMessageText`]: crate::requests::EditMessageText + #[serde(rename = "Bad Request: message is not modified: specified new \ + message content and reply markup are exactly the same \ + as a current content and reply markup of the message")] + MessageNotModified, + + /// Occurs when bot tries to forward or delete a message which was deleted. + /// + /// May happen in methods: + /// 1. [`ForwardMessage`] + /// 2. [`DeleteMessage`] + /// + /// [`ForwardMessage`]: crate::requests::ForwardMessage + /// [`DeleteMessage`]: crate::requests::DeleteMessage + #[serde(rename = "Bad Request: MESSAGE_ID_INVALID")] + MessageIdInvalid, + + /// Occurs when bot tries to forward a message which does not exists. + /// + /// May happen in methods: + /// 1. [`ForwardMessage`] + /// + /// [`ForwardMessage`]: crate::requests::ForwardMessage + #[serde(rename = "Bad Request: message to forward not found")] + MessageToForwardNotFound, + + /// Occurs when bot tries to delete a message which does not exists. + /// + /// May happen in methods: + /// 1. [`DeleteMessage`] + /// + /// [`DeleteMessage`]: crate::requests::DeleteMessage + #[serde(rename = "Bad Request: message to delete not found")] + MessageToDeleteNotFound, + + /// Occurs when bot tries to send a text message without text. + /// + /// May happen in methods: + /// 1. [`SendMessage`] + /// + /// [`SendMessage`]: crate::requests::SendMessage + #[serde(rename = "Bad Request: message text is empty")] + MessageTextIsEmpty, + + /// Occurs when bot tries to edit a message after long time. + /// + /// May happen in methods: + /// 1. [`EditMessageText`] + /// + /// [`EditMessageText`]: crate::requests::EditMessageText + #[serde(rename = "Bad Request: message can't be edited")] + MessageCantBeEdited, + + /// Occurs when bot tries to delete a someone else's message in group where + /// it does not have enough rights. + /// + /// May happen in methods: + /// 1. [`DeleteMessage`] + /// + /// [`DeleteMessage`]: crate::requests::DeleteMessage + #[serde(rename = "Bad Request: message can't be deleted")] + MessageCantBeDeleted, + + /// Occurs when bot tries to edit a message which does not exists. + /// + /// May happen in methods: + /// 1. [`EditMessageText`] + /// + /// [`EditMessageText`]: crate::requests::EditMessageText + #[serde(rename = "Bad Request: message to edit not found")] + MessageToEditNotFound, + + /// Occurs when bot tries to reply to a message which does not exists. + /// + /// May happen in methods: + /// 1. [`SendMessage`] + /// + /// [`SendMessage`]: crate::requests::SendMessage + #[serde(rename = "Bad Request: reply message not found")] + MessageToReplyNotFound, + + /// Occurs when bot tries to + #[serde(rename = "Bad Request: message identifier is not specified")] + MessageIdentifierNotSpecified, + + /// Occurs when bot tries to send a message with text size greater then + /// 4096 symbols. + /// + /// May happen in methods: + /// 1. [`SendMessage`] + /// + /// [`SendMessage`]: crate::requests::SendMessage + #[serde(rename = "Bad Request: message is too long")] + MessageIsTooLong, + + /// Occurs when bot tries to send media group with more than 10 items. + /// + /// May happen in methods: + /// 1. [`SendMediaGroup`] + /// + /// [`SendMediaGroup`]: crate::requests::SendMediaGroup + #[serde(rename = "Bad Request: Too much messages to send as an album")] + ToMuchMessages, + + /// Occurs when bot tries to stop poll that has already been stopped. + /// + /// May happen in methods: + /// 1. [`SendPoll`] + /// + /// [`SendPoll`]: crate::requests::SendPoll + #[serde(rename = "Bad Request: poll has already been closed")] + PollHasAlreadyClosed, + + /// Occurs when bot tries to send poll with less than 2 options. + /// + /// May happen in methods: + /// 1. [`SendPoll`] + /// + /// [`SendPoll`]: crate::requests::SendPoll + #[serde(rename = "Bad Request: poll must have at least 2 option")] + PollMustHaveMoreOptions, + + /// Occurs when bot tries to send poll with more than 10 options. + /// + /// May happen in methods: + /// 1. [`SendPoll`] + /// + /// [`SendPoll`]: crate::requests::SendPoll + #[serde(rename = "Bad Request: poll can't have more than 10 options")] + PollCantHaveMoreOptions, + + /// Occurs when bot tries to send poll with empty option (without text). + /// + /// May happen in methods: + /// 1. [`SendPoll`] + /// + /// [`SendPoll`]: crate::requests::SendPoll + #[serde(rename = "Bad Request: poll options must be non-empty")] + PollOptionsMustBeNonEmpty, + + /// Occurs when bot tries to send poll with empty question (without text). + /// + /// May happen in methods: + /// 1. [`SendPoll`] + /// + /// [`SendPoll`]: crate::requests::SendPoll + #[serde(rename = "Bad Request: poll question must be non-empty")] + PollQuestionMustBeNonEmpty, + + /// Occurs when bot tries to send poll with total size of options more than + /// 100 symbols. + /// + /// May happen in methods: + /// 1. [`SendPoll`] + /// + /// [`SendPoll`]: crate::requests::SendPoll + #[serde(rename = "Bad Request: poll options length must not exceed 100")] + PollOptionsLengthTooLong, + + /// Occurs when bot tries to send poll with question size more than 255 + /// symbols. + /// + /// May happen in methods: + /// 1. [`SendPoll`] + /// + /// [`SendPoll`]: crate::requests::SendPoll + #[serde(rename = "Bad Request: poll question length must not exceed 255")] + PollQuestionLengthTooLong, + + /// Occurs when bot tries to stop poll with message without poll. + /// + /// May happen in methods: + /// 1. [`StopPoll`] + /// + /// [`StopPoll`]: crate::requests::StopPoll + #[serde(rename = "Bad Request: message with poll to stop not found")] + MessageWithPollNotFound, + + /// Occurs when bot tries to stop poll with message without poll. + /// + /// May happen in methods: + /// 1. [`StopPoll`] + /// + /// [`StopPoll`]: crate::requests::StopPoll + #[serde(rename = "Bad Request: message is not a poll")] + MessageIsNotAPoll, + + /// Occurs when bot tries to send a message to chat in which it is not a + /// member. + /// + /// May happen in methods: + /// 1. [`SendMessage`] + /// + /// [`SendMessage`]: crate::requests::SendMessage + #[serde(rename = "Bad Request: chat not found")] + ChatNotFound, + + /// Occurs when bot tries to send method with unknown user_id. + /// + /// May happen in methods: + /// 1. [`getUserProfilePhotos`] + /// + /// [`getUserProfilePhotos`]: + /// crate::requests::GetUserProfilePhotos + #[serde(rename = "Bad Request: user not found")] + UserNotFound, + + /// Occurs when bot tries to send [`SetChatDescription`] with same text as + /// in the current description. + /// + /// May happen in methods: + /// 1. [`SetChatDescription`] + /// + /// [`SetChatDescription`]: crate::requests::SetChatDescription + #[serde(rename = "Bad Request: chat description is not modified")] + ChatDescriptionIsNotModified, + + /// Occurs when bot tries to answer to query after timeout expire. + /// + /// May happen in methods: + /// 1. [`AnswerCallbackQuery`] + /// + /// [`AnswerCallbackQuery`]: crate::requests::AnswerCallbackQuery + #[serde(rename = "Bad Request: query is too old and response timeout \ + expired or query id is invalid")] + InvalidQueryID, + + /// Occurs when bot tries to send InlineKeyboardMarkup with invalid button + /// url. + /// + /// May happen in methods: + /// 1. [`SendMessage`] + /// + /// [`SendMessage`]: crate::requests::SendMessage + #[serde(rename = "Bad Request: BUTTON_URL_INVALID")] + ButtonURLInvalid, + + /// Occurs when bot tries to send button with data size more than 64 bytes. + /// + /// May happen in methods: + /// 1. [`SendMessage`] + /// + /// [`SendMessage`]: crate::requests::SendMessage + #[serde(rename = "Bad Request: BUTTON_DATA_INVALID")] + ButtonDataInvalid, + + /// Occurs when bot tries to send button with data size == 0. + /// + /// May happen in methods: + /// 1. [`SendMessage`] + /// + /// [`SendMessage`]: crate::requests::SendMessage + #[serde(rename = "Bad Request: can't parse inline keyboard button: Text \ + buttons are unallowed in the inline keyboard")] + TextButtonsAreUnallowed, + + /// Occurs when bot tries to get file by wrong file id. + /// + /// May happen in methods: + /// 1. [`GetFile`] + /// + /// [`GetFile`]: crate::requests::GetFile + #[serde(rename = "Bad Request: wrong file id")] + WrongFileID, + + /// Occurs when bot tries to do some with group which was deactivated. + #[serde(rename = "Bad Request: group is deactivated")] + GroupDeactivated, + + /// Occurs when bot tries to set chat photo from file ID + /// + /// May happen in methods: + /// 1. [`SetChatPhoto`] + /// + /// [`SetChatPhoto`]: crate::requests::SetChatPhoto + #[serde(rename = "Bad Request: Photo should be uploaded as an InputFile")] + PhotoAsInputFileRequired, + + /// Occurs when bot tries to add sticker to stickerset by invalid name. + /// + /// May happen in methods: + /// 1. [`AddStickerToSet`] + /// + /// [`AddStickerToSet`]: crate::requests::AddStickerToSet + #[serde(rename = "Bad Request: STICKERSET_INVALID")] + InvalidStickersSet, + + /// Occurs when bot tries to pin a message without rights to pin in this + /// chat. + /// + /// May happen in methods: + /// 1. [`PinChatMessage`] + /// + /// [`PinChatMessage`]: crate::requests::PinChatMessage + #[serde(rename = "Bad Request: not enough rights to pin a message")] + NotEnoughRightsToPinMessage, + + /// Occurs when bot tries to use method in group which is allowed only in a + /// supergroup or channel. + #[serde(rename = "Bad Request: method is available only for supergroups \ + and channel")] + MethodNotAvailableInPrivateChats, + + /// Occurs when bot tries to demote chat creator. + /// + /// May happen in methods: + /// 1. [`PromoteChatMember`] + /// + /// [`PromoteChatMember`]: crate::requests::PromoteChatMember + #[serde(rename = "Bad Request: can't demote chat creator")] + CantDemoteChatCreator, + + /// Occurs when bot tries to restrict self in group chats. + /// + /// May happen in methods: + /// 1. [`RestrictChatMember`] + /// + /// [`RestrictChatMember`]: crate::requests::RestrictChatMember + #[serde(rename = "Bad Request: can't restrict self")] + CantRestrictSelf, + + /// Occurs when bot tries to restrict chat member without rights to + /// restrict in this chat. + /// + /// May happen in methods: + /// 1. [`RestrictChatMember`] + /// + /// [`RestrictChatMember`]: crate::requests::RestrictChatMember + #[serde(rename = "Bad Request: not enough rights to restrict/unrestrict \ + chat member")] + NotEnoughRightsToRestrict, + + /// Occurs when bot tries set webhook to protocol other than HTTPS. + /// + /// May happen in methods: + /// 1. [`SetWebhook`] + /// + /// [`SetWebhook`]: crate::requests::SetWebhook + #[serde(rename = "Bad Request: bad webhook: HTTPS url must be provided \ + for webhook")] + WebhookRequireHTTPS, + + /// Occurs when bot tries to set webhook to port other than 80, 88, 443 or + /// 8443. + /// + /// May happen in methods: + /// 1. [`SetWebhook`] + /// + /// [`SetWebhook`]: crate::requests::SetWebhook + #[serde(rename = "Bad Request: bad webhook: Webhook can be set up only \ + on ports 80, 88, 443 or 8443")] + BadWebhookPort, + + /// Occurs when bot tries to set webhook to unknown host. + /// + /// May happen in methods: + /// 1. [`SetWebhook`] + /// + /// [`SetWebhook`]: crate::requests::SetWebhook + #[serde(rename = "Bad Request: bad webhook: Failed to resolve host: \ + Name or service not known")] + UnknownHost, + + /// Occurs when bot tries to set webhook to invalid URL. + /// + /// May happen in methods: + /// 1. [`SetWebhook`] + /// + /// [`SetWebhook`]: crate::requests::SetWebhook + #[serde(rename = "Bad Request: can't parse URL")] + CantParseUrl, + + /// Occurs when bot tries to send message with unfinished entities. + /// + /// May happen in methods: + /// 1. [`SendMessage`] + /// + /// [`SendMessage`]: crate::requests::SendMessage + #[serde(rename = "Bad Request: can't parse entities")] + CantParseEntities, + + /// Occurs when bot tries to use getUpdates while webhook is active. + /// + /// May happen in methods: + /// 1. [`GetUpdates`] + /// + /// [`GetUpdates`]: crate::requests::GetUpdates + #[serde(rename = "can't use getUpdates method while webhook is active")] + CantGetUpdates, + + /// Occurs when bot tries to do some in group where bot was kicked. + /// + /// May happen in methods: + /// 1. [`SendMessage`] + /// + /// [`SendMessage`]: crate::requests::SendMessage + #[serde(rename = "Unauthorized: bot was kicked from a chat")] + BotKicked, + + /// Occurs when bot tries to send message to deactivated user. + /// + /// May happen in methods: + /// 1. [`SendMessage`] + /// + /// [`SendMessage`]: crate::requests::SendMessage + #[serde(rename = "Unauthorized: user is deactivated")] + UserDeactivated, + + /// Occurs when you tries to initiate conversation with a user. + /// + /// May happen in methods: + /// 1. [`SendMessage`] + /// + /// [`SendMessage`]: crate::requests::SendMessage + #[serde( + rename = "Unauthorized: bot can't initiate conversation with a user" + )] + CantInitiateConversation, + + /// Occurs when you tries to send message to bot. + /// + /// May happen in methods: + /// 1. [`SendMessage`] + /// + /// [`SendMessage`]: crate::requests::SendMessage + #[serde(rename = "Unauthorized: bot can't send messages to bots")] + CantTalkWithBots, + + /// Occurs when bot tries to send button with invalid http url. + /// + /// May happen in methods: + /// 1. [`SendMessage`] + /// + /// [`SendMessage`]: crate::requests::SendMessage + #[serde(rename = "Bad Request: wrong HTTP URL")] + WrongHTTPurl, + + /// Occurs when bot tries GetUpdate before the timeout. Make sure that only + /// one Updater is running. + /// + /// May happen in methods: + /// 1. [`GetUpdates`] + /// + /// [`GetUpdates`]: crate::requests::GetUpdates + #[serde(rename = "Conflict: terminated by other getUpdates request; \ + make sure that only one bot instance is running")] + TerminatedByOtherGetUpdates, + + /// Occurs when bot tries to get file by invalid file id. + /// + /// May happen in methods: + /// 1. [`GetFile`] + /// + /// [`GetFile`]: crate::requests::GetFile + #[serde(rename = "Bad Request: invalid file id")] + FileIdInvalid, + + #[serde(other)] + Other, +} diff --git a/src/lib.rs b/src/lib.rs index d274ca74..25b1b2a2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,299 @@ -#![feature(async_await)] +//! A full-featured framework that empowers you to easily build [Telegram bots] +//! using the [`async`/`.await`] syntax in [Rust]. It handles all the difficult +//! stuff so you can focus only on your business logic. +//! +//! ## Features +//! - **Type-safe.** teloxide leverages the Rust's type system with two serious +//! implications: resistance to human mistakes and tight integration with +//! IDEs. Write fast, avoid debugging as possible. +//! +//! - **Persistency.** By default, teloxide stores all user dialogues in RAM, +//! but you can store them somewhere else (for example, in DB) just by +//! implementing 2 functions. +//! +//! - **Convenient dialogues system.** Define a type-safe [finite automaton] +//! and transition functions to drive a user dialogue with ease (see the +//! examples below). +//! +//! - **Convenient API.** Automatic conversions are used to avoid boilerplate. +//! For example, functions accept `Into`, rather than `&str` or +//! `String`, so you can call them without `.to_string()`/`.as_str()`/etc. +//! +//! ## Getting started +//! 1. Create a new bot using [@Botfather] to get a token in the format +//! `123456789:blablabla`. 2. Initialise the `TELOXIDE_TOKEN` environmental +//! variable to your token: +//! ```bash +//! # Unix +//! $ export TELOXIDE_TOKEN=MyAwesomeToken +//! +//! # Windows +//! $ set TELOXITE_TOKEN=MyAwesomeToken +//! ``` +//! 3. Be sure that you are up to date: +//! ```bash +//! $ rustup update stable +//! ``` +//! +//! 4. Execute `cargo new my_bot`, enter the directory and put these lines into +//! your `Cargo.toml`: +//! ```toml +//! [dependencies] +//! teloxide = "0.1.0" +//! log = "0.4.8" +//! tokio = "0.2.11" +//! pretty_env_logger = "0.4.0" +//! ``` +//! +//! ## The ping-pong bot +//! This bot has a single message handler, which answers "pong" to each incoming +//! message: +//! +//! ([Full](https://github.com/teloxide/teloxide/blob/dev/examples/ping_pong_bot/src/main.rs)) +//! ```rust,no_run +//! use teloxide::prelude::*; +//! +//! # #[tokio::main] +//! # async fn main() { +//! teloxide::enable_logging!(); +//! log::info!("Starting the ping-pong bot!"); +//! +//! let bot = Bot::from_env(); +//! +//! Dispatcher::::new(bot) +//! .message_handler(&|ctx: DispatcherHandlerCtx| async move { +//! ctx.answer("pong").send().await?; +//! Ok(()) +//! }) +//! .dispatch() +//! .await; +//! # } +//! ``` +//! +//! ## Commands +//! Commands are defined similar to how we define CLI using [structopt]. This +//! bot says "I am a cat! Meow!" on `/meow`, generates a random number within +//! [0; 1) on `/generate`, and shows the usage guide on `/help`: +//! +//! ([Full](https://github.com/teloxide/teloxide/blob/dev/examples/simple_commands_bot/src/main.rs)) +//! ```rust,no_run +//! # use teloxide::{prelude::*, utils::command::BotCommand}; +//! # use rand::{thread_rng, Rng}; +//! // Imports are omitted... +//! +//! #[derive(BotCommand)] +//! #[command( +//! rename = "lowercase", +//! description = "These commands are supported:" +//! )] +//! enum Command { +//! #[command(description = "display this text.")] +//! Help, +//! #[command(description = "be a cat.")] +//! Meow, +//! #[command(description = "generate a random number within [0; 1).")] +//! Generate, +//! } +//! +//! async fn handle_command( +//! ctx: DispatcherHandlerCtx, +//! ) -> Result<(), RequestError> { +//! let text = match ctx.update.text() { +//! Some(text) => text, +//! None => { +//! log::info!("Received a message, but not text."); +//! return Ok(()); +//! } +//! }; +//! +//! let command = match Command::parse(text) { +//! Some((command, _)) => command, +//! None => { +//! log::info!("Received a text message, but not a command."); +//! return Ok(()); +//! } +//! }; +//! +//! match command { +//! Command::Help => ctx.answer(Command::descriptions()).send().await?, +//! Command::Generate => { +//! ctx.answer(thread_rng().gen_range(0.0, 1.0).to_string()) +//! .send() +//! .await? +//! } +//! Command::Meow => ctx.answer("I am a cat! Meow!").send().await?, +//! }; +//! +//! Ok(()) +//! } +//! +//! #[tokio::main] +//! async fn main() { +//! // Setup is omitted... +//! # teloxide::enable_logging!(); +//! # log::info!("Starting simple_commands_bot!"); +//! # +//! # let bot = Bot::from_env(); +//! # +//! # Dispatcher::::new(bot) +//! # .message_handler(&handle_command) +//! # .dispatch() +//! # .await; +//! } +//! ``` +//! +//! ## Guess a number +//! Wanna see more? This is a bot, which starts a game on each incoming message. +//! You must guess a number from 1 to 10 (inclusively): +//! +//! ([Full](https://github.com/teloxide/teloxide/blob/dev/examples/guess_a_number_bot/src/main.rs)) +//! ```rust,no_run +//! # #[macro_use] +//! # extern crate smart_default; +//! # use teloxide::prelude::*; +//! # use rand::{thread_rng, Rng}; +//! // Imports are omitted... +//! +//! #[derive(SmartDefault)] +//! enum Dialogue { +//! #[default] +//! Start, +//! ReceiveAttempt(u8), +//! } +//! async fn handle_message( +//! ctx: DialogueHandlerCtx, +//! ) -> Result, RequestError> { +//! match ctx.dialogue { +//! Dialogue::Start => { +//! ctx.answer( +//! "Let's play a game! Guess a number from 1 to 10 +//! (inclusively).", +//! ) +//! .send() +//! .await?; +//! next(Dialogue::ReceiveAttempt(thread_rng().gen_range(1, 11))) +//! } +//! Dialogue::ReceiveAttempt(secret) => match ctx.update.text() { +//! None => { +//! ctx.answer("Oh, please, send me a text message!") +//! .send() +//! .await?; +//! next(ctx.dialogue) +//! } +//! Some(text) => match text.parse::() { +//! Ok(attempt) => match attempt { +//! x if !(1..=10).contains(&x) => { +//! ctx.answer( +//! "Oh, please, send me a number in the range \ +//! [1; 10]!", +//! ) +//! .send() +//! .await?; +//! next(ctx.dialogue) +//! } +//! x if x == secret => { +//! ctx.answer("Congratulations! You won!") +//! .send() +//! .await?; +//! exit() +//! } +//! _ => { +//! ctx.answer("No.").send().await?; +//! next(ctx.dialogue) +//! } +//! }, +//! Err(_) => { +//! ctx.answer( +//! "Oh, please, send me a number in the range [1; \ +//! 10]!", +//! ) +//! .send() +//! .await?; +//! next(ctx.dialogue) +//! } +//! }, +//! }, +//! } +//! } +//! +//! #[tokio::main] +//! async fn main() { +//! # teloxide::enable_logging!(); +//! # log::info!("Starting guess_a_number_bot!"); +//! # let bot = Bot::from_env(); +//! // Setup is omitted... +//! +//! Dispatcher::new(bot) +//! .message_handler(&DialogueDispatcher::new(|ctx| async move { +//! handle_message(ctx) +//! .await +//! .expect("Something wrong with the bot!") +//! })) +//! .dispatch() +//! .await; +//! } +//! ``` +//! +//! Our [finite automaton], designating a user dialogue, cannot be in an invalid +//! state. See [examples/dialogue_bot] to see a bit more complicated bot with +//! dialogues. +//! +//! [See more examples](https://github.com/teloxide/teloxide/tree/dev/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 because of the +//! `#[tokio::main]` macro. However, the examples above use the second one for +//! brevity. +//! +//! [Telegram bots]: https://telegram.org/blog/bot-revolution +//! [`async`/`.await`]: https://rust-lang.github.io/async-book/01_getting_started/01_chapter.html +//! [Rust]: https://www.rust-lang.org/ +//! [finite automaton]: https://en.wikipedia.org/wiki/Finite-state_machine +//! [examples/dialogue_bot]: https://github.com/teloxide/teloxide/blob/dev/examples/dialogue_bot/src/main.rs +//! [structopt]: https://docs.rs/structopt/0.3.9/structopt/ +//! [@Botfather]: https://t.me/botfather -#[macro_use] -extern crate lazy_static; +#![doc( + html_logo_url = "https://github.com/teloxide/teloxide/raw/dev/logo.svg", + html_favicon_url = "https://github.com/teloxide/teloxide/raw/dev/ICON.png" +)] +#![allow(clippy::match_bool)] -mod core; +pub use bot::Bot; +pub use errors::{ApiErrorKind, DownloadError, RequestError}; + +mod errors; +mod net; + +mod bot; +pub mod dispatching; +mod logging; +pub mod prelude; +pub mod requests; +pub mod types; +pub mod utils; + +extern crate teloxide_macros; diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 00000000..ccb4c7c1 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,52 @@ +/// Enables logging through [pretty-env-logger]. +/// +/// A logger will **only** print errors 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. +/// +/// [pretty-env-logger]: https://crates.io/crates/pretty_env_logger +#[macro_export] +macro_rules! enable_logging { + () => { + teloxide::enable_logging_with_filter!(log::LevelFilter::Trace); + }; +} + +/// Enables logging through [pretty-env-logger] with a custom filter for your +/// program. +/// +/// A logger will **only** print errors from 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): +/// +/// ```no_compile +/// teloxide::enable_logging_with_filter!(log::LevelFilter::Debug); +/// ``` +/// +/// # Note +/// Calling this macro **is not mandatory**; you can setup if your own logger if +/// you want. +/// +/// [pretty-env-logger]: https://crates.io/crates/pretty_env_logger +/// [`LevelFilter::Debug`]: https://docs.rs/log/0.4.10/log/enum.LevelFilter.html +#[macro_export] +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")), $filter) + .filter(Some("teloxide"), log::LevelFilter::Error) + .init(); + }; +} diff --git a/src/net/download.rs b/src/net/download.rs new file mode 100644 index 00000000..05f08f5d --- /dev/null +++ b/src/net/download.rs @@ -0,0 +1,49 @@ +use reqwest::Client; +use tokio::io::{AsyncWrite, AsyncWriteExt}; + +use crate::errors::DownloadError; + +use super::TELEGRAM_API_URL; + +pub async fn download_file( + client: &Client, + token: &str, + path: &str, + destination: &mut D, +) -> Result<(), DownloadError> +where + D: AsyncWrite + Unpin, +{ + let mut res = client + .get(&super::file_url(TELEGRAM_API_URL, token, path)) + .send() + .await? + .error_for_status()?; + + while let Some(chunk) = res.chunk().await? { + destination.write_all(&chunk).await?; + } + + Ok(()) +} + +#[cfg(feature = "unstable-stream")] +pub async fn download_file_stream( + client: &Client, + token: &str, + path: &str, +) -> Result>, reqwest::Error> { + let res = client + .get(&super::file_url(TELEGRAM_API_URL, token, path)) + .send() + .await? + .error_for_status()?; + + Ok(futures::stream::unfold(res, |mut res| async { + match res.chunk().await { + Err(err) => Some((Err(err), res)), + Ok(Some(c)) => Some((Ok(c), res)), + Ok(None) => None, + } + })) +} diff --git a/src/net/mod.rs b/src/net/mod.rs new file mode 100644 index 00000000..1461304f --- /dev/null +++ b/src/net/mod.rs @@ -0,0 +1,71 @@ +#[cfg(feature = "unstable-stream")] +pub use download::download_file_stream; + +pub use self::{ + download::download_file, + request::{request_json, request_multipart}, + telegram_response::TelegramResponse, +}; + +mod download; +mod request; +mod telegram_response; + +const TELEGRAM_API_URL: &str = "https://api.telegram.org"; + +/// Creates URL for making HTTPS requests. See the [Telegram documentation]. +/// +/// [Telegram documentation]: https://core.telegram.org/bots/api#making-requests +fn method_url(base: &str, token: &str, method_name: &str) -> String { + format!( + "{url}/bot{token}/{method}", + url = base, + token = token, + method = method_name, + ) +} + +/// Creates URL for downloading a file. See the [Telegram documentation]. +/// +/// [Telegram documentation]: https://core.telegram.org/bots/api#file +fn file_url(base: &str, token: &str, file_path: &str) -> String { + format!( + "{url}/file/bot{token}/{file}", + url = base, + token = token, + file = file_path, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn method_url_test() { + let url = method_url( + TELEGRAM_API_URL, + "535362388:AAF7-g0gYncWnm5IyfZlpPRqRRv6kNAGlao", + "methodName", + ); + + assert_eq!( + url, + "https://api.telegram.org/bot535362388:AAF7-g0gYncWnm5IyfZlpPRqRRv6kNAGlao/methodName" + ); + } + + #[test] + fn file_url_test() { + let url = file_url( + TELEGRAM_API_URL, + "535362388:AAF7-g0gYncWnm5IyfZlpPRqRRv6kNAGlao", + "AgADAgADyqoxG2g8aEsu_KjjVsGF4-zetw8ABAEAAwIAA20AA_8QAwABFgQ", + ); + + assert_eq!( + url, + "https://api.telegram.org/file/bot535362388:AAF7-g0gYncWnm5IyfZlpPRqRRv6kNAGlao/AgADAgADyqoxG2g8aEsu_KjjVsGF4-zetw8ABAEAAwIAA20AA_8QAwABFgQ" + ); + } +} diff --git a/src/net/request.rs b/src/net/request.rs new file mode 100644 index 00000000..9f8d7069 --- /dev/null +++ b/src/net/request.rs @@ -0,0 +1,56 @@ +use reqwest::{multipart::Form, Client, Response}; +use serde::{de::DeserializeOwned, Serialize}; + +use crate::{requests::ResponseResult, RequestError}; + +use super::{TelegramResponse, TELEGRAM_API_URL}; + +pub async fn request_multipart( + client: &Client, + token: &str, + method_name: &str, + params: Form, +) -> ResponseResult +where + T: DeserializeOwned, +{ + let response = client + .post(&super::method_url(TELEGRAM_API_URL, token, method_name)) + .multipart(params) + .send() + .await + .map_err(RequestError::NetworkError)?; + + process_response(response).await +} + +pub async fn request_json( + client: &Client, + token: &str, + method_name: &str, + params: &P, +) -> ResponseResult +where + T: DeserializeOwned, + P: Serialize, +{ + let response = client + .post(&super::method_url(TELEGRAM_API_URL, token, method_name)) + .json(params) + .send() + .await + .map_err(RequestError::NetworkError)?; + + process_response(response).await +} + +async fn process_response(response: Response) -> ResponseResult +where + T: DeserializeOwned, +{ + serde_json::from_str::>( + &response.text().await.map_err(RequestError::NetworkError)?, + ) + .map_err(RequestError::InvalidJson)? + .into() +} diff --git a/src/net/telegram_response.rs b/src/net/telegram_response.rs new file mode 100644 index 00000000..40005f10 --- /dev/null +++ b/src/net/telegram_response.rs @@ -0,0 +1,77 @@ +use reqwest::StatusCode; +use serde::Deserialize; + +use crate::{ + requests::ResponseResult, + types::{False, ResponseParameters, True}, + ApiErrorKind, RequestError, +}; + +#[derive(Deserialize)] +#[serde(untagged)] +pub enum TelegramResponse { + Ok { + /// A dummy field. Used only for deserialization. + #[allow(dead_code)] + ok: True, + + result: R, + }, + Err { + /// A dummy field. Used only for deserialization. + #[allow(dead_code)] + ok: False, + + #[serde(rename = "description")] + kind: ApiErrorKind, + error_code: u16, + response_parameters: Option, + }, +} + +impl Into> for TelegramResponse { + fn into(self) -> Result { + match self { + TelegramResponse::Ok { result, .. } => Ok(result), + TelegramResponse::Err { + kind, + error_code, + response_parameters, + .. + } => { + if let Some(params) = response_parameters { + match params { + ResponseParameters::RetryAfter(i) => { + Err(RequestError::RetryAfter(i)) + } + ResponseParameters::MigrateToChatId(to) => { + Err(RequestError::MigrateToChatId(to)) + } + } + } else { + Err(RequestError::ApiError { + kind, + status_code: StatusCode::from_u16(error_code).unwrap(), + }) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::Update; + + #[test] + fn terminated_by_other_get_updates() { + let expected = ApiErrorKind::TerminatedByOtherGetUpdates; + if let TelegramResponse::Err{ kind, .. } = serde_json::from_str::>(r#"{"ok":false,"error_code":409,"description":"Conflict: terminated by other getUpdates request; make sure that only one bot instance is running"}"#).unwrap() { + assert_eq!(expected, kind); + } + else { + panic!("Этой херни здесь не должно быть"); + } + } +} diff --git a/src/prelude.rs b/src/prelude.rs new file mode 100644 index 00000000..97b01f16 --- /dev/null +++ b/src/prelude.rs @@ -0,0 +1,14 @@ +//! Commonly used items. + +pub use crate::{ + dispatching::{ + dialogue::{ + exit, next, DialogueDispatcher, DialogueHandlerCtx, DialogueStage, + GetChatId, + }, + Dispatcher, DispatcherHandlerCtx, DispatcherHandlerResult, + }, + requests::{Request, ResponseResult}, + types::{Message, Update}, + Bot, RequestError, +}; diff --git a/src/requests/all/add_sticker_to_set.rs b/src/requests/all/add_sticker_to_set.rs new file mode 100644 index 00000000..119bd7e1 --- /dev/null +++ b/src/requests/all/add_sticker_to_set.rs @@ -0,0 +1,119 @@ +use crate::{ + net, + requests::form_builder::FormBuilder, + types::{InputFile, MaskPosition, True}, + Bot, +}; + +use crate::requests::{Request, ResponseResult}; +use std::sync::Arc; + +/// Use this method to add a new sticker to a set created by the bot. +/// +/// [The official docs](https://core.telegram.org/bots/api#addstickertoset). +#[derive(Debug, Clone)] +pub struct AddStickerToSet { + bot: Arc, + user_id: i32, + name: String, + png_sticker: InputFile, + emojis: String, + mask_position: Option, +} + +#[async_trait::async_trait] +impl Request for AddStickerToSet { + type Output = True; + + async fn send(&self) -> ResponseResult { + 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 + .build(), + ) + .await + } +} + +impl AddStickerToSet { + pub(crate) fn new( + bot: Arc, + user_id: i32, + name: N, + png_sticker: InputFile, + emojis: E, + ) -> Self + where + N: Into, + E: Into, + { + Self { + bot, + user_id, + name: name.into(), + png_sticker, + emojis: emojis.into(), + mask_position: None, + } + } + + /// User identifier of sticker set owner. + pub fn user_id(mut self, val: i32) -> Self { + self.user_id = val; + self + } + + /// Sticker set name. + pub fn name(mut self, val: T) -> Self + where + T: Into, + { + self.name = val.into(); + self + } + + /// **Png** image with the sticker, must be up to 512 kilobytes in size, + /// dimensions must not exceed 512px, and either width or height must be + /// exactly 512px. + /// + /// Pass [`InputFile::File`] to send a file that exists on + /// the Telegram servers (recommended), pass an [`InputFile::Url`] for + /// Telegram to get a .webp file from the Internet, or upload a new one + /// using [`InputFile::FileId`]. [More info on Sending Files »]. + /// + /// [`InputFile::File`]: crate::types::InputFile::File + /// [`InputFile::Url`]: crate::types::InputFile::Url + /// [`InputFile::FileId`]: crate::types::InputFile::FileId + pub fn png_sticker(mut self, val: InputFile) -> Self { + self.png_sticker = val; + self + } + + /// One or more emoji corresponding to the sticker. + pub fn emojis(mut self, val: T) -> Self + where + T: Into, + { + self.emojis = val.into(); + self + } + + /// A JSON-serialized object for position where the mask should be placed on + /// faces. + pub fn mask_position(mut self, val: MaskPosition) -> Self { + self.mask_position = Some(val); + self + } +} diff --git a/src/requests/all/answer_callback_query.rs b/src/requests/all/answer_callback_query.rs new file mode 100644 index 00000000..a636e314 --- /dev/null +++ b/src/requests/all/answer_callback_query.rs @@ -0,0 +1,115 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::True, + Bot, +}; +use std::sync::Arc; + +/// Use this method to send answers to callback queries sent from [inline +/// keyboards]. +/// +/// The answer will be displayed to the user as a notification at +/// the top of the chat screen or as an alert. +/// +/// [The official docs](https://core.telegram.org/bots/api#answercallbackquery). +/// +/// [inline keyboards]: https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct AnswerCallbackQuery { + #[serde(skip_serializing)] + bot: Arc, + callback_query_id: String, + text: Option, + show_alert: Option, + url: Option, + cache_time: Option, +} + +#[async_trait::async_trait] +impl Request for AnswerCallbackQuery { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "answerCallbackQuery", + &self, + ) + .await + } +} + +impl AnswerCallbackQuery { + pub(crate) fn new(bot: Arc, callback_query_id: C) -> Self + where + C: Into, + { + let callback_query_id = callback_query_id.into(); + Self { + bot, + callback_query_id, + text: None, + show_alert: None, + url: None, + cache_time: None, + } + } + + /// Unique identifier for the query to be answered. + pub fn callback_query_id(mut self, val: T) -> Self + where + T: Into, + { + self.callback_query_id = val.into(); + self + } + + /// Text of the notification. If not specified, nothing will be shown to the + /// user, 0-200 characters. + pub fn text(mut self, val: T) -> Self + where + T: Into, + { + self.text = Some(val.into()); + self + } + + /// If `true`, an alert will be shown by the client instead of a + /// notification at the top of the chat screen. Defaults to `false`. + pub fn show_alert(mut self, val: bool) -> Self { + self.show_alert = Some(val); + self + } + + /// URL that will be opened by the user's client. If you have created a + /// [`Game`] and accepted the conditions via [@Botfather], specify the + /// URL that opens your game – note that this will only work if the + /// query comes from a [`callback_game`] button. + /// + /// Otherwise, you may use links like `t.me/your_bot?start=XXXX` that open + /// your bot with a parameter. + /// + /// [@Botfather]: https://t.me/botfather + /// [`callback_game`]: crate::types::InlineKeyboardButton + /// [`Game`]: crate::types::Game + pub fn url(mut self, val: T) -> Self + where + T: Into, + { + self.url = Some(val.into()); + self + } + + /// The maximum amount of time in seconds that the result of the callback + /// query may be cached client-side. Telegram apps will support caching + /// starting in version 3.14. Defaults to 0. + pub fn cache_time(mut self, val: i32) -> Self { + self.cache_time = Some(val); + self + } +} diff --git a/src/requests/all/answer_inline_query.rs b/src/requests/all/answer_inline_query.rs new file mode 100644 index 00000000..ad75ea06 --- /dev/null +++ b/src/requests/all/answer_inline_query.rs @@ -0,0 +1,157 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{InlineQueryResult, True}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to send answers to an inline query. +/// +/// No more than **50** results per query are allowed. +/// +/// [The official docs](https://core.telegram.org/bots/api#answerinlinequery). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct AnswerInlineQuery { + #[serde(skip_serializing)] + bot: Arc, + inline_query_id: String, + results: Vec, + cache_time: Option, + is_personal: Option, + next_offset: Option, + switch_pm_text: Option, + switch_pm_parameter: Option, +} + +#[async_trait::async_trait] +impl Request for AnswerInlineQuery { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "answerInlineQuery", + &self, + ) + .await + } +} + +impl AnswerInlineQuery { + pub(crate) fn new( + bot: Arc, + inline_query_id: I, + results: R, + ) -> Self + where + I: Into, + R: Into>, + { + let inline_query_id = inline_query_id.into(); + let results = results.into(); + Self { + bot, + inline_query_id, + results, + cache_time: None, + is_personal: None, + next_offset: None, + switch_pm_text: None, + switch_pm_parameter: None, + } + } + + /// Unique identifier for the answered query. + pub fn inline_query_id(mut self, val: T) -> Self + where + T: Into, + { + self.inline_query_id = val.into(); + self + } + + /// A JSON-serialized array of results for the inline query. + pub fn results(mut self, val: T) -> Self + where + T: Into>, + { + self.results = val.into(); + self + } + + /// The maximum amount of time in seconds that the result of the inline + /// query may be cached on the server. + /// + /// Defaults to 300. + pub fn cache_time(mut self, val: i32) -> Self { + self.cache_time = Some(val); + self + } + + /// Pass `true`, if results may be cached on the server side only for the + /// user that sent the query. + /// + /// By default, results may be returned to any user who sends the same + /// query. + #[allow(clippy::wrong_self_convention)] + pub fn is_personal(mut self, val: bool) -> Self { + self.is_personal = Some(val); + self + } + + /// Pass the offset that a client should send in the next query with the + /// same text to receive more results. + /// + /// Pass an empty string if there are no more results or if you don‘t + /// support pagination. Offset length can’t exceed 64 bytes. + pub fn next_offset(mut self, val: T) -> Self + where + T: Into, + { + self.next_offset = Some(val.into()); + self + } + + /// If passed, clients will display a button with specified text that + /// switches the user to a private chat with the bot and sends the bot a + /// start message with the parameter [`switch_pm_parameter`]. + /// + /// [`switch_pm_parameter`]: + /// crate::requests::AnswerInlineQuery::switch_pm_parameter + pub fn switch_pm_text(mut self, val: T) -> Self + where + T: Into, + { + self.switch_pm_text = Some(val.into()); + self + } + + /// [Deep-linking] parameter for the /start message sent to the bot when + /// user presses the switch button. 1-64 characters, only `A-Z`, `a-z`, + /// `0-9`, `_` and `-` are allowed. + /// + /// Example: An inline bot that sends YouTube videos can ask the user to + /// connect the bot to their YouTube account to adapt search results + /// accordingly. To do this, it displays a ‘Connect your YouTube account’ + /// button above the results, or even before showing any. The user presses + /// the button, switches to a private chat with the bot and, in doing so, + /// passes a start parameter that instructs the bot to return an oauth link. + /// Once done, the bot can offer a [`switch_inline`] button so that the user + /// can easily return to the chat where they wanted to use the bot's + /// inline capabilities. + /// + /// [Deep-linking]: https://core.telegram.org/bots#deep-linking + /// [`switch_inline`]: crate::types::InlineKeyboardMarkup + pub fn switch_pm_parameter(mut self, val: T) -> Self + where + T: Into, + { + self.switch_pm_parameter = Some(val.into()); + self + } +} diff --git a/src/requests/all/answer_pre_checkout_query.rs b/src/requests/all/answer_pre_checkout_query.rs new file mode 100644 index 00000000..cdff1e28 --- /dev/null +++ b/src/requests/all/answer_pre_checkout_query.rs @@ -0,0 +1,94 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::True, + Bot, +}; +use std::sync::Arc; + +/// Once the user has confirmed their payment and shipping details, the Bot API +/// sends the final confirmation in the form of an [`Update`] with the field +/// `pre_checkout_query`. Use this method to respond to such pre-checkout +/// queries. Note: The Bot API must receive an answer within 10 seconds after +/// the pre-checkout query was sent. +/// +/// [The official docs](https://core.telegram.org/bots/api#answerprecheckoutquery). +/// +/// [`Update`]: crate::types::Update +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct AnswerPreCheckoutQuery { + #[serde(skip_serializing)] + bot: Arc, + pre_checkout_query_id: String, + ok: bool, + error_message: Option, +} + +#[async_trait::async_trait] +impl Request for AnswerPreCheckoutQuery { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "answerPreCheckoutQuery", + &self, + ) + .await + } +} + +impl AnswerPreCheckoutQuery { + pub(crate) fn new

( + bot: Arc, + pre_checkout_query_id: P, + ok: bool, + ) -> Self + where + P: Into, + { + let pre_checkout_query_id = pre_checkout_query_id.into(); + Self { + bot, + pre_checkout_query_id, + ok, + error_message: None, + } + } + + /// Unique identifier for the query to be answered. + pub fn pre_checkout_query_id(mut self, val: T) -> Self + where + T: Into, + { + self.pre_checkout_query_id = val.into(); + self + } + + /// Specify `true` if everything is alright (goods are available, etc.) and + /// the bot is ready to proceed with the order. Use False if there are any + /// problems. + pub fn ok(mut self, val: bool) -> Self { + self.ok = val; + self + } + + /// Required if ok is `false`. Error message in human readable form that + /// explains the reason for failure to proceed with the checkout (e.g. + /// "Sorry, somebody just bought the last of our amazing black T-shirts + /// while you were busy filling out your payment details. Please choose a + /// different color or garment!"). + /// + /// Telegram will display this message to the user. + pub fn error_message(mut self, val: T) -> Self + where + T: Into, + { + self.error_message = Some(val.into()); + self + } +} diff --git a/src/requests/all/answer_shipping_query.rs b/src/requests/all/answer_shipping_query.rs new file mode 100644 index 00000000..452c93ed --- /dev/null +++ b/src/requests/all/answer_shipping_query.rs @@ -0,0 +1,99 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ShippingOption, True}, + Bot, +}; +use std::sync::Arc; + +/// If you sent an invoice requesting a shipping address and the parameter +/// `is_flexible` was specified, the Bot API will send an [`Update`] with a +/// shipping_query field to the bot. Use this method to reply to shipping +/// queries. +/// +/// [The official docs](https://core.telegram.org/bots/api#answershippingquery). +/// +/// [`Update`]: crate::types::Update +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct AnswerShippingQuery { + #[serde(skip_serializing)] + bot: Arc, + shipping_query_id: String, + ok: bool, + shipping_options: Option>, + error_message: Option, +} + +#[async_trait::async_trait] +impl Request for AnswerShippingQuery { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "answerShippingQuery", + &self, + ) + .await + } +} + +impl AnswerShippingQuery { + pub(crate) fn new(bot: Arc, shipping_query_id: S, ok: bool) -> Self + where + S: Into, + { + let shipping_query_id = shipping_query_id.into(); + Self { + bot, + shipping_query_id, + ok, + shipping_options: None, + error_message: None, + } + } + + /// Unique identifier for the query to be answered. + pub fn shipping_query_id(mut self, val: T) -> Self + where + T: Into, + { + self.shipping_query_id = val.into(); + self + } + + /// Specify `true` if delivery to the specified address is possible and + /// `false` if there are any problems (for example, if delivery to the + /// specified address is not possible). + pub fn ok(mut self, val: bool) -> Self { + self.ok = val; + self + } + + /// Required if ok is `true`. A JSON-serialized array of available shipping + /// options. + pub fn shipping_options(mut self, val: T) -> Self + where + T: Into>, + { + self.shipping_options = Some(val.into()); + self + } + + /// Required if ok is `false`. Error message in human readable form that + /// explains why it is impossible to complete the order (e.g. "Sorry, + /// delivery to your desired address is unavailable'). + /// + /// Telegram will display this message to the user. + pub fn error_message(mut self, val: T) -> Self + where + T: Into, + { + self.error_message = Some(val.into()); + self + } +} diff --git a/src/requests/all/create_new_sticker_set.rs b/src/requests/all/create_new_sticker_set.rs new file mode 100644 index 00000000..cfb98634 --- /dev/null +++ b/src/requests/all/create_new_sticker_set.rs @@ -0,0 +1,148 @@ +use crate::{ + net, + requests::{form_builder::FormBuilder, Request, ResponseResult}, + types::{InputFile, MaskPosition, True}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to create new sticker set owned by a user. The bot will be +/// able to edit the created sticker set. +/// +/// [The official docs](https://core.telegram.org/bots/api#createnewstickerset). +#[derive(Debug, Clone)] +pub struct CreateNewStickerSet { + bot: Arc, + user_id: i32, + name: String, + title: String, + png_sticker: InputFile, + emojis: String, + contains_masks: Option, + mask_position: Option, +} + +#[async_trait::async_trait] +impl Request for CreateNewStickerSet { + type Output = True; + + async fn send(&self) -> ResponseResult { + 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 + .build(), + ) + .await + } +} + +impl CreateNewStickerSet { + pub(crate) fn new( + bot: Arc, + user_id: i32, + name: N, + title: T, + png_sticker: InputFile, + emojis: E, + ) -> Self + where + N: Into, + T: Into, + E: Into, + { + Self { + bot, + user_id, + name: name.into(), + title: title.into(), + png_sticker, + emojis: emojis.into(), + contains_masks: None, + mask_position: None, + } + } + + /// User identifier of created sticker set owner. + pub fn user_id(mut self, val: i32) -> Self { + self.user_id = val; + self + } + + /// Short name of sticker set, to be used in `t.me/addstickers/` URLs (e.g., + /// animals). Can contain only english letters, digits and underscores. + /// + /// Must begin with a letter, can't contain consecutive underscores and must + /// end in `_by_`. `` is case insensitive. + /// 1-64 characters. + pub fn name(mut self, val: T) -> Self + where + T: Into, + { + self.name = val.into(); + self + } + + /// Sticker set title, 1-64 characters. + pub fn title(mut self, val: T) -> Self + where + T: Into, + { + self.title = val.into(); + self + } + + /// **Png** image with the sticker, must be up to 512 kilobytes in size, + /// dimensions must not exceed 512px, and either width or height must be + /// exactly 512px. + /// + /// Pass [`InputFile::File`] to send a file that exists on + /// the Telegram servers (recommended), pass an [`InputFile::Url`] for + /// Telegram to get a .webp file from the Internet, or upload a new one + /// using [`InputFile::FileId`]. [More info on Sending Files »]. + /// + /// [`InputFile::File`]: crate::types::InputFile::File + /// [`InputFile::Url`]: crate::types::InputFile::Url + /// [`InputFile::FileId`]: crate::types::InputFile::FileId + pub fn png_sticker(mut self, val: InputFile) -> Self { + self.png_sticker = val; + self + } + + /// One or more emoji corresponding to the sticker. + pub fn emojis(mut self, val: T) -> Self + where + T: Into, + { + self.emojis = val.into(); + self + } + + /// Pass `true`, if a set of mask stickers should be created. + pub fn contains_masks(mut self, val: bool) -> Self { + self.contains_masks = Some(val); + self + } + + /// A JSON-serialized object for position where the mask should be placed on + /// faces. + pub fn mask_position(mut self, val: MaskPosition) -> Self { + self.mask_position = Some(val); + self + } +} diff --git a/src/requests/all/delete_chat_photo.rs b/src/requests/all/delete_chat_photo.rs new file mode 100644 index 00000000..d9ab17e1 --- /dev/null +++ b/src/requests/all/delete_chat_photo.rs @@ -0,0 +1,57 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, True}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to delete a chat photo. Photos can't be changed for private +/// chats. The bot must be an administrator in the chat for this to work and +/// must have the appropriate admin rights. +/// +/// [The official docs](https://core.telegram.org/bots/api#deletechatphoto). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct DeleteChatPhoto { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, +} + +#[async_trait::async_trait] +impl Request for DeleteChatPhoto { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "deleteChatPhoto", + &self, + ) + .await + } +} + +impl DeleteChatPhoto { + pub(crate) fn new(bot: Arc, chat_id: C) -> Self + where + C: Into, + { + let chat_id = chat_id.into(); + Self { bot, chat_id } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } +} diff --git a/src/requests/all/delete_chat_sticker_set.rs b/src/requests/all/delete_chat_sticker_set.rs new file mode 100644 index 00000000..0ad7ead9 --- /dev/null +++ b/src/requests/all/delete_chat_sticker_set.rs @@ -0,0 +1,62 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, True}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to delete a group sticker set from a supergroup. +/// +/// The bot must be an administrator in the chat for this to work and must have +/// the appropriate admin rights. Use the field `can_set_sticker_set` optionally +/// returned in [`Bot::get_chat`] requests to check if the bot can use this +/// method. +/// +/// [The official docs](https://core.telegram.org/bots/api#deletechatstickerset). +/// +/// [`Bot::get_chat`]: crate::Bot::get_chat +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct DeleteChatStickerSet { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, +} + +#[async_trait::async_trait] +impl Request for DeleteChatStickerSet { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "deleteChatStickerSet", + &self, + ) + .await + } +} + +impl DeleteChatStickerSet { + pub(crate) fn new(bot: Arc, chat_id: C) -> Self + where + C: Into, + { + let chat_id = chat_id.into(); + Self { bot, chat_id } + } + + /// Unique identifier for the target chat or username of the target + /// supergroup (in the format `@supergroupusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } +} diff --git a/src/requests/all/delete_message.rs b/src/requests/all/delete_message.rs new file mode 100644 index 00000000..cf28eb23 --- /dev/null +++ b/src/requests/all/delete_message.rs @@ -0,0 +1,78 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, True}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to delete a message, including service messages. +/// +/// The limitations are: +/// - A message can only be deleted if it was sent less than 48 hours ago. +/// - Bots can delete outgoing messages in private chats, groups, and +/// supergroups. +/// - Bots can delete incoming messages in private chats. +/// - Bots granted can_post_messages permissions can delete outgoing messages +/// in channels. +/// - If the bot is an administrator of a group, it can delete any message +/// there. +/// - If the bot has can_delete_messages permission in a supergroup or a +/// channel, it can delete any message there. +/// +/// [The official docs](https://core.telegram.org/bots/api#deletemessage). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct DeleteMessage { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, + message_id: i32, +} + +#[async_trait::async_trait] +impl Request for DeleteMessage { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "deleteMessage", + &self, + ) + .await + } +} + +impl DeleteMessage { + pub(crate) fn new(bot: Arc, chat_id: C, message_id: i32) -> Self + where + C: Into, + { + let chat_id = chat_id.into(); + Self { + bot, + chat_id, + message_id, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Identifier of the message to delete. + pub fn message_id(mut self, val: i32) -> Self { + self.message_id = val; + self + } +} diff --git a/src/requests/all/delete_sticker_from_set.rs b/src/requests/all/delete_sticker_from_set.rs new file mode 100644 index 00000000..41f41f80 --- /dev/null +++ b/src/requests/all/delete_sticker_from_set.rs @@ -0,0 +1,54 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::True, + Bot, +}; +use std::sync::Arc; + +/// Use this method to delete a sticker from a set created by the bot. +/// +/// [The official docs](https://core.telegram.org/bots/api#deletestickerfromset). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct DeleteStickerFromSet { + #[serde(skip_serializing)] + bot: Arc, + sticker: String, +} + +#[async_trait::async_trait] +impl Request for DeleteStickerFromSet { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "deleteStickerFromSet", + &self, + ) + .await + } +} + +impl DeleteStickerFromSet { + pub(crate) fn new(bot: Arc, sticker: S) -> Self + where + S: Into, + { + let sticker = sticker.into(); + Self { bot, sticker } + } + + /// File identifier of the sticker. + pub fn sticker(mut self, val: T) -> Self + where + T: Into, + { + self.sticker = val.into(); + self + } +} diff --git a/src/requests/all/delete_webhook.rs b/src/requests/all/delete_webhook.rs new file mode 100644 index 00000000..dbfcda20 --- /dev/null +++ b/src/requests/all/delete_webhook.rs @@ -0,0 +1,44 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::True, + Bot, +}; +use std::sync::Arc; + +/// Use this method to remove webhook integration if you decide to switch back +/// to [Bot::get_updates]. +/// +/// [The official docs](https://core.telegram.org/bots/api#deletewebhook). +/// +/// [Bot::get_updates]: crate::Bot::get_updates +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct DeleteWebhook { + #[serde(skip_serializing)] + bot: Arc, +} + +#[async_trait::async_trait] +impl Request for DeleteWebhook { + type Output = True; + + #[allow(clippy::trivially_copy_pass_by_ref)] + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "deleteWebhook", + &self, + ) + .await + } +} + +impl DeleteWebhook { + pub(crate) fn new(bot: Arc) -> Self { + Self { bot } + } +} diff --git a/src/requests/all/edit_message_caption.rs b/src/requests/all/edit_message_caption.rs new file mode 100644 index 00000000..6a5d0e1d --- /dev/null +++ b/src/requests/all/edit_message_caption.rs @@ -0,0 +1,94 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatOrInlineMessage, InlineKeyboardMarkup, Message, ParseMode}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to edit captions of messages. +/// +/// On success, if edited message is sent by the bot, the edited [`Message`] is +/// returned, otherwise [`True`] is returned. +/// +/// [The official docs](https://core.telegram.org/bots/api#editmessagecaption). +/// +/// [`Message`]: crate::types::Message +/// [`True`]: crate::types::True +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct EditMessageCaption { + #[serde(skip_serializing)] + bot: Arc, + #[serde(flatten)] + chat_or_inline_message: ChatOrInlineMessage, + caption: Option, + parse_mode: Option, + reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for EditMessageCaption { + type Output = Message; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "editMessageCaption", + &self, + ) + .await + } +} + +impl EditMessageCaption { + pub(crate) fn new( + bot: Arc, + chat_or_inline_message: ChatOrInlineMessage, + ) -> Self { + Self { + bot, + chat_or_inline_message, + caption: None, + parse_mode: None, + reply_markup: None, + } + } + + pub fn chat_or_inline_message(mut self, val: ChatOrInlineMessage) -> Self { + self.chat_or_inline_message = val; + self + } + + /// New caption of the message. + pub fn caption(mut self, val: T) -> Self + where + T: Into, + { + self.caption = Some(val.into()); + self + } + + /// Send [Markdown] or [HTML], if you want Telegram apps to show + /// [bold, italic, fixed-width text or inline URLs] in the media caption. + /// + /// [Markdown]: crate::types::ParseMode::Markdown + /// [HTML]: crate::types::ParseMode::HTML + /// [bold, italic, fixed-width text or inline URLs]: + /// crate::types::ParseMode + pub fn parse_mode(mut self, val: ParseMode) -> Self { + self.parse_mode = Some(val); + self + } + + /// A JSON-serialized object for an [inline keyboard]. + /// + /// [inline keyboard]: https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating + pub fn reply_markup(mut self, val: InlineKeyboardMarkup) -> Self { + self.reply_markup = Some(val); + self + } +} diff --git a/src/requests/all/edit_message_live_location.rs b/src/requests/all/edit_message_live_location.rs new file mode 100644 index 00000000..1e6bf9b1 --- /dev/null +++ b/src/requests/all/edit_message_live_location.rs @@ -0,0 +1,89 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatOrInlineMessage, InlineKeyboardMarkup, Message}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to edit live location messages. +/// +/// A location can be edited until its live_period expires or editing is +/// explicitly disabled by a call to stopMessageLiveLocation. On success, if the +/// edited message was sent by the bot, the edited [`Message`] is returned, +/// otherwise [`True`] is returned. +/// +/// [The official docs](https://core.telegram.org/bots/api#editmessagelivelocation). +/// +/// [`Message`]: crate::types::Message +/// [`True`]: crate::types::True +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct EditMessageLiveLocation { + #[serde(skip_serializing)] + bot: Arc, + #[serde(flatten)] + chat_or_inline_message: ChatOrInlineMessage, + latitude: f32, + longitude: f32, + reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for EditMessageLiveLocation { + type Output = Message; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "editMessageLiveLocation", + &self, + ) + .await + } +} + +impl EditMessageLiveLocation { + pub(crate) fn new( + bot: Arc, + chat_or_inline_message: ChatOrInlineMessage, + latitude: f32, + longitude: f32, + ) -> Self { + Self { + bot, + chat_or_inline_message, + latitude, + longitude, + reply_markup: None, + } + } + + pub fn chat_or_inline_message(mut self, val: ChatOrInlineMessage) -> Self { + self.chat_or_inline_message = val; + self + } + + /// Latitude of new location. + pub fn latitude(mut self, val: f32) -> Self { + self.latitude = val; + self + } + + /// Longitude of new location. + pub fn longitude(mut self, val: f32) -> Self { + self.longitude = val; + self + } + + /// A JSON-serialized object for a new [inline keyboard]. + /// + /// [inline keyboard]: https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating + pub fn reply_markup(mut self, val: InlineKeyboardMarkup) -> Self { + self.reply_markup = Some(val); + self + } +} diff --git a/src/requests/all/edit_message_media.rs b/src/requests/all/edit_message_media.rs new file mode 100644 index 00000000..31cd350d --- /dev/null +++ b/src/requests/all/edit_message_media.rs @@ -0,0 +1,102 @@ +use crate::{ + net, + requests::{form_builder::FormBuilder, Request, ResponseResult}, + types::{ChatOrInlineMessage, InlineKeyboardMarkup, InputMedia, Message}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to edit animation, audio, document, photo, or video +/// messages. +/// +/// If a message is a part of a message album, then it can be edited only to a +/// photo or a video. Otherwise, message type can be changed arbitrarily. When +/// inline message is edited, new file can't be uploaded. Use previously +/// uploaded file via its `file_id` or specify a URL. On success, if the edited +/// message was sent by the bot, the edited [`Message`] is returned, +/// otherwise [`True`] is returned. +/// +/// [The official docs](https://core.telegram.org/bots/api#editmessagemedia). +/// +/// [`Message`]: crate::types::Message +/// [`True`]: crate::types::True +#[derive(Debug, Clone)] +pub struct EditMessageMedia { + bot: Arc, + chat_or_inline_message: ChatOrInlineMessage, + media: InputMedia, + reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for EditMessageMedia { + type Output = Message; + + async fn send(&self) -> ResponseResult { + let mut params = FormBuilder::new(); + + 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; + } + ChatOrInlineMessage::Inline { inline_message_id } => { + params = + params.add("inline_message_id", inline_message_id).await; + } + } + + net::request_multipart( + self.bot.client(), + self.bot.token(), + "editMessageMedia", + params + .add("media", &self.media) + .await + .add("reply_markup", &self.reply_markup) + .await + .build(), + ) + .await + } +} + +impl EditMessageMedia { + pub(crate) fn new( + bot: Arc, + chat_or_inline_message: ChatOrInlineMessage, + media: InputMedia, + ) -> Self { + Self { + bot, + chat_or_inline_message, + media, + reply_markup: None, + } + } + + pub fn chat_or_inline_message(mut self, val: ChatOrInlineMessage) -> Self { + self.chat_or_inline_message = val; + self + } + + /// A JSON-serialized object for a new media content of the message. + pub fn media(mut self, val: InputMedia) -> Self { + self.media = val; + self + } + + /// A JSON-serialized object for a new [inline keyboard]. + /// + /// [inline keyboard]: https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating + pub fn reply_markup(mut self, val: InlineKeyboardMarkup) -> Self { + self.reply_markup = Some(val); + self + } +} diff --git a/src/requests/all/edit_message_reply_markup.rs b/src/requests/all/edit_message_reply_markup.rs new file mode 100644 index 00000000..9b60c224 --- /dev/null +++ b/src/requests/all/edit_message_reply_markup.rs @@ -0,0 +1,69 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatOrInlineMessage, InlineKeyboardMarkup, Message}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to edit only the reply markup of messages. +/// +/// On success, if edited message is sent by the bot, the edited [`Message`] is +/// returned, otherwise [`True`] is returned. +/// +/// [The official docs](https://core.telegram.org/bots/api#editmessagereplymarkup). +/// +/// [`Message`]: crate::types::Message +/// [`True`]: crate::types::True +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct EditMessageReplyMarkup { + #[serde(skip_serializing)] + bot: Arc, + #[serde(flatten)] + chat_or_inline_message: ChatOrInlineMessage, + reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for EditMessageReplyMarkup { + type Output = Message; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "editMessageReplyMarkup", + &self, + ) + .await + } +} + +impl EditMessageReplyMarkup { + pub(crate) fn new( + bot: Arc, + chat_or_inline_message: ChatOrInlineMessage, + ) -> Self { + Self { + bot, + chat_or_inline_message, + reply_markup: None, + } + } + + pub fn chat_or_inline_message(mut self, val: ChatOrInlineMessage) -> Self { + self.chat_or_inline_message = val; + self + } + + /// A JSON-serialized object for an [inline keyboard]. + /// + /// [inline keyboard]: https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating + pub fn reply_markup(mut self, val: InlineKeyboardMarkup) -> Self { + self.reply_markup = Some(val); + self + } +} diff --git a/src/requests/all/edit_message_text.rs b/src/requests/all/edit_message_text.rs new file mode 100644 index 00000000..d517db38 --- /dev/null +++ b/src/requests/all/edit_message_text.rs @@ -0,0 +1,105 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatOrInlineMessage, InlineKeyboardMarkup, Message, ParseMode}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to edit text and game messages. +/// +/// On success, if edited message is sent by the bot, the edited [`Message`] is +/// returned, otherwise [`True`] is returned. +/// +/// [The official docs](https://core.telegram.org/bots/api#editmessagetext). +/// +/// [`Message`]: crate::types::Message +/// [`True`]: crate::types::True +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct EditMessageText { + #[serde(skip_serializing)] + bot: Arc, + #[serde(flatten)] + chat_or_inline_message: ChatOrInlineMessage, + text: String, + parse_mode: Option, + disable_web_page_preview: Option, + reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for EditMessageText { + type Output = Message; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "editMessageText", + &self, + ) + .await + } +} + +impl EditMessageText { + pub(crate) fn new( + bot: Arc, + chat_or_inline_message: ChatOrInlineMessage, + text: T, + ) -> Self + where + T: Into, + { + Self { + bot, + chat_or_inline_message, + text: text.into(), + parse_mode: None, + disable_web_page_preview: None, + reply_markup: None, + } + } + + pub fn chat_or_inline_message(mut self, val: ChatOrInlineMessage) -> Self { + self.chat_or_inline_message = val; + self + } + + /// New text of the message. + pub fn text(mut self, val: T) -> Self + where + T: Into, + { + self.text = val.into(); + self + } + + /// Send [Markdown] or [HTML], if you want Telegram apps to show [bold, + /// italic, fixed-width text or inline URLs] in your bot's message. + /// + /// [Markdown]: https://core.telegram.org/bots/api#markdown-style + /// [HTML]: https://core.telegram.org/bots/api#html-style + /// [bold, italic, fixed-width text or inline URLs]: https://core.telegram.org/bots/api#formatting-options + pub fn parse_mode(mut self, val: ParseMode) -> Self { + self.parse_mode = Some(val); + self + } + + /// Disables link previews for links in this message. + pub fn disable_web_page_preview(mut self, val: bool) -> Self { + self.disable_web_page_preview = Some(val); + self + } + + /// A JSON-serialized object for an [inline keyboard]. + /// + /// [inline keyboard]: https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating + pub fn reply_markup(mut self, val: InlineKeyboardMarkup) -> Self { + self.reply_markup = Some(val); + self + } +} diff --git a/src/requests/all/export_chat_invite_link.rs b/src/requests/all/export_chat_invite_link.rs new file mode 100644 index 00000000..7e31cf96 --- /dev/null +++ b/src/requests/all/export_chat_invite_link.rs @@ -0,0 +1,72 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::ChatId, + Bot, +}; +use std::sync::Arc; + +/// Use this method to generate a new invite link for a chat; any previously +/// generated link is revoked. +/// +/// The bot must be an administrator in the chat for this to work and must have +/// the appropriate admin rights. +/// +/// ## Note +/// Each administrator in a chat generates their own invite links. Bots can't +/// use invite links generated by other administrators. If you want your bot to +/// work with invite links, it will need to generate its own link using +/// [`Bot::export_chat_invite_link`] – after this the link will become available +/// to the bot via the [`Bot::get_chat`] method. If your bot needs to generate a +/// new invite link replacing its previous one, use +/// [`Bot::export_chat_invite_link`] again. +/// +/// [The official docs](https://core.telegram.org/bots/api#exportchatinvitelink). +/// +/// [`Bot::export_chat_invite_link`]: crate::Bot::export_chat_invite_link +/// [`Bot::get_chat`]: crate::Bot::get_chat +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct ExportChatInviteLink { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, +} + +#[async_trait::async_trait] +impl Request for ExportChatInviteLink { + type Output = String; + + /// Returns the new invite link as `String` on success. + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "exportChatInviteLink", + &self, + ) + .await + } +} + +impl ExportChatInviteLink { + pub(crate) fn new(bot: Arc, chat_id: C) -> Self + where + C: Into, + { + let chat_id = chat_id.into(); + Self { bot, chat_id } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } +} diff --git a/src/requests/all/forward_message.rs b/src/requests/all/forward_message.rs new file mode 100644 index 00000000..c37b71c6 --- /dev/null +++ b/src/requests/all/forward_message.rs @@ -0,0 +1,99 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, Message}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to forward messages of any kind. +/// +/// [`The official docs`](https://core.telegram.org/bots/api#forwardmessage). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct ForwardMessage { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, + from_chat_id: ChatId, + disable_notification: Option, + message_id: i32, +} + +#[async_trait::async_trait] +impl Request for ForwardMessage { + type Output = Message; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "forwardMessage", + &self, + ) + .await + } +} + +impl ForwardMessage { + pub(crate) fn new( + bot: Arc, + chat_id: C, + from_chat_id: F, + message_id: i32, + ) -> Self + where + C: Into, + F: Into, + { + let chat_id = chat_id.into(); + let from_chat_id = from_chat_id.into(); + Self { + bot, + chat_id, + from_chat_id, + message_id, + disable_notification: None, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Unique identifier for the chat where the original message was sent (or + /// channel username in the format `@channelusername`). + #[allow(clippy::wrong_self_convention)] + pub fn from_chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.from_chat_id = val.into(); + self + } + + /// Sends the message [silently]. Users will receive a notification with no + /// sound. + /// + /// [silently]: https://telegram.org/blog/channels-2-0#silent-messages + pub fn disable_notification(mut self, val: bool) -> Self { + self.disable_notification = Some(val); + self + } + + /// Message identifier in the chat specified in [`from_chat_id`]. + /// + /// [`from_chat_id`]: ForwardMessage::from_chat_id + pub fn message_id(mut self, val: i32) -> Self { + self.message_id = val; + self + } +} diff --git a/src/requests/all/get_chat.rs b/src/requests/all/get_chat.rs new file mode 100644 index 00000000..c726908e --- /dev/null +++ b/src/requests/all/get_chat.rs @@ -0,0 +1,52 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{Chat, ChatId}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to get up to date information about the chat (current name +/// of the user for one-on-one conversations, current username of a user, group +/// or channel, etc.). +/// +/// [The official docs](https://core.telegram.org/bots/api#getchat). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct GetChat { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, +} + +#[async_trait::async_trait] +impl Request for GetChat { + type Output = Chat; + + async fn send(&self) -> ResponseResult { + net::request_json(self.bot.client(), self.bot.token(), "getChat", &self) + .await + } +} + +impl GetChat { + pub(crate) fn new(bot: Arc, chat_id: C) -> Self + where + C: Into, + { + let chat_id = chat_id.into(); + Self { bot, chat_id } + } + + /// Unique identifier for the target chat or username of the target + /// supergroup or channel (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } +} diff --git a/src/requests/all/get_chat_administrators.rs b/src/requests/all/get_chat_administrators.rs new file mode 100644 index 00000000..cc575a27 --- /dev/null +++ b/src/requests/all/get_chat_administrators.rs @@ -0,0 +1,60 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, ChatMember}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to get a list of administrators in a chat. +/// +/// If the chat is a group or a supergroup and no administrators were appointed, +/// only the creator will be returned. +/// +/// [The official docs](https://core.telegram.org/bots/api#getchatadministrators). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct GetChatAdministrators { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, +} + +#[async_trait::async_trait] +impl Request for GetChatAdministrators { + type Output = Vec; + + /// On success, returns an array that contains information about all chat + /// administrators except other bots. + async fn send(&self) -> ResponseResult> { + net::request_json( + self.bot.client(), + self.bot.token(), + "getChatAdministrators", + &self, + ) + .await + } +} + +impl GetChatAdministrators { + pub(crate) fn new(bot: Arc, chat_id: C) -> Self + where + C: Into, + { + let chat_id = chat_id.into(); + Self { bot, chat_id } + } + + /// Unique identifier for the target chat or username of the target + /// supergroup or channel (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } +} diff --git a/src/requests/all/get_chat_member.rs b/src/requests/all/get_chat_member.rs new file mode 100644 index 00000000..6b5aabea --- /dev/null +++ b/src/requests/all/get_chat_member.rs @@ -0,0 +1,66 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, ChatMember}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to get information about a member of a chat. +/// +/// [The official docs](https://core.telegram.org/bots/api#getchatmember). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct GetChatMember { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, + user_id: i32, +} + +#[async_trait::async_trait] +impl Request for GetChatMember { + type Output = ChatMember; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "getChatMember", + &self, + ) + .await + } +} + +impl GetChatMember { + pub(crate) fn new(bot: Arc, chat_id: C, user_id: i32) -> Self + where + C: Into, + { + let chat_id = chat_id.into(); + Self { + bot, + chat_id, + user_id, + } + } + + /// Unique identifier for the target chat or username of the target + /// supergroup or channel (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Unique identifier of the target user. + pub fn user_id(mut self, val: i32) -> Self { + self.user_id = val; + self + } +} diff --git a/src/requests/all/get_chat_members_count.rs b/src/requests/all/get_chat_members_count.rs new file mode 100644 index 00000000..89deea55 --- /dev/null +++ b/src/requests/all/get_chat_members_count.rs @@ -0,0 +1,55 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::ChatId, + Bot, +}; +use std::sync::Arc; + +/// Use this method to get the number of members in a chat. +/// +/// [The official docs](https://core.telegram.org/bots/api#getchatmemberscount). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct GetChatMembersCount { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, +} + +#[async_trait::async_trait] +impl Request for GetChatMembersCount { + type Output = i32; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "getChatMembersCount", + &self, + ) + .await + } +} + +impl GetChatMembersCount { + pub(crate) fn new(bot: Arc, chat_id: C) -> Self + where + C: Into, + { + let chat_id = chat_id.into(); + Self { bot, chat_id } + } + + /// Unique identifier for the target chat or username of the target + /// supergroup or channel (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } +} diff --git a/src/requests/all/get_file.rs b/src/requests/all/get_file.rs new file mode 100644 index 00000000..2694e20f --- /dev/null +++ b/src/requests/all/get_file.rs @@ -0,0 +1,67 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::File, + Bot, +}; +use std::sync::Arc; + +/// Use this method to get basic info about a file and prepare it for +/// downloading. +/// +/// For the moment, bots can download files of up to `20MB` in size. +/// +/// The file can then be downloaded via the link +/// `https://api.telegram.org/file/bot/`, where `` +/// is taken from the response. It is guaranteed that the link will be valid +/// for at least `1` hour. When the link expires, a new one can be requested by +/// calling [`GetFile`] again. +/// +/// **Note**: This function may not preserve the original file name and MIME +/// type. You should save the file's MIME type and name (if available) when the +/// [`File`] object is received. +/// +/// [The official docs](https://core.telegram.org/bots/api#getfile). +/// +/// [`File`]: crate::types::file +/// [`GetFile`]: self::GetFile +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct GetFile { + #[serde(skip_serializing)] + bot: Arc, + file_id: String, +} + +#[async_trait::async_trait] +impl Request for GetFile { + type Output = File; + + async fn send(&self) -> ResponseResult { + net::request_json(self.bot.client(), self.bot.token(), "getFile", &self) + .await + } +} + +impl GetFile { + pub(crate) fn new(bot: Arc, file_id: F) -> Self + where + F: Into, + { + Self { + bot, + file_id: file_id.into(), + } + } + + /// File identifier to get info about. + pub fn file_id(mut self, value: F) -> Self + where + F: Into, + { + self.file_id = value.into(); + self + } +} diff --git a/src/requests/all/get_game_high_scores.rs b/src/requests/all/get_game_high_scores.rs new file mode 100644 index 00000000..6026bfef --- /dev/null +++ b/src/requests/all/get_game_high_scores.rs @@ -0,0 +1,71 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatOrInlineMessage, GameHighScore}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to get data for high score tables. +/// +/// Will return the score of the specified user and several of his neighbors in +/// a game. +/// +/// ## Note +/// This method will currently return scores for the target user, plus two of +/// his closest neighbors on each side. Will also return the top three users if +/// the user and his neighbors are not among them. Please note that this +/// behavior is subject to change. +/// +/// [The official docs](https://core.telegram.org/bots/api#getgamehighscores). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct GetGameHighScores { + #[serde(skip_serializing)] + bot: Arc, + #[serde(flatten)] + chat_or_inline_message: ChatOrInlineMessage, + user_id: i32, +} + +#[async_trait::async_trait] +impl Request for GetGameHighScores { + type Output = Vec; + + async fn send(&self) -> ResponseResult> { + net::request_json( + self.bot.client(), + self.bot.token(), + "getGameHighScores", + &self, + ) + .await + } +} + +impl GetGameHighScores { + pub(crate) fn new( + bot: Arc, + chat_or_inline_message: ChatOrInlineMessage, + user_id: i32, + ) -> Self { + Self { + bot, + chat_or_inline_message, + user_id, + } + } + + pub fn chat_or_inline_message(mut self, val: ChatOrInlineMessage) -> Self { + self.chat_or_inline_message = val; + self + } + + /// Target user id. + pub fn user_id(mut self, val: i32) -> Self { + self.user_id = val; + self + } +} diff --git a/src/requests/all/get_me.rs b/src/requests/all/get_me.rs new file mode 100644 index 00000000..857c8a6b --- /dev/null +++ b/src/requests/all/get_me.rs @@ -0,0 +1,35 @@ +use crate::{ + net, + requests::{Request, ResponseResult}, + types::Me, + Bot, +}; +use serde::Serialize; +use std::sync::Arc; + +/// A simple method for testing your bot's auth token. Requires no parameters. +/// +/// [The official docs](https://core.telegram.org/bots/api#getme). +#[derive(Debug, Clone, Serialize)] +pub struct GetMe { + #[serde(skip_serializing)] + bot: Arc, +} + +#[async_trait::async_trait] +impl Request for GetMe { + type Output = Me; + + /// Returns basic information about the bot. + #[allow(clippy::trivially_copy_pass_by_ref)] + async fn send(&self) -> ResponseResult { + net::request_json(self.bot.client(), self.bot.token(), "getMe", &self) + .await + } +} + +impl GetMe { + pub(crate) fn new(bot: Arc) -> Self { + Self { bot } + } +} diff --git a/src/requests/all/get_sticker_set.rs b/src/requests/all/get_sticker_set.rs new file mode 100644 index 00000000..8b1094a5 --- /dev/null +++ b/src/requests/all/get_sticker_set.rs @@ -0,0 +1,54 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::StickerSet, + Bot, +}; +use std::sync::Arc; + +/// Use this method to get a sticker set. +/// +/// [The official docs](https://core.telegram.org/bots/api#getstickerset). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct GetStickerSet { + #[serde(skip_serializing)] + bot: Arc, + name: String, +} + +#[async_trait::async_trait] +impl Request for GetStickerSet { + type Output = StickerSet; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "getStickerSet", + &self, + ) + .await + } +} + +impl GetStickerSet { + pub(crate) fn new(bot: Arc, name: N) -> Self + where + N: Into, + { + let name = name.into(); + Self { bot, name } + } + + /// Name of the sticker set. + pub fn name(mut self, val: T) -> Self + where + T: Into, + { + self.name = val.into(); + self + } +} diff --git a/src/requests/all/get_updates.rs b/src/requests/all/get_updates.rs new file mode 100644 index 00000000..f3fbe0c4 --- /dev/null +++ b/src/requests/all/get_updates.rs @@ -0,0 +1,139 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{AllowedUpdate, Update}, + Bot, RequestError, +}; +use serde_json::Value; +use std::sync::Arc; + +/// Use this method to receive incoming updates using long polling ([wiki]). +/// +/// **Notes:** +/// 1. This method will not work if an outgoing webhook is set up. +/// 2. In order to avoid getting duplicate updates, +/// recalculate offset after each server response. +/// +/// [The official docs](https://core.telegram.org/bots/api#getupdates). +/// +/// [wiki]: https://en.wikipedia.org/wiki/Push_technology#Long_polling +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct GetUpdates { + #[serde(skip_serializing)] + bot: Arc, + pub(crate) offset: Option, + pub(crate) limit: Option, + pub(crate) timeout: Option, + pub(crate) allowed_updates: Option>, +} + +#[async_trait::async_trait] +impl Request for GetUpdates { + type Output = Vec>; + + /// Deserialize to `Vec>` instead of + /// `Vec`, because we want to parse the rest of updates even if our + /// library hasn't parsed one. + async fn send( + &self, + ) -> ResponseResult>> { + let value: Value = net::request_json( + self.bot.client(), + self.bot.token(), + "getUpdates", + &self, + ) + .await?; + + match value { + Value::Array(array) => Ok(array + .into_iter() + .map(|value| { + serde_json::from_str(&value.to_string()) + .map_err(|error| (value, error)) + }) + .collect()), + _ => Err(RequestError::InvalidJson( + serde_json::from_value::>(value) + .expect_err("get_update must return Value::Array"), + )), + } + } +} + +impl GetUpdates { + pub(crate) fn new(bot: Arc) -> Self { + Self { + bot, + offset: None, + limit: None, + timeout: None, + allowed_updates: None, + } + } + + /// Identifier of the first update to be returned. + /// + /// Must be greater by one than the highest among the identifiers of + /// previously received updates. By default, updates starting with the + /// earliest unconfirmed update are returned. An update is considered + /// confirmed as soon as [`GetUpdates`] is called with an [`offset`] + /// higher than its [`id`]. The negative offset can be specified to + /// retrieve updates starting from `-offset` update from the end of the + /// updates queue. All previous updates will forgotten. + /// + /// [`GetUpdates`]: self::GetUpdates + /// [`offset`]: self::GetUpdates::offset + /// [`id`]: crate::types::Update::id + pub fn offset(mut self, value: i32) -> Self { + self.offset = Some(value); + self + } + + /// Limits the number of updates to be retrieved. + /// + /// Values between `1`—`100` are accepted. Defaults to `100`. + pub fn limit(mut self, value: u8) -> Self { + self.limit = Some(value); + self + } + + /// Timeout in seconds for long polling. + /// + /// Defaults to `0`, i.e. usual short polling. Should be positive, short + /// polling should be used for testing purposes only. + pub fn timeout(mut self, value: u32) -> Self { + self.timeout = Some(value); + self + } + + /// List the types of updates you want your bot to receive. + /// + /// For example, specify [[`Message`], [`EditedChannelPost`], + /// [`CallbackQuery`]] to only receive updates of these types. + /// See [`AllowedUpdate`] for a complete list of available update types. + /// + /// Specify an empty list to receive all updates regardless of type + /// (default). If not specified, the previous setting will be used. + /// + /// **Note:** + /// This parameter doesn't affect updates created before the call to the + /// [`Bot::get_updates`], so unwanted updates may be received for a short + /// period of time. + /// + /// [`Message`]: self::AllowedUpdate::Message + /// [`EditedChannelPost`]: self::AllowedUpdate::EditedChannelPost + /// [`CallbackQuery`]: self::AllowedUpdate::CallbackQuery + /// [`AllowedUpdate`]: self::AllowedUpdate + /// [`Bot::get_updates`]: crate::Bot::get_updates + pub fn allowed_updates(mut self, value: T) -> Self + where + T: Into>, + { + self.allowed_updates = Some(value.into()); + self + } +} diff --git a/src/requests/all/get_user_profile_photos.rs b/src/requests/all/get_user_profile_photos.rs new file mode 100644 index 00000000..c3939104 --- /dev/null +++ b/src/requests/all/get_user_profile_photos.rs @@ -0,0 +1,70 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::UserProfilePhotos, + Bot, +}; +use std::sync::Arc; + +/// Use this method to get a list of profile pictures for a user. +/// +/// [The official docs](https://core.telegram.org/bots/api#getuserprofilephotos). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct GetUserProfilePhotos { + #[serde(skip_serializing)] + bot: Arc, + user_id: i32, + offset: Option, + limit: Option, +} + +#[async_trait::async_trait] +impl Request for GetUserProfilePhotos { + type Output = UserProfilePhotos; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "getUserProfilePhotos", + &self, + ) + .await + } +} + +impl GetUserProfilePhotos { + pub(crate) fn new(bot: Arc, user_id: i32) -> Self { + Self { + bot, + user_id, + offset: None, + limit: None, + } + } + + /// Unique identifier of the target user. + pub fn user_id(mut self, val: i32) -> Self { + self.user_id = val; + self + } + + /// Sequential number of the first photo to be returned. By default, all + /// photos are returned. + pub fn offset(mut self, val: i32) -> Self { + self.offset = Some(val); + self + } + + /// Limits the number of photos to be retrieved. Values between 1—100 are + /// accepted. + /// + /// Defaults to 100. + pub fn limit(mut self, val: i32) -> Self { + self.limit = Some(val); + self + } +} diff --git a/src/requests/all/get_webhook_info.rs b/src/requests/all/get_webhook_info.rs new file mode 100644 index 00000000..99ece7ea --- /dev/null +++ b/src/requests/all/get_webhook_info.rs @@ -0,0 +1,45 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::WebhookInfo, + Bot, +}; +use std::sync::Arc; + +/// Use this method to get current webhook status. +/// +/// If the bot is using [`Bot::get_updates`], will return an object with the url +/// field empty. +/// +/// [The official docs](https://core.telegram.org/bots/api#getwebhookinfo). +/// +/// [`Bot::get_updates`]: crate::Bot::get_updates +#[derive(Debug, Clone, Serialize)] +pub struct GetWebhookInfo { + #[serde(skip_serializing)] + bot: Arc, +} + +#[async_trait::async_trait] +impl Request for GetWebhookInfo { + type Output = WebhookInfo; + + #[allow(clippy::trivially_copy_pass_by_ref)] + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "getWebhookInfo", + &self, + ) + .await + } +} + +impl GetWebhookInfo { + pub(crate) fn new(bot: Arc) -> Self { + Self { bot } + } +} diff --git a/src/requests/all/kick_chat_member.rs b/src/requests/all/kick_chat_member.rs new file mode 100644 index 00000000..fb02d384 --- /dev/null +++ b/src/requests/all/kick_chat_member.rs @@ -0,0 +1,84 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, True}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to kick a user from a group, a supergroup or a channel. +/// +/// In the case of supergroups and channels, the user will not be able to return +/// to the group on their own using invite links, etc., unless [unbanned] first. +/// The bot must be an administrator in the chat for this to work and must have +/// the appropriate admin rights. +/// +/// [The official docs](https://core.telegram.org/bots/api#kickchatmember). +/// +/// [unbanned]: crate::Bot::unban_chat_member +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct KickChatMember { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, + user_id: i32, + until_date: Option, +} + +#[async_trait::async_trait] +impl Request for KickChatMember { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "kickChatMember", + &self, + ) + .await + } +} + +impl KickChatMember { + pub(crate) fn new(bot: Arc, chat_id: C, user_id: i32) -> Self + where + C: Into, + { + let chat_id = chat_id.into(); + Self { + bot, + chat_id, + user_id, + until_date: None, + } + } + + /// Unique identifier for the target group or username of the target + /// supergroup or channel (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Unique identifier of the target user. + pub fn user_id(mut self, val: i32) -> Self { + self.user_id = val; + self + } + + /// Date when the user will be unbanned, unix time. + /// + /// If user is banned for more than 366 days or less than 30 seconds from + /// the current time they are considered to be banned forever. + pub fn until_date(mut self, val: i32) -> Self { + self.until_date = Some(val); + self + } +} diff --git a/src/requests/all/leave_chat.rs b/src/requests/all/leave_chat.rs new file mode 100644 index 00000000..d2dc5f14 --- /dev/null +++ b/src/requests/all/leave_chat.rs @@ -0,0 +1,55 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, True}, + Bot, +}; +use std::sync::Arc; + +/// Use this method for your bot to leave a group, supergroup or channel. +/// +/// [The official docs](https://core.telegram.org/bots/api#leavechat). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct LeaveChat { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, +} + +#[async_trait::async_trait] +impl Request for LeaveChat { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "leaveChat", + &self, + ) + .await + } +} + +impl LeaveChat { + pub(crate) fn new(bot: Arc, chat_id: C) -> Self + where + C: Into, + { + let chat_id = chat_id.into(); + Self { bot, chat_id } + } + + /// Unique identifier for the target chat or username of the target + /// supergroup or channel (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } +} diff --git a/src/requests/all/mod.rs b/src/requests/all/mod.rs new file mode 100644 index 00000000..7d8e6845 --- /dev/null +++ b/src/requests/all/mod.rs @@ -0,0 +1,132 @@ +mod add_sticker_to_set; +mod answer_callback_query; +mod answer_inline_query; +mod answer_pre_checkout_query; +mod answer_shipping_query; +mod create_new_sticker_set; +mod delete_chat_photo; +mod delete_chat_sticker_set; +mod delete_message; +mod delete_sticker_from_set; +mod delete_webhook; +mod edit_message_caption; +mod edit_message_live_location; +mod edit_message_media; +mod edit_message_reply_markup; +mod edit_message_text; +mod export_chat_invite_link; +mod forward_message; +mod get_chat; +mod get_chat_administrators; +mod get_chat_member; +mod get_chat_members_count; +mod get_file; +mod get_game_high_scores; +mod get_me; +mod get_sticker_set; +mod get_updates; +mod get_user_profile_photos; +mod get_webhook_info; +mod kick_chat_member; +mod leave_chat; +mod pin_chat_message; +mod promote_chat_member; +mod restrict_chat_member; +mod send_animation; +mod send_audio; +mod send_chat_action; +mod send_contact; +mod send_document; +mod send_game; +mod send_invoice; +mod send_location; +mod send_media_group; +mod send_message; +mod send_photo; +mod send_poll; +mod send_sticker; +mod send_venue; +mod send_video; +mod send_video_note; +mod send_voice; +mod set_chat_administrator_custom_title; +mod set_chat_description; +mod set_chat_permissions; +mod set_chat_photo; +mod set_chat_sticker_set; +mod set_chat_title; +mod set_game_score; +mod set_sticker_position_in_set; +mod set_webhook; +mod stop_message_live_location; +mod stop_poll; +mod unban_chat_member; +mod unpin_chat_message; +mod upload_sticker_file; + +pub use add_sticker_to_set::*; +pub use answer_callback_query::*; +pub use answer_inline_query::*; +pub use answer_pre_checkout_query::*; +pub use answer_shipping_query::*; +pub use create_new_sticker_set::*; +pub use delete_chat_photo::*; +pub use delete_chat_sticker_set::*; +pub use delete_message::*; +pub use delete_sticker_from_set::*; +pub use delete_webhook::*; +pub use edit_message_caption::*; +pub use edit_message_live_location::*; +pub use edit_message_media::*; +pub use edit_message_reply_markup::*; +pub use edit_message_text::*; +pub use export_chat_invite_link::*; +pub use forward_message::*; +pub use get_chat::*; +pub use get_chat_administrators::*; +pub use get_chat_member::*; +pub use get_chat_members_count::*; +pub use get_file::*; +pub use get_game_high_scores::*; +pub use get_me::*; +pub use get_sticker_set::*; +pub use get_updates::*; +pub use get_user_profile_photos::*; +pub use get_webhook_info::*; +pub use kick_chat_member::*; +pub use leave_chat::*; +pub use pin_chat_message::*; +pub use promote_chat_member::*; +pub use restrict_chat_member::*; +pub use send_animation::*; +pub use send_audio::*; +pub use send_chat_action::*; +pub use send_contact::*; +pub use send_document::*; +pub use send_game::*; +pub use send_invoice::*; +pub use send_location::*; +pub use send_media_group::*; +pub use send_message::*; +pub use send_photo::*; +pub use send_poll::*; +pub use send_sticker::*; +pub use send_venue::*; +pub use send_video::*; +pub use send_video_note::*; +pub use send_voice::*; +pub use set_chat_administrator_custom_title::*; +pub use set_chat_description::*; +pub use set_chat_permissions::*; +pub use set_chat_photo::*; +pub use set_chat_sticker_set::*; +pub use set_chat_title::*; +pub use set_game_score::*; +pub use set_sticker_position_in_set::*; +pub use set_webhook::*; +pub use std::pin::Pin; +pub use stop_message_live_location::*; +pub use stop_poll::*; +pub use unban_chat_member::*; +pub use unpin_chat_message::*; +pub use upload_sticker_file::*; diff --git a/src/requests/all/pin_chat_message.rs b/src/requests/all/pin_chat_message.rs new file mode 100644 index 00000000..256ddef1 --- /dev/null +++ b/src/requests/all/pin_chat_message.rs @@ -0,0 +1,81 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, True}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to pin a message in a group, a supergroup, or a channel. +/// +/// The bot must be an administrator in the chat for this to work and must have +/// the `can_pin_messages` admin right in the supergroup or `can_edit_messages` +/// admin right in the channel. +/// +/// [The official docs](https://core.telegram.org/bots/api#pinchatmessage). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct PinChatMessage { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, + message_id: i32, + disable_notification: Option, +} + +#[async_trait::async_trait] +impl Request for PinChatMessage { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "pinChatMessage", + &self, + ) + .await + } +} + +impl PinChatMessage { + pub(crate) fn new(bot: Arc, chat_id: C, message_id: i32) -> Self + where + C: Into, + { + let chat_id = chat_id.into(); + Self { + bot, + chat_id, + message_id, + disable_notification: None, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Identifier of a message to pin. + pub fn message_id(mut self, val: i32) -> Self { + self.message_id = val; + self + } + + /// Pass `true`, if it is not necessary to send a notification to all chat + /// members about the new pinned message. + /// + /// Notifications are always disabled in channels. + pub fn disable_notification(mut self, val: bool) -> Self { + self.disable_notification = Some(val); + self + } +} diff --git a/src/requests/all/promote_chat_member.rs b/src/requests/all/promote_chat_member.rs new file mode 100644 index 00000000..32919b0f --- /dev/null +++ b/src/requests/all/promote_chat_member.rs @@ -0,0 +1,141 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, True}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to promote or demote a user in a supergroup or a channel. +/// +/// The bot must be an administrator in the chat for this to work and must have +/// the appropriate admin rights. Pass False for all boolean parameters to +/// demote a user. +/// +/// [The official docs](https://core.telegram.org/bots/api#promotechatmember). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct PromoteChatMember { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, + user_id: i32, + can_change_info: Option, + can_post_messages: Option, + can_edit_messages: Option, + can_delete_messages: Option, + can_invite_users: Option, + can_restrict_members: Option, + can_pin_messages: Option, + can_promote_members: Option, +} + +#[async_trait::async_trait] +impl Request for PromoteChatMember { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "promoteChatMember", + &self, + ) + .await + } +} + +impl PromoteChatMember { + pub(crate) fn new(bot: Arc, chat_id: C, user_id: i32) -> Self + where + C: Into, + { + let chat_id = chat_id.into(); + Self { + bot, + chat_id, + user_id, + can_change_info: None, + can_post_messages: None, + can_edit_messages: None, + can_delete_messages: None, + can_invite_users: None, + can_restrict_members: None, + can_pin_messages: None, + can_promote_members: None, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Unique identifier of the target user. + pub fn user_id(mut self, val: i32) -> Self { + self.user_id = val; + self + } + + /// Pass `true`, if the administrator can change chat title, photo and other + /// settings. + pub fn can_change_info(mut self, val: bool) -> Self { + self.can_change_info = Some(val); + self + } + + /// Pass `true`, if the administrator can create channel posts, channels + /// only. + pub fn can_post_messages(mut self, val: bool) -> Self { + self.can_post_messages = Some(val); + self + } + + /// Pass `true`, if the administrator can edit messages of other users and + /// can pin messages, channels only. + pub fn can_edit_messages(mut self, val: bool) -> Self { + self.can_edit_messages = Some(val); + self + } + + /// Pass `true`, if the administrator can delete messages of other users. + pub fn can_delete_messages(mut self, val: bool) -> Self { + self.can_delete_messages = Some(val); + self + } + + /// Pass `true`, if the administrator can invite new users to the chat. + pub fn can_invite_users(mut self, val: bool) -> Self { + self.can_invite_users = Some(val); + self + } + + /// Pass `true`, if the administrator can restrict, ban or unban chat + /// members. + pub fn can_restrict_members(mut self, val: bool) -> Self { + self.can_restrict_members = Some(val); + self + } + + /// Pass `true`, if the administrator can pin messages, supergroups only. + pub fn can_pin_messages(mut self, val: bool) -> Self { + self.can_pin_messages = Some(val); + self + } + + /// Pass `true`, if the administrator can add new administrators with a + /// subset of his own privileges or demote administrators that he has + /// promoted, directly or indirectly (promoted by administrators that were + /// appointed by him). + pub fn can_promote_members(mut self, val: bool) -> Self { + self.can_promote_members = Some(val); + self + } +} diff --git a/src/requests/all/restrict_chat_member.rs b/src/requests/all/restrict_chat_member.rs new file mode 100644 index 00000000..90dda304 --- /dev/null +++ b/src/requests/all/restrict_chat_member.rs @@ -0,0 +1,94 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, ChatPermissions, True}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to restrict a user in a supergroup. +/// +/// The bot must be an administrator in the supergroup for this to work and must +/// have the appropriate admin rights. Pass `true` for all permissions to lift +/// restrictions from a user. +/// +/// [The official docs](https://core.telegram.org/bots/api#restrictchatmember). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct RestrictChatMember { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, + user_id: i32, + permissions: ChatPermissions, + until_date: Option, +} + +#[async_trait::async_trait] +impl Request for RestrictChatMember { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "restrictChatMember", + &self, + ) + .await + } +} + +impl RestrictChatMember { + pub(crate) fn new( + bot: Arc, + chat_id: C, + user_id: i32, + permissions: ChatPermissions, + ) -> Self + where + C: Into, + { + let chat_id = chat_id.into(); + Self { + bot, + chat_id, + user_id, + permissions, + until_date: None, + } + } + + /// Unique identifier for the target chat or username of the target + /// supergroup (in the format `@supergroupusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Unique identifier of the target user. + pub fn user_id(mut self, val: i32) -> Self { + self.user_id = val; + self + } + + /// New user permissions. + pub fn permissions(mut self, val: ChatPermissions) -> Self { + self.permissions = val; + self + } + + /// Date when restrictions will be lifted for the user, unix time. + /// + /// If user is restricted for more than 366 days or less than 30 seconds + /// from the current time, they are considered to be restricted forever. + pub fn until_date(mut self, val: i32) -> Self { + self.until_date = Some(val); + self + } +} diff --git a/src/requests/all/send_animation.rs b/src/requests/all/send_animation.rs new file mode 100644 index 00000000..cbd40485 --- /dev/null +++ b/src/requests/all/send_animation.rs @@ -0,0 +1,188 @@ +use crate::{ + net, + requests::{form_builder::FormBuilder, Request, ResponseResult}, + types::{ChatId, InputFile, Message, ParseMode, ReplyMarkup}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to send animation files (GIF or H.264/MPEG-4 AVC video +/// without sound). +/// +/// Bots can currently send animation files of up to 50 MB in size, this limit +/// may be changed in the future. +/// +/// [The official docs](https://core.telegram.org/bots/api#sendanimation). +#[derive(Debug, Clone)] +pub struct SendAnimation { + bot: Arc, + pub chat_id: ChatId, + pub animation: InputFile, + pub duration: Option, + pub width: Option, + pub height: Option, + pub thumb: Option, + pub caption: Option, + pub parse_mode: Option, + pub disable_notification: Option, + pub reply_to_message_id: Option, + pub reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for SendAnimation { + type Output = Message; + + async fn send(&self) -> ResponseResult { + 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(), + ) + .await + } +} + +impl SendAnimation { + pub(crate) fn new( + bot: Arc, + chat_id: C, + animation: InputFile, + ) -> Self + where + C: Into, + { + Self { + bot, + chat_id: chat_id.into(), + animation, + duration: None, + width: None, + height: None, + thumb: None, + caption: None, + parse_mode: None, + disable_notification: None, + reply_to_message_id: None, + reply_markup: None, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, value: T) -> Self + where + T: Into, + { + self.chat_id = value.into(); + self + } + + /// Animation to send. + pub fn animation(mut self, val: InputFile) -> Self { + self.animation = val; + self + } + + /// Duration of sent animation in seconds. + pub fn duration(mut self, value: u32) -> Self { + self.duration = Some(value); + self + } + + /// Animation width. + pub fn width(mut self, value: u32) -> Self { + self.width = Some(value); + self + } + + /// Animation height. + pub fn height(mut self, value: u32) -> Self { + self.height = Some(value); + self + } + + /// Thumbnail of the file sent; can be ignored if thumbnail generation for + /// the file is supported server-side. + /// + /// The thumbnail should be in JPEG format and less than 200 kB in size. A + /// thumbnail‘s width and height should not exceed 320. Ignored if the + /// file is not uploaded using [`InputFile::File`]. Thumbnails can’t be + /// reused and can be only uploaded as a new file, with + /// [`InputFile::File`]. + /// + /// [`InputFile::File`]: crate::types::InputFile::File + pub fn thumb(mut self, value: InputFile) -> Self { + self.thumb = Some(value); + self + } + + /// Animation caption, `0`-`1024` characters. + pub fn caption(mut self, value: T) -> Self + where + T: Into, + { + self.caption = Some(value.into()); + self + } + + /// Send [Markdown] or [HTML], if you want Telegram apps to show + /// [bold, italic, fixed-width text or inline URLs] in the media caption. + /// + /// [Markdown]: crate::types::ParseMode::Markdown + /// [HTML]: crate::types::ParseMode::HTML + /// [bold, italic, fixed-width text or inline URLs]: + /// crate::types::ParseMode + pub fn parse_mode(mut self, value: ParseMode) -> Self { + self.parse_mode = Some(value); + self + } + + /// Sends the message silently. Users will receive a notification with no + /// sound. + pub fn disable_notification(mut self, value: bool) -> Self { + self.disable_notification = Some(value); + self + } + + /// If the message is a reply, [id] of the original message. + /// + /// [id]: crate::types::Message::id + pub fn reply_to_message_id(mut self, value: i32) -> Self { + self.reply_to_message_id = Some(value); + self + } + + /// Additional interface options. + pub fn reply_markup(mut self, value: T) -> Self + where + T: Into, + { + self.reply_markup = Some(value.into()); + self + } +} diff --git a/src/requests/all/send_audio.rs b/src/requests/all/send_audio.rs new file mode 100644 index 00000000..829e41f9 --- /dev/null +++ b/src/requests/all/send_audio.rs @@ -0,0 +1,209 @@ +use crate::{ + net, + requests::{form_builder::FormBuilder, Request, ResponseResult}, + types::{ChatId, InputFile, Message, ParseMode, ReplyMarkup}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to send audio files, if you want Telegram clients to display +/// them in the music player. +/// +/// Your audio must be in the .MP3 or .M4A format. Bots can currently send audio +/// files of up to 50 MB in size, this limit may be changed in the future. +/// +/// For sending voice messages, use the [`Bot::send_voice`] method instead. +/// +/// [The official docs](https://core.telegram.org/bots/api#sendaudio). +/// +/// [`Bot::send_voice`]: crate::Bot::send_voice +#[derive(Debug, Clone)] +pub struct SendAudio { + bot: Arc, + chat_id: ChatId, + audio: InputFile, + caption: Option, + parse_mode: Option, + duration: Option, + performer: Option, + title: Option, + thumb: Option, + disable_notification: Option, + reply_to_message_id: Option, + reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for SendAudio { + type Output = Message; + + async fn send(&self) -> ResponseResult { + 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(), + ) + .await + } +} + +impl SendAudio { + pub(crate) fn new(bot: Arc, chat_id: C, audio: InputFile) -> Self + where + C: Into, + { + Self { + bot, + chat_id: chat_id.into(), + audio, + caption: None, + parse_mode: None, + duration: None, + performer: None, + title: None, + thumb: None, + disable_notification: None, + reply_to_message_id: None, + reply_markup: None, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Audio file to send. + /// + /// Pass [`InputFile::File`] to send a file that exists on + /// the Telegram servers (recommended), pass an [`InputFile::Url`] for + /// Telegram to get a .webp file from the Internet, or upload a new one + /// using [`InputFile::FileId`]. [More info on Sending Files »]. + /// + /// [`InputFile::File`]: crate::types::InputFile::File + /// [`InputFile::Url`]: crate::types::InputFile::Url + /// [`InputFile::FileId`]: crate::types::InputFile::FileId + /// + /// [More info on Sending Files »]: https://core.telegram.org/bots/api#sending-files + pub fn audio(mut self, val: InputFile) -> Self { + self.audio = val; + self + } + + /// Audio caption, 0-1024 characters. + pub fn caption(mut self, val: T) -> Self + where + T: Into, + { + self.caption = Some(val.into()); + self + } + + /// Send [Markdown] or [HTML], if you want Telegram apps to show + /// [bold, italic, fixed-width text or inline URLs] in the media caption. + /// + /// [Markdown]: crate::types::ParseMode::Markdown + /// [HTML]: crate::types::ParseMode::HTML + /// [bold, italic, fixed-width text or inline URLs]: + /// crate::types::ParseMode + pub fn parse_mode(mut self, val: ParseMode) -> Self { + self.parse_mode = Some(val); + self + } + + /// Duration of the audio in seconds. + pub fn duration(mut self, val: i32) -> Self { + self.duration = Some(val); + self + } + + /// Performer. + pub fn performer(mut self, val: T) -> Self + where + T: Into, + { + self.performer = Some(val.into()); + self + } + + /// Track name. + pub fn title(mut self, val: T) -> Self + where + T: Into, + { + self.title = Some(val.into()); + self + } + + /// Thumbnail of the file sent; can be ignored if thumbnail generation for + /// the file is supported server-side. + /// + /// The thumbnail should be in JPEG format and less than 200 kB in size. A + /// thumbnail‘s width and height should not exceed 320. Ignored if the + /// file is not uploaded using `multipart/form-data`. Thumbnails can’t + /// be reused and can be only uploaded as a new file, so you can pass + /// `attach://` if the thumbnail was uploaded using + /// `multipart/form-data` under ``. [More info on + /// Sending Files »]. + /// + /// [More info on Sending Files »]: https://core.telegram.org/bots/api#sending-files + pub fn thumb(mut self, val: InputFile) -> Self { + self.thumb = Some(val); + self + } + + /// Sends the message [silently]. Users will receive a notification with no + /// sound. + /// + /// [silently]: https://telegram.org/blog/channels-2-0#silent-messages + pub fn disable_notification(mut self, val: bool) -> Self { + self.disable_notification = Some(val); + self + } + + /// If the message is a reply, ID of the original message. + pub fn reply_to_message_id(mut self, val: i32) -> Self { + self.reply_to_message_id = Some(val); + self + } + + /// Additional interface options. A JSON-serialized object for an [inline + /// keyboard], [custom reply keyboard], instructions to remove reply + /// keyboard or to force a reply from the user. + /// + /// [inline keyboard]: https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating + /// [custom reply keyboard]: https://core.telegram.org/bots#keyboards + pub fn reply_markup(mut self, val: ReplyMarkup) -> Self { + self.reply_markup = Some(val); + self + } +} diff --git a/src/requests/all/send_chat_action.rs b/src/requests/all/send_chat_action.rs new file mode 100644 index 00000000..1d7b0032 --- /dev/null +++ b/src/requests/all/send_chat_action.rs @@ -0,0 +1,120 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, True}, + Bot, +}; +use std::sync::Arc; + +/// Use this method when you need to tell the user that something is happening +/// on the bot's side. +/// +/// The status is set for 5 seconds or less (when a message arrives from your +/// bot, Telegram clients clear its typing status). +/// +/// ## Note +/// Example: The [ImageBot] needs some time to process a request and upload the +/// image. Instead of sending a text message along the lines of “Retrieving +/// image, please wait…”, the bot may use [`Bot::send_chat_action`] with `action +/// = upload_photo`. The user will see a `sending photo` status for the bot. +/// +/// We only recommend using this method when a response from the bot will take a +/// **noticeable** amount of time to arrive. +/// +/// [ImageBot]: https://t.me/imagebot +/// [`Bot::send_chat_action`]: crate::Bot::send_chat_action +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct SendChatAction { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, + action: SendChatActionKind, +} + +/// A type of action used in [`SendChatAction`]. +/// +/// [`SendChatAction`]: crate::requests::SendChatAction +#[derive(Copy, Clone, Debug, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SendChatActionKind { + /// For [text messages](crate::Bot::send_message). + Typing, + + /// For [photos](crate::Bot::send_photo). + UploadPhoto, + + /// For [videos](crate::Bot::send_video). + RecordVideo, + + /// For [videos](crate::Bot::send_video). + UploadVideo, + + /// For [audio files](crate::Bot::send_audio). + RecordAudio, + + /// For [audio files](crate::Bot::send_audio). + UploadAudio, + + /// For [general files](crate::Bot::send_document). + UploadDocument, + + /// For [location data](crate::Bot::send_location). + FindLocation, + + /// For [video notes](crate::Bot::send_video_note). + RecordVideoNote, + + /// For [video notes](crate::Bot::send_video_note). + UploadVideoNote, +} + +#[async_trait::async_trait] +impl Request for SendChatAction { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "sendChatAction", + &self, + ) + .await + } +} + +impl SendChatAction { + pub(crate) fn new( + bot: Arc, + chat_id: C, + action: SendChatActionKind, + ) -> Self + where + C: Into, + { + Self { + bot, + chat_id: chat_id.into(), + action, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Type of action to broadcast. + pub fn action(mut self, val: SendChatActionKind) -> Self { + self.action = val; + self + } +} diff --git a/src/requests/all/send_contact.rs b/src/requests/all/send_contact.rs new file mode 100644 index 00000000..1ad6d0b6 --- /dev/null +++ b/src/requests/all/send_contact.rs @@ -0,0 +1,141 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, Message, ReplyMarkup}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to send phone contacts. +/// +/// [The official docs](https://core.telegram.org/bots/api#sendcontact). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct SendContact { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, + phone_number: String, + first_name: String, + last_name: Option, + vcard: Option, + disable_notification: Option, + reply_to_message_id: Option, + reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for SendContact { + type Output = Message; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "sendContact", + &self, + ) + .await + } +} + +impl SendContact { + pub(crate) fn new( + bot: Arc, + chat_id: C, + phone_number: P, + first_name: F, + ) -> Self + where + C: Into, + P: Into, + F: Into, + { + let chat_id = chat_id.into(); + let phone_number = phone_number.into(); + let first_name = first_name.into(); + Self { + bot, + chat_id, + phone_number, + first_name, + last_name: None, + vcard: None, + disable_notification: None, + reply_to_message_id: None, + reply_markup: None, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Contact's phone number. + pub fn phone_number(mut self, val: T) -> Self + where + T: Into, + { + self.phone_number = val.into(); + self + } + + /// Contact's first name. + pub fn first_name(mut self, val: T) -> Self + where + T: Into, + { + self.first_name = val.into(); + self + } + + /// Contact's last name. + pub fn last_name(mut self, val: T) -> Self + where + T: Into, + { + self.last_name = Some(val.into()); + self + } + + /// Additional data about the contact in the form of a [vCard], 0-2048 + /// bytes. + /// + /// [vCard]: https://en.wikipedia.org/wiki/VCard + pub fn vcard(mut self, val: T) -> Self + where + T: Into, + { + self.vcard = Some(val.into()); + self + } + + /// Sends the message [silently]. Users will receive a notification with no + /// sound. + /// + /// [silently]: https://telegram.org/blog/channels-2-0#silent-messages + pub fn disable_notification(mut self, val: bool) -> Self { + self.disable_notification = Some(val); + self + } + + /// If the message is a reply, ID of the original message. + pub fn reply_to_message_id(mut self, val: i32) -> Self { + self.reply_to_message_id = Some(val); + self + } + + /// Additional interface options. + pub fn reply_markup(mut self, val: ReplyMarkup) -> Self { + self.reply_markup = Some(val); + self + } +} diff --git a/src/requests/all/send_document.rs b/src/requests/all/send_document.rs new file mode 100644 index 00000000..3e2cfd46 --- /dev/null +++ b/src/requests/all/send_document.rs @@ -0,0 +1,160 @@ +use crate::{ + net, + requests::{form_builder::FormBuilder, Request, ResponseResult}, + types::{ChatId, InputFile, Message, ParseMode, ReplyMarkup}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to send general files. +/// +/// Bots can currently send files of any type of up to 50 MB in size, this limit +/// may be changed in the future. +/// +/// [The official docs](https://core.telegram.org/bots/api#senddocument). +#[derive(Debug, Clone)] +pub struct SendDocument { + bot: Arc, + chat_id: ChatId, + document: InputFile, + thumb: Option, + caption: Option, + parse_mode: Option, + disable_notification: Option, + reply_to_message_id: Option, + reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for SendDocument { + type Output = Message; + + async fn send(&self) -> ResponseResult { + 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(), + ) + .await + } +} + +impl SendDocument { + pub(crate) fn new(bot: Arc, chat_id: C, document: InputFile) -> Self + where + C: Into, + { + Self { + bot, + chat_id: chat_id.into(), + document, + thumb: None, + caption: None, + parse_mode: None, + disable_notification: None, + reply_to_message_id: None, + reply_markup: None, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// File to send. + /// + /// Pass a file_id as String to send a file that exists on the + /// Telegram servers (recommended), pass an HTTP URL as a String for + /// Telegram to get a file from the Internet, or upload a new one using + /// `multipart/form-data`. [More info on Sending Files »]. + /// + /// [More info on Sending Files »]: https://core.telegram.org/bots/api#sending-files + pub fn document(mut self, val: InputFile) -> Self { + self.document = val; + self + } + + /// Thumbnail of the file sent; can be ignored if thumbnail generation for + /// the file is supported server-side. + /// + /// The thumbnail should be in JPEG format and less than 200 kB in size. A + /// thumbnail‘s width and height should not exceed 320. Ignored if the + /// file is not uploaded using `multipart/form-data`. Thumbnails can’t + /// be reused and can be only uploaded as a new file, so you can pass + /// “attach://” if the thumbnail was uploaded using + /// `multipart/form-data` under ``. [More info on + /// Sending Files »]. + /// + /// [More info on Sending Files »]: https://core.telegram.org/bots/api#sending-files + pub fn thumb(mut self, val: InputFile) -> Self { + self.thumb = Some(val); + self + } + + /// Document caption (may also be used when resending documents by + /// `file_id`), 0-1024 characters. + pub fn caption(mut self, val: T) -> Self + where + T: Into, + { + self.caption = Some(val.into()); + self + } + + /// Send [Markdown] or [HTML], if you want Telegram apps to show + /// [bold, italic, fixed-width text or inline URLs] in the media caption. + /// + /// [Markdown]: crate::types::ParseMode::Markdown + /// [HTML]: crate::types::ParseMode::HTML + /// [bold, italic, fixed-width text or inline URLs]: + /// crate::types::ParseMode + pub fn parse_mode(mut self, val: ParseMode) -> Self { + self.parse_mode = Some(val); + self + } + + /// Sends the message [silently]. Users will receive a notification with no + /// sound. + /// + /// [silently]: https://telegram.org/blog/channels-2-0#silent-messages + pub fn disable_notification(mut self, val: bool) -> Self { + self.disable_notification = Some(val); + self + } + + /// If the message is a reply, ID of the original message. + pub fn reply_to_message_id(mut self, val: i32) -> Self { + self.reply_to_message_id = Some(val); + self + } + + /// Additional interface options. + pub fn reply_markup(mut self, val: ReplyMarkup) -> Self { + self.reply_markup = Some(val); + self + } +} diff --git a/src/requests/all/send_game.rs b/src/requests/all/send_game.rs new file mode 100644 index 00000000..53555c06 --- /dev/null +++ b/src/requests/all/send_game.rs @@ -0,0 +1,103 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{InlineKeyboardMarkup, Message}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to send a game. +/// +/// [The official docs](https://core.telegram.org/bots/api#sendgame). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct SendGame { + #[serde(skip_serializing)] + bot: Arc, + chat_id: i32, + game_short_name: String, + disable_notification: Option, + reply_to_message_id: Option, + reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for SendGame { + type Output = Message; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "sendGame", + &self, + ) + .await + } +} + +impl SendGame { + pub(crate) fn new( + bot: Arc, + chat_id: i32, + game_short_name: G, + ) -> Self + where + G: Into, + { + let game_short_name = game_short_name.into(); + Self { + bot, + chat_id, + game_short_name, + disable_notification: None, + reply_to_message_id: None, + reply_markup: None, + } + } + + /// Unique identifier for the target chat. + pub fn chat_id(mut self, val: i32) -> Self { + self.chat_id = val; + self + } + + /// Short name of the game, serves as the unique identifier for the game. + /// Set up your games via [@Botfather]. + /// + /// [@Botfather]: https://t.me/botfather + pub fn game_short_name(mut self, val: T) -> Self + where + T: Into, + { + self.game_short_name = val.into(); + self + } + + /// Sends the message [silently]. Users will receive a notification with no + /// sound. + /// + /// [silently]: https://telegram.org/blog/channels-2-0#silent-messages + pub fn disable_notification(mut self, val: bool) -> Self { + self.disable_notification = Some(val); + self + } + + /// If the message is a reply, ID of the original message. + pub fn reply_to_message_id(mut self, val: i32) -> Self { + self.reply_to_message_id = Some(val); + self + } + + /// A JSON-serialized object for an [inline keyboard]. If empty, one `Play + /// game_title` button will be shown. If not empty, the first button must + /// launch the game. + /// + /// [inline keyboard]: https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating + pub fn reply_markup(mut self, val: InlineKeyboardMarkup) -> Self { + self.reply_markup = Some(val); + self + } +} diff --git a/src/requests/all/send_invoice.rs b/src/requests/all/send_invoice.rs new file mode 100644 index 00000000..b9e710e3 --- /dev/null +++ b/src/requests/all/send_invoice.rs @@ -0,0 +1,306 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{InlineKeyboardMarkup, LabeledPrice, Message}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to send invoices. +/// +/// [The official docs](https://core.telegram.org/bots/api#sendinvoice). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct SendInvoice { + #[serde(skip_serializing)] + bot: Arc, + chat_id: i32, + title: String, + description: String, + payload: String, + provider_token: String, + start_parameter: String, + currency: String, + prices: Vec, + provider_data: Option, + photo_url: Option, + photo_size: Option, + photo_width: Option, + photo_height: Option, + need_name: Option, + need_phone_number: Option, + need_email: Option, + need_shipping_address: Option, + send_phone_number_to_provider: Option, + send_email_to_provider: Option, + is_flexible: Option, + disable_notification: Option, + reply_to_message_id: Option, + reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for SendInvoice { + type Output = Message; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "sendInvoice", + &self, + ) + .await + } +} + +impl SendInvoice { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + bot: Arc, + chat_id: i32, + title: T, + description: D, + payload: Pl, + provider_token: Pt, + start_parameter: S, + currency: C, + prices: Pr, + ) -> Self + where + T: Into, + D: Into, + Pl: Into, + Pt: Into, + S: Into, + C: Into, + Pr: Into>, + { + let title = title.into(); + let description = description.into(); + let payload = payload.into(); + let provider_token = provider_token.into(); + let start_parameter = start_parameter.into(); + let currency = currency.into(); + let prices = prices.into(); + Self { + bot, + chat_id, + title, + description, + payload, + provider_token, + start_parameter, + currency, + prices, + provider_data: None, + photo_url: None, + photo_size: None, + photo_width: None, + photo_height: None, + need_name: None, + need_phone_number: None, + need_email: None, + need_shipping_address: None, + send_phone_number_to_provider: None, + send_email_to_provider: None, + is_flexible: None, + disable_notification: None, + reply_to_message_id: None, + reply_markup: None, + } + } + + /// Unique identifier for the target private chat. + pub fn chat_id(mut self, val: i32) -> Self { + self.chat_id = val; + self + } + + /// Product name, 1-32 characters. + pub fn title(mut self, val: T) -> Self + where + T: Into, + { + self.title = val.into(); + self + } + + /// Product description, 1-255 characters. + pub fn description(mut self, val: T) -> Self + where + T: Into, + { + self.description = val.into(); + self + } + + /// Bot-defined invoice payload, 1-128 bytes. This will not be displayed to + /// the user, use for your internal processes. + pub fn payload(mut self, val: T) -> Self + where + T: Into, + { + self.payload = val.into(); + self + } + + /// Payments provider token, obtained via [@Botfather]. + /// + /// [@Botfather]: https://t.me/botfather + pub fn provider_token(mut self, val: T) -> Self + where + T: Into, + { + self.provider_token = val.into(); + self + } + + /// Unique deep-linking parameter that can be used to generate this invoice + /// when used as a start parameter. + pub fn start_parameter(mut self, val: T) -> Self + where + T: Into, + { + self.start_parameter = val.into(); + self + } + + /// Three-letter ISO 4217 currency code, see [more on currencies]. + /// + /// [more on currencies]: https://core.telegram.org/bots/payments#supported-currencies + pub fn currency(mut self, val: T) -> Self + where + T: Into, + { + self.currency = val.into(); + self + } + + /// Price breakdown, a list of components (e.g. product price, tax, + /// discount, delivery cost, delivery tax, bonus, etc.). + pub fn prices(mut self, val: T) -> Self + where + T: Into>, + { + self.prices = val.into(); + self + } + + /// JSON-encoded data about the invoice, which will be shared with the + /// payment provider. + /// + /// A detailed description of required fields should be provided by the + /// payment provider. + pub fn provider_data(mut self, val: T) -> Self + where + T: Into, + { + self.provider_data = Some(val.into()); + self + } + + /// URL of the product photo for the invoice. + /// + /// Can be a photo of the goods or a marketing image for a service. People + /// like it better when they see what they are paying for. + pub fn photo_url(mut self, val: T) -> Self + where + T: Into, + { + self.photo_url = Some(val.into()); + self + } + + /// Photo size. + pub fn photo_size(mut self, val: i32) -> Self { + self.photo_size = Some(val); + self + } + + /// Photo width. + pub fn photo_width(mut self, val: i32) -> Self { + self.photo_width = Some(val); + self + } + + /// Photo height. + pub fn photo_height(mut self, val: i32) -> Self { + self.photo_height = Some(val); + self + } + + /// Pass `true`, if you require the user's full name to complete the order. + pub fn need_name(mut self, val: bool) -> Self { + self.need_name = Some(val); + self + } + + /// Pass `true`, if you require the user's phone number to complete the + /// order. + pub fn need_phone_number(mut self, val: bool) -> Self { + self.need_phone_number = Some(val); + self + } + + /// Pass `true`, if you require the user's email address to complete the + /// order. + pub fn need_email(mut self, val: bool) -> Self { + self.need_email = Some(val); + self + } + + /// Pass `true`, if you require the user's shipping address to complete the + /// order. + pub fn need_shipping_address(mut self, val: bool) -> Self { + self.need_shipping_address = Some(val); + self + } + + /// Pass `true`, if user's phone number should be sent to provider. + pub fn send_phone_number_to_provider(mut self, val: bool) -> Self { + self.send_phone_number_to_provider = Some(val); + self + } + + /// Pass `true`, if user's email address should be sent to provider. + pub fn send_email_to_provider(mut self, val: bool) -> Self { + self.send_email_to_provider = Some(val); + self + } + + /// Pass `true`, if the final price depends on the shipping method. + #[allow(clippy::wrong_self_convention)] + pub fn is_flexible(mut self, val: bool) -> Self { + self.is_flexible = Some(val); + self + } + + /// Sends the message [silently]. Users will receive a notification with no + /// sound. + /// + /// [silently]: https://telegram.org/blog/channels-2-0#silent-messages + pub fn disable_notification(mut self, val: bool) -> Self { + self.disable_notification = Some(val); + self + } + + /// If the message is a reply, ID of the original message. + pub fn reply_to_message_id(mut self, val: i32) -> Self { + self.reply_to_message_id = Some(val); + self + } + + /// A JSON-serialized object for an [inline keyboard]. + /// + /// If empty, one 'Pay `total price`' button will be shown. If not empty, + /// the first button must be a Pay button. + /// + /// [inlint keyboard]: https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating + pub fn reply_markup(mut self, val: InlineKeyboardMarkup) -> Self { + self.reply_markup = Some(val); + self + } +} diff --git a/src/requests/all/send_location.rs b/src/requests/all/send_location.rs new file mode 100644 index 00000000..149d7a5c --- /dev/null +++ b/src/requests/all/send_location.rs @@ -0,0 +1,122 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, Message, ReplyMarkup}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to send point on the map. +/// +/// [The official docs](https://core.telegram.org/bots/api#sendlocation). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct SendLocation { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, + latitude: f32, + longitude: f32, + live_period: Option, + disable_notification: Option, + reply_to_message_id: Option, + reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for SendLocation { + type Output = Message; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "sendLocation", + &self, + ) + .await + } +} + +impl SendLocation { + pub(crate) fn new( + bot: Arc, + chat_id: C, + latitude: f32, + longitude: f32, + ) -> Self + where + C: Into, + { + let chat_id = chat_id.into(); + Self { + bot, + chat_id, + latitude, + longitude, + live_period: None, + disable_notification: None, + reply_to_message_id: None, + reply_markup: None, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Latitude of the location. + pub fn latitude(mut self, val: f32) -> Self { + self.latitude = val; + self + } + + /// Longitude of the location. + pub fn longitude(mut self, val: f32) -> Self { + self.longitude = val; + self + } + + /// Period in seconds for which the location will be updated (see [Live + /// Locations], should be between 60 and 86400). + /// + /// [Live Locations]: https://telegram.org/blog/live-locations + pub fn live_period(mut self, val: i64) -> Self { + self.live_period = Some(val); + self + } + + /// Sends the message [silently]. Users will receive a notification with no + /// sound. + /// + /// [silently]: https://telegram.org/blog/channels-2-0#silent-messages + pub fn disable_notification(mut self, val: bool) -> Self { + self.disable_notification = Some(val); + self + } + + /// If the message is a reply, ID of the original message. + pub fn reply_to_message_id(mut self, val: i32) -> Self { + self.reply_to_message_id = Some(val); + self + } + + /// A JSON-serialized object for an [inline keyboard]. + /// + /// If empty, one 'Pay `total price`' button will be shown. If not empty, + /// the first button must be a Pay button. + /// + /// [inlint keyboard]: https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating + pub fn reply_markup(mut self, val: ReplyMarkup) -> Self { + self.reply_markup = Some(val); + self + } +} diff --git a/src/requests/all/send_media_group.rs b/src/requests/all/send_media_group.rs new file mode 100644 index 00000000..cae6604d --- /dev/null +++ b/src/requests/all/send_media_group.rs @@ -0,0 +1,96 @@ +use crate::{ + net, + requests::{form_builder::FormBuilder, Request, ResponseResult}, + types::{ChatId, InputMedia, Message}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to send a group of photos or videos as an album. +/// +/// [The official docs](https://core.telegram.org/bots/api#sendmediagroup). +#[derive(Debug, Clone)] +pub struct SendMediaGroup { + bot: Arc, + chat_id: ChatId, + media: Vec, // TODO: InputMediaPhoto and InputMediaVideo + disable_notification: Option, + reply_to_message_id: Option, +} + +#[async_trait::async_trait] +impl Request for SendMediaGroup { + type Output = Vec; + + async fn send(&self) -> ResponseResult> { + net::request_multipart( + self.bot.client(), + 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 + .build(), + ) + .await + } +} + +impl SendMediaGroup { + pub(crate) fn new(bot: Arc, chat_id: C, media: M) -> Self + where + C: Into, + M: Into>, + { + let chat_id = chat_id.into(); + let media = media.into(); + Self { + bot, + chat_id, + media, + disable_notification: None, + reply_to_message_id: None, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// A JSON-serialized array describing photos and videos to be sent, must + /// include 2–10 items. + pub fn media(mut self, val: T) -> Self + where + T: Into>, + { + self.media = val.into(); + self + } + + /// Sends the message [silently]. Users will receive a notification with no + /// sound. + /// + /// [silently]: https://telegram.org/blog/channels-2-0#silent-messages + pub fn disable_notification(mut self, val: bool) -> Self { + self.disable_notification = Some(val); + self + } + + /// If the messages are a reply, ID of the original message. + pub fn reply_to_message_id(mut self, val: i32) -> Self { + self.reply_to_message_id = Some(val); + self + } +} diff --git a/src/requests/all/send_message.rs b/src/requests/all/send_message.rs new file mode 100644 index 00000000..689d9671 --- /dev/null +++ b/src/requests/all/send_message.rs @@ -0,0 +1,128 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, Message, ParseMode, ReplyMarkup}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to send text messages. +/// +/// [The official docs](https://core.telegram.org/bots/api#sendmessage). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct SendMessage { + #[serde(skip_serializing)] + bot: Arc, + pub chat_id: ChatId, + pub text: String, + pub parse_mode: Option, + pub disable_web_page_preview: Option, + pub disable_notification: Option, + pub reply_to_message_id: Option, + pub reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for SendMessage { + type Output = Message; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "sendMessage", + &self, + ) + .await + } +} + +impl SendMessage { + pub(crate) fn new(bot: Arc, chat_id: C, text: T) -> Self + where + C: Into, + T: Into, + { + Self { + bot, + chat_id: chat_id.into(), + text: text.into(), + parse_mode: None, + disable_web_page_preview: None, + disable_notification: None, + reply_to_message_id: None, + reply_markup: None, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, value: T) -> Self + where + T: Into, + { + self.chat_id = value.into(); + self + } + + /// Text of the message to be sent. + pub fn text(mut self, value: T) -> Self + where + T: Into, + { + self.text = value.into(); + self + } + + /// Send [Markdown] or [HTML], if you want Telegram apps to show + /// [bold, italic, fixed-width text or inline URLs] in the media caption. + /// + /// [Markdown]: crate::types::ParseMode::Markdown + /// [HTML]: crate::types::ParseMode::HTML + /// [bold, italic, fixed-width text or inline URLs]: + /// crate::types::ParseMode + pub fn parse_mode(mut self, value: ParseMode) -> Self { + self.parse_mode = Some(value); + self + } + + /// Disables link previews for links in this message. + pub fn disable_web_page_preview(mut self, value: bool) -> Self { + self.disable_web_page_preview = Some(value); + self + } + + /// Sends the message [silently]. Users will receive a notification with no + /// sound. + /// + /// [silently]: https://telegram.org/blog/channels-2-0#silent-messages + pub fn disable_notification(mut self, value: bool) -> Self { + self.disable_notification = Some(value); + self + } + + /// If the message is a reply, ID of the original message. + pub fn reply_to_message_id(mut self, value: i32) -> Self { + self.reply_to_message_id = Some(value); + self + } + + /// Additional interface options. + /// + /// A JSON-serialized object for an [inline keyboard], [custom reply + /// keyboard], instructions to remove reply keyboard or to force a reply + /// from the user. + /// + /// [inline keyboard]: https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating + /// [custom reply keyboard]: https://core.telegram.org/bots#keyboards + pub fn reply_markup(mut self, value: T) -> Self + where + T: Into, + { + self.reply_markup = Some(value.into()); + self + } +} diff --git a/src/requests/all/send_photo.rs b/src/requests/all/send_photo.rs new file mode 100644 index 00000000..ea603eca --- /dev/null +++ b/src/requests/all/send_photo.rs @@ -0,0 +1,145 @@ +use crate::{ + net, + requests::{form_builder::FormBuilder, Request, ResponseResult}, + types::{ChatId, InputFile, Message, ParseMode, ReplyMarkup}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to send photos. +/// +/// [The official docs](https://core.telegram.org/bots/api#sendphoto). +#[derive(Debug, Clone)] +pub struct SendPhoto { + bot: Arc, + chat_id: ChatId, + photo: InputFile, + caption: Option, + parse_mode: Option, + disable_notification: Option, + reply_to_message_id: Option, + reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for SendPhoto { + type Output = Message; + + async fn send(&self) -> ResponseResult { + 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 + .build(), + ) + .await + } +} + +impl SendPhoto { + pub(crate) fn new(bot: Arc, chat_id: C, photo: InputFile) -> Self + where + C: Into, + { + Self { + bot, + chat_id: chat_id.into(), + photo, + caption: None, + parse_mode: None, + disable_notification: None, + reply_to_message_id: None, + reply_markup: None, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Photo to send. + /// + /// Pass [`InputFile::File`] to send a photo that exists on + /// the Telegram servers (recommended), pass an [`InputFile::Url`] for + /// Telegram to get a .webp file from the Internet, or upload a new one + /// using [`InputFile::FileId`]. [More info on Sending Files »]. + /// + /// [`InputFile::File`]: crate::types::InputFile::File + /// [`InputFile::Url`]: crate::types::InputFile::Url + /// [`InputFile::FileId`]: crate::types::InputFile::FileId + /// + /// [More info on Sending Files »]: https://core.telegram.org/bots/api#sending-files + pub fn photo(mut self, val: InputFile) -> Self { + self.photo = val; + self + } + + ///Photo caption (may also be used when resending photos by file_id), + /// 0-1024 characters. + pub fn caption(mut self, val: T) -> Self + where + T: Into, + { + self.caption = Some(val.into()); + self + } + + /// Send [Markdown] or [HTML], if you want Telegram apps to show + /// [bold, italic, fixed-width text or inline URLs] in the media caption. + /// + /// [Markdown]: crate::types::ParseMode::Markdown + /// [HTML]: crate::types::ParseMode::HTML + /// [bold, italic, fixed-width text or inline URLs]: + /// crate::types::ParseMode + pub fn parse_mode(mut self, val: ParseMode) -> Self { + self.parse_mode = Some(val); + self + } + + /// Sends the message [silently]. Users will receive a notification with no + /// sound. + /// + /// [silently]: https://telegram.org/blog/channels-2-0#silent-messages + pub fn disable_notification(mut self, val: bool) -> Self { + self.disable_notification = Some(val); + self + } + + /// If the message is a reply, ID of the original message. + pub fn reply_to_message_id(mut self, val: i32) -> Self { + self.reply_to_message_id = Some(val); + self + } + + /// Additional interface options. A JSON-serialized object for an [inline + /// keyboard], [custom reply keyboard], instructions to remove reply + /// keyboard or to force a reply from the user. + /// + /// [inline keyboard]: https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating + /// [custom reply keyboard]: https://core.telegram.org/bots#keyboards + pub fn reply_markup(mut self, val: ReplyMarkup) -> Self { + self.reply_markup = Some(val); + self + } +} diff --git a/src/requests/all/send_poll.rs b/src/requests/all/send_poll.rs new file mode 100644 index 00000000..a2fafd42 --- /dev/null +++ b/src/requests/all/send_poll.rs @@ -0,0 +1,186 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, Message, PollType, ReplyMarkup}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to send a native poll. +/// +/// [The official docs](https://core.telegram.org/bots/api#sendpoll). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct SendPoll { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, + question: String, + options: Vec, + is_anonymous: Option, + poll_type: Option, + allows_multiple_answers: Option, + correct_option_id: Option, + is_closed: Option, + disable_notification: Option, + reply_to_message_id: Option, + reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for SendPoll { + type Output = Message; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "sendPoll", + &self, + ) + .await + } +} + +impl SendPoll { + pub(crate) fn new( + bot: Arc, + chat_id: C, + question: Q, + options: O, + ) -> Self + where + C: Into, + Q: Into, + O: Into>, + { + let chat_id = chat_id.into(); + let question = question.into(); + let options = options.into(); + Self { + bot, + chat_id, + question, + options, + is_anonymous: None, + poll_type: None, + allows_multiple_answers: None, + correct_option_id: None, + is_closed: None, + disable_notification: None, + reply_to_message_id: None, + reply_markup: None, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + /// + /// A native poll can't be sent to a private chat. + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Poll question, 1-255 characters. + pub fn question(mut self, val: T) -> Self + where + T: Into, + { + self.question = val.into(); + self + } + + /// List of answer options, 2-10 strings 1-100 characters each. + pub fn options(mut self, val: T) -> Self + where + T: Into>, + { + self.options = val.into(); + self + } + + /// `true`, if the poll needs to be anonymous, defaults to `true`. + #[allow(clippy::wrong_self_convention)] + pub fn is_anonymous(mut self, val: T) -> Self + where + T: Into, + { + self.is_anonymous = Some(val.into()); + self + } + + /// Poll type, `quiz` or `regular`, defaults to `regular`. + pub fn poll_type(mut self, val: PollType) -> Self { + self.poll_type = Some(val); + self + } + + /// `true`, if the poll allows multiple answers, ignored for polls in quiz + /// mode. + /// + /// Defaults to `false`. + pub fn allows_multiple_answers(mut self, val: T) -> Self + where + T: Into, + { + self.allows_multiple_answers = Some(val.into()); + self + } + + /// 0-based identifier of the correct answer option, required for polls in + /// quiz mode. + pub fn correct_option_id(mut self, val: T) -> Self + where + T: Into, + { + self.correct_option_id = Some(val.into()); + self + } + + /// Pass `true`, if the poll needs to be immediately closed. + /// + /// This can be useful for poll preview. + #[allow(clippy::wrong_self_convention)] + pub fn is_closed(mut self, val: T) -> Self + where + T: Into, + { + self.is_closed = Some(val.into()); + self + } + + /// Sends the message [silently]. + /// + /// Users will receive a notification with no sound. + /// + /// [silently]: https://telegram.org/blog/channels-2-0#silent-messages + pub fn disable_notification(mut self, val: bool) -> Self { + self.disable_notification = Some(val); + self + } + + /// If the message is a reply, ID of the original message. + pub fn reply_to_message_id(mut self, val: i32) -> Self { + self.reply_to_message_id = Some(val); + self + } + + /// Additional interface options. + /// + /// A JSON-serialized object for an [inline keyboard], [custom reply + /// keyboard], instructions to remove reply keyboard or to force a reply + /// from the user. + /// + /// [inline keyboard]: https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating + /// [custom reply keyboard]: https://core.telegram.org/bots#keyboards + pub fn reply_markup(mut self, val: ReplyMarkup) -> Self { + self.reply_markup = Some(val); + self + } +} diff --git a/src/requests/all/send_sticker.rs b/src/requests/all/send_sticker.rs new file mode 100644 index 00000000..e44dbb80 --- /dev/null +++ b/src/requests/all/send_sticker.rs @@ -0,0 +1,118 @@ +use crate::{ + net, + requests::{form_builder::FormBuilder, Request, ResponseResult}, + types::{ChatId, InputFile, Message, ReplyMarkup}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to send static .WEBP or [animated] .TGS stickers. +/// +/// [The official docs](https://core.telegram.org/bots/api#sendsticker). +/// +/// [animated]: https://telegram.org/blog/animated-stickers +#[derive(Debug, Clone)] +pub struct SendSticker { + bot: Arc, + chat_id: ChatId, + sticker: InputFile, + disable_notification: Option, + reply_to_message_id: Option, + reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for SendSticker { + type Output = Message; + + async fn send(&self) -> ResponseResult { + 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 + .build(), + ) + .await + } +} + +impl SendSticker { + pub(crate) fn new(bot: Arc, chat_id: C, sticker: InputFile) -> Self + where + C: Into, + { + Self { + bot, + chat_id: chat_id.into(), + sticker, + disable_notification: None, + reply_to_message_id: None, + reply_markup: None, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Sticker to send. + /// + /// Pass [`InputFile::File`] to send a file that exists on + /// the Telegram servers (recommended), pass an [`InputFile::Url`] for + /// Telegram to get a .webp file from the Internet, or upload a new one + /// using [`InputFile::FileId`]. [More info on Sending Files »]. + /// + /// [`InputFile::File`]: crate::types::InputFile::File + /// [`InputFile::Url`]: crate::types::InputFile::Url + /// [`InputFile::FileId`]: crate::types::InputFile::FileId + /// [More info on Sending Files »]: https://core.telegram.org/bots/api#sending-files + pub fn sticker(mut self, val: InputFile) -> Self { + self.sticker = val; + self + } + + /// Sends the message [silently]. Users will receive a notification with no + /// sound. + /// + /// [silently]: https://telegram.org/blog/channels-2-0#silent-messages + pub fn disable_notification(mut self, val: bool) -> Self { + self.disable_notification = Some(val); + self + } + + /// If the message is a reply, ID of the original message. + pub fn reply_to_message_id(mut self, val: i32) -> Self { + self.reply_to_message_id = Some(val); + self + } + + /// Additional interface options. + /// + /// A JSON-serialized object for an [inline keyboard], [custom reply + /// keyboard], instructions to remove reply keyboard or to force a reply + /// from the user. + /// + /// [inline keyboard]: https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating + /// [custom reply keyboard]: https://core.telegram.org/bots#keyboards + pub fn reply_markup(mut self, val: ReplyMarkup) -> Self { + self.reply_markup = Some(val); + self + } +} diff --git a/src/requests/all/send_venue.rs b/src/requests/all/send_venue.rs new file mode 100644 index 00000000..c049aeb2 --- /dev/null +++ b/src/requests/all/send_venue.rs @@ -0,0 +1,166 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, Message, ReplyMarkup}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to send information about a venue. +/// +/// [The official docs](https://core.telegram.org/bots/api#sendvenue). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct SendVenue { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, + latitude: f32, + longitude: f32, + title: String, + address: String, + foursquare_id: Option, + foursquare_type: Option, + disable_notification: Option, + reply_to_message_id: Option, + reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for SendVenue { + type Output = Message; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "sendVenue", + &self, + ) + .await + } +} + +impl SendVenue { + pub(crate) fn new( + bot: Arc, + chat_id: C, + latitude: f32, + longitude: f32, + title: T, + address: A, + ) -> Self + where + C: Into, + T: Into, + A: Into, + { + let chat_id = chat_id.into(); + let title = title.into(); + let address = address.into(); + Self { + bot, + chat_id, + latitude, + longitude, + title, + address, + foursquare_id: None, + foursquare_type: None, + disable_notification: None, + reply_to_message_id: None, + reply_markup: None, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Latitude of the venue. + pub fn latitude(mut self, val: f32) -> Self { + self.latitude = val; + self + } + + /// Longitude of the venue. + pub fn longitude(mut self, val: f32) -> Self { + self.longitude = val; + self + } + + /// Name of the venue. + pub fn title(mut self, val: T) -> Self + where + T: Into, + { + self.title = val.into(); + self + } + + /// Address of the venue. + pub fn address(mut self, val: T) -> Self + where + T: Into, + { + self.address = val.into(); + self + } + + /// Foursquare identifier of the venue. + pub fn foursquare_id(mut self, val: T) -> Self + where + T: Into, + { + self.foursquare_id = Some(val.into()); + self + } + + /// Foursquare type of the venue, if known. + /// + /// For example, `arts_entertainment/default`, `arts_entertainment/aquarium` + /// or `food/icecream`. + pub fn foursquare_type(mut self, val: T) -> Self + where + T: Into, + { + self.foursquare_type = Some(val.into()); + self + } + + /// Sends the message [silently]. Users will receive a notification with no + /// sound. + /// + /// [silently]: https://telegram.org/blog/channels-2-0#silent-messages + pub fn disable_notification(mut self, val: bool) -> Self { + self.disable_notification = Some(val); + self + } + + /// If the message is a reply, ID of the original message. + pub fn reply_to_message_id(mut self, val: i32) -> Self { + self.reply_to_message_id = Some(val); + self + } + + /// Additional interface options. + /// + /// A JSON-serialized object for an [inline keyboard], [custom reply + /// keyboard], instructions to remove reply keyboard or to force a reply + /// from the user. + /// + /// [inline keyboard]: https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating + /// [custom reply keyboard]: https://core.telegram.org/bots#keyboards + pub fn reply_markup(mut self, val: ReplyMarkup) -> Self { + self.reply_markup = Some(val); + self + } +} diff --git a/src/requests/all/send_video.rs b/src/requests/all/send_video.rs new file mode 100644 index 00000000..8e89ef2e --- /dev/null +++ b/src/requests/all/send_video.rs @@ -0,0 +1,210 @@ +use crate::{ + net, + requests::{form_builder::FormBuilder, Request, ResponseResult}, + types::{ChatId, InputFile, Message, ParseMode, ReplyMarkup}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to send video files, Telegram clients support mp4 videos +/// (other formats may be sent as Document). +/// +/// Bots can currently send video files of up to 50 MB in size, this +/// limit may be changed in the future. +/// +/// [The official docs](https://core.telegram.org/bots/api#sendvideo). +#[derive(Debug, Clone)] +pub struct SendVideo { + bot: Arc, + chat_id: ChatId, + video: InputFile, + duration: Option, + width: Option, + height: Option, + thumb: Option, + caption: Option, + parse_mode: Option, + supports_streaming: Option, + disable_notification: Option, + reply_to_message_id: Option, + reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for SendVideo { + type Output = Message; + + async fn send(&self) -> ResponseResult { + 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(), + ) + .await + } +} + +impl SendVideo { + pub(crate) fn new(bot: Arc, chat_id: C, video: InputFile) -> Self + where + C: Into, + { + Self { + bot, + chat_id: chat_id.into(), + video, + duration: None, + width: None, + height: None, + thumb: None, + caption: None, + parse_mode: None, + supports_streaming: None, + disable_notification: None, + reply_to_message_id: None, + reply_markup: None, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Video to sent. + /// + /// Pass [`InputFile::File`] to send a file that exists on + /// the Telegram servers (recommended), pass an [`InputFile::Url`] for + /// Telegram to get a .webp file from the Internet, or upload a new one + /// using [`InputFile::FileId`]. [More info on Sending Files »]. + /// + /// [`InputFile::File`]: crate::types::InputFile::File + /// [`InputFile::Url`]: crate::types::InputFile::Url + /// [`InputFile::FileId`]: crate::types::InputFile::FileId + pub fn video(mut self, val: InputFile) -> Self { + self.video = val; + self + } + + /// Duration of sent video in seconds. + pub fn duration(mut self, val: i32) -> Self { + self.duration = Some(val); + self + } + + /// Video width. + pub fn width(mut self, val: i32) -> Self { + self.width = Some(val); + self + } + + /// Video height. + pub fn height(mut self, val: i32) -> Self { + self.height = Some(val); + self + } + + /// Thumbnail of the file sent; can be ignored if thumbnail generation for + /// the file is supported server-side. + /// + /// The thumbnail should be in JPEG format and less than 200 kB in size. A + /// thumbnail‘s width and height should not exceed 320. Ignored if the + /// file is not uploaded using `multipart/form-data`. Thumbnails can’t be + /// reused and can be only uploaded as a new file, so you can pass + /// `attach://` if the thumbnail was uploaded using + /// `multipart/form-data` under ``. [More info on Sending + /// Files »]. + /// + /// [More info on Sending Files »]: https://core.telegram.org/bots/api#sending-files + pub fn thumb(mut self, val: InputFile) -> Self { + self.thumb = Some(val); + self + } + + /// Video caption (may also be used when resending videos by file_id), + /// 0-1024 characters. + pub fn caption(mut self, val: T) -> Self + where + T: Into, + { + self.caption = Some(val.into()); + self + } + + /// Send [Markdown] or [HTML], if you want Telegram apps to show + /// [bold, italic, fixed-width text or inline URLs] in the media caption. + /// + /// [Markdown]: crate::types::ParseMode::Markdown + /// [HTML]: crate::types::ParseMode::HTML + /// [bold, italic, fixed-width text or inline URLs]: + /// crate::types::ParseMode + pub fn parse_mode(mut self, val: ParseMode) -> Self { + self.parse_mode = Some(val); + self + } + + /// Pass `true`, if the uploaded video is suitable for streaming. + pub fn supports_streaming(mut self, val: bool) -> Self { + self.supports_streaming = Some(val); + self + } + + /// Sends the message [silently]. Users will receive a notification with no + /// sound. + /// + /// [silently]: https://telegram.org/blog/channels-2-0#silent-messages + pub fn disable_notification(mut self, val: bool) -> Self { + self.disable_notification = Some(val); + self + } + + /// If the message is a reply, ID of the original message. + pub fn reply_to_message_id(mut self, val: i32) -> Self { + self.reply_to_message_id = Some(val); + self + } + + /// Additional interface options. + /// + /// A JSON-serialized object for an [inline keyboard], [custom reply + /// keyboard], instructions to remove reply keyboard or to force a reply + /// from the user. + /// + /// [inline keyboard]: https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating + /// [custom reply keyboard]: https://core.telegram.org/bots#keyboards + pub fn reply_markup(mut self, val: ReplyMarkup) -> Self { + self.reply_markup = Some(val); + self + } +} diff --git a/src/requests/all/send_video_note.rs b/src/requests/all/send_video_note.rs new file mode 100644 index 00000000..e3099c35 --- /dev/null +++ b/src/requests/all/send_video_note.rs @@ -0,0 +1,162 @@ +use crate::{ + net, + requests::{form_builder::FormBuilder, Request, ResponseResult}, + types::{ChatId, InputFile, Message, ReplyMarkup}, + Bot, +}; +use std::sync::Arc; + +/// As of [v.4.0], Telegram clients support rounded square mp4 videos of up to 1 +/// minute long. Use this method to send video messages. +/// +/// [The official docs](https://core.telegram.org/bots/api#sendvideonote). +/// +/// [v.4.0]: https://telegram.org/blog/video-messages-and-telescope +#[derive(Debug, Clone)] +pub struct SendVideoNote { + bot: Arc, + chat_id: ChatId, + video_note: InputFile, + duration: Option, + length: Option, + thumb: Option, + disable_notification: Option, + reply_to_message_id: Option, + reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for SendVideoNote { + type Output = Message; + + async fn send(&self) -> ResponseResult { + 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(), + ) + .await + } +} + +impl SendVideoNote { + pub(crate) fn new( + bot: Arc, + chat_id: C, + video_note: InputFile, + ) -> Self + where + C: Into, + { + Self { + bot, + chat_id: chat_id.into(), + video_note, + duration: None, + length: None, + thumb: None, + disable_notification: None, + reply_to_message_id: None, + reply_markup: None, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Video note to send. + /// + /// Pass [`InputFile::File`] to send a file that exists on + /// the Telegram servers (recommended), pass an [`InputFile::Url`] for + /// Telegram to get a .webp file from the Internet, or upload a new one + /// using [`InputFile::FileId`]. [More info on Sending Files »]. + /// + /// [`InputFile::File`]: crate::types::InputFile::File + /// [`InputFile::Url`]: crate::types::InputFile::Url + /// [`InputFile::FileId`]: crate::types::InputFile::FileId + /// [More info on Sending Files »]: https://core.telegram.org/bots/api#sending-files + pub fn video_note(mut self, val: InputFile) -> Self { + self.video_note = val; + self + } + + /// Duration of sent video in seconds. + pub fn duration(mut self, val: i32) -> Self { + self.duration = Some(val); + self + } + + /// Video width and height, i.e. diameter of the video message. + pub fn length(mut self, val: i32) -> Self { + self.length = Some(val); + self + } + + /// Thumbnail of the file sent; can be ignored if thumbnail generation for + /// the file is supported server-side. + /// + /// The thumbnail should be in JPEG format and less than 200 kB in size. A + /// thumbnail‘s width and height should not exceed 320. Ignored if the + /// file is not uploaded using `multipart/form-data`. Thumbnails can’t + /// be reused and can be only uploaded as a new file, so you can pass + /// `attach://` if the thumbnail was uploaded using + /// `multipart/form-data` under ``. [More info on + /// Sending Files »]. + pub fn thumb(mut self, val: InputFile) -> Self { + self.thumb = Some(val); + self + } + + /// Sends the message [silently]. Users will receive a notification with no + /// sound. + /// + /// [silently]: https://telegram.org/blog/channels-2-0#silent-messages + pub fn disable_notification(mut self, val: bool) -> Self { + self.disable_notification = Some(val); + self + } + + /// If the message is a reply, ID of the original message. + pub fn reply_to_message_id(mut self, val: i32) -> Self { + self.reply_to_message_id = Some(val); + self + } + + /// Additional interface options. + /// + /// A JSON-serialized object for an [inline keyboard], [custom reply + /// keyboard], instructions to remove reply keyboard or to force a reply + /// from the user. + /// + /// [inline keyboard]: https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating + /// [custom reply keyboard]: https://core.telegram.org/bots#keyboards + pub fn reply_markup(mut self, val: ReplyMarkup) -> Self { + self.reply_markup = Some(val); + self + } +} diff --git a/src/requests/all/send_voice.rs b/src/requests/all/send_voice.rs new file mode 100644 index 00000000..b5ef7b5d --- /dev/null +++ b/src/requests/all/send_voice.rs @@ -0,0 +1,164 @@ +use crate::{ + net, + requests::{form_builder::FormBuilder, Request, ResponseResult}, + types::{ChatId, InputFile, Message, ParseMode, ReplyMarkup}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to send audio files, if you want Telegram clients to display +/// the file as a playable voice message. +/// +/// For this to work, your audio must be in an .ogg file encoded with OPUS +/// (other formats may be sent as [`Audio`] or [`Document`]). Bots can currently +/// send voice messages of up to 50 MB in size, this limit may be changed in the +/// future. +/// +/// [The official docs](https://core.telegram.org/bots/api#sendvoice). +/// +/// [`Audio`]: crate::types::Audio +/// [`Document`]: crate::types::Document +#[derive(Debug, Clone)] +pub struct SendVoice { + bot: Arc, + chat_id: ChatId, + voice: InputFile, + caption: Option, + parse_mode: Option, + duration: Option, + disable_notification: Option, + reply_to_message_id: Option, + reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for SendVoice { + type Output = Message; + + async fn send(&self) -> ResponseResult { + 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 + .build(), + ) + .await + } +} + +impl SendVoice { + pub(crate) fn new(bot: Arc, chat_id: C, voice: InputFile) -> Self + where + C: Into, + { + Self { + bot, + chat_id: chat_id.into(), + voice, + caption: None, + parse_mode: None, + duration: None, + disable_notification: None, + reply_to_message_id: None, + reply_markup: None, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Audio file to send. + /// + /// Pass [`InputFile::File`] to send a file that exists on + /// the Telegram servers (recommended), pass an [`InputFile::Url`] for + /// Telegram to get a .webp file from the Internet, or upload a new one + /// using [`InputFile::FileId`]. [More info on Sending Files »]. + /// + /// [`InputFile::File`]: crate::types::InputFile::File + /// [`InputFile::Url`]: crate::types::InputFile::Url + /// [`InputFile::FileId`]: crate::types::InputFile::FileId + /// [More info on Sending Files »]: https://core.telegram.org/bots/api#sending-files + pub fn voice(mut self, val: InputFile) -> Self { + self.voice = val; + self + } + + /// Voice message caption, 0-1024 characters. + pub fn caption(mut self, val: T) -> Self + where + T: Into, + { + self.caption = Some(val.into()); + self + } + + /// Send [Markdown] or [HTML], if you want Telegram apps to show + /// [bold, italic, fixed-width text or inline URLs] in the media caption. + /// + /// [Markdown]: crate::types::ParseMode::Markdown + /// [HTML]: crate::types::ParseMode::HTML + /// [bold, italic, fixed-width text or inline URLs]: + /// crate::types::ParseMode + pub fn parse_mode(mut self, val: ParseMode) -> Self { + self.parse_mode = Some(val); + self + } + + /// Duration of the voice message in seconds. + pub fn duration(mut self, val: i32) -> Self { + self.duration = Some(val); + self + } + + /// Sends the message [silently]. Users will receive a notification with no + /// sound. + /// + /// [silently]: https://telegram.org/blog/channels-2-0#silent-messages + pub fn disable_notification(mut self, val: bool) -> Self { + self.disable_notification = Some(val); + self + } + + /// If the message is a reply, ID of the original message. + pub fn reply_to_message_id(mut self, val: i32) -> Self { + self.reply_to_message_id = Some(val); + self + } + + /// Additional interface options. + /// + /// A JSON-serialized object for an [inline keyboard], [custom reply + /// keyboard], instructions to remove reply keyboard or to force a reply + /// from the user. + /// + /// [inline keyboard]: https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating + /// [custom reply keyboard]: https://core.telegram.org/bots#keyboards + pub fn reply_markup(mut self, val: ReplyMarkup) -> Self { + self.reply_markup = Some(val); + self + } +} diff --git a/src/requests/all/set_chat_administrator_custom_title.rs b/src/requests/all/set_chat_administrator_custom_title.rs new file mode 100644 index 00000000..322f72f7 --- /dev/null +++ b/src/requests/all/set_chat_administrator_custom_title.rs @@ -0,0 +1,86 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, True}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to set a custom title for an administrator in a supergroup +/// promoted by the bot. +/// +/// [The official docs](https://core.telegram.org/bots/api#setchatadministratorcustomtitle). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct SetChatAdministratorCustomTitle { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, + user_id: i32, + custom_title: String, +} + +#[async_trait::async_trait] +impl Request for SetChatAdministratorCustomTitle { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "setChatAdministratorCustomTitle", + &self, + ) + .await + } +} + +impl SetChatAdministratorCustomTitle { + pub(crate) fn new( + bot: Arc, + chat_id: C, + user_id: i32, + custom_title: CT, + ) -> Self + where + C: Into, + CT: Into, + { + let chat_id = chat_id.into(); + let custom_title = custom_title.into(); + Self { + bot, + chat_id, + user_id, + custom_title, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Unique identifier of the target user. + pub fn user_id(mut self, val: i32) -> Self { + self.user_id = val; + self + } + + /// New custom title for the administrator; 0-16 characters, emoji are not + /// allowed. + pub fn custom_title(mut self, val: T) -> Self + where + T: Into, + { + self.custom_title = val.into(); + self + } +} diff --git a/src/requests/all/set_chat_description.rs b/src/requests/all/set_chat_description.rs new file mode 100644 index 00000000..5896584e --- /dev/null +++ b/src/requests/all/set_chat_description.rs @@ -0,0 +1,73 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, True}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to change the description of a group, a supergroup or a +/// channel. +/// +/// The bot must be an administrator in the chat for this to work and must have +/// the appropriate admin rights. +/// +/// [The official docs](https://core.telegram.org/bots/api#setchatdescription). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct SetChatDescription { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, + description: Option, +} + +#[async_trait::async_trait] +impl Request for SetChatDescription { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "setChatDescription", + &self, + ) + .await + } +} + +impl SetChatDescription { + pub(crate) fn new(bot: Arc, chat_id: C) -> Self + where + C: Into, + { + let chat_id = chat_id.into(); + Self { + bot, + chat_id, + description: None, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// New chat description, 0-255 characters. + pub fn description(mut self, val: T) -> Self + where + T: Into, + { + self.description = Some(val.into()); + self + } +} diff --git a/src/requests/all/set_chat_permissions.rs b/src/requests/all/set_chat_permissions.rs new file mode 100644 index 00000000..4dec39f8 --- /dev/null +++ b/src/requests/all/set_chat_permissions.rs @@ -0,0 +1,73 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, ChatPermissions, True}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to set default chat permissions for all members. +/// +/// The bot must be an administrator in the group or a supergroup for this to +/// work and must have the can_restrict_members admin rights. +/// +/// [The official docs](https://core.telegram.org/bots/api#setchatpermissions). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct SetChatPermissions { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, + permissions: ChatPermissions, +} + +#[async_trait::async_trait] +impl Request for SetChatPermissions { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "sendChatPermissions", + &self, + ) + .await + } +} + +impl SetChatPermissions { + pub(crate) fn new( + bot: Arc, + chat_id: C, + permissions: ChatPermissions, + ) -> Self + where + C: Into, + { + let chat_id = chat_id.into(); + Self { + bot, + chat_id, + permissions, + } + } + + /// Unique identifier for the target chat or username of the target + /// supergroup (in the format `@supergroupusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// New default chat permissions. + pub fn permissions(mut self, val: ChatPermissions) -> Self { + self.permissions = val; + self + } +} diff --git a/src/requests/all/set_chat_photo.rs b/src/requests/all/set_chat_photo.rs new file mode 100644 index 00000000..0d152b4d --- /dev/null +++ b/src/requests/all/set_chat_photo.rs @@ -0,0 +1,69 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, InputFile, True}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to set a new profile photo for the chat. +/// +/// Photos can't be changed for private chats. The bot must be an administrator +/// in the chat for this to work and must have the appropriate admin rights. +/// +/// [The official docs](https://core.telegram.org/bots/api#setchatphoto). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct SetChatPhoto { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, + photo: InputFile, +} + +#[async_trait::async_trait] +impl Request for SetChatPhoto { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "setChatPhoto", + &self, + ) + .await + } +} + +impl SetChatPhoto { + pub(crate) fn new(bot: Arc, chat_id: C, photo: InputFile) -> Self + where + C: Into, + { + let chat_id = chat_id.into(); + Self { + bot, + chat_id, + photo, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// New chat photo, uploaded using `multipart/form-data`. + pub fn photo(mut self, val: InputFile) -> Self { + self.photo = val; + self + } +} diff --git a/src/requests/all/set_chat_sticker_set.rs b/src/requests/all/set_chat_sticker_set.rs new file mode 100644 index 00000000..64d37017 --- /dev/null +++ b/src/requests/all/set_chat_sticker_set.rs @@ -0,0 +1,79 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, True}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to set a new group sticker set for a supergroup. +/// +/// The bot must be an administrator in the chat for this to work and must have +/// the appropriate admin rights. Use the field can_set_sticker_set optionally +/// returned in getChat requests to check if the bot can use this method. +/// +/// [The official docs](https://core.telegram.org/bots/api#setchatstickerset). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct SetChatStickerSet { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, + sticker_set_name: String, +} + +#[async_trait::async_trait] +impl Request for SetChatStickerSet { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "setChatStickerSet", + &self, + ) + .await + } +} + +impl SetChatStickerSet { + pub(crate) fn new( + bot: Arc, + chat_id: C, + sticker_set_name: S, + ) -> Self + where + C: Into, + S: Into, + { + let chat_id = chat_id.into(); + let sticker_set_name = sticker_set_name.into(); + Self { + bot, + chat_id, + sticker_set_name, + } + } + + /// Unique identifier for the target chat or username of the target + /// supergroup (in the format `@supergroupusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Name of the sticker set to be set as the group sticker set. + pub fn sticker_set_name(mut self, val: T) -> Self + where + T: Into, + { + self.sticker_set_name = val.into(); + self + } +} diff --git a/src/requests/all/set_chat_title.rs b/src/requests/all/set_chat_title.rs new file mode 100644 index 00000000..6e05fc64 --- /dev/null +++ b/src/requests/all/set_chat_title.rs @@ -0,0 +1,74 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, True}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to change the title of a chat. +/// +/// Titles can't be changed for private chats. The bot must be an administrator +/// in the chat for this to work and must have the appropriate admin rights. +/// +/// [The official docs](https://core.telegram.org/bots/api#setchattitle). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct SetChatTitle { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, + title: String, +} + +#[async_trait::async_trait] +impl Request for SetChatTitle { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "setChatTitle", + &self, + ) + .await + } +} + +impl SetChatTitle { + pub(crate) fn new(bot: Arc, chat_id: C, title: T) -> Self + where + C: Into, + T: Into, + { + let chat_id = chat_id.into(); + let title = title.into(); + Self { + bot, + chat_id, + title, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// New chat title, 1-255 characters. + pub fn title(mut self, val: T) -> Self + where + T: Into, + { + self.title = val.into(); + self + } +} diff --git a/src/requests/all/set_game_score.rs b/src/requests/all/set_game_score.rs new file mode 100644 index 00000000..679ca708 --- /dev/null +++ b/src/requests/all/set_game_score.rs @@ -0,0 +1,100 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatOrInlineMessage, Message}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to set the score of the specified user in a game. +/// +/// On success, if the message was sent by the bot, returns the edited +/// [`Message`], otherwise returns [`True`]. Returns an error, if the new score +/// is not greater than the user's current score in the chat and force is +/// `false`. +/// +/// [The official docs](https://core.telegram.org/bots/api#setgamescore). +/// +/// [`Message`]: crate::types::Message +/// [`True`]: crate::types::True +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct SetGameScore { + #[serde(skip_serializing)] + bot: Arc, + #[serde(flatten)] + chat_or_inline_message: ChatOrInlineMessage, + user_id: i32, + score: i32, + force: Option, + disable_edit_message: Option, +} + +#[async_trait::async_trait] +impl Request for SetGameScore { + type Output = Message; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "setGameScore", + &self, + ) + .await + } +} + +impl SetGameScore { + pub(crate) fn new( + bot: Arc, + chat_or_inline_message: ChatOrInlineMessage, + user_id: i32, + score: i32, + ) -> Self { + Self { + bot, + chat_or_inline_message, + user_id, + score, + force: None, + disable_edit_message: None, + } + } + + pub fn chat_or_inline_message(mut self, val: ChatOrInlineMessage) -> Self { + self.chat_or_inline_message = val; + self + } + + /// User identifier. + pub fn user_id(mut self, val: i32) -> Self { + self.user_id = val; + self + } + + /// New score, must be non-negative. + pub fn score(mut self, val: i32) -> Self { + self.score = val; + self + } + + /// Pass `true`, if the high score is allowed to decrease. + /// + /// This can be useful when fixing mistakes or banning cheaters. + pub fn force(mut self, val: bool) -> Self { + self.force = Some(val); + self + } + + /// Sends the message [silently]. Users will receive a notification with no + /// sound. + /// + /// [silently]: https://telegram.org/blog/channels-2-0#silent-messages + pub fn disable_edit_message(mut self, val: bool) -> Self { + self.disable_edit_message = Some(val); + self + } +} diff --git a/src/requests/all/set_sticker_position_in_set.rs b/src/requests/all/set_sticker_position_in_set.rs new file mode 100644 index 00000000..7bcea444 --- /dev/null +++ b/src/requests/all/set_sticker_position_in_set.rs @@ -0,0 +1,66 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::True, + Bot, +}; +use std::sync::Arc; + +/// Use this method to move a sticker in a set created by the bot to a specific +/// position. +/// +/// [The official docs](https://core.telegram.org/bots/api#setstickerpositioninset). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct SetStickerPositionInSet { + #[serde(skip_serializing)] + bot: Arc, + sticker: String, + position: i32, +} + +#[async_trait::async_trait] +impl Request for SetStickerPositionInSet { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "setStickerPositionInSet", + &self, + ) + .await + } +} + +impl SetStickerPositionInSet { + pub(crate) fn new(bot: Arc, sticker: S, position: i32) -> Self + where + S: Into, + { + let sticker = sticker.into(); + Self { + bot, + sticker, + position, + } + } + + /// File identifier of the sticker. + pub fn sticker(mut self, val: T) -> Self + where + T: Into, + { + self.sticker = val.into(); + self + } + + /// New sticker position in the set, zero-based. + pub fn position(mut self, val: i32) -> Self { + self.position = val; + self + } +} diff --git a/src/requests/all/set_webhook.rs b/src/requests/all/set_webhook.rs new file mode 100644 index 00000000..ad293b5c --- /dev/null +++ b/src/requests/all/set_webhook.rs @@ -0,0 +1,127 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{AllowedUpdate, InputFile, True}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to specify a url and receive incoming updates via an +/// outgoing webhook. +/// +/// Whenever there is an update for the bot, we will send an +/// HTTPS POST request to the specified url, containing a JSON-serialized +/// [`Update`]. In case of an unsuccessful request, we will give up after a +/// reasonable amount of attempts. +/// +/// If you'd like to make sure that the Webhook request comes from Telegram, +/// we recommend using a secret path in the URL, e.g. +/// `https://www.example.com/`. Since nobody else knows your bot‘s +/// token, you can be pretty sure it’s us. +/// +/// [The official docs](https://core.telegram.org/bots/api#setwebhook). +/// +/// [`Update`]: crate::types::Update +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct SetWebhook { + #[serde(skip_serializing)] + bot: Arc, + url: String, + certificate: Option, + max_connections: Option, + allowed_updates: Option>, +} + +#[async_trait::async_trait] +impl Request for SetWebhook { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "setWebhook", + &self, + ) + .await + } +} + +impl SetWebhook { + pub(crate) fn new(bot: Arc, url: U) -> Self + where + U: Into, + { + let url = url.into(); + Self { + bot, + url, + certificate: None, + max_connections: None, + allowed_updates: None, + } + } + + /// HTTPS url to send updates to. + /// + /// Use an empty string to remove webhook integration. + pub fn url(mut self, val: T) -> Self + where + T: Into, + { + self.url = val.into(); + self + } + + /// Upload your public key certificate so that the root certificate in use + /// can be checked. + /// + /// See our [self-signed guide] for details. + /// + /// [self-signed guide]: https://core.telegram.org/bots/self-signed + pub fn certificate(mut self, val: InputFile) -> Self { + self.certificate = Some(val); + self + } + + /// Maximum allowed number of simultaneous HTTPS connections to the webhook + /// for update delivery, 1-100. + /// + /// Defaults to 40. Use lower values to limit the load on your bot‘s server, + /// and higher values to increase your bot’s throughput. + pub fn max_connections(mut self, val: i32) -> Self { + self.max_connections = Some(val); + self + } + + /// List the types of updates you want your bot to receive. + /// + /// For example, specify [`AllowedUpdate::Message`], + /// [`AllowedUpdate::EditedChannelPost`], [`AllowedUpdate::CallbackQuery`] + /// to only receive updates of these types. Specify an empty list to receive + /// all updates regardless of type (default). If not specified, the + /// previous setting will be used. See [`AllowedUpdate`] for a complete list + /// of available update types. + /// + /// Please note that this parameter doesn't affect updates created before + /// the call to the [`Bot::set_webhook`], so unwanted updates may be + /// received for a short period of time. + /// + /// [`Bot::set_webhook`]: crate::Bot::set_webhook + /// [`AllowedUpdate::Message`]: crate::types::AllowedUpdate::Message + /// [`AllowedUpdate::EditedChannelPost`]: + /// crate::types::AllowedUpdate::EditedChannelPost + /// [`AllowedUpdate::CallbackQuery`]: + /// crate::types::AllowedUpdate::CallbackQuery + /// [`AllowedUpdate`]: crate::types::AllowedUpdate + pub fn allowed_updates(mut self, val: T) -> Self + where + T: Into>, + { + self.allowed_updates = Some(val.into()); + self + } +} diff --git a/src/requests/all/stop_message_live_location.rs b/src/requests/all/stop_message_live_location.rs new file mode 100644 index 00000000..2c970b2b --- /dev/null +++ b/src/requests/all/stop_message_live_location.rs @@ -0,0 +1,70 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatOrInlineMessage, InlineKeyboardMarkup, Message}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to stop updating a live location message before +/// `live_period` expires. +/// +/// On success, if the message was sent by the bot, the sent [`Message`] is +/// returned, otherwise [`True`] is returned. +/// +/// [The official docs](https://core.telegram.org/bots/api#stopmessagelivelocation). +/// +/// [`Message`]: crate::types::Message +/// [`True`]: crate::types::True +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct StopMessageLiveLocation { + #[serde(skip_serializing)] + bot: Arc, + #[serde(flatten)] + chat_or_inline_message: ChatOrInlineMessage, + reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for StopMessageLiveLocation { + type Output = Message; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "stopMessageLiveLocation", + &self, + ) + .await + } +} + +impl StopMessageLiveLocation { + pub(crate) fn new( + bot: Arc, + chat_or_inline_message: ChatOrInlineMessage, + ) -> Self { + Self { + bot, + chat_or_inline_message, + reply_markup: None, + } + } + + pub fn chat_or_inline_message(mut self, val: ChatOrInlineMessage) -> Self { + self.chat_or_inline_message = val; + self + } + + /// A JSON-serialized object for a new [inline keyboard]. + /// + /// [inline keyboard]: https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating + pub fn reply_markup(mut self, val: InlineKeyboardMarkup) -> Self { + self.reply_markup = Some(val); + self + } +} diff --git a/src/requests/all/stop_poll.rs b/src/requests/all/stop_poll.rs new file mode 100644 index 00000000..f0d97b48 --- /dev/null +++ b/src/requests/all/stop_poll.rs @@ -0,0 +1,78 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, InlineKeyboardMarkup, Poll}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to stop a poll which was sent by the bot. +/// +/// [The official docs](https://core.telegram.org/bots/api#stoppoll). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct StopPoll { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, + message_id: i32, + reply_markup: Option, +} + +#[async_trait::async_trait] +impl Request for StopPoll { + type Output = Poll; + + /// On success, the stopped [`Poll`] with the final results is returned. + /// + /// [`Poll`]: crate::types::Poll + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "stopPoll", + &self, + ) + .await + } +} +impl StopPoll { + pub(crate) fn new(bot: Arc, chat_id: C, message_id: i32) -> Self + where + C: Into, + { + let chat_id = chat_id.into(); + Self { + bot, + chat_id, + message_id, + reply_markup: None, + } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Identifier of the original message with the poll. + pub fn message_id(mut self, val: i32) -> Self { + self.message_id = val; + self + } + + /// A JSON-serialized object for a new [inline keyboard]. + /// + /// [inline keyboard]: https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating + pub fn reply_markup(mut self, val: InlineKeyboardMarkup) -> Self { + self.reply_markup = Some(val); + self + } +} diff --git a/src/requests/all/unban_chat_member.rs b/src/requests/all/unban_chat_member.rs new file mode 100644 index 00000000..eb643301 --- /dev/null +++ b/src/requests/all/unban_chat_member.rs @@ -0,0 +1,69 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, True}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to unban a previously kicked user in a supergroup or +/// channel. The user will **not** return to the group or channel automatically, +/// but will be able to join via link, etc. The bot must be an administrator for +/// this to work. +/// +/// [The official docs](https://core.telegram.org/bots/api#unbanchatmember). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct UnbanChatMember { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, + user_id: i32, +} + +#[async_trait::async_trait] +impl Request for UnbanChatMember { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "unbanChatMember", + &self, + ) + .await + } +} + +impl UnbanChatMember { + pub(crate) fn new(bot: Arc, chat_id: C, user_id: i32) -> Self + where + C: Into, + { + let chat_id = chat_id.into(); + Self { + bot, + chat_id, + user_id, + } + } + + /// Unique identifier for the target group or username of the target + /// supergroup or channel (in the format `@username`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } + + /// Unique identifier of the target user. + pub fn user_id(mut self, val: i32) -> Self { + self.user_id = val; + self + } +} diff --git a/src/requests/all/unpin_chat_message.rs b/src/requests/all/unpin_chat_message.rs new file mode 100644 index 00000000..926719b2 --- /dev/null +++ b/src/requests/all/unpin_chat_message.rs @@ -0,0 +1,59 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{ChatId, True}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to unpin a message in a group, a supergroup, or a channel. +/// +/// The bot must be an administrator in the chat for this to work and must have +/// the `can_pin_messages` admin right in the supergroup or `can_edit_messages` +/// admin right in the channel. +/// +/// [The official docs](https://core.telegram.org/bots/api#unpinchatmessage). +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct UnpinChatMessage { + #[serde(skip_serializing)] + bot: Arc, + chat_id: ChatId, +} + +#[async_trait::async_trait] +impl Request for UnpinChatMessage { + type Output = True; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "unpinChatMessage", + &self, + ) + .await + } +} + +impl UnpinChatMessage { + pub(crate) fn new(bot: Arc, chat_id: C) -> Self + where + C: Into, + { + let chat_id = chat_id.into(); + Self { bot, chat_id } + } + + /// Unique identifier for the target chat or username of the target channel + /// (in the format `@channelusername`). + pub fn chat_id(mut self, val: T) -> Self + where + T: Into, + { + self.chat_id = val.into(); + self + } +} diff --git a/src/requests/all/upload_sticker_file.rs b/src/requests/all/upload_sticker_file.rs new file mode 100644 index 00000000..68015c1c --- /dev/null +++ b/src/requests/all/upload_sticker_file.rs @@ -0,0 +1,70 @@ +use serde::Serialize; + +use crate::{ + net, + requests::{Request, ResponseResult}, + types::{File, InputFile}, + Bot, +}; +use std::sync::Arc; + +/// Use this method to upload a .png file with a sticker for later use in +/// [`Bot::create_new_sticker_set`] and [`Bot::add_sticker_to_set`] methods (can +/// be used multiple times). +/// +/// [The official docs](https://core.telegram.org/bots/api#uploadstickerfile). +/// +/// [`Bot::create_new_sticker_set`]: crate::Bot::create_new_sticker_set +/// [`Bot::add_sticker_to_set`]: crate::Bot::add_sticker_to_set +#[serde_with_macros::skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct UploadStickerFile { + #[serde(skip_serializing)] + bot: Arc, + user_id: i32, + png_sticker: InputFile, +} +#[async_trait::async_trait] +impl Request for UploadStickerFile { + type Output = File; + + async fn send(&self) -> ResponseResult { + net::request_json( + self.bot.client(), + self.bot.token(), + "uploadStickerFile", + &self, + ) + .await + } +} + +impl UploadStickerFile { + pub(crate) fn new( + bot: Arc, + user_id: i32, + png_sticker: InputFile, + ) -> Self { + Self { + bot, + user_id, + png_sticker, + } + } + + /// User identifier of sticker file owner. + pub fn user_id(mut self, val: i32) -> Self { + self.user_id = val; + self + } + + /// **Png** image with the sticker, must be up to 512 kilobytes in size, + /// dimensions must not exceed 512px, and either width or height must be + /// exactly 512px. [More info on Sending Files »]. + /// + /// [More info on Sending Files »]: https://core.telegram.org/bots/api#sending-files + pub fn png_sticker(mut self, val: InputFile) -> Self { + self.png_sticker = val; + self + } +} diff --git a/src/requests/form_builder.rs b/src/requests/form_builder.rs new file mode 100644 index 00000000..543932b0 --- /dev/null +++ b/src/requests/form_builder.rs @@ -0,0 +1,160 @@ +use std::{borrow::Cow, path::PathBuf}; + +use reqwest::multipart::Form; + +use crate::{ + requests::utils::file_to_part, + types::{ + ChatId, InlineKeyboardMarkup, InputFile, InputMedia, MaskPosition, + ParseMode, ReplyMarkup, + }, +}; + +/// This is a convenient struct that builds `reqwest::multipart::Form` +/// from scratch. +pub(crate) struct FormBuilder { + form: Form, +} + +impl FormBuilder { + pub(crate) fn new() -> Self { + 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 + where + N: Into>, + T: IntoFormValue, + { + 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, + None => self, + } + } + + // used in SendMediaGroup + pub async fn add_file<'a, N>(self, name: N, path_to_file: PathBuf) -> Self + where + N: Into>, + { + Self { + form: self.form.part( + name.into().into_owned(), + file_to_part(path_to_file).await, + ), + } + } + + pub fn build(self) -> Form { + self.form + } +} + +pub(crate) enum FormValue { + File(PathBuf), + Str(String), +} + +pub(crate) trait IntoFormValue { + fn into_form_value(&self) -> Option; +} + +macro_rules! impl_for_struct { + ($($name:ty),*) => { + $( + impl IntoFormValue for $name { + fn into_form_value(&self) -> Option { + let json = serde_json::to_string(self) + .expect("serde_json::to_string failed"); + Some(FormValue::Str(json)) + } + } + )* + }; +} + +impl_for_struct!( + bool, + i32, + i64, + u32, + ReplyMarkup, + InlineKeyboardMarkup, + MaskPosition +); + +impl IntoFormValue for Option +where + T: IntoFormValue, +{ + fn into_form_value(&self) -> Option { + self.as_ref().and_then(IntoFormValue::into_form_value) + } +} + +// TODO: fix InputMedia implementation of IntoFormValue (for now it doesn't +// encode files :|) +impl IntoFormValue for Vec { + fn into_form_value(&self) -> Option { + let json = + serde_json::to_string(self).expect("serde_json::to_string failed"); + Some(FormValue::Str(json)) + } +} + +impl IntoFormValue for InputMedia { + fn into_form_value(&self) -> Option { + let json = + serde_json::to_string(self).expect("serde_json::to_string failed"); + Some(FormValue::Str(json)) + } +} + +impl IntoFormValue for str { + fn into_form_value(&self) -> Option { + Some(FormValue::Str(self.to_owned())) + } +} + +impl IntoFormValue for ParseMode { + fn into_form_value(&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)) + } +} + +impl IntoFormValue for ChatId { + fn into_form_value(&self) -> Option { + let string = match self { + ChatId::Id(id) => id.to_string(), + ChatId::ChannelUsername(username) => username.clone(), + }; + Some(FormValue::Str(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::Url(url) => Some(FormValue::Str(url.clone())), + InputFile::FileId(file_id) => Some(FormValue::Str(file_id.clone())), + } + } +} diff --git a/src/requests/mod.rs b/src/requests/mod.rs new file mode 100644 index 00000000..7f121d29 --- /dev/null +++ b/src/requests/mod.rs @@ -0,0 +1,20 @@ +//! API requests. + +mod all; +mod form_builder; +mod utils; + +pub use all::*; + +/// A type that is returned after making a request to Telegram. +pub type ResponseResult = Result; + +/// Designates an API request. +#[async_trait::async_trait] +pub trait Request { + /// A data structure returned if success. + type Output; + + /// Asynchronously sends this request to Telegram and returns the result. + async fn send(&self) -> ResponseResult; +} diff --git a/src/requests/utils.rs b/src/requests/utils.rs new file mode 100644 index 00000000..d501c2c6 --- /dev/null +++ b/src/requests/utils.rs @@ -0,0 +1,39 @@ +use std::path::PathBuf; + +use bytes::{Bytes, BytesMut}; +use reqwest::{multipart::Part, Body}; +use tokio_util::codec::{Decoder, FramedRead}; + +struct FileDecoder; + +impl Decoder for FileDecoder { + type Item = Bytes; + type Error = std::io::Error; + + fn decode( + &mut self, + src: &mut BytesMut, + ) -> Result, Self::Error> { + if src.is_empty() { + return Ok(None); + } + Ok(Some(src.split().freeze())) + } +} + +pub async fn file_to_part(path_to_file: PathBuf) -> 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 */ + FileDecoder, + ); + + Part::stream(Body::wrap_stream(file)).file_name(file_name) +} diff --git a/src/types/allowed_update.rs b/src/types/allowed_update.rs new file mode 100644 index 00000000..932f053e --- /dev/null +++ b/src/types/allowed_update.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AllowedUpdate { + Message, + EditedMessage, + ChannelPost, + EditedChannelPost, + InlineQuery, + ChosenInlineResult, + CallbackQuery, +} diff --git a/src/types/animation.rs b/src/types/animation.rs new file mode 100644 index 00000000..54694665 --- /dev/null +++ b/src/types/animation.rs @@ -0,0 +1,84 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::{MimeWrapper, PhotoSize}; + +/// This object represents an animation file (GIF or H.264/MPEG-4 AVC video +/// without sound). +/// +/// [The official docs](https://core.telegram.org/bots/api#animation). +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct Animation { + /// An identifier for this file. + pub file_id: String, + + /// Unique identifier for this file, which is supposed to be the same over + /// time and for different bots. Can't be used to download or reuse the + /// file. + pub file_unique_id: String, + + /// A video width as defined by a sender. + pub width: u32, + + /// A video height as defined by a sender. + pub height: u32, + + /// A duration of the video in seconds as defined by a sender. + pub duration: u32, + + /// An animation thumbnail as defined by a sender. + pub thumb: Option, + + /// An original animation filename as defined by a sender. + pub file_name: Option, + + /// A MIME type of the file as defined by a sender. + pub mime_type: Option, + + /// A size of a file. + pub file_size: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize() { + let json = r#"{ + "file_id":"id", + "file_unique_id":"", + "width":320, + "height":320, + "duration":59, + "thumb":{ + "file_id":"id", + "file_unique_id":"", + "width":320, + "height":320, + "file_size":3452 + }, + "file_name":"some", + "mime_type":"video/gif", + "file_size":6500}"#; + let expected = Animation { + file_id: "id".to_string(), + file_unique_id: "".to_string(), + width: 320, + height: 320, + duration: 59, + thumb: Some(PhotoSize { + file_id: "id".to_string(), + file_unique_id: "".to_string(), + width: 320, + height: 320, + file_size: Some(3452), + }), + file_name: Some("some".to_string()), + mime_type: Some(MimeWrapper("video/gif".parse().unwrap())), + file_size: Some(6500), + }; + let actual = serde_json::from_str::(json).unwrap(); + assert_eq!(actual, expected) + } +} diff --git a/src/types/audio.rs b/src/types/audio.rs new file mode 100644 index 00000000..eea13214 --- /dev/null +++ b/src/types/audio.rs @@ -0,0 +1,80 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::PhotoSize; + +/// This object represents an audio file to be treated as music by the Telegram +/// clients. +/// +/// [The official docs](https://core.telegram.org/bots/api#audio). +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct Audio { + /// An identifier for this file. + pub file_id: String, + + /// Unique identifier for this file, which is supposed to be the same over + /// time and for different bots. Can't be used to download or reuse the + /// file. + pub file_unique_id: String, + + /// A duration of the audio in seconds as defined by a sender. + pub duration: u32, + + /// A performer of the audio as defined by a sender or by audio tags. + pub performer: Option, + + /// A title of the audio as defined by sender or by audio tags. + pub title: Option, + + /// A MIME type of the file as defined by a sender. + pub mime_type: Option, + + /// A size of a file. + pub file_size: Option, + + /// A thumbnail of the album cover to which the music file belongs. + pub thumb: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize() { + let json = r#"{ + "file_id":"id", + "file_unique_id":"", + "duration":60, + "performer":"Performer", + "title":"Title", + "mime_type":"MimeType", + "file_size":123456, + "thumb":{ + "file_id":"id", + "file_unique_id":"", + "width":320, + "height":320, + "file_size":3452 + } + }"#; + let expected = Audio { + file_id: "id".to_string(), + file_unique_id: "".to_string(), + duration: 60, + performer: Some("Performer".to_string()), + title: Some("Title".to_string()), + mime_type: Some("MimeType".to_string()), + file_size: Some(123_456), + thumb: Some(PhotoSize { + file_id: "id".to_string(), + file_unique_id: "".to_string(), + width: 320, + height: 320, + file_size: Some(3452), + }), + }; + let actual = serde_json::from_str::

(path: P) -> Self + where + P: Into, + { + Self::File(path.into()) + } + + pub fn url(url: T) -> Self + where + T: Into, + { + Self::Url(url.into()) + } + + pub fn file_id(file_id: T) -> Self + where + T: Into, + { + Self::FileId(file_id.into()) + } + + pub fn as_file(&self) -> Option<&PathBuf> { + match self { + Self::File(path) => Some(path), + _ => None, + } + } + + pub fn as_url(&self) -> Option<&String> { + match self { + Self::Url(url) => Some(url), + _ => None, + } + } + + pub fn as_file_id(&self) -> Option<&String> { + match self { + Self::FileId(id) => Some(id), + _ => None, + } + } +} + +impl From for Option { + fn from(file: InputFile) -> Self { + match file { + InputFile::File(path) => Some(path), + _ => None, + } + } +} + +impl Serialize for InputFile { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + InputFile::File(path) => { + // NOTE: file should be actually attached with + // multipart/form-data + serializer.serialize_str( + // TODO: remove unwrap (?) + &format!( + "attach://{}", + path.file_name().unwrap().to_string_lossy() + ), + ) + } + InputFile::Url(url) => serializer.serialize_str(url), + InputFile::FileId(id) => serializer.serialize_str(id), + } + } +} diff --git a/src/types/input_media.rs b/src/types/input_media.rs new file mode 100644 index 00000000..365597d7 --- /dev/null +++ b/src/types/input_media.rs @@ -0,0 +1,275 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::{InputFile, ParseMode}; + +// TODO: should variants use new-type? +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type")] +#[serde(rename_all = "snake_case")] +/// This object represents the content of a media message to be sent. +/// +/// [The official docs](https://core.telegram.org/bots/api#inputmedia). +pub enum InputMedia { + /// Represents a photo to be sent. + /// + /// [The official docs](https://core.telegram.org/bots/api#inputmediaphoto). + Photo { + /// File to send. + media: InputFile, + + /// Caption of the photo to be sent, 0-1024 characters. + caption: Option, + + /// Send [Markdown] or [HTML], if you want Telegram apps to show [bold, + /// italic, fixed-width text or inline URLs] in the media caption. + /// + /// [Markdown]: https://core.telegram.org/bots/api#markdown-style + /// [HTML]: https://core.telegram.org/bots/api#html-style + /// [bold, italic, fixed-width text or inline URLs]: https://core.telegram.org/bots/api#formatting-options + parse_mode: Option, + }, + + /// Represents a video to be sent. + /// + /// [The official docs](https://core.telegram.org/bots/api#inputmediavideo). + Video { + // File to send. + media: InputFile, + + /// Thumbnail of the file sent; can be ignored if thumbnail generation + /// for the file is supported server-side. The thumbnail should be in + /// JPEG format and less than 200 kB in size. A thumbnail‘s width and + /// height should not exceed 320. Ignored if the file is not uploaded + /// using multipart/form-data. + thumb: Option, + + /// Caption of the video to be sent, 0-1024 characters. + caption: Option, + + /// Send [Markdown] or [HTML], if you want Telegram apps to show [bold, + /// italic, fixed-width text or inline URLs] in the media caption. + /// + /// [Markdown]: https://core.telegram.org/bots/api#markdown-style + /// [HTML]: https://core.telegram.org/bots/api#html-style + /// [bold, italic, fixed-width text or inline URLs]: https://core.telegram.org/bots/api#formatting-options + parse_mode: Option, + + /// Video width. + width: Option, + + /// Video height. + height: Option, + + /// Video duration. + duration: Option, + + /// Pass `true`, if the uploaded video is suitable for streaming. + supports_streaming: Option, + }, + + /// Represents an animation file (GIF or H.264/MPEG-4 AVC video without + /// sound) to be sent. + /// + /// [The official docs](https://core.telegram.org/bots/api#inputmediaanimation). + Animation { + /// File to send. + media: InputFile, + + /// Thumbnail of the file sent; can be ignored if thumbnail generation + /// for the file is supported server-side. The thumbnail should be in + /// JPEG format and less than 200 kB in size. A thumbnail‘s width and + /// height should not exceed 320. Ignored if the file is not uploaded + /// using multipart/form-data. + thumb: Option, + + /// Caption of the animation to be sent, 0-1024 characters. + caption: Option, + + /// Send [Markdown] or [HTML], if you want Telegram apps to show [bold, + /// italic, fixed-width text or inline URLs] in the media caption. + /// + /// [Markdown]: https://core.telegram.org/bots/api#markdown-style + /// [HTML]: https://core.telegram.org/bots/api#html-style + /// [bold, italic, fixed-width text or inline URLs]: https://core.telegram.org/bots/api#formatting-options + parse_mode: Option, + + /// Animation width. + width: Option, + + /// Animation height. + height: Option, + + /// Animation duration. + duration: Option, + }, + + /// Represents an audio file to be treated as music to be sent. + /// + /// [The official docs](https://core.telegram.org/bots/api#inputmediaaudio). + Audio { + /// File to send. + media: InputFile, + + /// Thumbnail of the file sent; can be ignored if thumbnail generation + /// for the file is supported server-side. The thumbnail should be in + /// JPEG format and less than 200 kB in size. A thumbnail‘s width and + /// height should not exceed 320. Ignored if the file is not uploaded + /// using multipart/form-data. + thumb: Option, + + /// Caption of the audio to be sent, 0-1024 characters. + caption: Option, + + /// Send [Markdown] or [HTML], if you want Telegram apps to show [bold, + /// italic, fixed-width text or inline URLs] in the media caption. + /// + /// [Markdown]: https://core.telegram.org/bots/api#markdown-style + /// [HTML]: https://core.telegram.org/bots/api#html-style + /// [bold, italic, fixed-width text or inline URLs]: https://core.telegram.org/bots/api#formatting-options + parse_mode: Option, + + /// Duration of the audio in seconds. + duration: Option, + + /// Performer of the audio. + performer: Option, + + /// Title of the audio. + title: Option, + }, + + /// Represents a general file to be sent. + /// + /// [The official docs](https://core.telegram.org/bots/api#inputmediadocument). + Document { + /// File to send. + media: InputFile, + + /// Thumbnail of the file sent; can be ignored if thumbnail generation + /// for the file is supported server-side. The thumbnail should be in + /// JPEG format and less than 200 kB in size. A thumbnail‘s width and + /// height should not exceed 320. Ignored if the file is not uploaded + /// using multipart/form-data. + thumb: Option, + + /// Caption of the document to be sent, 0-1024 charactersю + caption: Option, + + /// Send [Markdown] or [HTML], if you want Telegram apps to show [bold, + /// italic, fixed-width text or inline URLs] in the media caption. + /// + /// [Markdown]: https://core.telegram.org/bots/api#markdown-style + /// [HTML]: https://core.telegram.org/bots/api#html-style + /// [bold, italic, fixed-width text or inline URLs]: https://core.telegram.org/bots/api#formatting-options + parse_mode: Option, + }, +} + +impl InputMedia { + pub fn media(&self) -> &InputFile { + match self { + InputMedia::Photo { media, .. } + | InputMedia::Document { media, .. } + | InputMedia::Audio { media, .. } + | InputMedia::Animation { media, .. } + | InputMedia::Video { media, .. } => media, + } + } +} + +impl From for InputFile { + fn from(media: InputMedia) -> InputFile { + match media { + InputMedia::Photo { media, .. } + | InputMedia::Document { media, .. } + | InputMedia::Audio { media, .. } + | InputMedia::Animation { media, .. } + | InputMedia::Video { media, .. } => media, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn photo_serialize() { + let expected_json = r#"{"type":"photo","media":"123456"}"#; + let photo = InputMedia::Photo { + media: InputFile::FileId(String::from("123456")), + caption: None, + parse_mode: None, + }; + + let actual_json = serde_json::to_string(&photo).unwrap(); + assert_eq!(expected_json, actual_json); + } + + #[test] + fn video_serialize() { + let expected_json = r#"{"type":"video","media":"123456"}"#; + let video = InputMedia::Video { + media: InputFile::FileId(String::from("123456")), + thumb: None, + caption: None, + parse_mode: None, + width: None, + height: None, + duration: None, + supports_streaming: None, + }; + + let actual_json = serde_json::to_string(&video).unwrap(); + assert_eq!(expected_json, actual_json); + } + + #[test] + fn animation_serialize() { + let expected_json = r#"{"type":"animation","media":"123456"}"#; + let video = InputMedia::Animation { + media: InputFile::FileId(String::from("123456")), + thumb: None, + caption: None, + parse_mode: None, + width: None, + height: None, + duration: None, + }; + + let actual_json = serde_json::to_string(&video).unwrap(); + assert_eq!(expected_json, actual_json); + } + + #[test] + fn audio_serialize() { + let expected_json = r#"{"type":"audio","media":"123456"}"#; + let video = InputMedia::Audio { + media: InputFile::FileId(String::from("123456")), + thumb: None, + caption: None, + parse_mode: None, + duration: None, + performer: None, + title: None, + }; + + let actual_json = serde_json::to_string(&video).unwrap(); + assert_eq!(expected_json, actual_json); + } + + #[test] + fn document_serialize() { + let expected_json = r#"{"type":"document","media":"123456"}"#; + let video = InputMedia::Document { + media: InputFile::FileId(String::from("123456")), + thumb: None, + caption: None, + parse_mode: None, + }; + + let actual_json = serde_json::to_string(&video).unwrap(); + assert_eq!(expected_json, actual_json); + } +} diff --git a/src/types/input_message_content.rs b/src/types/input_message_content.rs new file mode 100644 index 00000000..a9563211 --- /dev/null +++ b/src/types/input_message_content.rs @@ -0,0 +1,149 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::ParseMode; + +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +/// This object represents the content of a message to be sent as a result of an +/// inline query. +/// +/// [The official docs](https://core.telegram.org/bots/api#inputmessagecontent). +pub enum InputMessageContent { + /// Represents the content of a text message to be sent as the result of an + /// inline query. + Text { + /// Text of the message to be sent, 1-4096 characters. + message_text: String, + + /// Send [Markdown] or [HTML], if you want Telegram apps to show [bold, + /// italic, fixed-width text or inline URLs] in the media caption. + /// + /// [Markdown]: https://core.telegram.org/bots/api#markdown-style + /// [HTML]: https://core.telegram.org/bots/api#html-style + /// [bold, italic, fixed-width text or inline URLs]: https://core.telegram.org/bots/api#formatting-options + parse_mode: Option, + + /// Disables link previews for links in the sent message. + disable_web_page_preview: Option, + }, + + /// Represents the content of a location message to be sent as the result + /// of an inline query. + Location { + /// Latitude of the location in degrees. + latitude: f64, + + /// Longitude of the location in degrees. + longitude: f64, + + /// Period in seconds for which the location can be updated, should be + /// between 60 and 86400. + live_period: Option, + }, + + /// Represents the content of a venue message to be sent as the result of + /// an inline query. + Venue { + /// Latitude of the venue in degrees. + latitude: f64, + + /// Longitude of the venue in degrees. + longitude: f64, + + /// Name of the venue. + title: String, + + /// Address of the venue. + address: String, + + /// Foursquare identifier of the venue, if known. + foursquare_id: Option, + + /// Foursquare type of the venue, if known. (For example, + /// `arts_entertainment/default`, `arts_entertainment/aquarium` + /// or `food/icecream`.) + foursquare_type: Option, + }, + + /// Represents the content of a contact message to be sent as the result of + /// an inline query. + Contact { + /// Contact's phone number. + phone_number: String, + + /// Contact's first name. + first_name: String, + + /// Contact's last name. + last_name: Option, + + /// Additional data about the contact in the form of a [vCard], 0-2048 + /// bytes. + /// + /// [vCard]: https://en.wikipedia.org/wiki/VCard + vcard: Option, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_serialize() { + let expected_json = r#"{"message_text":"text"}"#; + let text_content = InputMessageContent::Text { + message_text: String::from("text"), + parse_mode: None, + disable_web_page_preview: None, + }; + + let actual_json = serde_json::to_string(&text_content).unwrap(); + assert_eq!(expected_json, actual_json); + } + + #[test] + fn location_serialize() { + let expected_json = r#"{"latitude":59.08,"longitude":38.4326}"#; + let location_content = InputMessageContent::Location { + latitude: 59.08, + longitude: 38.4326, + live_period: None, + }; + + let actual_json = serde_json::to_string(&location_content).unwrap(); + assert_eq!(expected_json, actual_json); + } + + #[test] + fn venue_serialize() { + let expected_json = r#"{"latitude":59.08,"longitude":38.4326,"title":"some title","address":"some address"}"#; + let venue_content = InputMessageContent::Venue { + latitude: 59.08, + longitude: 38.4326, + title: String::from("some title"), + address: String::from("some address"), + foursquare_id: None, + foursquare_type: None, + }; + + let actual_json = serde_json::to_string(&venue_content).unwrap(); + assert_eq!(expected_json, actual_json); + } + + #[test] + fn contact_serialize() { + let expected_json = + r#"{"phone_number":"+3800000000","first_name":"jhon"}"#; + let contact_content = InputMessageContent::Contact { + phone_number: String::from("+3800000000"), + first_name: String::from("jhon"), + last_name: None, + vcard: None, + }; + + let actual_json = serde_json::to_string(&contact_content).unwrap(); + assert_eq!(expected_json, actual_json); + } +} diff --git a/src/types/invoice.rs b/src/types/invoice.rs new file mode 100644 index 00000000..b9f2f5b6 --- /dev/null +++ b/src/types/invoice.rs @@ -0,0 +1,29 @@ +use serde::{Deserialize, Serialize}; + +/// This object contains basic information about an invoice. +/// +/// [The official docs](https://core.telegram.org/bots/api#invoice). +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct Invoice { + /// Product name. + pub title: String, + + /// Product description. + pub description: String, + + /// Unique bot deep-linking parameter that can be used to generate this + /// invoice. + pub start_parameter: String, + + /// Three-letter ISO 4217 currency code. + pub currency: String, + + /// Total price in the smallest units of the currency (integer, **not** + /// float/double). For example, for a price of `US$ 1.45` pass `amount = + /// 145`. See the exp parameter in [`currencies.json`], it shows the number + /// of digits past the decimal point for each currency (2 for the + /// majority of currencies). + /// + /// [`currencies.json`]: https://core.telegram.org/bots/payments/currencies.json + pub total_amount: i32, +} diff --git a/src/types/keyboard_button.rs b/src/types/keyboard_button.rs new file mode 100644 index 00000000..d2152184 --- /dev/null +++ b/src/types/keyboard_button.rs @@ -0,0 +1,186 @@ +use serde::{de::Error, Deserialize, Deserializer, Serialize, Serializer}; + +use crate::types::{KeyboardButtonPollType, True}; + +/// This object represents one button of the reply keyboard. +/// +/// For filter text buttons String can be used instead of this object to specify +/// text of the button. +/// +/// [The official docs](https://core.telegram.org/bots/api#keyboardbutton). +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct KeyboardButton { + /// Text of the button. If none of the optional fields are used, it will + /// be sent as a message when the button is pressed. + pub text: String, + + /// Request something from user. + /// - If `Some(Contact)`, the user's phone number will be sent as a contact + /// when the button is pressed. Available in private chats only + /// - If `Some(Location)`, the user's current location will be sent when + /// the button is pressed. Available in private chats only + #[serde(flatten)] + pub request: Option, +} + +impl KeyboardButton { + /// Creates `KeyboardButton` with the provided `text` and all the other + /// fields set to `None`. + pub fn new(text: T) -> Self + where + T: Into, + { + Self { + text: text.into(), + request: None, + } + } + + pub fn request(mut self, val: T) -> Self + where + T: Into>, + { + self.request = val.into(); + self + } +} + +// Serialize + Deserialize are implemented by hand +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum ButtonRequest { + Location, + Contact, + KeyboardButtonPollType(KeyboardButtonPollType), +} + +/// Helper struct for (de)serializing [`ButtonRequest`](ButtonRequest) +#[serde_with_macros::skip_serializing_none] +#[derive(Serialize, Deserialize)] +struct RawRequest { + /// If `true`, the user's phone number will be sent as a contact + /// when the button is pressed. Available in private chats only. + #[serde(rename = "request_contact")] + contact: Option, + + /// If `true`, the user's current location will be sent when the + /// button is pressed. Available in private chats only. + #[serde(rename = "request_location")] + location: Option, + + /// If specified, the user will be asked to create a poll and + /// send it to the bot when the button is pressed. Available in private + /// chats only. + #[serde(rename = "request_poll")] + poll: Option, +} + +impl<'de> Deserialize<'de> for ButtonRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = RawRequest::deserialize(deserializer)?; + match raw { + RawRequest { + contact: Some(_), + location: Some(_), + poll: Some(_), + } => Err(D::Error::custom( + "`request_contact` and `request_location` fields are mutually \ + exclusive, but both were provided", + )), + RawRequest { + contact: Some(_), .. + } => Ok(Self::Contact), + RawRequest { + location: Some(_), .. + } => Ok(Self::Location), + RawRequest { + poll: Some(poll_type), + .. + } => Ok(Self::KeyboardButtonPollType(poll_type)), + _ => Err(D::Error::custom( + "Either one of `request_contact` and `request_location` \ + fields is required", + )), + } + } +} + +impl Serialize for ButtonRequest { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Contact => RawRequest { + contact: Some(True), + location: None, + poll: None, + } + .serialize(serializer), + Self::Location => RawRequest { + contact: None, + location: Some(True), + poll: None, + } + .serialize(serializer), + Self::KeyboardButtonPollType(poll_type) => RawRequest { + contact: None, + location: None, + poll: Some(poll_type.clone()), + } + .serialize(serializer), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serialize_no_request() { + let button = KeyboardButton { + text: String::from(""), + request: None, + }; + let expected = r#"{"text":""}"#; + let actual = serde_json::to_string(&button).unwrap(); + assert_eq!(expected, actual); + } + + #[test] + fn serialize_request_contact() { + let button = KeyboardButton { + text: String::from(""), + request: Some(ButtonRequest::Contact), + }; + let expected = r#"{"text":"","request_contact":true}"#; + let actual = serde_json::to_string(&button).unwrap(); + assert_eq!(expected, actual); + } + + #[test] + fn deserialize_no_request() { + let json = r#"{"text":""}"#; + let expected = KeyboardButton { + text: String::from(""), + request: None, + }; + let actual = serde_json::from_str(json).unwrap(); + assert_eq!(expected, actual); + } + + #[test] + fn deserialize_request_contact() { + let json = r#"{"text":"","request_contact":true}"#; + let expected = KeyboardButton { + text: String::from(""), + request: Some(ButtonRequest::Contact), + }; + let actual = serde_json::from_str(json).unwrap(); + assert_eq!(expected, actual); + } +} diff --git a/src/types/keyboard_button_poll_type.rs b/src/types/keyboard_button_poll_type.rs new file mode 100644 index 00000000..b1880f2c --- /dev/null +++ b/src/types/keyboard_button_poll_type.rs @@ -0,0 +1,6 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct KeyboardButtonPollType { + poll_type: String, +} diff --git a/src/types/label_price.rs b/src/types/label_price.rs new file mode 100644 index 00000000..b9aea944 --- /dev/null +++ b/src/types/label_price.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; + +/// This object represents a portion of the price for goods or services. +/// +/// [The official docs](https://core.telegram.org/bots/api#labeledprice). +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct LabeledPrice { + /// Portion label. + pub label: String, + + /// Price of the product in the smallest units of the [currency] (integer, + /// **not** float/double). For example, for a price of `US$ 1.45` pass + /// `amount = 145`. See the exp parameter in [`currencies.json`], it shows + /// the number of digits past the decimal point for each currency (2 + /// for the majority of currencies). + /// + /// [currency]: https://core.telegram.org/bots/payments#supported-currencies + /// [`currencies.json`]: https://core.telegram.org/bots/payments/currencies.json + pub amount: i32, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serialize() { + let labeled_price = LabeledPrice { + label: "Label".to_string(), + amount: 60, + }; + let expected = r#"{"label":"Label","amount":60}"#; + let actual = serde_json::to_string(&labeled_price).unwrap(); + assert_eq!(actual, expected); + } +} diff --git a/src/types/location.rs b/src/types/location.rs new file mode 100644 index 00000000..c624d638 --- /dev/null +++ b/src/types/location.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; + +/// This object represents a point on the map. +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Location { + /// Longitude as defined by sender. + pub longitude: f64, + + /// Latitude as defined by sender. + pub latitude: f64, +} diff --git a/src/types/login_url.rs b/src/types/login_url.rs new file mode 100644 index 00000000..2e2b7f94 --- /dev/null +++ b/src/types/login_url.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; + +/// This object represents a parameter of the inline keyboard button used to +/// automatically authorize a user. +/// +/// Serves as a great replacement for the [Telegram Login Widget] when the user +/// is coming from Telegram. All the user needs to do is tap/click a button and +/// confirm that they want to log in: +/// +///

+/// +///
+/// +/// [Telegram Login Widget]: https://core.telegram.org/widgets/login +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct LoginUrl { + pub url: String, + pub forward_text: Option, + pub bot_username: Option, + pub request_write_access: Option, +} diff --git a/src/types/mask_position.rs b/src/types/mask_position.rs new file mode 100644 index 00000000..f5758335 --- /dev/null +++ b/src/types/mask_position.rs @@ -0,0 +1,25 @@ +use serde::{Deserialize, Serialize}; + +/// This object describes the position on faces where a mask should be placed by +/// default. +/// +/// [The official docs](https://core.telegram.org/bots/api#maskposition). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MaskPosition { + /// The part of the face relative to which the mask should be placed. One + /// of `forehead`, `eyes`, `mouth`, or `chin`. + pub point: String, + + /// Shift by X-axis measured in widths of the mask scaled to the face size, + /// from left to right. For example, choosing `-1.0` will place mask just + /// to the left of the default mask position. + pub x_shift: f64, + + /// Shift by Y-axis measured in heights of the mask scaled to the face + /// size, from top to bottom. For example, `1.0` will place the mask just + /// below the default mask position. + pub y_shift: f64, + + /// Mask scaling coefficient. For example, `2.0` means double size. + pub scale: f64, +} diff --git a/src/types/message.rs b/src/types/message.rs new file mode 100644 index 00000000..b324493f --- /dev/null +++ b/src/types/message.rs @@ -0,0 +1,1022 @@ +#![allow(clippy::large_enum_variant)] + +use serde::{Deserialize, Serialize}; + +use crate::types::{ + chat::{ChatKind, NonPrivateChatKind}, + Animation, Audio, Chat, Contact, Document, Game, InlineKeyboardMarkup, + Invoice, Location, MessageEntity, PassportData, PhotoSize, Poll, Sticker, + SuccessfulPayment, True, User, Venue, Video, VideoNote, Voice, +}; + +/// This object represents a message. +/// +/// [The official docs](https://core.telegram.org/bots/api#message). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Message { + /// Unique message identifier inside this chat. + #[serde(rename = "message_id")] + pub id: i32, + + /// Date the message was sent in Unix time. + pub date: i32, + + /// Conversation the message belongs to. + pub chat: Chat, + + #[serde(flatten)] + pub kind: MessageKind, +} + +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum MessageKind { + Common { + /// Sender, empty for messages sent to channels. + from: Option, + + #[serde(flatten)] + forward_kind: ForwardKind, + + /// Date the message was last edited in Unix time. + edit_date: Option, + + #[serde(flatten)] + media_kind: MediaKind, + + /// Inline keyboard attached to the message. `login_url` buttons are + /// represented as ordinary `url` buttons. + reply_markup: Option, + }, + NewChatMembers { + /// New members that were added to the group or supergroup and + /// information about them (the bot itself may be one of these + /// members). + new_chat_members: Vec, + }, + LeftChatMember { + /// A member was removed from the group, information about them (this + /// member may be the bot itself). + left_chat_member: User, + }, + NewChatTitle { + /// A chat title was changed to this value. + new_chat_title: String, + }, + NewChatPhoto { + /// A chat photo was change to this value. + new_chat_photo: Vec, + }, + DeleteChatPhoto { + /// Service message: the chat photo was deleted. + delete_chat_photo: True, + }, + GroupChatCreated { + /// Service message: the group has been created. + group_chat_created: True, + }, + SupergroupChatCreated { + /// Service message: the supergroup has been created. This field can‘t + /// be received in a message coming through updates, because bot can’t + /// be a member of a supergroup when it is created. It can only be + /// found in `reply_to_message` if someone replies to a very first + /// message in a directly created supergroup. + supergroup_chat_created: True, + }, + ChannelChatCreated { + /// Service message: the channel has been created. This field can‘t be + /// received in a message coming through updates, because bot can’t be + /// a member of a channel when it is created. It can only be found in + /// `reply_to_message` if someone replies to a very first message in a + /// channel. + channel_chat_created: True, + }, + Migrate { + /// The group has been migrated to a supergroup with the specified + /// identifier. This number may be greater than 32 bits and some + /// programming languages may have difficulty/silent defects in + /// interpreting it. But it is smaller than 52 bits, so a signed 64 bit + /// integer or double-precision float type are safe for storing this + /// identifier. + migrate_to_chat_id: i64, + + /// The supergroup has been migrated from a group with the specified + /// identifier. This number may be greater than 32 bits and some + /// programming languages may have difficulty/silent defects in + /// interpreting it. But it is smaller than 52 bits, so a signed 64 bit + /// integer or double-precision float type are safe for storing this + /// identifier. + migrate_from_chat_id: i64, + }, + Pinned { + /// Specified message was pinned. Note that the Message object in this + /// field will not contain further `reply_to_message` fields even if it + /// is itself a reply. + pinned: Box, + }, + Invoice { + /// Message is an invoice for a [payment], information about the + /// invoice. [More about payments »]. + /// + /// [payment]: https://core.telegram.org/bots/api#payments + /// [More about payments »]: https://core.telegram.org/bots/api#payments + invoice: Invoice, + }, + SuccessfulPayment { + /// Message is a service message about a successful payment, + /// information about the payment. [More about payments »]. + /// + /// [More about payments »]: https://core.telegram.org/bots/api#payments + successful_payment: SuccessfulPayment, + }, + ConnectedWebsite { + /// The domain name of the website on which the user has logged in. + /// [More about Telegram Login »]. + /// + /// [More about Telegram Login »]: https://core.telegram.org/widgets/login + connected_website: String, + }, + PassportData { + /// Telegram Passport data. + passport_data: PassportData, + }, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub enum ForwardedFrom { + #[serde(rename = "forward_from")] + User(User), + #[serde(rename = "forward_sender_name")] + SenderName(String), +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ForwardKind { + ChannelForward { + #[serde(rename = "forward_date")] + date: i32, + #[serde(rename = "forward_from_chat")] + chat: Chat, + #[serde(rename = "forward_from_message_id")] + message_id: i32, + #[serde(rename = "forward_signature")] + signature: Option, + }, + NonChannelForward { + #[serde(rename = "forward_date")] + date: i32, + #[serde(flatten)] + from: ForwardedFrom, + }, + Origin { + reply_to_message: Option>, + }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum MediaKind { + Animation { + /// Message is an animation, information about the animation. For + /// backward compatibility, when this field is set, the document field + /// will also be set. + animation: Animation, + + #[doc(hidden)] + /// "For backward compatibility" (c) Telegram Docs. + #[serde(skip)] + document: (), + + /// Caption for the animation, 0-1024 characters. + caption: Option, + + /// For messages with a caption, special entities like usernames, URLs, + /// bot commands, etc. that appear in the caption. + #[serde(default = "Vec::new")] + caption_entities: Vec, + }, + Audio { + /// Message is an audio file, information about the file. + audio: Audio, + + /// Caption for the audio, 0-1024 characters. + caption: Option, + + /// For messages with a caption, special entities like usernames, URLs, + /// bot commands, etc. that appear in the caption. + #[serde(default = "Vec::new")] + caption_entities: Vec, + }, + Contact { + /// Message is a shared contact, information about the contact. + contact: Contact, + }, + Document { + /// Message is a general file, information about the file. + document: Document, + + /// Caption for the document, 0-1024 characters. + caption: Option, + + /// For messages with a caption, special entities like usernames, URLs, + /// bot commands, etc. that appear in the caption. + #[serde(default = "Vec::new")] + caption_entities: Vec, + }, + Game { + /// Message is a game, information about the game. [More + /// about games »]. + /// + /// [More about games »]: https://core.telegram.org/bots/api#games + game: Game, + }, + Location { + /// Message is a shared location, information about the location. + location: Location, + }, + Photo { + /// Message is a photo, available sizes of the photo. + photo: Vec, + + /// Caption for the photo, 0-1024 characters. + caption: Option, + + /// For messages with a caption, special entities like usernames, URLs, + /// bot commands, etc. that appear in the caption. + #[serde(default = "Vec::new")] + caption_entities: Vec, + + /// The unique identifier of a media message group this message belongs + /// to. + media_group_id: Option, + }, + Poll { + /// Message is a native poll, information about the poll. + poll: Poll, + }, + Sticker { + /// Message is a sticker, information about the sticker. + sticker: Sticker, + }, + Text { + /// For text messages, the actual UTF-8 text of the message, 0-4096 + /// characters. + text: String, + + /// For text messages, special entities like usernames, URLs, bot + /// commands, etc. that appear in the text. + #[serde(default = "Vec::new")] + entities: Vec, + }, + Video { + /// Message is a video, information about the video. + video: Video, + + /// Caption for the video, 0-1024 characters. + caption: Option, + + /// For messages with a caption, special entities like usernames, URLs, + /// bot commands, etc. that appear in the caption. + #[serde(default = "Vec::new")] + caption_entities: Vec, + + /// The unique identifier of a media message group this message belongs + /// to. + media_group_id: Option, + }, + VideoNote { + /// Message is a [video note], information about the video message. + /// + /// [video note]: https://telegram.org/blog/video-messages-and-telescope + video_note: VideoNote, + }, + Voice { + /// Message is a voice message, information about the file. + voice: Voice, + + /// Caption for the voice, 0-1024 characters. + caption: Option, + + /// For messages with a caption, special entities like usernames, URLs, + /// bot commands, etc. that appear in the caption. + #[serde(default = "Vec::new")] + caption_entities: Vec, + }, + Venue { + /// Message is a venue, information about the venue. + venue: Venue, + }, +} + +mod getters { + use std::ops::Deref; + + use crate::types::{ + self, + message::{ + ForwardKind::{ChannelForward, NonChannelForward, Origin}, + MediaKind::{ + Animation, Audio, Contact, Document, Game, Location, Photo, + Poll, Sticker, Text, Venue, Video, VideoNote, Voice, + }, + MessageKind::{ + ChannelChatCreated, Common, ConnectedWebsite, DeleteChatPhoto, + GroupChatCreated, Invoice, LeftChatMember, Migrate, + NewChatMembers, NewChatPhoto, NewChatTitle, PassportData, + Pinned, SuccessfulPayment, SupergroupChatCreated, + }, + }, + Chat, ForwardedFrom, Message, MessageEntity, PhotoSize, True, User, + }; + + /// Getters for [Message] fields from [telegram docs]. + /// + /// [Message]: crate::types::Message + /// [telegram docs]: https://core.telegram.org/bots/api#message + impl Message { + /// NOTE: this is getter for both `from` and `author_signature` + pub fn from(&self) -> Option<&User> { + match &self.kind { + Common { from, .. } => from.as_ref(), + _ => None, + } + } + + pub fn chat_id(&self) -> i64 { + self.chat.id + } + + /// NOTE: this is getter for both `forward_from` and + /// `forward_sender_name` + pub fn forward_from(&self) -> Option<&ForwardedFrom> { + match &self.kind { + Common { + forward_kind: NonChannelForward { from, .. }, + .. + } => Some(from), + _ => None, + } + } + + pub fn forward_from_chat(&self) -> Option<&Chat> { + match &self.kind { + Common { + forward_kind: ChannelForward { chat, .. }, + .. + } => Some(chat), + _ => None, + } + } + + pub fn forward_from_message_id(&self) -> Option<&i32> { + match &self.kind { + Common { + forward_kind: ChannelForward { message_id, .. }, + .. + } => Some(message_id), + _ => None, + } + } + + pub fn forward_signature(&self) -> Option<&str> { + match &self.kind { + Common { + forward_kind: ChannelForward { signature, .. }, + .. + } => signature.as_ref().map(Deref::deref), + _ => None, + } + } + + pub fn forward_date(&self) -> Option<&i32> { + match &self.kind { + Common { + forward_kind: ChannelForward { date, .. }, + .. + } + | Common { + forward_kind: NonChannelForward { date, .. }, + .. + } => Some(date), + _ => None, + } + } + + pub fn reply_to_message(&self) -> Option<&Message> { + match &self.kind { + Common { + forward_kind: + Origin { + reply_to_message, .. + }, + .. + } => reply_to_message.as_ref().map(Deref::deref), + _ => None, + } + } + + pub fn edit_date(&self) -> Option<&i32> { + match &self.kind { + Common { edit_date, .. } => edit_date.as_ref(), + _ => None, + } + } + + pub fn media_group_id(&self) -> Option<&str> { + match &self.kind { + Common { + media_kind: Video { media_group_id, .. }, + .. + } + | Common { + media_kind: Photo { media_group_id, .. }, + .. + } => media_group_id.as_ref().map(Deref::deref), + _ => None, + } + } + + pub fn text(&self) -> Option<&str> { + match &self.kind { + Common { + media_kind: Text { text, .. }, + .. + } => Some(text), + _ => None, + } + } + + pub fn entities(&self) -> Option<&[MessageEntity]> { + match &self.kind { + Common { + media_kind: Text { entities, .. }, + .. + } => Some(entities), + _ => None, + } + } + + pub fn caption_entities(&self) -> Option<&[MessageEntity]> { + match &self.kind { + Common { + media_kind: + Animation { + caption_entities, .. + }, + .. + } + | Common { + media_kind: + Audio { + caption_entities, .. + }, + .. + } + | Common { + media_kind: + Document { + caption_entities, .. + }, + .. + } + | Common { + media_kind: + Photo { + caption_entities, .. + }, + .. + } + | Common { + media_kind: + Video { + caption_entities, .. + }, + .. + } + | Common { + media_kind: + Voice { + caption_entities, .. + }, + .. + } => Some(caption_entities), + _ => None, + } + } + + pub fn audio(&self) -> Option<&types::Audio> { + match &self.kind { + Common { + media_kind: Audio { audio, .. }, + .. + } => Some(audio), + _ => None, + } + } + + pub fn document(&self) -> Option<&types::Document> { + match &self.kind { + Common { + media_kind: Document { document, .. }, + .. + } => Some(document), + _ => None, + } + } + + pub fn animation(&self) -> Option<&types::Animation> { + match &self.kind { + Common { + media_kind: Animation { animation, .. }, + .. + } => Some(animation), + _ => None, + } + } + + pub fn game(&self) -> Option<&types::Game> { + match &self.kind { + Common { + media_kind: Game { game, .. }, + .. + } => Some(game), + _ => None, + } + } + + pub fn photo(&self) -> Option<&[PhotoSize]> { + match &self.kind { + Common { + media_kind: Photo { photo, .. }, + .. + } => Some(photo), + _ => None, + } + } + + pub fn sticker(&self) -> Option<&types::Sticker> { + match &self.kind { + Common { + media_kind: Sticker { sticker, .. }, + .. + } => Some(sticker), + _ => None, + } + } + + pub fn video(&self) -> Option<&types::Video> { + match &self.kind { + Common { + media_kind: Video { video, .. }, + .. + } => Some(video), + _ => None, + } + } + + pub fn voice(&self) -> Option<&types::Voice> { + match &self.kind { + Common { + media_kind: Voice { voice, .. }, + .. + } => Some(voice), + _ => None, + } + } + + pub fn video_note(&self) -> Option<&types::VideoNote> { + match &self.kind { + Common { + media_kind: VideoNote { video_note, .. }, + .. + } => Some(video_note), + _ => None, + } + } + + pub fn caption(&self) -> Option<&str> { + match &self.kind { + Common { media_kind, .. } => match media_kind { + Animation { caption, .. } + | Audio { caption, .. } + | Document { caption, .. } + | Photo { caption, .. } + | Video { caption, .. } + | Voice { caption, .. } => { + caption.as_ref().map(Deref::deref) + } + _ => None, + }, + _ => None, + } + } + + pub fn contact(&self) -> Option<&types::Contact> { + match &self.kind { + Common { + media_kind: Contact { contact }, + .. + } => Some(contact), + _ => None, + } + } + + pub fn location(&self) -> Option<&types::Location> { + match &self.kind { + Common { + media_kind: Location { location, .. }, + .. + } => Some(location), + _ => None, + } + } + + pub fn venue(&self) -> Option<&types::Venue> { + match &self.kind { + Common { + media_kind: Venue { venue, .. }, + .. + } => Some(venue), + _ => None, + } + } + + pub fn poll(&self) -> Option<&types::Poll> { + match &self.kind { + Common { + media_kind: Poll { poll, .. }, + .. + } => Some(poll), + _ => None, + } + } + + pub fn new_chat_members(&self) -> Option<&[User]> { + match &self.kind { + NewChatMembers { new_chat_members } => Some(new_chat_members), + _ => None, + } + } + + pub fn left_chat_member(&self) -> Option<&User> { + match &self.kind { + LeftChatMember { left_chat_member } => Some(left_chat_member), + _ => None, + } + } + + pub fn new_chat_title(&self) -> Option<&str> { + match &self.kind { + NewChatTitle { new_chat_title } => Some(new_chat_title), + _ => None, + } + } + + pub fn new_chat_photo(&self) -> Option<&[PhotoSize]> { + match &self.kind { + NewChatPhoto { new_chat_photo } => Some(new_chat_photo), + _ => None, + } + } + + // TODO: OK, `Option` is weird, can we do something with it? + // mb smt like `is_delete_chat_photo(&self) -> bool`? + pub fn delete_chat_photo(&self) -> Option { + match &self.kind { + DeleteChatPhoto { delete_chat_photo } => { + Some(*delete_chat_photo) + } + _ => None, + } + } + + pub fn group_chat_created(&self) -> Option { + match &self.kind { + GroupChatCreated { group_chat_created } => { + Some(*group_chat_created) + } + _ => None, + } + } + + pub fn super_group_chat_created(&self) -> Option { + match &self.kind { + SupergroupChatCreated { + supergroup_chat_created, + } => Some(*supergroup_chat_created), + _ => None, + } + } + + pub fn channel_chat_created(&self) -> Option { + match &self.kind { + ChannelChatCreated { + channel_chat_created, + } => Some(*channel_chat_created), + _ => None, + } + } + + pub fn migrate_to_chat_id(&self) -> Option { + match &self.kind { + Migrate { + migrate_to_chat_id, .. + } => Some(*migrate_to_chat_id), + _ => None, + } + } + + pub fn migrate_from_chat_id(&self) -> Option { + match &self.kind { + Migrate { + migrate_from_chat_id, + .. + } => Some(*migrate_from_chat_id), + _ => None, + } + } + + pub fn pinned_message(&self) -> Option<&Message> { + match &self.kind { + Pinned { pinned } => Some(pinned), + _ => None, + } + } + + pub fn invoice(&self) -> Option<&types::Invoice> { + match &self.kind { + Invoice { invoice } => Some(invoice), + _ => None, + } + } + + pub fn successful_payment(&self) -> Option<&types::SuccessfulPayment> { + match &self.kind { + SuccessfulPayment { successful_payment } => { + Some(successful_payment) + } + _ => None, + } + } + + pub fn connected_website(&self) -> Option<&str> { + match &self.kind { + ConnectedWebsite { connected_website } => { + Some(connected_website) + } + _ => None, + } + } + + pub fn passport_data(&self) -> Option<&types::PassportData> { + match &self.kind { + PassportData { passport_data } => Some(passport_data), + _ => None, + } + } + + pub fn reply_markup(&self) -> Option<&types::InlineKeyboardMarkup> { + match &self.kind { + Common { reply_markup, .. } => reply_markup.as_ref(), + _ => None, + } + } + } +} + +impl Message { + pub fn url(&self) -> Option { + match &self.chat.kind { + ChatKind::NonPrivate { + kind: + NonPrivateChatKind::Channel { + username: Some(username), + }, + .. + } + | ChatKind::NonPrivate { + kind: + NonPrivateChatKind::Supergroup { + username: Some(username), + .. + }, + .. + } => Some( + reqwest::Url::parse( + format!("https://t.me/{0}/{1}/", username, self.id) + .as_str(), + ) + .unwrap(), + ), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::from_str; + + use crate::types::*; + + #[test] + fn de_media_forwarded() { + let json = r#"{ + "message_id": 198283, + "from": { + "id": 250918540, + "is_bot": false, + "first_name": "Андрей", + "last_name": "Власов", + "username": "aka_dude", + "language_code": "en" + }, + "chat": { + "id": 250918540, + "first_name": "Андрей", + "last_name": "Власов", + "username": "aka_dude", + "type": "private" + }, + "date": 1567927221, + "video": { + "duration": 13, + "width": 512, + "height": 640, + "mime_type": "video/mp4", + "thumb": { + "file_id": "AAQCAAOmBAACBf2oS53pByA-I4CWWCObDwAEAQAHbQADMWcAAhYE", + "file_unique_id":"", + "file_size": 10339, + "width": 256, + "height": 320 + }, + "file_id": "BAADAgADpgQAAgX9qEud6QcgPiOAlhYE", + "file_unique_id":"", + "file_size": 1381334 + } + }"#; + let message = from_str::(json); + assert!(message.is_ok()); + } + + #[test] + fn de_media_group_forwarded() { + let json = r#"{ + "message_id": 198283, + "from": { + "id": 250918540, + "is_bot": false, + "first_name": "Андрей", + "last_name": "Власов", + "username": "aka_dude", + "language_code": "en" + }, + "chat": { + "id": 250918540, + "first_name": "Андрей", + "last_name": "Власов", + "username": "aka_dude", + "type": "private" + }, + "date": 1567927221, + "media_group_id": "12543417770506682", + "video": { + "duration": 13, + "width": 512, + "height": 640, + "mime_type": "video/mp4", + "thumb": { + "file_id": "AAQCAAOmBAACBf2oS53pByA-I4CWWCObDwAEAQAHbQADMWcAAhYE", + "file_unique_id":"", + "file_size": 10339, + "width": 256, + "height": 320 + }, + "file_id": "BAADAgADpgQAAgX9qEud6QcgPiOAlhYE", + "file_unique_id":"", + "file_size": 1381334 + } + }"#; + let message = from_str::(json); + assert!(message.is_ok()); + } + + #[test] + fn de_text() { + let json = r#"{ + "message_id": 199785, + "from": { + "id": 250918540, + "is_bot": false, + "first_name": "Андрей", + "last_name": "Власов", + "username": "aka_dude", + "language_code": "en" + }, + "chat": { + "id": 250918540, + "first_name": "Андрей", + "last_name": "Власов", + "username": "aka_dude", + "type": "private" + }, + "date": 1568289890, + "text": "Лол кек 😂" + }"#; + let message = from_str::(json); + assert!(message.is_ok()); + } + + #[test] + fn de_sticker() { + let json = r#"{ + "message_id": 199787, + "from": { + "id": 250918540, + "is_bot": false, + "first_name": "Андрей", + "last_name": "Власов", + "username": "aka_dude", + "language_code": "en" + }, + "chat": { + "id": 250918540, + "first_name": "Андрей", + "last_name": "Власов", + "username": "aka_dude", + "type": "private" + }, + "date": 1568290188, + "sticker": { + "width": 512, + "height": 512, + "emoji": "😡", + "set_name": "AdvenTimeAnim", + "is_animated": true, + "thumb": { + "file_id": "AAQCAAMjAAOw0PgMaabKAcaXKCBLubkPAAQBAAdtAAPGKwACFgQ", + "file_unique_id":"", + "file_size": 4118, + "width": 128, + "height": 128 + }, + "file_id": "CAADAgADIwADsND4DGmmygHGlyggFgQ", + "file_unique_id":"", + "file_size": 16639 + } + }"#; + let message = from_str::(json); + assert!(message.is_ok()); + } + + #[test] + fn de_image() { + let json = r#"{ + "message_id": 199791, + "from": { + "id": 250918540, + "is_bot": false, + "first_name": "Андрей", + "last_name": "Власов", + "username": "aka_dude", + "language_code": "en" + }, + "chat": { + "id": 250918540, + "first_name": "Андрей", + "last_name": "Власов", + "username": "aka_dude", + "type": "private" + }, + "date": 1568290622, + "photo": [ + { + "file_id": "AgADAgAD36sxG-PX0UvQSXIn9rccdw-ACA4ABAEAAwIAA20AAybcBAABFgQ", + "file_unique_id":"", + "file_size": 18188, + "width": 320, + "height": 239 + }, + { + "file_id": "AgADAgAD36sxG-PX0UvQSXIn9rccdw-ACA4ABAEAAwIAA3gAAyfcBAABFgQ", + "file_unique_id":"", + "file_size": 62123, + "width": 800, + "height": 598 + }, + { + "file_id": "AgADAgAD36sxG-PX0UvQSXIn9rccdw-ACA4ABAEAAwIAA3kAAyTcBAABFgQ", + "file_unique_id":"", + "file_size": 75245, + "width": 962, + "height": 719 + } + ] + }"#; + let message = from_str::(json); + assert!(message.is_ok()); + } +} diff --git a/src/types/message_entity.rs b/src/types/message_entity.rs new file mode 100644 index 00000000..31627b12 --- /dev/null +++ b/src/types/message_entity.rs @@ -0,0 +1,141 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::{Message, User}; + +/// This object represents one special entity in a text message. +/// +/// For example, hashtags, usernames, URLs, etc. +/// +/// [The official docs](https://core.telegram.org/bots/api#messageentity). +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct MessageEntity { + #[serde(flatten)] + pub kind: MessageEntityKind, + + /// Offset in UTF-16 code units to the start of the entity. + pub offset: usize, + + /// Length of the entity in UTF-16 code units. + pub length: usize, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[serde(tag = "type")] +pub enum MessageEntityKind { + Mention, + Hashtag, + Cashtag, + BotCommand, + Url, + Email, + PhoneNumber, + Bold, + Italic, + Code, + Pre { language: Option }, + TextLink { url: String }, + TextMention { user: User }, + Underline, + Strikethrough, +} + +impl MessageEntity { + pub fn text_from(&self, message: &Message) -> Option { + let text = message.text(); + Some(String::from(&text?[self.offset..self.offset + self.length])) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{Chat, ChatKind, ForwardKind, MediaKind, MessageKind}; + + #[test] + fn recursive_kind() { + use serde_json::from_str; + + assert_eq!( + MessageEntity { + kind: MessageEntityKind::TextLink { + url: "ya.ru".into() + }, + offset: 1, + length: 2, + }, + from_str::( + r#"{"type":"text_link","url":"ya.ru","offset":1,"length":2}"# + ) + .unwrap() + ); + } + + #[test] + fn pre() { + use serde_json::from_str; + + assert_eq!( + MessageEntity { + kind: MessageEntityKind::Pre { + language: Some("rust".to_string()), + }, + offset: 1, + length: 2, + }, + from_str::( + r#"{"type":"pre","url":"ya.ru","offset":1,"length":2,"language":"rust"}"# + ) + .unwrap() + ); + } + + #[test] + fn text_from() { + let message = message(); + let expected = Some("yes".to_string()); + let entity = message.entities().unwrap()[0].clone(); + let actual = entity.text_from(&message); + assert_eq!(actual, expected); + } + + fn message() -> Message { + Message { + id: 0, + date: 0, + chat: Chat { + id: 0, + kind: ChatKind::Private { + type_: (), + username: None, + first_name: None, + last_name: None, + }, + photo: None, + }, + kind: MessageKind::Common { + from: Some(User { + id: 0, + is_bot: false, + first_name: "".to_string(), + last_name: None, + username: None, + language_code: None, + }), + forward_kind: ForwardKind::Origin { + reply_to_message: None, + }, + edit_date: None, + media_kind: MediaKind::Text { + text: "no yes no".to_string(), + entities: vec![MessageEntity { + kind: MessageEntityKind::Mention, + offset: 3, + length: 3, + }], + }, + reply_markup: None, + }, + } + } +} diff --git a/src/types/mod.rs b/src/types/mod.rs new file mode 100644 index 00000000..d917c94c --- /dev/null +++ b/src/types/mod.rs @@ -0,0 +1,184 @@ +//! API types. + +pub use allowed_update::*; +pub use animation::*; +pub use audio::*; +pub use callback_game::*; +pub use callback_query::*; +pub use chat::*; +pub use chat_action::*; +pub use chat_id::*; +pub use chat_member::*; +pub use chat_or_inline_message::*; +pub use chat_permissions::*; +pub use chat_photo::*; +pub use chosen_inline_result::*; +pub use contact::*; +pub use document::*; +pub use encrypted_credentials::*; +pub use encrypted_passport_element::*; +pub use file::*; +pub use force_reply::*; +pub use game::*; +pub use game_high_score::*; +pub use inline_keyboard_button::*; +pub use inline_keyboard_markup::*; +pub use inline_query::*; +pub use inline_query_result::*; +pub use inline_query_result_article::*; +pub use inline_query_result_audio::*; +pub use inline_query_result_cached_audio::*; +pub use inline_query_result_cached_document::*; +pub use inline_query_result_cached_gif::*; +pub use inline_query_result_cached_mpeg4_gif::*; +pub use inline_query_result_cached_photo::*; +pub use inline_query_result_cached_sticker::*; +pub use inline_query_result_cached_video::*; +pub use inline_query_result_cached_voice::*; +pub use inline_query_result_contact::*; +pub use inline_query_result_document::*; +pub use inline_query_result_game::*; +pub use inline_query_result_gif::*; +pub use inline_query_result_location::*; +pub use inline_query_result_mpeg4_gif::*; +pub use inline_query_result_photo::*; +pub use inline_query_result_venue::*; +pub use inline_query_result_video::*; +pub use inline_query_result_voice::*; +pub use input_file::*; +pub use input_media::*; +pub use input_message_content::*; +pub use invoice::*; +pub use keyboard_button::*; +pub use keyboard_button_poll_type::*; +pub use label_price::*; +pub use location::*; +pub use login_url::*; +pub use mask_position::*; +pub use message::*; +pub use message_entity::*; +pub use order_info::*; +pub use parse_mode::*; +pub use passport_data::*; +pub use passport_element_error::*; +pub use passport_file::*; +pub use photo_size::*; +pub use poll::*; +pub use poll_answer::*; +pub use poll_type::*; +pub use pre_checkout_query::*; +pub use reply_keyboard_markup::*; +pub use reply_keyboard_remove::*; +pub use reply_markup::*; +pub use response_parameters::*; +pub use send_invoice::*; +pub use shipping_address::*; +pub use shipping_option::*; +pub use shipping_query::*; +pub use sticker::*; +pub use sticker_set::*; +pub use successful_payment::*; +pub use unit_false::*; +pub use unit_true::*; +pub use update::*; +pub use user::*; +pub use user_profile_photos::*; +pub use venue::*; +pub use video::*; +pub use video_note::*; +pub use voice::*; +pub use webhook_info::*; + +mod allowed_update; +mod animation; +mod audio; +mod callback_game; +mod callback_query; +mod chat; +mod chat_action; +mod chat_id; +mod chat_member; +mod chat_or_inline_message; +mod chat_permissions; +mod chat_photo; +mod chosen_inline_result; +mod contact; +mod document; +mod file; +mod force_reply; +mod game; +mod game_high_score; +mod inline_keyboard_button; +mod inline_keyboard_markup; +mod input_file; +mod input_media; +mod input_message_content; +mod invoice; +mod keyboard_button; +mod keyboard_button_poll_type; +mod label_price; +mod location; +mod login_url; +mod mask_position; +mod message; +mod message_entity; +mod order_info; +mod parse_mode; +mod photo_size; +mod poll; +mod poll_answer; +mod poll_type; +mod pre_checkout_query; +mod reply_keyboard_markup; +mod reply_keyboard_remove; +mod reply_markup; +mod response_parameters; +mod send_invoice; +mod shipping_address; +mod shipping_option; +mod shipping_query; +mod sticker; +mod sticker_set; +mod successful_payment; +mod unit_false; +mod unit_true; +mod update; +mod user; +mod user_profile_photos; +mod venue; +mod video; +mod video_note; +mod voice; +mod webhook_info; + +mod inline_query; +mod inline_query_result; +mod inline_query_result_article; +mod inline_query_result_audio; +mod inline_query_result_cached_audio; +mod inline_query_result_cached_document; +mod inline_query_result_cached_gif; +mod inline_query_result_cached_mpeg4_gif; +mod inline_query_result_cached_photo; +mod inline_query_result_cached_sticker; +mod inline_query_result_cached_video; +mod inline_query_result_cached_voice; +mod inline_query_result_contact; +mod inline_query_result_document; +mod inline_query_result_game; +mod inline_query_result_gif; +mod inline_query_result_location; +mod inline_query_result_mpeg4_gif; +mod inline_query_result_photo; +mod inline_query_result_venue; +mod inline_query_result_video; +mod inline_query_result_voice; + +mod encrypted_credentials; +mod encrypted_passport_element; +mod passport_data; +mod passport_element_error; +mod passport_file; + +pub use non_telegram_types::*; +mod non_telegram_types; diff --git a/src/types/non_telegram_types/country_code.rs b/src/types/non_telegram_types/country_code.rs new file mode 100644 index 00000000..4f7705ac --- /dev/null +++ b/src/types/non_telegram_types/country_code.rs @@ -0,0 +1,254 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub enum CountryCode { + AD, + AE, + AF, + AG, + AI, + AL, + AM, + AO, + AQ, + AR, + AS, + AT, + AU, + AW, + AX, + AZ, + BA, + BB, + BD, + BE, + BF, + BG, + BH, + BI, + BJ, + BL, + BM, + BN, + BO, + BQ, + BR, + BS, + BT, + BV, + BW, + BY, + BZ, + CA, + CC, + CD, + CF, + CG, + CH, + CI, + CK, + CL, + CM, + CN, + CO, + CR, + CU, + CV, + CW, + CX, + CY, + CZ, + DE, + DJ, + DK, + DM, + DO, + DZ, + EC, + EE, + EG, + EH, + ER, + ES, + ET, + FI, + FJ, + FK, + FM, + FO, + FR, + GA, + GB, + GD, + GE, + GF, + GG, + GH, + GI, + GL, + GM, + GN, + GP, + GQ, + GR, + GS, + GT, + GU, + GW, + GY, + HK, + HM, + HN, + HR, + HT, + HU, + ID, + IE, + IL, + IM, + IN, + IO, + IQ, + IR, + IS, + IT, + JE, + JM, + JO, + JP, + KE, + KG, + KH, + KI, + KM, + KN, + KP, + KR, + KW, + KY, + KZ, + LA, + LB, + LC, + LI, + LK, + LR, + LS, + LT, + LU, + LV, + LY, + MA, + MC, + MD, + ME, + MF, + MG, + MH, + MK, + ML, + MM, + MN, + MO, + MP, + MQ, + MR, + MS, + MT, + MU, + MV, + MW, + MX, + MY, + MZ, + NA, + NC, + NE, + NF, + NG, + NI, + NL, + NO, + NP, + NR, + NU, + NZ, + OM, + PA, + PE, + PF, + PG, + PH, + PK, + PL, + PM, + PN, + PR, + PS, + PT, + PW, + PY, + QA, + RE, + RO, + RS, + RU, + RW, + SA, + SB, + SC, + SD, + SE, + SG, + SH, + SI, + SJ, + SK, + SL, + SM, + SN, + SO, + SR, + SS, + ST, + SV, + SX, + SY, + SZ, + TC, + TD, + TF, + TG, + TH, + TJ, + TK, + TL, + TM, + TN, + TO, + TR, + TT, + TV, + TW, + TZ, + UA, + UG, + UM, + US, + UY, + UZ, + VA, + VC, + VE, + VG, + VI, + VN, + VU, + WF, + WS, + YE, + YT, + ZA, + ZM, + ZW, +} diff --git a/src/types/non_telegram_types/currency.rs b/src/types/non_telegram_types/currency.rs new file mode 100644 index 00000000..0cdebce6 --- /dev/null +++ b/src/types/non_telegram_types/currency.rs @@ -0,0 +1,89 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub enum Currency { + AED, + AFN, + ALL, + AMD, + ARS, + AUD, + AZN, + BAM, + BDT, + BGN, + BND, + BOB, + BRL, + CAD, + CHF, + CLP, + CNY, + COP, + CRC, + CZK, + DKK, + DOP, + DZD, + EGP, + EUR, + GBP, + GEL, + GTQ, + HKD, + HNL, + HRK, + HUF, + IDR, + ILS, + INR, + ISK, + JMD, + JPY, + KES, + KGS, + KRW, + KZT, + LBP, + LKR, + MAD, + MDL, + MNT, + MUR, + MVR, + MXN, + MYR, + MZN, + NGN, + NIO, + NOK, + NPR, + NZD, + PAB, + PEN, + PHP, + PKR, + PLN, + PYG, + QAR, + RON, + RSD, + RUB, + SAR, + SEK, + SGD, + THB, + TJS, + TRY, + TTD, + TWD, + TZS, + UAH, + UGX, + USD, + UYU, + UZS, + VND, + YER, + ZAR, +} diff --git a/src/types/non_telegram_types/language_code.rs b/src/types/non_telegram_types/language_code.rs new file mode 100644 index 00000000..1837db6e --- /dev/null +++ b/src/types/non_telegram_types/language_code.rs @@ -0,0 +1,190 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LanguageCode { + AA, + AB, + AE, + AF, + AK, + AM, + AN, + AR, + AS, + AV, + AY, + AZ, + BA, + BE, + BG, + BH, + BI, + BM, + BN, + BO, + BR, + BS, + CA, + CE, + CH, + CO, + CR, + CS, + CU, + CV, + CY, + DA, + DE, + DV, + DZ, + EE, + EL, + EN, + EO, + ES, + ET, + EU, + FA, + FF, + FI, + FJ, + FO, + FR, + FY, + GA, + GD, + GL, + GN, + GU, + GV, + HA, + HE, + HI, + HO, + HR, + HT, + HU, + HY, + HZ, + IA, + ID, + IE, + IG, + II, + IK, + IO, + IS, + IT, + IU, + JA, + JV, + KA, + KG, + KI, + KJ, + KK, + KL, + KM, + KN, + KO, + KR, + KS, + KU, + KV, + KW, + KY, + LA, + LB, + LG, + LI, + LN, + LO, + LT, + LU, + LV, + MG, + MH, + MI, + MK, + ML, + MN, + MR, + MS, + MT, + MY, + NA, + NB, + ND, + NE, + NG, + NL, + NN, + NO, + NR, + NV, + NY, + OC, + OJ, + OM, + OR, + OS, + PA, + PI, + PL, + PS, + PT, + QU, + RM, + RN, + RO, + RU, + RW, + SA, + SC, + SD, + SE, + SG, + SI, + SK, + SL, + SM, + SN, + SO, + SQ, + SR, + SS, + ST, + SU, + SV, + SW, + TA, + TE, + TG, + TH, + TI, + TK, + TL, + TN, + TO, + TR, + TS, + TT, + TW, + TY, + UG, + UK, + UR, + UZ, + VE, + VI, + VO, + WA, + WO, + XH, + YI, + YO, + ZA, + ZH, + ZU, +} diff --git a/src/types/non_telegram_types/mime_wrapper.rs b/src/types/non_telegram_types/mime_wrapper.rs new file mode 100644 index 00000000..d45a2dfb --- /dev/null +++ b/src/types/non_telegram_types/mime_wrapper.rs @@ -0,0 +1,55 @@ +use derive_more::From; +use mime::Mime; +use serde::{ + de::Visitor, export::Formatter, Deserialize, Deserializer, Serialize, + Serializer, +}; + +/// Serializable & deserializable `MIME` wrapper. +#[derive(Clone, Debug, Eq, Hash, PartialEq, From)] +pub struct MimeWrapper(pub Mime); + +impl Serialize for MimeWrapper { + fn serialize( + &self, + serializer: S, + ) -> Result<::Ok, ::Error> + where + S: Serializer, + { + serializer.serialize_str(self.0.as_ref()) + } +} + +struct MimeVisitor; +impl<'a> Visitor<'a> for MimeVisitor { + type Value = MimeWrapper; + + fn expecting( + &self, + formatter: &mut Formatter<'_>, + ) -> Result<(), serde::export::fmt::Error> { + formatter.write_str("mime type") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + match v.parse::() { + Ok(mime_type) => Ok(MimeWrapper(mime_type)), + Err(e) => Err(E::custom(e)), + } + } +} + +impl<'de> Deserialize<'de> for MimeWrapper { + fn deserialize( + deserializer: D, + ) -> Result>::Error> + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(MimeVisitor) + } +} diff --git a/src/types/non_telegram_types/mod.rs b/src/types/non_telegram_types/mod.rs new file mode 100644 index 00000000..2202122b --- /dev/null +++ b/src/types/non_telegram_types/mod.rs @@ -0,0 +1,9 @@ +pub use country_code::*; +pub use currency::*; +pub use language_code::*; +pub use mime_wrapper::*; + +mod country_code; +mod currency; +mod language_code; +mod mime_wrapper; diff --git a/src/types/order_info.rs b/src/types/order_info.rs new file mode 100644 index 00000000..e5b35bbc --- /dev/null +++ b/src/types/order_info.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::ShippingAddress; + +/// This object represents information about an order. +/// +/// [The official docs](https://core.telegram.org/bots/api#orderinfo). +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct OrderInfo { + /// User's name. + pub name: String, + + /// User's phone number. + pub phone_number: String, + + /// User's email. + pub email: String, + + /// User's shipping address. + pub shipping_address: ShippingAddress, +} diff --git a/src/types/parse_mode.rs b/src/types/parse_mode.rs new file mode 100644 index 00000000..862c7c02 --- /dev/null +++ b/src/types/parse_mode.rs @@ -0,0 +1,188 @@ +// see https://github.com/rust-lang/rust/issues/38832 +// (for built ins there no warnings, but for (De)Serialize, there are) +#![allow(deprecated)] + +use std::{ + convert::{TryFrom, TryInto}, + str::FromStr, +}; + +use serde::{Deserialize, Serialize}; + +/// Formatting options. +/// +/// The Bot API supports basic formatting for messages. You can use bold, +/// italic, underlined and strikethrough text, as well as inline links and +/// pre-formatted code in your bots' messages. Telegram clients will render +/// them accordingly. You can use either markdown-style or HTML-style +/// formatting. +/// +/// Note that Telegram clients will display an **alert** to the user before +/// opening an inline link (‘Open this link?’ together with the full URL). +/// +/// Links `tg://user?id=` can be used to mention a user by their ID +/// without using a username. Please note: +/// +/// - These links will work **only** if they are used inside an inline link. For +/// example, they will not work, when used in an inline keyboard button or in +/// a message text. +/// - These mentions are only guaranteed to work if the user has contacted the +/// bot in the past, has sent a callback query to the bot via inline button or +/// is a member in the group where he was mentioned. +/// +/// ## MarkdownV2 style +/// +/// To use this mode, pass [`MarkdownV2`] in the `parse_mode` field. +/// Use the following syntax in your message: +/// ````text +/// *bold \*text* +/// _italic \*text_ +/// __underline__ +/// ~strikethrough~ +/// *bold _italic bold ~italic bold strikethrough~ __underline italic bold___ bold* +/// [inline URL](http://www.example.com/) +/// [inline mention of a user](tg://user?id=123456789) +/// `inline fixed-width code` +/// ``` +/// pre-formatted fixed-width code block +/// ``` +/// ```rust +/// pre-formatted fixed-width code block written in the Rust programming +/// language ``` +/// ```` +/// +/// Please note: +/// - Any character between 1 and 126 inclusively can be escaped anywhere with a +/// preceding '\' character, in which case it is treated as an ordinary +/// character and not a part of the markup. +/// - Inside `pre` and `code` entities, all '`‘ and ’\‘ characters must be +/// escaped with a preceding ’\' character. +/// - Inside `(...)` part of inline link definition, all ')‘ and ’\‘ must be +/// escaped with a preceding ’\' character. +/// - In all other places characters ’_‘, ’*‘, ’[‘, ’]‘, ’(‘, ’)‘, ’~‘, ’`‘, +/// ’>‘, ’#‘, ’+‘, ’+‘, ’-‘, ’|‘, ’{‘, ’}‘, ’.‘, ’!‘ must be escaped with the +/// preceding character ’\'. +/// - In case of ambiguity between `italic` and `underline` entities ‘__’ is +/// always greadily treated from left to right as beginning or end of +/// `underline` entity, so instead of `___italic underline___` use `___italic +/// underline_\r__`, where `\r` is a character with code `13`, which will be +/// ignored. +/// +/// ## HTML style +/// To use this mode, pass [`HTML`] in the `parse_mode` field. +/// The following tags are currently supported: +/// ````text +/// bold, bold +/// italic, italic +/// underline, underline +/// strikethrough, strikethrough, +/// strikethrough bold italic bold italic bold +/// strikethrough underline italic bold bold inline URL +/// inline mention of a user +/// inline fixed-width code +///
pre-formatted fixed-width code block
+///
pre-formatted fixed-width code block
+/// written in the Rust programming language
```` +/// +/// Please note: +/// +/// - Only the tags mentioned above are currently supported. +/// - All `<`, `>` and `&` symbols that are not a part of a tag or an HTML +/// entity must be replaced with the corresponding HTML entities (`<` with +/// `<`, `>` with `>` and `&` with `&`). +/// - All numerical HTML entities are supported. +/// - The API currently supports only the following named HTML entities: `<`, +/// `>`, `&` and `"`. +/// - Use nested `pre` and `code` tags, to define programming language for `pre` +/// entity. +/// - Programming language can't be specified for standalone `code` tags. +/// +/// ## Markdown style +/// This is a legacy mode, retained for backward compatibility. To use this +/// mode, pass [`Markdown`] in the `parse_mode` field. +/// Use the following syntax in your message: +/// ````text +/// *bold text* +/// _italic text_ +/// [inline URL](http://www.example.com/) +/// [inline mention of a user](tg://user?id=123456789) +/// `inline fixed-width code` +/// ```rust +/// pre-formatted fixed-width code block written in the Rust programming +/// language ``` +/// ```` +/// +/// Please note: +/// - Entities must not be nested, use parse mode [`MarkdownV2`] instead. +/// - There is no way to specify underline and strikethrough entities, use parse +/// mode [`MarkdownV2`] instead. +/// - To escape characters ’_‘, ’*‘, ’`‘, ’[‘ outside of an entity, prepend the +/// characters ’\' before them. +/// - Escaping inside entities is not allowed, so entity must be closed first +/// and reopened again: use `_snake_\__case_` for italic `snake_case` and +/// `*2*\**2=4*` for bold `2*2=4`. +/// +/// [`MarkdownV2`]: ParseMode::MarkdownV2 +/// [`HTML`]: ParseMode::HTML +/// [`Markdown`]: ParseMode::Markdown +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub enum ParseMode { + MarkdownV2, + HTML, + #[deprecated = "This is a legacy mode, retained for backward \ + compatibility. Use `MarkdownV2` instead."] + Markdown, +} + +impl TryFrom<&str> for ParseMode { + type Error = (); + + fn try_from(value: &str) -> Result { + let normalized = value.to_lowercase(); + match normalized.as_ref() { + "html" => Ok(ParseMode::HTML), + "markdown" => Ok(ParseMode::Markdown), + "markdownv2" => Ok(ParseMode::MarkdownV2), + _ => Err(()), + } + } +} + +impl TryFrom for ParseMode { + type Error = (); + + fn try_from(value: String) -> Result { + value.as_str().try_into() + } +} + +impl FromStr for ParseMode { + type Err = (); + + fn from_str(s: &str) -> Result { + s.try_into() + } +} + +#[cfg(test)] +mod tests { + #![allow(deprecated)] + + use super::*; + + #[test] + fn html_serialization() { + let expected_json = String::from(r#""HTML""#); + let actual_json = serde_json::to_string(&ParseMode::HTML).unwrap(); + + assert_eq!(expected_json, actual_json) + } + + #[test] + fn markdown_serialization() { + let expected_json = String::from(r#""Markdown""#); + let actual_json = serde_json::to_string(&ParseMode::Markdown).unwrap(); + + assert_eq!(expected_json, actual_json) + } +} diff --git a/src/types/passport_data.rs b/src/types/passport_data.rs new file mode 100644 index 00000000..5e548f57 --- /dev/null +++ b/src/types/passport_data.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; + +use super::{EncryptedCredentials, EncryptedPassportElement}; + +/// Contains information about Telegram Passport data shared with the bot by the +/// user. +/// +/// [The official docs](https://core.telegram.org/bots/api#passportdata). +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct PassportData { + /// Array with information about documents and other Telegram Passport + /// elements that was shared with the bot. + pub data: Vec, + + /// Encrypted credentials required to decrypt the data. + pub credentials: EncryptedCredentials, +} diff --git a/src/types/passport_element_error.rs b/src/types/passport_element_error.rs new file mode 100644 index 00000000..9657969f --- /dev/null +++ b/src/types/passport_element_error.rs @@ -0,0 +1,267 @@ +use serde::{Deserialize, Serialize}; + +/// This object represents an error in the Telegram Passport element which was +/// submitted that should be resolved by the user. +/// +/// [The official docs](https://core.telegram.org/bots/api#passportelementerror). +#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)] +pub struct PassportElementError { + /// Error message. + message: String, + + #[serde(flatten)] + kind: PassportElementErrorKind, +} + +// TODO: use different types? +#[serde(tag = "source")] +#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)] +pub enum PassportElementErrorKind { + /// Represents an issue in one of the data fields that was provided by the + /// user. The error is considered resolved when the field's value changes. + /// + /// [The official docs](https://core.telegram.org/bots/api#passportelementerrordatafield). + #[serde(rename = "data")] + DataField { + /// The section of the user's Telegram Passport which has the error. + r#type: PassportElementErrorDataFieldType, + + /// Name of the data field which has the error. + field_name: String, + + /// Base64-encoded data hash. + data_hash: String, + }, + + /// Represents an issue with the front side of a document. The error is + /// considered resolved when the file with the front side of the document + /// changes. + /// + /// [The official docs](https://core.telegram.org/bots/api#passportelementerrorfrontside). + #[serde(rename = "snake_case")] + FrontSide { + /// The section of the user's Telegram Passport which has the issue. + r#type: PassportElementErrorFrontSideType, + + /// Base64-encoded hash of the file with the front side of the + /// document. + file_hash: String, + }, + + /// Represents an issue with the reverse side of a document. The error is + /// considered resolved when the file with reverse side of the document + /// changes. + /// + /// [The official docs](https://core.telegram.org/bots/api#passportelementerrorreverseside). + #[serde(rename = "snake_case")] + ReverseSide { + /// The section of the user's Telegram Passport which has the issue. + r#type: PassportElementErrorReverseSideType, + + //// Base64-encoded hash of the file with the reverse side of the + //// document. + file_hash: String, + }, + + //// Represents an issue with the selfie with a document. The error is + //// considered resolved when the file with the selfie changes. + /// + /// [The official docs](https://core.telegram.org/bots/api#passportelementerrorselfie). + #[serde(rename = "snake_case")] + Selfie { + /// The section of the user's Telegram Passport which has the issue. + r#type: PassportElementErrorSelfieType, + + /// Base64-encoded hash of the file with the selfie. + file_hash: String, + }, + + /// Represents an issue with a document scan. The error is considered + /// resolved when the file with the document scan changes. + /// + /// [The official docs](https://core.telegram.org/bots/api#passportelementerrorfile). + #[serde(rename = "snake_case")] + File { + /// The section of the user's Telegram Passport which has the issue. + r#type: PassportElementErrorFileType, + + /// Base64-encoded file hash. + file_hash: String, + }, + + /// Represents an issue with a list of scans. The error is considered + /// resolved when the list of files containing the scans changes. + /// + /// [The official docs](https://core.telegram.org/bots/api#passportelementerrorfiles). + #[serde(rename = "snake_case")] + Files { + /// The section of the user's Telegram Passport which has the issue. + r#type: PassportElementErrorFilesType, + + /// List of base64-encoded file hashes. + file_hashes: Vec, + }, + + /// Represents an issue with one of the files that constitute the + /// translation of a document. The error is considered resolved when the + /// file changes. + /// + /// [The official docs](https://core.telegram.org/bots/api#passportelementerrortranslationfile). + #[serde(rename = "snake_case")] + TranslationFile { + /// Type of element of the user's Telegram Passport which has the + /// issue. + r#type: PassportElementErrorTranslationFileType, + + /// Base64-encoded file hash. + file_hash: String, + }, + + /// Represents an issue with the translated version of a document. The + /// error is considered resolved when a file with the document translation + /// change. + /// + /// [The official docs](https://core.telegram.org/bots/api#passportelementerrortranslationfiles). + #[serde(rename = "snake_case")] + TranslationFiles { + /// Type of element of the user's Telegram Passport which has the issue + r#type: PassportElementErrorTranslationFilesType, + + /// List of base64-encoded file hashes + file_hashes: Vec, + }, + + /// Represents an issue in an unspecified place. The error is considered + /// resolved when new data is added. + /// + /// [The official docs](https://core.telegram.org/bots/api#passportelementerrorunspecified). + #[serde(rename = "snake_case")] + Unspecified { + /// Type of element of the user's Telegram Passport which has the + /// issue. + r#type: PassportElementErrorUnspecifiedType, + + /// Base64-encoded element hash. + element_hash: String, + }, +} + +#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PassportElementErrorDataFieldType { + PersonalDetails, + Passport, + DriverLicense, + IdentityCard, + InternalPassport, + Address, +} + +#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PassportElementErrorFrontSideType { + Passport, + DriverLicense, + IdentityCard, + InternalPassport, +} + +#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PassportElementErrorReverseSideType { + DriverLicense, + IdentityCard, +} + +#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PassportElementErrorSelfieType { + Passport, + DriverLicense, + IdentityCard, + InternalPassport, +} + +#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PassportElementErrorFileType { + UtilityBill, + BankStatement, + RentalAgreement, + PassportRegistration, + TemporaryRegistration, +} + +#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PassportElementErrorFilesType { + UtilityBill, + BankStatement, + RentalAgreement, + PassportRegistration, + TemporaryRegistration, +} + +#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PassportElementErrorTranslationFileType { + Passport, + DriverLicense, + IdentityCard, + InternalPassport, + UtilityBill, + BankStatement, + RentalAgreement, + PassportRegistration, + TemporaryRegistration, +} + +#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PassportElementErrorTranslationFilesType { + Passport, + DriverLicense, + IdentityCard, + InternalPassport, + UtilityBill, + BankStatement, + RentalAgreement, + PassportRegistration, + TemporaryRegistration, +} + +#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PassportElementErrorUnspecifiedType { + DataField, + FrontSide, + ReverseSide, + Selfie, + File, + Files, + TranslationFile, + TranslationFiles, + Unspecified, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serialize_data_field() { + let data = PassportElementError { + message: "This is an error message!".to_owned(), + kind: PassportElementErrorKind::DataField { + r#type: PassportElementErrorDataFieldType::InternalPassport, + field_name: "The field name".to_owned(), + data_hash: "This is a data hash".to_owned(), + }, + }; + + assert_eq!( + serde_json::to_string(&data).unwrap(), + r#"{"message":"This is an error message!","source":"data","type":"internal_passport","field_name":"The field name","data_hash":"This is a data hash"}"# + ); + } +} diff --git a/src/types/passport_file.rs b/src/types/passport_file.rs new file mode 100644 index 00000000..18f1a5d4 --- /dev/null +++ b/src/types/passport_file.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; + +/// This object represents a file uploaded to Telegram Passport. +/// +/// Currently all Telegram Passport files are in JPEG format when decrypted and +/// don't exceed 10MB. +/// +/// [The official docs](https://core.telegram.org/bots/api#passportfile). +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct PassportFile { + /// Identifier for this file. + pub file_id: String, + + /// Unique identifier for this file, which is supposed to be the same over + /// time and for different bots. Can't be used to download or reuse the + /// file. + pub file_unique_id: String, + + /// File size. + pub file_size: u64, + + /// Unix time when the file was uploaded. + pub file_date: u64, +} diff --git a/src/types/photo_size.rs b/src/types/photo_size.rs new file mode 100644 index 00000000..44ddfe32 --- /dev/null +++ b/src/types/photo_size.rs @@ -0,0 +1,46 @@ +use serde::{Deserialize, Serialize}; + +/// This object represents one size of a photo or a [file]/[sticker] thumbnail. +/// +/// [file]: crate::types::Document +/// [sticker]: crate::types::Sticker +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct PhotoSize { + /// Identifier for this file. + pub file_id: String, + + /// Unique identifier for this file, which is supposed to be the same over + /// time and for different bots. Can't be used to download or reuse the + /// file. + pub file_unique_id: String, + + /// Photo width. + pub width: i32, + + /// Photo height. + pub height: i32, + + /// File size. + pub file_size: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize() { + let json = r#"{"file_id":"id","file_unique_id":"","width":320,"height":320, + "file_size":3452}"#; + let expected = PhotoSize { + file_id: "id".to_string(), + file_unique_id: "".to_string(), + width: 320, + height: 320, + file_size: Some(3452), + }; + let actual = serde_json::from_str::(json).unwrap(); + assert_eq!(actual, expected); + } +} diff --git a/src/types/poll.rs b/src/types/poll.rs new file mode 100644 index 00000000..2492babd --- /dev/null +++ b/src/types/poll.rs @@ -0,0 +1,49 @@ +use crate::types::PollType; +use serde::{Deserialize, Serialize}; + +/// This object contains information about a poll. +/// +/// [The official docs](https://core.telegram.org/bots/api#poll). +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct Poll { + /// Unique poll identifier. + pub id: String, + + /// Poll question, 1-255 characters. + pub question: String, + + /// List of poll options. + pub options: Vec, + + /// `true`, if the poll is closed. + pub is_closed: bool, + + /// Total number of users that voted in the poll + pub total_voter_count: i32, + + /// True, if the poll is anonymous + pub is_anonymous: bool, + + /// Poll type, currently can be “regular” or “quiz” + pub poll_type: PollType, + + /// True, if the poll allows multiple answers + pub allows_multiple_answers: bool, + + /// 0-based identifier of the correct answer option. Available only for + /// polls in the quiz mode, which are closed, or was sent (not + /// forwarded) by the bot or to the private chat with the bot. + pub correct_option_id: Option, +} + +/// This object contains information about one answer option in a poll. +/// +/// [The official docs](https://core.telegram.org/bots/api#polloption). +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct PollOption { + /// Option text, 1-100 characters. + pub text: String, + + /// Number of users that voted for this option. + pub voter_count: i32, +} diff --git a/src/types/poll_answer.rs b/src/types/poll_answer.rs new file mode 100644 index 00000000..b8f26968 --- /dev/null +++ b/src/types/poll_answer.rs @@ -0,0 +1,16 @@ +use crate::types::User; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct PollAnswer { + /// Unique poll identifier. + pub poll_id: String, + + /// The user, who changed the answer to the poll. + pub user: User, + + /// 0-based identifiers of answer options, chosen by the user. + /// + /// May be empty if the user retracted their vote. + pub option_ids: Vec, +} diff --git a/src/types/poll_type.rs b/src/types/poll_type.rs new file mode 100644 index 00000000..61b8acd9 --- /dev/null +++ b/src/types/poll_type.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PollType { + Quiz, + Regular, +} diff --git a/src/types/pre_checkout_query.rs b/src/types/pre_checkout_query.rs new file mode 100644 index 00000000..172e5e8f --- /dev/null +++ b/src/types/pre_checkout_query.rs @@ -0,0 +1,39 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::{Currency, OrderInfo, User}; + +/// This object contains information about an incoming pre-checkout query. +/// +/// [The official docs](https://core.telegram.org/bots/api#precheckoutquery). +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct PreCheckoutQuery { + /// Unique query identifier. + pub id: String, + + /// User who sent the query. + pub from: User, + + /// Three-letter ISO 4217 [currency] code. + /// + /// [currency]: https://core.telegram.org/bots/payments#supported-currencies + pub currency: Currency, + + /// Total price in the _smallest units_ of the currency (integer, **not** + /// float/double). For example, for a price of `US$ 1.45` pass `amount = + /// 145`. See the exp parameter in [`currencies.json`], it shows the number + /// of digits past the decimal point for each currency (2 for the + /// majority of currencies). + /// + /// [`currencies.json`]: https://core.telegram.org/bots/payments/currencies.json + pub total_amount: i32, + + /// Bot specified invoice payload. + pub invoice_payload: String, + + /// Identifier of the shipping option chosen by the user. + pub shipping_option_id: Option, + + /// Order info provided by the user. + pub order_info: Option, +} diff --git a/src/types/reply_keyboard_markup.rs b/src/types/reply_keyboard_markup.rs new file mode 100644 index 00000000..59707764 --- /dev/null +++ b/src/types/reply_keyboard_markup.rs @@ -0,0 +1,88 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::KeyboardButton; + +/// This object represents a [custom keyboard] with reply options (see +/// [Introduction to bots] for details and examples). +/// +/// [The official docs](https://core.telegram.org/bots/api#replykeyboardmarkup). +/// +/// [custom keyboard]: https://core.telegram.org/bots#keyboards +/// [Introduction to bots]: https://core.telegram.org/bots#keyboards +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize, Default)] +pub struct ReplyKeyboardMarkup { + /// Array of button rows, each represented by an Array of + /// [`KeyboardButton`] objects + /// + /// [`KeyboardButton`]: crate::types::KeyboardButton + pub keyboard: Vec>, + + /// Requests clients to resize the keyboard vertically for optimal fit + /// (e.g., make the keyboard smaller if there are just two rows of + /// buttons). Defaults to `false`, in which case the custom keyboard is + /// always of the same height as the app's standard keyboard. + pub resize_keyboard: Option, + + /// Requests clients to hide the keyboard as soon as it's been used. The + /// keyboard will still be available, but clients will automatically + /// display the usual letter-keyboard in the chat – the user can press a + /// special button in the input field to see the custom keyboard again. + /// Defaults to `false`. + pub one_time_keyboard: Option, + + /// Use this parameter if you want to show the keyboard to specific users + /// only. Targets: 1) users that are `@mentioned` in the `text` of the + /// [`Message`] object; 2) if the bot's message is a reply (has + /// `reply_to_message_id`), sender of the original message. + /// + /// Example: A user requests to change the bot‘s language, bot replies to + /// the request with a keyboard to select the new language. Other users + /// in the group don’t see the keyboard. + /// + /// [`Message`]: crate::types::Message + pub selective: Option, +} + +impl ReplyKeyboardMarkup { + pub fn append_row(mut self, buttons: Vec) -> Self { + self.keyboard.push(buttons); + self + } + + pub fn append_to_row( + mut self, + button: KeyboardButton, + index: usize, + ) -> Self { + match self.keyboard.get_mut(index) { + Some(buttons) => buttons.push(button), + None => self.keyboard.push(vec![button]), + }; + self + } + + pub fn resize_keyboard(mut self, val: T) -> Self + where + T: Into>, + { + self.resize_keyboard = val.into(); + self + } + + pub fn one_time_keyboard(mut self, val: T) -> Self + where + T: Into>, + { + self.one_time_keyboard = val.into(); + self + } + + pub fn selective(mut self, val: T) -> Self + where + T: Into>, + { + self.selective = val.into(); + self + } +} diff --git a/src/types/reply_keyboard_remove.rs b/src/types/reply_keyboard_remove.rs new file mode 100644 index 00000000..35d55db7 --- /dev/null +++ b/src/types/reply_keyboard_remove.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::True; + +/// Upon receiving a message with this object, Telegram clients will remove the +/// current custom keyboard and display the default letter-keyboard. +/// +/// By default, custom keyboards are displayed until a new keyboard is sent by a +/// bot. An exception is made for one-time keyboards that are hidden immediately +/// after the user presses a button (see [`ReplyKeyboardMarkup`]). +/// +/// [The official docs](https://core.telegram.org/bots/api#replykeyboardremove). +/// +/// [`ReplyKeyboardMarkup`]: crate::types::ReplyKeyboardMarkup +#[serde_with_macros::skip_serializing_none] +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct ReplyKeyboardRemove { + /// Requests clients to remove the custom keyboard (user will not be able + /// to summon this keyboard; if you want to hide the keyboard from sight + /// but keep it accessible, use one_time_keyboard in + /// [`ReplyKeyboardMarkup`]). + /// + /// [`ReplyKeyboardMarkup`]: crate::types::ReplyKeyboardMarkup + pub remove_keyboard: True, + + /// Use this parameter if you want to remove the keyboard for specific + /// users only. Targets: 1) users that are `@mentioned` in the `text` of + /// the [`Message`] object; 2) if the bot's message is a reply (has + /// `reply_to_message_id`), sender of the original message. + /// + /// Example: A user votes in a poll, bot returns confirmation message in + /// reply to the vote and removes the keyboard for that user, while still + /// showing the keyboard with poll options to users who haven't voted yet. + /// + /// [`Message`]: crate::types::Message + pub selective: Option, +} diff --git a/src/types/reply_markup.rs b/src/types/reply_markup.rs new file mode 100644 index 00000000..f69245e6 --- /dev/null +++ b/src/types/reply_markup.rs @@ -0,0 +1,28 @@ +use derive_more::From; +use serde::{Deserialize, Serialize}; + +use crate::types::{ + ForceReply, InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, +}; + +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize, From)] +#[serde(untagged)] +pub enum ReplyMarkup { + InlineKeyboardMarkup(InlineKeyboardMarkup), + ReplyKeyboardMarkup(ReplyKeyboardMarkup), + ReplyKeyboardRemove(ReplyKeyboardRemove), + ForceReply(ForceReply), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inline_keyboard_markup() { + let data = InlineKeyboardMarkup::default(); + let expected = ReplyMarkup::InlineKeyboardMarkup(data.clone()); + let actual: ReplyMarkup = data.into(); + assert_eq!(actual, expected) + } +} diff --git a/src/types/response_parameters.rs b/src/types/response_parameters.rs new file mode 100644 index 00000000..ee8cc2d7 --- /dev/null +++ b/src/types/response_parameters.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; + +/// Contains information about why a request was unsuccessful. +/// +/// [The official docs](https://core.telegram.org/bots/api#responseparameters). +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResponseParameters { + /// The group has been migrated to a supergroup with the specified + /// identifier. This number may be greater than 32 bits and some + /// programming languages may have difficulty/silent defects in + /// interpreting it. But it is smaller than 52 bits, so a signed 64 bit + /// integer or double-precision float type are safe for storing this + /// identifier. + MigrateToChatId(i64), + + /// In case of exceeding flood control, the number of seconds left to wait + /// before the request can be repeated. + RetryAfter(i32), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn migrate_to_chat_id_deserialization() { + let expected = ResponseParameters::MigrateToChatId(123_456); + let actual: ResponseParameters = + serde_json::from_str(r#"{"migrate_to_chat_id":123456}"#).unwrap(); + + assert_eq!(expected, actual); + } + + #[test] + fn retry_after_deserialization() { + let expected = ResponseParameters::RetryAfter(123_456); + let actual: ResponseParameters = + serde_json::from_str(r#"{"retry_after":123456}"#).unwrap(); + + assert_eq!(expected, actual); + } +} diff --git a/src/types/send_invoice.rs b/src/types/send_invoice.rs new file mode 100644 index 00000000..3e0bbd40 --- /dev/null +++ b/src/types/send_invoice.rs @@ -0,0 +1,32 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::{ChatId, InlineKeyboardMarkup, LabeledPrice}; + +// TODO: missing docs +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct SendInvoice { + pub chat_id: ChatId, + pub title: String, + pub description: String, + pub payload: String, + pub provider_token: String, + pub start_parameter: String, + pub currency: String, + pub prices: Vec, + pub provider_data: Option, + pub photo_url: Option, + pub photo_size: Option, + pub photo_width: Option, + pub photo_height: Option, + pub need_name: Option, + pub need_phone_number: Option, + pub need_email: Option, + pub need_shipping_address: Option, + pub send_phone_number_to_provider: Option, + pub send_email_to_provider: Option, + pub is_flexible: Option, + pub disable_notification: Option, + pub reply_to_message_id: Option, + pub reply_markup: Option, +} diff --git a/src/types/shipping_address.rs b/src/types/shipping_address.rs new file mode 100644 index 00000000..478ad714 --- /dev/null +++ b/src/types/shipping_address.rs @@ -0,0 +1,26 @@ +use crate::types::CountryCode; +use serde::{Deserialize, Serialize}; + +/// This object represents a shipping address. +/// +/// [The official docs](https://core.telegram.org/bots/api#shippingaddress). +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct ShippingAddress { + /// ISO 3166-1 alpha-2 country code. + pub country_code: CountryCode, + + /// State, if applicable. + pub state: String, + + /// City. + pub city: String, + + /// First line for the address. + pub street_line1: String, + + /// Second line for the address. + pub street_line2: String, + + /// Address post code. + pub post_code: String, +} diff --git a/src/types/shipping_option.rs b/src/types/shipping_option.rs new file mode 100644 index 00000000..aa5115dd --- /dev/null +++ b/src/types/shipping_option.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::LabeledPrice; + +/// This object represents one shipping option. +/// +/// [The official docs](https://core.telegram.org/bots/api#shippingoption). +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct ShippingOption { + /// Shipping option identifier. + pub id: String, + + /// Option title. + pub title: String, + + /// List of price portions. + pub prices: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serialize() { + let shipping_option = ShippingOption { + id: "0".to_string(), + title: "Option".to_string(), + prices: vec![LabeledPrice { + label: "Label".to_string(), + amount: 60, + }], + }; + let expected = r#"{"id":"0","title":"Option","prices":[{"label":"Label","amount":60}]}"#; + let actual = serde_json::to_string(&shipping_option).unwrap(); + assert_eq!(actual, expected); + } +} diff --git a/src/types/shipping_query.rs b/src/types/shipping_query.rs new file mode 100644 index 00000000..f8df55d4 --- /dev/null +++ b/src/types/shipping_query.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::{ShippingAddress, User}; + +/// This object contains information about an incoming shipping query. +/// +/// [The official docs](https://core.telegram.org/bots/api#shippingquery). +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct ShippingQuery { + /// Unique query identifier. + pub id: String, + + /// User who sent the query. + pub from: User, + + /// Bot specified invoice payload. + pub invoice_payload: String, + + /// User specified shipping address. + pub shipping_address: ShippingAddress, +} diff --git a/src/types/sticker.rs b/src/types/sticker.rs new file mode 100644 index 00000000..235f774d --- /dev/null +++ b/src/types/sticker.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::{MaskPosition, PhotoSize}; + +/// This object represents a sticker. +/// +/// [The official docs](https://core.telegram.org/bots/api#sticker). +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Sticker { + /// Identifier for this file. + pub file_id: String, + + /// Unique identifier for this file, which is supposed to be the same over + /// time and for different bots. Can't be used to download or reuse the + /// file. + pub file_unique_id: String, + + /// Sticker width. + pub width: u16, + + /// Sticker height. + pub height: u16, + + /// `true`, if the sticker is [animated]. + /// + /// [animated]: https://telegram.org/blog/animated-stickers + pub is_animated: bool, + + /// Sticker thumbnail in the .webp or .jpg format. + pub thumb: Option, + + /// Emoji associated with the sticker. + pub emoji: Option, + + /// Name of the sticker set to which the sticker belongs. + pub set_name: Option, + + /// For mask stickers, the position where the mask should be placed. + pub mask_position: Option, + + /// File size. + pub file_size: Option, +} diff --git a/src/types/sticker_set.rs b/src/types/sticker_set.rs new file mode 100644 index 00000000..f57dec65 --- /dev/null +++ b/src/types/sticker_set.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::Sticker; + +/// This object represents a sticker set. +/// +/// [The official docs](https://core.telegram.org/bots/api#stickerset). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StickerSet { + /// Sticker set name. + pub name: String, + + /// Sticker set title. + pub title: String, + + /// `true`, if the sticker set contains [animated stickers]. + /// + /// [animates stickers]: https://telegram.org/blog/animated-stickers + pub is_animated: bool, + + /// `true`, if the sticker set contains masks. + pub contains_masks: bool, + + /// List of all set stickers. + pub stickers: Vec, +} diff --git a/src/types/successful_payment.rs b/src/types/successful_payment.rs new file mode 100644 index 00000000..d38a36b5 --- /dev/null +++ b/src/types/successful_payment.rs @@ -0,0 +1,39 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::{Currency, OrderInfo}; + +/// This object contains basic information about a successful payment. +/// +/// [The official docs](https://core.telegram.org/bots/api#successfulpayment). +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct SuccessfulPayment { + /// Three-letter ISO 4217 [currency] code. + /// + /// [currency]: https://core.telegram.org/bots/payments#supported-currencies + pub currency: Currency, + + /// Total price in the smallest units of the currency (integer, not + /// float/double). For example, for a price of `US$ 1.45` pass `amount = + /// 145`. See the exp parameter in [`currencies.json`], it shows the + /// number of digits past the decimal point for each currency (2 for + /// the majority of currencies). + /// + /// [`currencies.json`]: https://core.telegram.org/bots/payments/currencies.json + pub total_amount: i32, + + /// Bot specified invoice payload. + pub invoice_payload: String, + + /// Identifier of the shipping option chosen by the user. + pub shipping_option_id: Option, + + /// Order info provided by the user. + pub order_info: Option, + + /// Telegram payment identifier. + pub telegram_payment_charge_id: String, + + /// Provider payment identifier. + pub provider_payment_charge_id: String, +} diff --git a/src/types/unit_false.rs b/src/types/unit_false.rs new file mode 100644 index 00000000..058758f9 --- /dev/null +++ b/src/types/unit_false.rs @@ -0,0 +1,79 @@ +use serde::{de::Visitor, Deserialize, Deserializer, Serialize, Serializer}; + +/// A type that is always false. +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, Default)] +pub struct False; + +impl std::convert::TryFrom for False { + type Error = (); + + fn try_from(value: bool) -> Result { + match value { + true => Err(()), + false => Ok(False), + } + } +} + +impl<'de> Deserialize<'de> for False { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_bool(FalseVisitor) + } +} + +struct FalseVisitor; + +impl<'de> Visitor<'de> for FalseVisitor { + type Value = False; + + fn expecting( + &self, + formatter: &mut std::fmt::Formatter, + ) -> std::fmt::Result { + write!(formatter, "bool, equal to `false`") + } + + fn visit_bool(self, value: bool) -> Result + where + E: serde::de::Error, + { + match value { + true => Err(E::custom("expected `false`, found `true`")), + false => Ok(False), + } + } +} + +impl Serialize for False { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_bool(false) + } +} + +#[cfg(test)] +mod tests { + use serde_json::{from_str, to_string}; + + use super::False; + + #[test] + fn unit_false_de() { + let json = "false"; + let expected = False; + let actual = from_str(json).unwrap(); + assert_eq!(expected, actual); + } + + #[test] + fn unit_false_se() { + let actual = to_string(&False).unwrap(); + let expected = "false"; + assert_eq!(expected, actual); + } +} diff --git a/src/types/unit_true.rs b/src/types/unit_true.rs new file mode 100644 index 00000000..cd71e5c2 --- /dev/null +++ b/src/types/unit_true.rs @@ -0,0 +1,79 @@ +use serde::{ + de::{self, Deserialize, Deserializer, Visitor}, + ser::{Serialize, Serializer}, +}; + +/// A type that is always true. +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, Default)] +pub struct True; + +impl std::convert::TryFrom for True { + type Error = (); + + fn try_from(value: bool) -> Result { + match value { + true => Ok(True), + false => Err(()), + } + } +} + +impl<'de> Deserialize<'de> for True { + fn deserialize(des: D) -> Result + where + D: Deserializer<'de>, + { + des.deserialize_bool(TrueVisitor) + } +} + +struct TrueVisitor; + +impl<'de> Visitor<'de> for TrueVisitor { + type Value = True; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "bool, equal to `true`") + } + + fn visit_bool(self, value: bool) -> Result + where + E: de::Error, + { + match value { + true => Ok(True), + false => Err(E::custom("expected `true`, found `false`")), + } + } +} + +impl Serialize for True { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_bool(true) + } +} + +#[cfg(test)] +mod tests { + use serde_json::{from_str, to_string}; + + use super::True; + + #[test] + fn unit_true_de() { + let json = "true"; + let expected = True; + let actual = from_str(json).unwrap(); + assert_eq!(expected, actual); + } + + #[test] + fn unit_true_se() { + let actual = to_string(&True).unwrap(); + let expected = "true"; + assert_eq!(expected, actual); + } +} diff --git a/src/types/update.rs b/src/types/update.rs new file mode 100644 index 00000000..e0db02a3 --- /dev/null +++ b/src/types/update.rs @@ -0,0 +1,208 @@ +#![allow(clippy::large_enum_variant)] + +use serde::{Deserialize, Serialize}; + +use crate::types::{ + CallbackQuery, Chat, ChosenInlineResult, InlineQuery, Message, Poll, + PollAnswer, PreCheckoutQuery, ShippingQuery, User, +}; + +/// This [object] represents an incoming update. +/// +/// [The official docs](https://core.telegram.org/bots/api#update). +/// +/// [object]: https://core.telegram.org/bots/api#available-types +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Update { + /// The update‘s unique identifier. Update identifiers start from a certain + /// positive number and increase sequentially. This ID becomes especially + /// handy if you’re using [Webhooks], since it allows you to ignore + /// repeated updates or to restore the correct update sequence, should + /// they get out of order. If there are no new updates for at least a + /// week, then identifier of the next update will be chosen randomly + /// instead of sequentially. + /// + /// [Webhooks]: crate::Bot::set_webhook + #[serde(rename = "update_id")] + pub id: i32, + + #[serde(flatten)] + pub kind: UpdateKind, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UpdateKind { + /// New incoming message of any kind — text, photo, sticker, etc. + Message(Message), + + /// New version of a message that is known to the bot and was edited. + EditedMessage(Message), + + /// New incoming channel post of any kind — text, photo, sticker, etc. + ChannelPost(Message), + + /// New version of a channel post that is known to the bot and was edited. + EditedChannelPost(Message), + + /// New incoming [inline] query. + /// + /// [inline]: https://core.telegram.org/bots/api#inline-mode + InlineQuery(InlineQuery), + + /// The result of an [inline] query that was chosen by a user and sent to + /// their chat partner. Please see our documentation on the [feedback + /// collecting] for details on how to enable these updates for your bot. + /// + /// [inline]: https://core.telegram.org/bots/api#inline-mode + /// [feedback collecting]: https://core.telegram.org/bots/inline#collecting-feedback + ChosenInlineResult(ChosenInlineResult), + + /// New incoming callback query. + CallbackQuery(CallbackQuery), + + /// New incoming shipping query. Only for invoices with flexible price. + ShippingQuery(ShippingQuery), + + /// New incoming pre-checkout query. Contains full information about + /// checkout. + PreCheckoutQuery(PreCheckoutQuery), + + /// New poll state. Bots receive only updates about stopped polls and + /// polls, which are sent by the bot. + Poll(Poll), + + /// A user changed their answer in a non-anonymous poll. Bots receive new + /// votes only in polls that were sent by the bot itself. + PollAnswer(PollAnswer), +} + +impl Update { + pub fn user(&self) -> Option<&User> { + match &self.kind { + UpdateKind::Message(m) => m.from(), + UpdateKind::EditedMessage(m) => m.from(), + UpdateKind::CallbackQuery(query) => Some(&query.from), + UpdateKind::ChosenInlineResult(chosen) => Some(&chosen.from), + UpdateKind::InlineQuery(query) => Some(&query.from), + UpdateKind::ShippingQuery(query) => Some(&query.from), + UpdateKind::PreCheckoutQuery(query) => Some(&query.from), + UpdateKind::PollAnswer(answer) => Some(&answer.user), + _ => None, + } + } + + pub fn chat(&self) -> Option<&Chat> { + match &self.kind { + UpdateKind::Message(m) => Some(&m.chat), + UpdateKind::EditedMessage(m) => Some(&m.chat), + UpdateKind::ChannelPost(p) => Some(&p.chat), + UpdateKind::EditedChannelPost(p) => Some(&p.chat), + UpdateKind::CallbackQuery(q) => Some(&q.message.as_ref()?.chat), + _ => None, + } + } +} + +#[cfg(test)] +mod test { + use crate::types::{ + Chat, ChatKind, ForwardKind, LanguageCode, MediaKind, Message, + MessageKind, Update, UpdateKind, User, + }; + + // TODO: more tests for deserialization + #[test] + fn message() { + let json = r#"{ + "update_id":892252934, + "message":{ + "message_id":6557, + "from":{ + "id":218485655, + "is_bot": false, + "first_name":"Waffle", + "username":"WaffleLapkin", + "language_code":"en" + }, + "chat":{ + "id":218485655, + "first_name":"Waffle", + "username":"WaffleLapkin", + "type":"private" + }, + "date":1569518342, + "text":"hello there" + } + }"#; + + let expected = Update { + id: 892_252_934, + kind: UpdateKind::Message(Message { + id: 6557, + date: 1_569_518_342, + chat: Chat { + id: 218_485_655, + kind: ChatKind::Private { + type_: (), + username: Some(String::from("WaffleLapkin")), + first_name: Some(String::from("Waffle")), + last_name: None, + }, + photo: None, + }, + kind: MessageKind::Common { + from: Some(User { + id: 218_485_655, + is_bot: false, + first_name: String::from("Waffle"), + last_name: None, + username: Some(String::from("WaffleLapkin")), + language_code: Some(LanguageCode::EN), + }), + forward_kind: ForwardKind::Origin { + reply_to_message: None, + }, + edit_date: None, + media_kind: MediaKind::Text { + text: String::from("hello there"), + entities: vec![], + }, + reply_markup: None, + }, + }), + }; + + let actual = serde_json::from_str::(json).unwrap(); + assert_eq!(expected, actual); + } + + #[test] + fn de_private_chat_text_message() { + let text = r#" + { + "message": { + "chat": { + "first_name": "Hirrolot", + "id": 408258968, + "type": "private", + "username": "hirrolot" + }, + "date": 1581448857, + "from": { + "first_name": "Hirrolot", + "id": 408258968, + "is_bot": false, + "language_code": "en", + "username": "hirrolot" + }, + "message_id": 154, + "text": "4" + }, + "update_id": 306197398 + } +"#; + + assert!(serde_json::from_str::(text).is_ok()); + } +} diff --git a/src/types/user.rs b/src/types/user.rs new file mode 100644 index 00000000..aaf2e8f5 --- /dev/null +++ b/src/types/user.rs @@ -0,0 +1,94 @@ +use crate::types::LanguageCode; +use serde::{Deserialize, Serialize}; + +/// This object represents a Telegram user or bot. +/// +/// [The official docs](https://core.telegram.org/bots/api#user). +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct User { + /// Unique identifier for this user or bot. + pub id: i32, + + /// `true`, if this user is a bot. + pub is_bot: bool, + + /// User‘s or bot’s first name. + pub first_name: String, + + /// User‘s or bot’s last name. + pub last_name: Option, + + /// User‘s or bot’s username. + pub username: Option, + + /// [IETF language tag] of the user's language. + /// + /// [IETF language tag]: https://en.wikipedia.org/wiki/IETF_language_tag + pub language_code: Option, +} + +impl User { + pub fn full_name(&self) -> String { + match &self.last_name { + Some(last_name) => (format!("{0} {1}", self.first_name, last_name)), + None => self.first_name.clone(), + } + } + + pub fn mention(&self) -> Option { + Some(format!("@{}", self.username.as_ref()?)) + } + + pub fn url(&self) -> reqwest::Url { + reqwest::Url::parse(format!("tg://user/?id={}", self.id).as_str()) + .unwrap() + } +} + +/// Returned only in [`Bot::get_me`]. +/// +/// [`Bot::get_me`]: crate::Bot::get_me +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct Me { + #[serde(flatten)] + pub user: User, + + /// `true`, if the bot can be invited to groups. + pub can_join_groups: bool, + + /// `true`, if [privacy mode] is disabled for the bot. + /// + /// [privacy mode]: https://core.telegram.org/bots#privacy-mode + pub can_read_all_group_messages: bool, + + /// `true`, if the bot supports inline queries. + pub supports_inline_queries: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize() { + let json = r#"{ + "id":12345, + "is_bot":false, + "first_name":"firstName", + "last_name":"lastName", + "username":"Username", + "language_code":"ru" + }"#; + let expected = User { + id: 12345, + is_bot: false, + first_name: "firstName".to_string(), + last_name: Some("lastName".to_string()), + username: Some("Username".to_string()), + language_code: Some(LanguageCode::RU), + }; + let actual = serde_json::from_str::(&json).unwrap(); + assert_eq!(actual, expected) + } +} diff --git a/src/types/user_profile_photos.rs b/src/types/user_profile_photos.rs new file mode 100644 index 00000000..ac0a9b5d --- /dev/null +++ b/src/types/user_profile_photos.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::PhotoSize; + +/// This object represent a user's profile pictures. +/// +/// [The official docs](https://core.telegram.org/bots/api#userprofilephotos). +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct UserProfilePhotos { + /// Total number of profile pictures the target user has. + pub total_count: u32, + + /// Requested profile pictures (in up to 4 sizes each). + pub photos: Vec>, +} diff --git a/src/types/venue.rs b/src/types/venue.rs new file mode 100644 index 00000000..afb0cc90 --- /dev/null +++ b/src/types/venue.rs @@ -0,0 +1,25 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::Location; + +/// This object represents a venue. +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Venue { + /// Venue location. + pub location: Location, + + /// Name of the venue. + pub title: String, + + /// Address of the venue. + pub address: String, + + /// Foursquare identifier of the venue. + pub foursquare_id: Option, + + /// Foursquare type of the venue. (For example, + /// `arts_entertainment/default`, `arts_entertainment/aquarium` or + /// `food/icecream`.) + pub foursquare_type: Option, +} diff --git a/src/types/video.rs b/src/types/video.rs new file mode 100644 index 00000000..8e7a415f --- /dev/null +++ b/src/types/video.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::PhotoSize; + +/// This object represents a video file. +/// +/// [The official docs](https://core.telegram.org/bots/api#video). +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct Video { + /// Identifier for this file. + pub file_id: String, + + /// Unique identifier for this file, which is supposed to be the same over + /// time and for different bots. Can't be used to download or reuse the + /// file. + pub file_unique_id: String, + + /// Video width as defined by sender. + pub width: u32, + + /// Video height as defined by sender. + pub height: u32, + + /// Duration of the video in seconds as defined by sender. + pub duration: u32, + + /// Video thumbnail. + pub thumb: Option, + + /// Mime type of a file as defined by sender. + pub mime_type: Option, + + /// File size. + pub file_size: Option, +} diff --git a/src/types/video_note.rs b/src/types/video_note.rs new file mode 100644 index 00000000..9a751e20 --- /dev/null +++ b/src/types/video_note.rs @@ -0,0 +1,35 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::PhotoSize; + +/// This object represents a [video message] (available in Telegram apps as of +/// [v.4.0]). +/// +/// [The official docs](https://core.telegram.org/bots/api#videonote). +/// +/// [video message]: https://telegram.org/blog/video-messages-and-telescope +/// [v4.0]: https://telegram.org/blog/video-messages-and-telescope +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct VideoNote { + /// Identifier for this file. + pub file_id: String, + + /// Unique identifier for this file, which is supposed to be the same over + /// time and for different bots. Can't be used to download or reuse the + /// file. + pub file_unique_id: String, + + /// Video width and height (diameter of the video message) as defined by + /// sender. + pub length: u32, + + /// Duration of the video in seconds as defined by sender. + pub duration: u32, + + /// Video thumbnail. + pub thumb: Option, + + /// File size. + pub file_size: Option, +} diff --git a/src/types/voice.rs b/src/types/voice.rs new file mode 100644 index 00000000..6b9d8347 --- /dev/null +++ b/src/types/voice.rs @@ -0,0 +1,25 @@ +use serde::{Deserialize, Serialize}; + +/// This object represents a voice note. +/// +/// [The official docs](https://core.telegram.org/bots/api#voice). +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct Voice { + /// Identifier for this file. + pub file_id: String, + + /// Unique identifier for this file, which is supposed to be the same over + /// time and for different bots. Can't be used to download or reuse the + /// file. + pub file_unique_id: String, + + /// Duration of the audio in seconds as defined by sender. + pub duration: u32, + + /// MIME type of the file as defined by sender. + pub mime_type: Option, + + /// File size. + pub file_size: Option, +} diff --git a/src/types/webhook_info.rs b/src/types/webhook_info.rs new file mode 100644 index 00000000..e7658381 --- /dev/null +++ b/src/types/webhook_info.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; + +/// Contains information about the current status of a webhook. +/// +/// [The official docs](https://core.telegram.org/bots/api#webhookinfo). +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct WebhookInfo { + /// Webhook URL, may be empty if webhook is not set up. + pub url: String, + + /// `true`, if a custom certificate was provided for webhook certificate + /// checks. + pub has_custom_certificate: bool, + + /// Number of updates awaiting delivery. + pub pending_update_count: u32, + + /// Unix time for the most recent error that happened when trying to + /// deliver an update via webhook. + pub last_error_date: Option, + + /// Error message in human-readable format for the most recent error that + /// happened when trying to deliver an update via webhook. + pub last_error_message: Option, + + /// Maximum allowed number of simultaneous HTTPS connections to the webhook + /// for update delivery. + pub max_connections: Option, + + /// A list of update types the bot is subscribed to. Defaults to all update + /// types. + pub allowed_updates: Option>, +} diff --git a/src/utils/command.rs b/src/utils/command.rs new file mode 100644 index 00000000..7ca76b83 --- /dev/null +++ b/src/utils/command.rs @@ -0,0 +1,240 @@ +//! Command parsers. +//! +//! You can either create an `enum`, containing commands of your bot, or use +//! functions, which split input text into a string command with its arguments. +//! +//! ## Examples +//! Using `enum`: +//! ``` +//! use teloxide::utils::command::BotCommand; +//! +//! #[derive(BotCommand, PartialEq, Debug)] +//! #[command(rename = "lowercase")] +//! enum AdminCommand { +//! Kick, +//! Ban, +//! } +//! +//! let (command, args) = AdminCommand::parse("/ban 3 hours").unwrap(); +//! assert_eq!(command, AdminCommand::Ban); +//! assert_eq!(args, vec!["3", "hours"]); +//! ``` +//! +//! Using [`parse_command`]: +//! ``` +//! use teloxide::utils::command::parse_command; +//! +//! let (command, args) = parse_command("/ban 3 hours").unwrap(); +//! assert_eq!(command, "/ban"); +//! assert_eq!(args, vec!["3", "hours"]); +//! ``` +//! +//! Using [`parse_command_with_prefix`]: +//! ``` +//! use teloxide::utils::command::parse_command_with_prefix; +//! +//! let text = "!ban 3 hours"; +//! let (command, args) = parse_command_with_prefix("!", text).unwrap(); +//! assert_eq!(command, "ban"); +//! assert_eq!(args, vec!["3", "hours"]); +//! ``` +//! +//! See [examples/admin_bot] as a more complicated examples. +//! +//! [`parse_command`]: crate::utils::command::parse_command +//! [`parse_command_with_prefix`]: +//! crate::utils::command::parse_command_with_prefix +//! [examples/admin_bot]: https://github.com/teloxide/teloxide/blob/dev/examples/miltiple_handlers_bot/ + +pub use teloxide_macros::BotCommand; + +/// An enumeration of bot's commands. +/// +/// ## Example +/// ``` +/// use teloxide::utils::command::BotCommand; +/// +/// #[derive(BotCommand, PartialEq, Debug)] +/// #[command(rename = "lowercase")] +/// enum AdminCommand { +/// Mute, +/// Ban, +/// } +/// +/// let (command, args) = AdminCommand::parse("/ban 5 h").unwrap(); +/// assert_eq!(command, AdminCommand::Ban); +/// assert_eq!(args, vec!["5", "h"]); +/// ``` +/// +/// ## Enum attributes +/// 1. `#[command(rename = "rule")]` +/// Rename all commands by rule. Allowed rules are `lowercase`. If you will not +/// use this attribute, commands will be parsed by their original names. +/// +/// 2. `#[command(prefix = "prefix")]` +/// Change a prefix for all commands (the default is `/`). +/// +/// 3. `#[command(description = "description")]` +/// Add a sumary description of commands before all commands. +/// +/// ## Variant attributes +/// 1. `#[command(rename = "rule")]` +/// Rename one command by a rule. Allowed rules are `lowercase`, `%some_name%`, +/// where `%some_name%` is any string, a new name. +/// +/// 2. `#[command(prefix = "prefix")]` +/// Change a prefix for one command (the default is `/`). +/// +/// 3. `#[command(description = "description")]` +/// Add a description of one command. +/// +/// All variant attributes overlap the `enum` attributes. +pub trait BotCommand: Sized { + fn try_from(s: &str) -> Option; + fn descriptions() -> String; + fn parse(s: &str) -> Option<(Self, Vec<&str>)>; +} + +/// Parses a string into a command with args. +/// +/// It calls [`parse_command_with_prefix`] with default prefix `/`. +/// +/// ## Example +/// ``` +/// use teloxide::utils::command::parse_command; +/// +/// let text = "/mute 5 hours"; +/// let (command, args) = parse_command(text).unwrap(); +/// assert_eq!(command, "/mute"); +/// assert_eq!(args, vec!["5", "hours"]); +/// ``` +pub fn parse_command(text: &str) -> Option<(&str, Vec<&str>)> { + let mut words = text.split_whitespace(); + let command = words.next()?; + Some((command, words.collect())) +} + +/// Parses a string into a command with args (custom prefix). +/// +/// `prefix`: start symbols which denote start of a command. +/// +/// Example: +/// ``` +/// use teloxide::utils::command::parse_command_with_prefix; +/// +/// let text = "!mute 5 hours"; +/// let (command, args) = parse_command_with_prefix("!", text).unwrap(); +/// assert_eq!(command, "mute"); +/// assert_eq!(args, vec!["5", "hours"]); +/// ``` +pub fn parse_command_with_prefix<'a>( + prefix: &str, + text: &'a str, +) -> Option<(&'a str, Vec<&'a str>)> { + if !text.starts_with(prefix) { + return None; + } + let mut words = text.split_whitespace(); + let command = &words.next()?[prefix.len()..]; + Some((command, words.collect())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_command_with_args_() { + let data = "/command arg1 arg2"; + let expected = Some(("/command", vec!["arg1", "arg2"])); + let actual = parse_command(data); + assert_eq!(actual, expected) + } + + #[test] + fn parse_command_with_args_without_args() { + let data = "/command"; + let expected = Some(("/command", vec![])); + let actual = parse_command(data); + assert_eq!(actual, expected) + } + + #[test] + fn parse_command_with_args() { + #[command(rename = "lowercase")] + #[derive(BotCommand, Debug, PartialEq)] + enum DefaultCommands { + Start, + Help, + } + + let data = "/start arg1 arg2"; + let expected = Some((DefaultCommands::Start, vec!["arg1", "arg2"])); + let actual = DefaultCommands::parse(data); + assert_eq!(actual, expected) + } + + #[test] + fn attribute_prefix() { + #[command(rename = "lowercase")] + #[derive(BotCommand, Debug, PartialEq)] + enum DefaultCommands { + #[command(prefix = "!")] + Start, + Help, + } + + let data = "!start arg1 arg2"; + let expected = Some((DefaultCommands::Start, vec!["arg1", "arg2"])); + let actual = DefaultCommands::parse(data); + assert_eq!(actual, expected) + } + + #[test] + fn many_attributes() { + #[command(rename = "lowercase")] + #[derive(BotCommand, Debug, PartialEq)] + enum DefaultCommands { + #[command(prefix = "!", description = "desc")] + Start, + Help, + } + + assert_eq!( + DefaultCommands::Start, + DefaultCommands::parse("!start").unwrap().0 + ); + assert_eq!( + DefaultCommands::descriptions(), + "!start - desc\n/help - \n" + ); + } + + #[test] + fn global_attributes() { + #[command( + prefix = "!", + rename = "lowercase", + description = "Bot commands" + )] + #[derive(BotCommand, Debug, PartialEq)] + enum DefaultCommands { + #[command(prefix = "/")] + Start, + Help, + } + + assert_eq!( + DefaultCommands::Start, + DefaultCommands::parse("/start").unwrap().0 + ); + assert_eq!( + DefaultCommands::Help, + DefaultCommands::parse("!help").unwrap().0 + ); + assert_eq!( + DefaultCommands::descriptions(), + "Bot commands\n/start - \n!help - \n" + ); + } +} diff --git a/src/utils/html.rs b/src/utils/html.rs new file mode 100644 index 00000000..e28e5102 --- /dev/null +++ b/src/utils/html.rs @@ -0,0 +1,215 @@ +//! Utils for working with the [HTML message style][spec]. +//! +//! [spec]: https://core.telegram.org/bots/api#html-style +use crate::types::User; +use std::string::String; + +/// Applies the bold font style to the string. +/// +/// Passed string will not be automatically escaped because it can contain +/// nested markup. +pub fn bold(s: &str) -> String { + format!("{}", s) +} + +/// Applies the italic font style to the string. +/// +/// Passed string will not be automatically escaped because it can contain +/// nested markup. +pub fn italic(s: &str) -> String { + format!("{}", s) +} + +/// Applies the underline font style to the string. +/// +/// Passed string will not be automatically escaped because it can contain +/// nested markup. +pub fn underline(s: &str) -> String { + format!("{}", s) +} + +/// Applies the strikethrough font style to the string. +/// +/// Passed string will not be automatically escaped because it can contain +/// nested markup. +pub fn strike(s: &str) -> String { + format!("{}", s) +} + +/// Builds an inline link with an anchor. +/// +/// Escapes the passed URL and the link text. +pub fn link(url: &str, text: &str) -> String { + format!("{}", escape(url), escape(text)) +} + +/// Builds an inline user mention link with an anchor. +pub fn user_mention(user_id: i32, text: &str) -> String { + link(format!("tg://user?id={}", user_id).as_str(), text) +} + +/// Formats the code block. +/// +/// Escapes HTML characters inside the block. +pub fn code_block(code: &str) -> String { + format!("
{}
", escape(code)) +} + +/// Formats the code block with a specific language syntax. +/// +/// Escapes HTML characters inside the block. +pub fn code_block_with_lang(code: &str, lang: &str) -> String { + format!( + "
{}
", + escape(lang).replace("\"", """), + escape(code) + ) +} + +/// Formats the string as an inline code. +/// +/// Escapes HTML characters inside the block. +pub fn code_inline(s: &str) -> String { + format!("{}", escape(s)) +} + +/// Escapes the string to be shown "as is" within the Telegram HTML message +/// style. +/// +/// Does not escape ' and " characters (as should be for usual HTML), because +/// they shoudn't be escaped by the [spec]. +/// +/// [spec]: https://core.telegram.org/bots/api#html-style +pub fn escape(s: &str) -> String { + s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") +} + +pub fn user_mention_or_link(user: &User) -> String { + match user.mention() { + Some(mention) => mention, + None => link(user.url().as_str(), &user.full_name()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bold() { + assert_eq!(bold(" foobar "), " foobar "); + assert_eq!(bold(" foobar "), " foobar "); + assert_eq!(bold("(`foobar`)"), "(`foobar`)"); + } + + #[test] + fn test_italic() { + assert_eq!(italic(" foobar "), " foobar "); + assert_eq!(italic(" foobar "), " foobar "); + assert_eq!(italic("(`foobar`)"), "(`foobar`)"); + } + + #[test] + fn test_underline() { + assert_eq!(underline(" foobar "), " foobar "); + assert_eq!(underline(" foobar "), " foobar "); + assert_eq!(underline("(`foobar`)"), "(`foobar`)"); + } + + #[test] + fn test_strike() { + assert_eq!(strike(" foobar "), " foobar "); + assert_eq!(strike(" foobar "), " foobar "); + assert_eq!(strike("(`foobar`)"), "(`foobar`)"); + } + + #[test] + fn test_link() { + assert_eq!( + link("https://www.google.com/?q=foo&l=ru", ""), + "<google>\ + ", + ); + } + + #[test] + fn test_user_mention() { + assert_eq!( + user_mention(123_456_789, ""), + "<pwner666>", + ); + } + + #[test] + fn test_code_block() { + assert_eq!( + code_block("

pre-'formatted'\n & fixed-width \\code `block`

"), + "
<p>pre-'formatted'\n & fixed-width \\code \
+             `block`</p>
" + ); + } + + #[test] + fn test_code_block_with_lang() { + assert_eq!( + code_block_with_lang( + "

pre-'formatted'\n & fixed-width \\code `block`

", + "\"" + ), + concat!( + "
",
+                "<p>pre-'formatted'\n & fixed-width \\code \
+                 `block`</p>",
+                "
", + ) + ); + } + + #[test] + fn test_code_inline() { + assert_eq!( + code_inline("foo & bar"), + "<span class=\"foo\">foo & bar</span>", + ); + } + + #[test] + fn test_escape() { + assert_eq!( + escape(" Foo & Bar "), + " <title>Foo & Bar</title> " + ); + assert_eq!( + escape("

你好 & 再見

"), + "<p>你好 & 再見</p>" + ); + assert_eq!(escape("'foo\""), "'foo\""); + } + + #[test] + fn user_mention_link() { + let user_with_username = User { + id: 0, + is_bot: false, + first_name: "".to_string(), + last_name: None, + username: Some("abcd".to_string()), + language_code: None, + }; + assert_eq!(user_mention_or_link(&user_with_username), "@abcd"); + let user_without_username = User { + id: 123_456_789, + is_bot: false, + first_name: "Name".to_string(), + last_name: None, + username: None, + language_code: None, + }; + assert_eq!( + user_mention_or_link(&user_without_username), + r#"Name"# + ) + } +} diff --git a/src/utils/markdown.rs b/src/utils/markdown.rs new file mode 100644 index 00000000..9e5b7d41 --- /dev/null +++ b/src/utils/markdown.rs @@ -0,0 +1,277 @@ +//! Utils for working with the [Markdown V2 message style][spec]. +//! +//! [spec]: https://core.telegram.org/bots/api#markdownv2-style +use crate::types::User; +use std::string::String; + +/// Applies the bold font style to the string. +/// +/// Passed string will not be automatically escaped because it can contain +/// nested markup. +pub fn bold(s: &str) -> String { + format!("*{}*", s) +} + +/// Applies the italic font style to the string. +/// +/// Can be safely used with `utils::markdown::underline()`. +/// Passed string will not be automatically escaped because it can contain +/// nested markup. +pub fn italic(s: &str) -> String { + if s.starts_with("__") && s.ends_with("__") { + format!(r"_{}\r__", &s[..s.len() - 1]) + } else { + format!("_{}_", s) + } +} + +/// Applies the underline font style to the string. +/// +/// Can be safely used with `utils::markdown::italic()`. +/// Passed string will not be automatically escaped because it can contain +/// nested markup. +pub fn underline(s: &str) -> String { + // In case of ambiguity between italic and underline entities + // ‘__’ is always greadily treated from left to right as beginning or end of + // underline entity, so instead of ___italic underline___ we should use + // ___italic underline_\r__, where \r is a character with code 13, which + // will be ignored. + if s.starts_with('_') && s.ends_with('_') { + format!(r"__{}\r__", s) + } else { + format!("__{}__", s) + } +} + +/// Applies the strikethrough font style to the string. +/// +/// Passed string will not be automatically escaped because it can contain +/// nested markup. +pub fn strike(s: &str) -> String { + format!("~{}~", s) +} + +/// Builds an inline link with an anchor. +/// +/// Escapes `)` and ``` characters inside the link url. +pub fn link(url: &str, text: &str) -> String { + format!("[{}]({})", text, escape_link_url(url)) +} + +/// Builds an inline user mention link with an anchor. +pub fn user_mention(user_id: i32, text: &str) -> String { + link(format!("tg://user?id={}", user_id).as_str(), text) +} + +/// Formats the code block. +/// +/// Escapes ``` and `\` characters inside the block. +pub fn code_block(code: &str) -> String { + format!("```\n{}\n```", escape_code(code)) +} + +/// Formats the code block with a specific language syntax. +/// +/// Escapes ``` and `\` characters inside the block. +pub fn code_block_with_lang(code: &str, lang: &str) -> String { + format!("```{}\n{}\n```", escape(lang), escape_code(code)) +} + +/// Formats the string as an inline code. +/// +/// Escapes ``` and `\` characters inside the block. +pub fn code_inline(s: &str) -> String { + format!("`{}`", escape_code(s)) +} + +/// Escapes the string to be shown "as is" within the Telegram [Markdown +/// v2][spec] message style. +/// +/// [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"\!") +} + +/// 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"\)") +} + +/// Escapes all markdown special characters specific for the code block (``` and +/// `\`). +pub fn escape_code(s: &str) -> String { + s.replace(r"\", r"\\").replace("`", r"\`") +} + +pub fn user_mention_or_link(user: &User) -> String { + match user.mention() { + Some(mention) => mention, + None => link(user.url().as_str(), &user.full_name()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bold() { + assert_eq!(bold(" foobar "), "* foobar *"); + assert_eq!(bold(" _foobar_ "), "* _foobar_ *"); + assert_eq!(bold("~(`foobar`)~"), "*~(`foobar`)~*"); + } + + #[test] + fn test_italic() { + assert_eq!(italic(" foobar "), "_ foobar _"); + assert_eq!(italic("*foobar*"), "_*foobar*_"); + assert_eq!(italic("~(foobar)~"), "_~(foobar)~_"); + } + + #[test] + fn test_underline() { + assert_eq!(underline(" foobar "), "__ foobar __"); + assert_eq!(underline("*foobar*"), "__*foobar*__"); + assert_eq!(underline("~(foobar)~"), "__~(foobar)~__"); + } + + #[test] + fn test_strike() { + assert_eq!(strike(" foobar "), "~ foobar ~"); + assert_eq!(strike("*foobar*"), "~*foobar*~"); + assert_eq!(strike("*(foobar)*"), "~*(foobar)*~"); + } + + #[test] + fn test_italic_with_underline() { + assert_eq!(underline(italic("foobar").as_str()), r"___foobar_\r__"); + assert_eq!(italic(underline("foobar").as_str()), r"___foobar_\r__"); + } + + #[test] + fn test_link() { + assert_eq!( + link("https://www.google.com/(`foobar`)", "google"), + r"[google](https://www.google.com/(\`foobar\`\))", + ); + } + + #[test] + fn test_user_mention() { + assert_eq!( + user_mention(123_456_789, "pwner666"), + "[pwner666](tg://user?id=123456789)" + ); + } + + #[test] + fn test_code_block() { + assert_eq!( + code_block("pre-'formatted'\nfixed-width \\code `block`"), + "```\npre-'formatted'\nfixed-width \\\\code \\`block\\`\n```" + ); + } + + #[test] + fn test_code_block_with_lang() { + assert_eq!( + code_block_with_lang( + "pre-'formatted'\nfixed-width \\code `block`", + "[python]" + ), + "```\\[python\\]\npre-'formatted'\nfixed-width \\\\code \ + \\`block\\`\n```" + ); + } + + #[test] + fn test_code_inline() { + assert_eq!( + code_inline(" let x = (1, 2, 3); "), + "` let x = (1, 2, 3); `" + ); + assert_eq!(code_inline("foo"), "`foo`"); + assert_eq!( + code_inline(r" `(code inside code \ )` "), + r"` \`(code inside code \\ )\` `" + ); + } + + #[test] + fn test_escape() { + assert_eq!(escape("* foobar *"), r"\* foobar \*"); + assert_eq!( + escape(r"_ * [ ] ( ) ~ \ ` # + - = | { } . !"), + r"\_ \* \[ \] \( \) \~ \ \` \# \+ \- \= \| \{ \} \. \!", + ); + } + + #[test] + fn test_escape_link_url() { + assert_eq!( + escape_link_url( + r"https://en.wikipedia.org/wiki/Development+(Software)" + ), + r"https://en.wikipedia.org/wiki/Development+(Software\)" + ); + assert_eq!( + escape_link_url(r"https://en.wikipedia.org/wiki/`"), + r"https://en.wikipedia.org/wiki/\`" + ); + assert_eq!( + escape_link_url(r"_*[]()~`#+-=|{}.!\"), + r"_*[](\)~\`#+-=|{}.!\" + ); + } + + #[test] + fn test_escape_code() { + assert_eq!( + escape_code(r"` \code inside the code\ `"), + r"\` \\code inside the code\\ \`" + ); + assert_eq!(escape_code(r"_*[]()~`#+-=|{}.!\"), r"_*[]()~\`#+-=|{}.!\\"); + } + + #[test] + fn user_mention_link() { + let user_with_username = User { + id: 0, + is_bot: false, + first_name: "".to_string(), + last_name: None, + username: Some("abcd".to_string()), + language_code: None, + }; + assert_eq!(user_mention_or_link(&user_with_username), "@abcd"); + let user_without_username = User { + id: 123_456_789, + is_bot: false, + first_name: "Name".to_string(), + last_name: None, + username: None, + language_code: None, + }; + assert_eq!( + user_mention_or_link(&user_without_username), + r#"[Name](tg://user/?id=123456789)"# + ) + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 00000000..15ff9ad4 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,5 @@ +//! Some useful utilities. + +pub mod command; +pub mod html; +pub mod markdown; diff --git a/teloxide-macros/Cargo.toml b/teloxide-macros/Cargo.toml new file mode 100644 index 00000000..cd38ba15 --- /dev/null +++ b/teloxide-macros/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "teloxide-macros" +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] +quote = "1.0.2" +syn = "1.0.13" + +[lib] +proc-macro = true \ No newline at end of file diff --git a/teloxide-macros/src/attr.rs b/teloxide-macros/src/attr.rs new file mode 100644 index 00000000..f57c1783 --- /dev/null +++ b/teloxide-macros/src/attr.rs @@ -0,0 +1,64 @@ +use syn::{ + parse::{Parse, ParseStream}, + LitStr, Token, +}; + +pub enum BotCommandAttribute { + Prefix, + Description, + RenameRule, +} + +impl Parse for BotCommandAttribute { + fn parse(input: ParseStream) -> Result { + let name_arg: syn::Ident = input.parse()?; + match name_arg.to_string().as_str() { + "prefix" => Ok(BotCommandAttribute::Prefix), + "description" => Ok(BotCommandAttribute::Description), + "rename" => Ok(BotCommandAttribute::RenameRule), + _ => Err(syn::Error::new(name_arg.span(), "unexpected argument")), + } + } +} + +pub struct Attr { + name: BotCommandAttribute, + value: String, +} + +impl Parse for Attr { + fn parse(input: ParseStream) -> Result { + let name = input.parse::()?; + input.parse::()?; + let value = input.parse::()?.value(); + + Ok(Self { name, value }) + } +} + +impl Attr { + pub fn name(&self) -> &BotCommandAttribute { + &self.name + } + + pub fn value(&self) -> String { + self.value.clone() + } +} + +pub struct VecAttrs { + pub data: Vec, +} + +impl Parse for VecAttrs { + fn parse(input: ParseStream) -> Result { + let mut data = vec![]; + while !input.is_empty() { + data.push(input.parse()?); + if !input.is_empty() { + input.parse::()?; + } + } + Ok(Self { data }) + } +} diff --git a/teloxide-macros/src/command.rs b/teloxide-macros/src/command.rs new file mode 100644 index 00000000..69be6e58 --- /dev/null +++ b/teloxide-macros/src/command.rs @@ -0,0 +1,63 @@ +use crate::{ + attr::{Attr, BotCommandAttribute}, + rename_rules::rename_by_rule, +}; + +pub struct Command { + pub prefix: Option, + pub description: Option, + pub name: String, + pub renamed: bool, +} + +impl Command { + pub fn try_from(attrs: &[Attr], name: &str) -> Result { + let attrs = parse_attrs(attrs)?; + let mut new_name = name.to_string(); + let mut renamed = false; + + let prefix = attrs.prefix; + let description = attrs.description; + let rename = attrs.rename; + if let Some(rename_rule) = rename { + new_name = rename_by_rule(name, &rename_rule); + renamed = true; + } + Ok(Self { + prefix, + description, + name: new_name, + renamed, + }) + } +} + +struct CommandAttrs { + prefix: Option, + description: Option, + rename: Option, +} + +fn parse_attrs(attrs: &[Attr]) -> Result { + let mut prefix = None; + let mut description = None; + let mut rename_rule = None; + + for attr in attrs { + match attr.name() { + BotCommandAttribute::Prefix => prefix = Some(attr.value()), + BotCommandAttribute::Description => { + description = Some(attr.value()) + } + BotCommandAttribute::RenameRule => rename_rule = Some(attr.value()), + #[allow(unreachable_patterns)] + _ => return Err("unexpected attribute".to_owned()), + } + } + + Ok(CommandAttrs { + prefix, + description, + rename: rename_rule, + }) +} diff --git a/teloxide-macros/src/enum_attributes.rs b/teloxide-macros/src/enum_attributes.rs new file mode 100644 index 00000000..b54dc9a6 --- /dev/null +++ b/teloxide-macros/src/enum_attributes.rs @@ -0,0 +1,58 @@ +use crate::attr::{Attr, BotCommandAttribute}; + +pub struct CommandEnum { + pub prefix: Option, + pub description: Option, + pub rename_rule: Option, +} + +impl CommandEnum { + pub fn try_from(attrs: &[Attr]) -> Result { + let attrs = parse_attrs(attrs)?; + + let prefix = attrs.prefix; + let description = attrs.description; + let rename = attrs.rename; + if let Some(rename_rule) = &rename { + match rename_rule.as_str() { + "lowercase" => {} + _ => return Err("disallowed value".to_owned()), + } + } + Ok(Self { + prefix, + description, + rename_rule: rename, + }) + } +} + +struct CommandAttrs { + prefix: Option, + description: Option, + rename: Option, +} + +fn parse_attrs(attrs: &[Attr]) -> Result { + let mut prefix = None; + let mut description = None; + let mut rename_rule = None; + + for attr in attrs { + match attr.name() { + BotCommandAttribute::Prefix => prefix = Some(attr.value()), + BotCommandAttribute::Description => { + description = Some(attr.value()) + } + BotCommandAttribute::RenameRule => rename_rule = Some(attr.value()), + #[allow(unreachable_patterns)] + _ => return Err("unexpected attribute".to_owned()), + } + } + + Ok(CommandAttrs { + prefix, + description, + rename: rename_rule, + }) +} diff --git a/teloxide-macros/src/lib.rs b/teloxide-macros/src/lib.rs new file mode 100644 index 00000000..b15f3492 --- /dev/null +++ b/teloxide-macros/src/lib.rs @@ -0,0 +1,151 @@ +mod attr; +mod command; +mod enum_attributes; +mod rename_rules; + +extern crate proc_macro; +extern crate syn; +use crate::{ + attr::{Attr, VecAttrs}, + command::Command, + enum_attributes::CommandEnum, + rename_rules::rename_by_rule, +}; +use proc_macro::TokenStream; +use quote::{quote, ToTokens}; +use syn::{parse_macro_input, DeriveInput}; + +macro_rules! get_or_return { + ($($some:tt)*) => { + match $($some)* { + Ok(elem) => elem, + Err(e) => return e + }; + } +} + +#[proc_macro_derive(BotCommand, attributes(command))] +pub fn derive_telegram_command_enum(tokens: TokenStream) -> TokenStream { + let input = parse_macro_input!(tokens as DeriveInput); + + let data_enum: &syn::DataEnum = get_or_return!(get_enum_data(&input)); + + let enum_attrs: Vec = get_or_return!(parse_attributes(&input.attrs)); + + let command_enum = match CommandEnum::try_from(enum_attrs.as_slice()) { + Ok(command_enum) => command_enum, + Err(e) => return compile_error(e), + }; + + let variants: Vec<&syn::Variant> = + data_enum.variants.iter().map(|attr| attr).collect(); + + let mut variant_infos = vec![]; + for variant in variants.iter() { + let mut attrs = Vec::new(); + for attr in &variant.attrs { + match attr.parse_args::() { + Ok(mut attrs_) => { + attrs.append(attrs_.data.as_mut()); + } + Err(e) => { + return compile_error(e.to_compile_error()); + } + } + } + match Command::try_from(attrs.as_slice(), &variant.ident.to_string()) { + Ok(command) => variant_infos.push(command), + Err(e) => return compile_error(e), + } + } + + let variant_ident = variants.iter().map(|variant| &variant.ident); + let variant_name = variant_infos.iter().map(|info| { + if info.renamed { + info.name.clone() + } else if let Some(rename_rule) = &command_enum.rename_rule { + rename_by_rule(&info.name, rename_rule) + } else { + info.name.clone() + } + }); + let variant_prefixes = variant_infos.iter().map(|info| { + if let Some(prefix) = &info.prefix { + prefix + } else if let Some(prefix) = &command_enum.prefix { + prefix + } else { + "/" + } + }); + let variant_str1 = variant_prefixes + .zip(variant_name) + .map(|(prefix, command)| prefix.to_string() + command.as_str()); + let variant_str2 = variant_str1.clone(); + let variant_description = variant_infos + .iter() + .map(|info| info.description.as_deref().unwrap_or("")); + + let ident = &input.ident; + + let global_description = if let Some(s) = &command_enum.description { + quote! { #s, "\n", } + } else { + quote! {} + }; + + let expanded = quote! { + impl BotCommand for #ident { + fn try_from(value: &str) -> Option { + match value { + #( + #variant_str1 => Some(Self::#variant_ident), + )* + _ => None + } + } + fn descriptions() -> String { + std::concat!(#global_description #(#variant_str2, " - ", #variant_description, '\n'),*).to_string() + } + fn parse(s: &str) -> Option<(Self, Vec<&str>)> { + let mut words = s.split_whitespace(); + let command = Self::try_from(words.next()?)?; + Some((command, words.collect())) + } + } + }; + //for debug + //println!("{}", &expanded.to_string()); + TokenStream::from(expanded) +} + +fn get_enum_data(input: &DeriveInput) -> Result<&syn::DataEnum, TokenStream> { + match &input.data { + syn::Data::Enum(data) => Ok(data), + _ => Err(compile_error("TelegramBotCommand allowed only for enums")), + } +} + +fn parse_attributes( + input: &[syn::Attribute], +) -> Result, TokenStream> { + let mut enum_attrs = Vec::new(); + for attr in input.iter() { + match attr.parse_args::() { + Ok(mut attrs_) => { + enum_attrs.append(attrs_.data.as_mut()); + } + Err(e) => { + return Err(compile_error(e.to_compile_error())); + } + } + } + Ok(enum_attrs) +} + +fn compile_error(data: T) -> TokenStream +where + T: ToTokens, +{ + TokenStream::from(quote! { compile_error!(#data) }) +} diff --git a/teloxide-macros/src/rename_rules.rs b/teloxide-macros/src/rename_rules.rs new file mode 100644 index 00000000..ebba4eef --- /dev/null +++ b/teloxide-macros/src/rename_rules.rs @@ -0,0 +1,6 @@ +pub fn rename_by_rule(input: &str, rule: &str) -> String { + match rule { + "lowercase" => input.to_string().to_lowercase(), + _ => rule.to_string(), + } +}