├── .gitignore ├── examples ├── LICENSE ├── phrase_bot │ ├── migrations │ │ ├── .keep │ │ ├── 2024-07-14-144258_add_database │ │ │ ├── down.sql │ │ │ └── up.sql │ │ └── 00000000000000_diesel_initial_setup │ │ │ ├── down.sql │ │ │ └── up.sql │ ├── src │ │ ├── handlers │ │ │ ├── mod.rs │ │ │ └── public.rs │ │ ├── resources │ │ │ ├── mod.rs │ │ │ ├── keyboards.rs │ │ │ ├── text.rs │ │ │ └── handler_tree.rs │ │ ├── db │ │ │ ├── schema.rs │ │ │ ├── models.rs │ │ │ └── mod.rs │ │ └── main.rs │ ├── diesel.toml │ └── Cargo.toml ├── Cargo.toml ├── .example.env ├── hello_world_bot │ ├── Cargo.toml │ └── src │ │ └── main.rs ├── file_download_bot │ ├── Cargo.toml │ └── src │ │ └── main.rs ├── album_bot │ └── Cargo.toml ├── deep_linking_bot │ ├── Cargo.toml │ └── src │ │ ├── text.rs │ │ ├── handler_tree.rs │ │ ├── main.rs │ │ ├── handlers.rs │ │ └── tests.rs ├── calculator_bot │ ├── Cargo.toml │ └── src │ │ ├── text.rs │ │ ├── main.rs │ │ ├── handler_tree.rs │ │ ├── handlers.rs │ │ └── tests.rs └── README.md ├── teloxide_tests ├── LICENSE ├── src │ ├── server │ │ ├── routes │ │ │ ├── get_updates.rs │ │ │ ├── get_me.rs │ │ │ ├── get_webhook_info.rs │ │ │ ├── get_file.rs │ │ │ ├── unpin_all_chat_messages.rs │ │ │ ├── set_my_commands.rs │ │ │ ├── unban_chat_member.rs │ │ │ ├── answer_callback_query.rs │ │ │ ├── send_chat_action.rs │ │ │ ├── download_file.rs │ │ │ ├── set_message_reaction.rs │ │ │ ├── pin_chat_message.rs │ │ │ ├── restrict_chat_member.rs │ │ │ ├── delete_message.rs │ │ │ ├── unpin_chat_message.rs │ │ │ ├── ban_chat_member.rs │ │ │ ├── delete_messages.rs │ │ │ ├── send_dice.rs │ │ │ ├── edit_message_reply_markup.rs │ │ │ ├── edit_message_caption.rs │ │ │ ├── send_message.rs │ │ │ ├── send_contact.rs │ │ │ ├── edit_message_text.rs │ │ │ ├── forward_message.rs │ │ │ ├── send_location.rs │ │ │ ├── send_venue.rs │ │ │ ├── send_sticker.rs │ │ │ ├── send_invoice.rs │ │ │ ├── send_video_note.rs │ │ │ ├── copy_message.rs │ │ │ ├── send_voice.rs │ │ │ ├── send_photo.rs │ │ │ ├── send_poll.rs │ │ │ ├── send_audio.rs │ │ │ ├── send_document.rs │ │ │ ├── send_video.rs │ │ │ ├── send_animation.rs │ │ │ └── mod.rs │ │ ├── mod.rs │ │ └── messages.rs │ ├── listener.rs │ ├── utils.rs │ ├── state.rs │ ├── dataset │ │ ├── update.rs │ │ ├── queries.rs │ │ └── chat.rs │ └── lib.rs └── Cargo.toml ├── teloxide_tests_macros ├── LICENSE └── Cargo.toml ├── rustfmt.toml ├── rust-toolchain.toml ├── Cargo.toml ├── .github ├── pull_request_template.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── add-endpoint-template.md └── workflows │ └── ci.yml ├── LICENSE ├── CONTRIBUTING.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | .env 3 | -------------------------------------------------------------------------------- /examples/LICENSE: -------------------------------------------------------------------------------- 1 | ../LICENSE 2 | -------------------------------------------------------------------------------- /examples/phrase_bot/migrations/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /teloxide_tests/LICENSE: -------------------------------------------------------------------------------- 1 | ../LICENSE 2 | -------------------------------------------------------------------------------- /teloxide_tests_macros/LICENSE: -------------------------------------------------------------------------------- 1 | ../LICENSE 2 | -------------------------------------------------------------------------------- /examples/phrase_bot/src/handlers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod private; 2 | pub mod public; 3 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | imports_granularity = "Crate" 2 | group_imports = "StdExternalCrate" 3 | -------------------------------------------------------------------------------- /examples/phrase_bot/src/resources/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod handler_tree; 2 | pub mod keyboards; 3 | pub mod text; 4 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly-2024-10-10" 3 | components = [ "rustfmt", "clippy" ] 4 | -------------------------------------------------------------------------------- /examples/phrase_bot/migrations/2024-07-14-144258_add_database/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE phrases; 2 | DROP TABLE users; 3 | -------------------------------------------------------------------------------- /examples/Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ "album_bot", "calculator_bot", "deep_linking_bot", "file_download_bot", "hello_world_bot", "phrase_bot"] 3 | resolver = "2" 4 | -------------------------------------------------------------------------------- /examples/.example.env: -------------------------------------------------------------------------------- 1 | TELOXIDE_TOKEN=YOUR_TELOXIDE_TOKEN 2 | REDIS_URL=redis://127.0.0.1:6379/1 # Only for bots with state 3 | DATABASE_URL=postgres://postgres:password@localhost/phrase_bot # Only for phrase_bot 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["teloxide_tests", "teloxide_tests_macros"] 3 | resolver = "2" 4 | 5 | [workspace.package] 6 | # MSRV 7 | rust-version = "1.80" 8 | edition = "2021" 9 | 10 | license = "MIT" 11 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/get_updates.rs: -------------------------------------------------------------------------------- 1 | use actix_web::Responder; 2 | use serde_json::json; 3 | 4 | use super::make_telegram_result; 5 | 6 | pub async fn get_updates() -> impl Responder { 7 | make_telegram_result(json!([])) 8 | } 9 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/get_me.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{web, Responder}; 2 | use teloxide::types::Me; 3 | 4 | use super::make_telegram_result; 5 | 6 | pub async fn get_me(me: web::Data) -> impl Responder { 7 | make_telegram_result(me) 8 | } 9 | -------------------------------------------------------------------------------- /examples/phrase_bot/diesel.toml: -------------------------------------------------------------------------------- 1 | # For documentation on how to configure this file, 2 | # see https://diesel.rs/guides/configuring-diesel-cli 3 | 4 | [print_schema] 5 | file = "src/db/schema.rs" 6 | custom_type_derives = ["diesel::query_builder::QueryId"] 7 | 8 | [migrations_directory] 9 | dir = "migrations" 10 | -------------------------------------------------------------------------------- /teloxide_tests_macros/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "teloxide_tests_macros" 3 | version = "0.2.0" 4 | edition = "2021" 5 | 6 | description = "Proc macros for teloxide_tests" 7 | license = "MIT" 8 | 9 | [dependencies] 10 | syn = "2.0" 11 | quote = "1.0" 12 | proc-macro2 = "1.0" 13 | 14 | [lib] 15 | proc-macro = true 16 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/get_webhook_info.rs: -------------------------------------------------------------------------------- 1 | use actix_web::Responder; 2 | use serde_json::json; 3 | 4 | use super::make_telegram_result; 5 | 6 | pub async fn get_webhook_info() -> impl Responder { 7 | make_telegram_result( 8 | json!({"url": "", "has_custom_certificate":false,"pending_update_count":0}), 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /examples/hello_world_bot/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hello_world_bot" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | teloxide = { version = "0.17.0", features = ["macros"] } 8 | tokio = { version = "1.38", features = ["rt-multi-thread", "macros"] } 9 | dotenv = "0.15.0" 10 | 11 | [dev-dependencies] 12 | teloxide_tests = { path = "../../teloxide_tests" } 13 | -------------------------------------------------------------------------------- /examples/file_download_bot/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "file_download_bot" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | teloxide = { version = "0.17.0", features = ["macros"] } 8 | tokio = { version = "1.38", features = ["rt-multi-thread", "macros"] } 9 | dotenv = "0.15.0" 10 | 11 | [dev-dependencies] 12 | teloxide_tests = { path = "../../teloxide_tests" } 13 | -------------------------------------------------------------------------------- /examples/phrase_bot/migrations/00000000000000_diesel_initial_setup/down.sql: -------------------------------------------------------------------------------- 1 | -- This file was automatically created by Diesel to setup helper functions 2 | -- and other internal bookkeeping. This file is safe to edit, any future 3 | -- changes will be added to existing projects as new migrations. 4 | 5 | DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); 6 | DROP FUNCTION IF EXISTS diesel_set_updated_at(); 7 | -------------------------------------------------------------------------------- /examples/album_bot/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "album_bot" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | teloxide = { version = "0.17.0", features = ["macros"] } 8 | tokio = { version = "1.38", features = ["rt-multi-thread", "macros"] } 9 | dotenv = "0.15.0" 10 | log = "0.4" 11 | pretty_env_logger = "0.5" 12 | 13 | [dev-dependencies] 14 | teloxide_tests = { path = "../../teloxide_tests" } 15 | -------------------------------------------------------------------------------- /examples/deep_linking_bot/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "deep_linking_bot" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | teloxide = { version = "0.17.0", features = ["macros"] } 8 | tokio = { version = "1.38", features = ["rt-multi-thread", "macros"] } 9 | dotenv = "0.15.0" 10 | serde = { version = "1.0", features = ["derive"] } 11 | serde_json = "1.0" 12 | 13 | [dev-dependencies] 14 | teloxide_tests = { path = "../../teloxide_tests" } 15 | -------------------------------------------------------------------------------- /examples/phrase_bot/migrations/2024-07-14-144258_add_database/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users ( 2 | id BIGINT NOT NULL PRIMARY KEY, 3 | nickname TEXT 4 | ); 5 | 6 | CREATE TABLE phrases ( 7 | id SERIAL PRIMARY KEY, 8 | user_id BIGINT NOT NULL, 9 | emoji TEXT NOT NULL, 10 | text TEXT NOT NULL, 11 | bot_text TEXT NOT NULL 12 | ); 13 | 14 | ALTER TABLE phrases ADD CONSTRAINT phrases_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; 15 | -------------------------------------------------------------------------------- /examples/calculator_bot/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "calculator_bot" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | teloxide = { version = "0.17.0", features = ["macros", "redis-storage", "cbor-serializer"] } 8 | tokio = { version = "1.38", features = ["rt-multi-thread", "macros"] } 9 | dotenv = "0.15.0" 10 | serde = { version = "1.0", features = ["derive"] } 11 | serde_json = "1.0" 12 | log = "0.4" 13 | pretty_env_logger = "0.5" 14 | 15 | [dev-dependencies] 16 | teloxide_tests = { path = "../../teloxide_tests" } 17 | -------------------------------------------------------------------------------- /examples/phrase_bot/src/db/schema.rs: -------------------------------------------------------------------------------- 1 | // @generated automatically by Diesel CLI. 2 | 3 | diesel::table! { 4 | phrases (id) { 5 | id -> Int4, 6 | user_id -> Int8, 7 | emoji -> Text, 8 | text -> Text, 9 | bot_text -> Text, 10 | } 11 | } 12 | 13 | diesel::table! { 14 | users (id) { 15 | id -> Int8, 16 | nickname -> Nullable, 17 | } 18 | } 19 | 20 | diesel::joinable!(phrases -> users (user_id)); 21 | 22 | diesel::allow_tables_to_appear_in_same_query!(phrases, users,); 23 | -------------------------------------------------------------------------------- /examples/phrase_bot/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "phrase_bot" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | teloxide = { version = "0.17.0", features = ["macros", "redis-storage", "cbor-serializer"] } 8 | tokio = { version = "1.38", features = ["rt-multi-thread", "macros"] } 9 | dotenv = "0.15.0" 10 | serde = { version = "1.0", features = ["derive"] } 11 | serde_json = "1.0" 12 | log = "0.4" 13 | pretty_env_logger = "0.5" 14 | diesel = { version = "2.1.6", features = ["postgres"] } 15 | 16 | [dev-dependencies] 17 | teloxide_tests = { path = "../../teloxide_tests" } 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What went wrong:** 11 | __A clear and concise description of what the bug is.__ 12 | 13 | **To Reproduce:** 14 | Run this code 15 | ```rust 16 | /* your code */ 17 | ``` 18 | Expected result is ... 19 | But this code returns ... 20 | 21 | **Additional context:** 22 | __Add any other context about the problem here.__ 23 | 24 | **Meta:** 25 | `teloxide_tests` version: __your version__ 26 | `teloxide` version: __your version__ 27 | -------------------------------------------------------------------------------- /examples/calculator_bot/src/text.rs: -------------------------------------------------------------------------------- 1 | // It's just a good practice to keep all the texts in one place in my opinion 2 | pub const WHAT_DO_YOU_WANT: &str = "Do you want to add or subtract two numbers?"; 3 | pub const ENTER_THE_FIRST_NUMBER: &str = "Enter the first number"; 4 | pub const ENTER_THE_SECOND_NUMBER: &str = "Enter the second number"; 5 | pub const PLEASE_ENTER_A_NUMBER: &str = "Please enter a number"; 6 | pub const PLEASE_SEND_TEXT: &str = "Please send text, not anything else"; 7 | pub const YOUR_RESULT: &str = "Your result: "; 8 | pub const SORRY_BOT_UPDATED: &str = 9 | "Sorry, bot updated and we lost where you were. Please try again."; 10 | -------------------------------------------------------------------------------- /examples/deep_linking_bot/src/text.rs: -------------------------------------------------------------------------------- 1 | pub const START: &str = 2 | "Hello! This bot is made to ask or say something to someone completely anonymously! 3 | 4 | This link allows anyone to message you secretly: {deep_link}"; 5 | 6 | pub const SEND_YOUR_MESSAGE: &str = "Send your message:"; 7 | 8 | pub const MESSAGE_SENT: &str = "Message sent! 9 | 10 | Your link is: {deep_link}"; 11 | 12 | pub const ERROR_SENDING_MESSAGE: &str = "Error sending message! Maybe user blocked the bot?"; 13 | 14 | pub const WRONG_LINK: &str = "Bad link!"; 15 | 16 | pub const SEND_TEXT: &str = "This bot can send only text."; 17 | 18 | pub const YOU_HAVE_A_NEW_MESSAGE: &str = "You have a new message! 19 | 20 | {message}"; 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /examples/phrase_bot/src/resources/keyboards.rs: -------------------------------------------------------------------------------- 1 | use teloxide::types::{KeyboardButton, KeyboardMarkup}; 2 | 3 | pub const PROFILE_BUTTON: &str = "Profile"; 4 | pub const ADD_PHRASE_BUTTON: &str = "Add a phrase"; 5 | pub const REMOVE_PHRASE_BUTTON: &str = "Remove a phrase"; 6 | pub const CHANGE_NICKNAME_BUTTON: &str = "Change nickname"; 7 | 8 | pub fn menu_keyboard() -> KeyboardMarkup { 9 | KeyboardMarkup::new(vec![ 10 | vec![KeyboardButton::new(PROFILE_BUTTON)], 11 | vec![ 12 | KeyboardButton::new(ADD_PHRASE_BUTTON), 13 | KeyboardButton::new(REMOVE_PHRASE_BUTTON), 14 | ], 15 | vec![KeyboardButton::new(CHANGE_NICKNAME_BUTTON)], 16 | ]) 17 | .persistent() 18 | } 19 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/get_file.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{error::ErrorBadRequest, web, Responder}; 4 | use serde::Deserialize; 5 | use teloxide::types::FileId; 6 | 7 | use super::make_telegram_result; 8 | use crate::state::State; 9 | 10 | #[derive(Deserialize)] 11 | pub struct GetFileQuery { 12 | file_id: FileId, 13 | } 14 | 15 | pub async fn get_file( 16 | query: web::Json, 17 | state: web::Data>, 18 | ) -> impl Responder { 19 | let lock = state.lock().unwrap(); 20 | let Some(file) = lock.files.iter().find(|f| f.id == query.file_id) else { 21 | return ErrorBadRequest("File not found").into(); 22 | }; 23 | make_telegram_result(file) 24 | } 25 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/unpin_all_chat_messages.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{web, Responder}; 4 | use serde::Deserialize; 5 | 6 | use super::BodyChatId; 7 | use crate::{server::routes::make_telegram_result, state::State}; 8 | 9 | #[derive(Debug, Deserialize, Clone)] 10 | pub struct UnpinAllChatMessagesBody { 11 | pub chat_id: BodyChatId, 12 | } 13 | 14 | pub async fn unpin_all_chat_messages( 15 | state: web::Data>, 16 | body: web::Json, 17 | ) -> impl Responder { 18 | let mut lock = state.lock().unwrap(); 19 | lock.responses 20 | .unpinned_all_chat_messages 21 | .push(body.into_inner()); 22 | 23 | make_telegram_result(true) 24 | } 25 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/set_my_commands.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{web, Responder}; 4 | use serde::Deserialize; 5 | use teloxide::types::{BotCommand, BotCommandScope}; 6 | 7 | use super::make_telegram_result; 8 | use crate::state::State; 9 | 10 | #[derive(Debug, Deserialize, Clone)] 11 | pub struct SetMyCommandsBody { 12 | pub commands: Vec, 13 | pub scope: Option, 14 | pub language_code: Option, 15 | } 16 | 17 | pub async fn set_my_commands( 18 | state: web::Data>, 19 | body: web::Json, 20 | ) -> impl Responder { 21 | let mut lock = state.lock().unwrap(); 22 | lock.responses.set_my_commands.push(body.into_inner()); 23 | 24 | make_telegram_result(true) 25 | } 26 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/unban_chat_member.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{web, Responder}; 4 | use serde::Deserialize; 5 | 6 | use super::BodyChatId; 7 | use crate::{server::routes::make_telegram_result, state::State}; 8 | 9 | #[derive(Debug, Deserialize, Clone)] 10 | pub struct UnbanChatMemberBody { 11 | pub chat_id: BodyChatId, 12 | pub user_id: u64, 13 | pub only_if_banned: Option, 14 | } 15 | 16 | pub async fn unban_chat_member( 17 | state: web::Data>, 18 | body: web::Json, 19 | ) -> impl Responder { 20 | // Idk what to verify here 21 | let mut lock = state.lock().unwrap(); 22 | lock.responses.unbanned_chat_members.push(body.into_inner()); 23 | 24 | make_telegram_result(true) 25 | } 26 | -------------------------------------------------------------------------------- /examples/deep_linking_bot/src/handler_tree.rs: -------------------------------------------------------------------------------- 1 | use dptree::case; 2 | use teloxide::{ 3 | dispatching::{dialogue, dialogue::InMemStorage, UpdateFilterExt, UpdateHandler}, 4 | prelude::*, 5 | }; 6 | 7 | use crate::{handlers::*, StartCommand, State}; 8 | 9 | pub fn handler_tree() -> UpdateHandler> { 10 | dialogue::enter::, State, _>() 11 | .branch( 12 | Update::filter_message() 13 | .filter_command::() 14 | .branch(case![StartCommand::Start(start)].endpoint(start)), 15 | ) 16 | .branch( 17 | Update::filter_message() 18 | .branch(case![State::WriteToSomeone { id }].endpoint(send_message)), 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/answer_callback_query.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{web, Responder}; 4 | use serde::Deserialize; 5 | 6 | use super::make_telegram_result; 7 | use crate::state::State; 8 | 9 | #[derive(Debug, Deserialize, Clone)] 10 | pub struct AnswerCallbackQueryBody { 11 | pub callback_query_id: String, 12 | pub text: Option, 13 | pub show_alert: Option, 14 | pub url: Option, 15 | pub cache_time: Option, 16 | } 17 | 18 | pub async fn answer_callback_query( 19 | state: web::Data>, 20 | body: web::Json, 21 | ) -> impl Responder { 22 | let mut lock = state.lock().unwrap(); 23 | lock.responses 24 | .answered_callback_queries 25 | .push(body.into_inner()); 26 | make_telegram_result(true) 27 | } 28 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/send_chat_action.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{web, Responder}; 4 | use serde::Deserialize; 5 | use teloxide::types::BusinessConnectionId; 6 | 7 | use super::BodyChatId; 8 | use crate::{server::routes::make_telegram_result, state::State}; 9 | 10 | #[derive(Debug, Deserialize, Clone)] 11 | pub struct SendChatActionBody { 12 | pub chat_id: BodyChatId, 13 | pub message_thread_id: Option, 14 | pub action: String, 15 | pub business_connection_id: Option, 16 | } 17 | 18 | pub async fn send_chat_action( 19 | state: web::Data>, 20 | body: web::Json, 21 | ) -> impl Responder { 22 | let mut lock = state.lock().unwrap(); 23 | lock.responses.sent_chat_actions.push(body.into_inner()); 24 | 25 | make_telegram_result(true) 26 | } 27 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/download_file.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Error, sync::Mutex}; 2 | 3 | use actix_web::{ 4 | error::ErrorBadRequest, 5 | web::{self, Bytes}, 6 | HttpResponse, 7 | }; 8 | use futures_util::{future::ok, stream::once}; 9 | 10 | use crate::state::State; 11 | 12 | pub async fn download_file( 13 | path: web::Path<(String, String)>, 14 | state: web::Data>, 15 | ) -> HttpResponse { 16 | if state 17 | .lock() 18 | .unwrap() 19 | .files 20 | .clone() 21 | .into_iter() 22 | .find(|f| f.path == path.1) 23 | .is_none() 24 | { 25 | return ErrorBadRequest("No such file found").into(); 26 | } 27 | 28 | let stream = once(ok::<_, Error>(Bytes::copy_from_slice( 29 | "Hello, world!".as_bytes(), 30 | ))); 31 | 32 | HttpResponse::Ok().streaming(stream) 33 | } 34 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/set_message_reaction.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{error::ErrorBadRequest, web, Responder}; 4 | use serde::Deserialize; 5 | use teloxide::types::ReactionType; 6 | 7 | use super::{make_telegram_result, BodyChatId}; 8 | use crate::{server::routes::check_if_message_exists, state::State}; 9 | 10 | #[derive(Debug, Deserialize, Clone)] 11 | pub struct SetMessageReactionBody { 12 | pub chat_id: BodyChatId, 13 | pub message_id: i32, 14 | pub reaction: Option>, 15 | pub is_big: Option, 16 | } 17 | 18 | pub async fn set_message_reaction( 19 | state: web::Data>, 20 | body: web::Json, 21 | ) -> impl Responder { 22 | let mut lock = state.lock().unwrap(); 23 | 24 | check_if_message_exists!(lock, body.message_id); 25 | 26 | lock.responses.set_message_reaction.push(body.into_inner()); 27 | 28 | make_telegram_result(true) 29 | } 30 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/pin_chat_message.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{error::ErrorBadRequest, web, Responder}; 4 | use serde::Deserialize; 5 | use teloxide::types::BusinessConnectionId; 6 | 7 | use super::{check_if_message_exists, BodyChatId}; 8 | use crate::{server::routes::make_telegram_result, state::State}; 9 | 10 | #[derive(Debug, Deserialize, Clone)] 11 | pub struct PinChatMessageBody { 12 | pub chat_id: BodyChatId, 13 | pub message_id: i32, 14 | pub disable_notification: Option, 15 | pub business_connection_id: Option, 16 | } 17 | 18 | pub async fn pin_chat_message( 19 | state: web::Data>, 20 | body: web::Json, 21 | ) -> impl Responder { 22 | let mut lock = state.lock().unwrap(); 23 | check_if_message_exists!(lock, body.message_id); 24 | lock.responses.pinned_chat_messages.push(body.into_inner()); 25 | make_telegram_result(true) 26 | } 27 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/restrict_chat_member.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{web, Responder}; 4 | use serde::Deserialize; 5 | use teloxide::types::ChatPermissions; 6 | 7 | use super::BodyChatId; 8 | use crate::{server::routes::make_telegram_result, state::State}; 9 | 10 | #[derive(Debug, Deserialize, Clone)] 11 | pub struct RestrictChatMemberBody { 12 | pub chat_id: BodyChatId, 13 | pub user_id: u64, 14 | pub permissions: ChatPermissions, 15 | pub use_independent_chat_permissions: Option, 16 | pub until_date: Option, 17 | } 18 | 19 | pub async fn restrict_chat_member( 20 | state: web::Data>, 21 | body: web::Json, 22 | ) -> impl Responder { 23 | // Idk what to verify here 24 | let mut lock = state.lock().unwrap(); 25 | lock.responses 26 | .restricted_chat_members 27 | .push(body.into_inner()); 28 | 29 | make_telegram_result(true) 30 | } 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/add-endpoint-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Add endpoint template 3 | about: Suggest to add a fake server endpoint 4 | title: '' 5 | labels: no endpoint 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | **Endpoint:** 13 | https://core.telegram.org/bots/api#yourendpoint 14 | 15 | **This endpoint has to simulate:** 16 | __A description of what you want to see this endpoint do in a testing environment__ 17 | __For example:__ 18 | This endpoint has to return a fake user on request 19 | 20 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/delete_message.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{error::ErrorBadRequest, web, Responder}; 4 | use serde::Deserialize; 5 | 6 | use super::{check_if_message_exists, BodyChatId}; 7 | use crate::{ 8 | server::{routes::make_telegram_result, DeletedMessage}, 9 | state::State, 10 | }; 11 | 12 | #[derive(Debug, Deserialize, Clone)] 13 | pub struct DeleteMessageBody { 14 | pub chat_id: BodyChatId, 15 | pub message_id: i32, 16 | } 17 | 18 | pub async fn delete_message( 19 | state: web::Data>, 20 | body: web::Json, 21 | ) -> impl Responder { 22 | let mut lock = state.lock().unwrap(); 23 | check_if_message_exists!(lock, body.message_id); 24 | let deleted_message = lock.messages.delete_message(body.message_id).unwrap(); 25 | lock.responses.deleted_messages.push(DeletedMessage { 26 | message: deleted_message.clone(), 27 | bot_request: body.into_inner(), 28 | }); 29 | 30 | make_telegram_result(true) 31 | } 32 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/unpin_chat_message.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{error::ErrorBadRequest, web, Responder}; 4 | use serde::Deserialize; 5 | use teloxide::types::BusinessConnectionId; 6 | 7 | use super::{check_if_message_exists, BodyChatId}; 8 | use crate::{server::routes::make_telegram_result, state::State}; 9 | 10 | #[derive(Debug, Deserialize, Clone)] 11 | pub struct UnpinChatMessageBody { 12 | pub chat_id: BodyChatId, 13 | pub message_id: Option, 14 | pub business_connection_id: Option, 15 | } 16 | 17 | pub async fn unpin_chat_message( 18 | state: web::Data>, 19 | body: web::Json, 20 | ) -> impl Responder { 21 | let mut lock = state.lock().unwrap(); 22 | if let Some(message_id) = body.message_id { 23 | check_if_message_exists!(lock, message_id); 24 | } 25 | lock.responses 26 | .unpinned_chat_messages 27 | .push(body.into_inner()); 28 | 29 | make_telegram_result(true) 30 | } 31 | -------------------------------------------------------------------------------- /examples/phrase_bot/src/db/models.rs: -------------------------------------------------------------------------------- 1 | use diesel::prelude::*; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::schema; 5 | 6 | #[derive(Queryable, Selectable)] 7 | #[diesel(table_name = schema::users)] 8 | #[diesel(check_for_backend(diesel::pg::Pg))] 9 | #[derive(Debug, Clone, PartialEq)] 10 | pub struct User { 11 | pub id: i64, 12 | pub nickname: Option, 13 | } 14 | 15 | #[derive(Insertable)] 16 | #[diesel(table_name = schema::users)] 17 | pub struct NewUser { 18 | pub id: i64, 19 | } 20 | 21 | #[derive(Queryable, Selectable, Serialize, Deserialize)] 22 | #[diesel(table_name = schema::phrases)] 23 | #[diesel(check_for_backend(diesel::pg::Pg))] 24 | #[derive(Debug, Clone, PartialEq)] 25 | pub struct Phrase { 26 | pub id: i32, 27 | pub user_id: i64, 28 | pub emoji: String, 29 | pub text: String, 30 | pub bot_text: String, 31 | } 32 | 33 | #[derive(Insertable)] 34 | #[diesel(table_name = schema::phrases)] 35 | pub struct NewPhrase { 36 | pub user_id: i64, 37 | pub emoji: String, 38 | pub text: String, 39 | pub bot_text: String, 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 LasterAlex 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/ban_chat_member.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{web, Responder}; 4 | use serde::Deserialize; 5 | 6 | use super::BodyChatId; 7 | use crate::{server::routes::make_telegram_result, state::State}; 8 | 9 | #[derive(Debug, Deserialize, Clone)] 10 | pub struct BanChatMemberBody { 11 | pub chat_id: BodyChatId, 12 | pub user_id: u64, 13 | pub until_date: Option, 14 | pub revoke_messages: Option, 15 | } 16 | 17 | pub async fn ban_chat_member( 18 | state: web::Data>, 19 | body: web::Json, 20 | ) -> impl Responder { 21 | let mut lock = state.lock().unwrap(); 22 | let chat_id = body.chat_id.id(); 23 | if body.revoke_messages.is_some() && body.revoke_messages.unwrap() { 24 | for message in lock.messages.messages.clone() { 25 | if message.chat.id.0 == chat_id 26 | && message.from.is_some() 27 | && message.from.unwrap().id.0 == body.user_id 28 | { 29 | lock.messages.delete_message(message.id.0); 30 | } 31 | } 32 | } 33 | lock.responses.banned_chat_members.push(body.into_inner()); 34 | 35 | make_telegram_result(true) 36 | } 37 | -------------------------------------------------------------------------------- /teloxide_tests/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "teloxide_tests" 3 | version = "0.4.0" 4 | edition = "2021" 5 | description = "Test suite for teloxide bots" 6 | 7 | license = "MIT" 8 | readme = "../README.md" 9 | documentation = "https://docs.rs/teloxide_tests/" 10 | repository = "https://github.com/LasterAlex/teloxide_tests" 11 | 12 | keywords = ["teloxide", "telegram", "unit_test", "test", "testing"] 13 | categories = ["development-tools::testing"] 14 | 15 | [dependencies] 16 | ctrlc = "3.4.4" 17 | gag = "1.0.0" 18 | dotenv = "0.15.0" 19 | log = "0.4" 20 | pretty_env_logger = "0.5" 21 | url = "2.5.1" 22 | reqwest = "0.12.5" 23 | teloxide = { version = "0.17.0", features = ["macros", "sqlite-storage-nativetls"] } 24 | tokio = { version = "1.38", features = ["rt-multi-thread", "macros"] } 25 | serde = { version = "1.0", features = ["derive"] } 26 | serde_json = "1.0" 27 | teloxide_tests_macros = "0.2.0" 28 | mime = "0.3.17" 29 | chrono = "0.4.38" 30 | actix-web-lab = "0.23.0" 31 | mime_guess = "2.0.5" 32 | rand = "0.9.0" 33 | actix-multipart = "0.7.2" 34 | lazy_static = "1.5.0" 35 | futures-util = "0.3" 36 | actix-web = "4.9" 37 | env_logger = "0.11.5" 38 | tokio-util = "0.7.12" 39 | 40 | [dev-dependencies] 41 | serial_test = { version = "3.1.1" } 42 | 43 | [lib] 44 | name = "teloxide_tests" 45 | -------------------------------------------------------------------------------- /examples/phrase_bot/migrations/00000000000000_diesel_initial_setup/up.sql: -------------------------------------------------------------------------------- 1 | -- This file was automatically created by Diesel to setup helper functions 2 | -- and other internal bookkeeping. This file is safe to edit, any future 3 | -- changes will be added to existing projects as new migrations. 4 | 5 | 6 | 7 | 8 | -- Sets up a trigger for the given table to automatically set a column called 9 | -- `updated_at` whenever the row is modified (unless `updated_at` was included 10 | -- in the modified columns) 11 | -- 12 | -- # Example 13 | -- 14 | -- ```sql 15 | -- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); 16 | -- 17 | -- SELECT diesel_manage_updated_at('users'); 18 | -- ``` 19 | CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ 20 | BEGIN 21 | EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s 22 | FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); 23 | END; 24 | $$ LANGUAGE plpgsql; 25 | 26 | CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ 27 | BEGIN 28 | IF ( 29 | NEW IS DISTINCT FROM OLD AND 30 | NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at 31 | ) THEN 32 | NEW.updated_at := current_timestamp; 33 | END IF; 34 | RETURN NEW; 35 | END; 36 | $$ LANGUAGE plpgsql; 37 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/delete_messages.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{web, Responder}; 4 | use serde::Deserialize; 5 | 6 | use super::BodyChatId; 7 | use crate::{ 8 | server::{ 9 | routes::{delete_message::DeleteMessageBody, make_telegram_result}, 10 | DeletedMessage, 11 | }, 12 | state::State, 13 | }; 14 | 15 | #[derive(Debug, Deserialize, Clone)] 16 | pub struct DeleteMessagesBody { 17 | pub chat_id: BodyChatId, 18 | pub message_ids: Vec, 19 | } 20 | 21 | pub async fn delete_messages( 22 | state: web::Data>, 23 | body: web::Json, 24 | ) -> impl Responder { 25 | let mut lock = state.lock().unwrap(); 26 | let bot_request = body.into_inner(); 27 | // deleteMessages skips messages that are not found, no error is returned. 28 | let mut deleted_messages = lock 29 | .messages 30 | .delete_messages(&bot_request.message_ids) 31 | .into_iter() 32 | .map(|m| DeletedMessage { 33 | message: m.clone(), 34 | bot_request: DeleteMessageBody { 35 | chat_id: bot_request.chat_id.clone(), 36 | message_id: m.id.0, 37 | }, 38 | }) 39 | .collect(); 40 | 41 | lock.responses 42 | .deleted_messages 43 | .append(&mut deleted_messages); 44 | 45 | make_telegram_result(true) 46 | } 47 | -------------------------------------------------------------------------------- /examples/deep_linking_bot/src/main.rs: -------------------------------------------------------------------------------- 1 | pub mod handler_tree; 2 | pub mod handlers; 3 | #[cfg(test)] 4 | pub mod tests; 5 | pub mod text; 6 | 7 | use std::error::Error; 8 | 9 | use dptree::deps; 10 | use handler_tree::handler_tree; 11 | use teloxide::{dispatching::dialogue::InMemStorage, macros::BotCommands, prelude::*, types::Me}; 12 | 13 | pub type MyDialogue = Dialogue>; 14 | pub type HandlerResult = Result<(), Box>; 15 | 16 | #[derive(Clone, PartialEq, Debug, Default, serde::Serialize, serde::Deserialize)] 17 | pub enum State { 18 | #[default] 19 | Start, 20 | WriteToSomeone { 21 | id: i64, 22 | }, 23 | } 24 | 25 | #[derive(BotCommands, Clone, Debug)] 26 | #[command(rename_rule = "lowercase")] 27 | pub enum StartCommand { 28 | #[command()] 29 | Start(String), // Because deep linking (links like https://t.me/some_bot?start=123456789) is the 30 | // same as sending "/start 123456789", we can treat it as just an argument to a command 31 | // 32 | // https://core.telegram.org/bots/features#deep-linking 33 | } 34 | 35 | pub fn add_deep_link(text: &str, me: Me, chat_id: ChatId) -> String { 36 | text.replace( 37 | "{deep_link}", // Just a shortcut to not write it multiple times 38 | format!("{}?start={}", me.tme_url(), chat_id.0).as_str(), 39 | ) 40 | } 41 | 42 | #[tokio::main] 43 | async fn main() { 44 | dotenv::dotenv().ok(); // Loads the .env file 45 | 46 | let bot = Bot::from_env(); 47 | 48 | Dispatcher::builder(bot, handler_tree()) 49 | .dependencies(deps![InMemStorage::::new()]) 50 | .build() 51 | .dispatch() 52 | .await; 53 | } 54 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Teloxide tests examples 2 | To run them, firstly create a .env file like .example.env 3 | 4 | ### hello_world 5 | 6 | A simple bot that sends Hello World! and introduces to the bot testing, telling how to test anything. 7 | 8 | ### calculator_bot 9 | 10 | A little harder bot that shows the sintactic sugar for testing, and how to work with persistent state storage (using redis, but changing to InMemStorage is possible and easy). 11 | 12 | Tests are in src/tests.rs 13 | 14 | ### file_download_bot 15 | 16 | Bot that shows how to download files from the server and test it. 17 | 18 | ### album_bot 19 | 20 | Bot that tests the album sending and sending multiple updates at once. 21 | 22 | ### deep_linking_bot 23 | 24 | Bot that shows how to handle deep links with a simple chat bot with InMemStorage (links like https://t.me/some_bot?start=123456789, you've probably seen them as 'referral bot links'). 25 | 26 | Tests are in src/tests.rs 27 | 28 | ### phrase_bot 29 | 30 | The biggest bot, a bot that adds reactions, similar to some chat bots. Not particularly made to show some features, more like battle testing the crate and showing, how i will use this crate. 31 | 32 | ![image](https://github.com/user-attachments/assets/87ddae85-4166-48d4-b006-909b0f37d2f9) 33 | 34 | The tests are in the same files as handlers. 35 | 36 | To run it you need to set up diesel for database. 37 | 38 | 1. You need to install and start postgres on your machine, here is [ubuntu install](https://www.digitalocean.com/community/tutorials/how-to-install-postgresql-on-ubuntu-20-04-quickstart) 39 | 2. `cargo install diesel_cli --no-default-features --features postgres` 40 | 3. Add `~/.cargo/bin` to `PATH` (or just run ~/.cargo/bin/diesel by itself) 41 | 4. `diesel setup --database-url postgres://postgres:password@localhost/phrase_bot` in the phrase_bot directory (don't forget to change the password!) 42 | 5. `cargo run` or `cargo test`! 43 | 44 | Fun fact: I did not run the bot until i've written everything! The tests really helped! 45 | -------------------------------------------------------------------------------- /examples/phrase_bot/src/main.rs: -------------------------------------------------------------------------------- 1 | pub mod db; 2 | pub mod handlers; 3 | pub mod resources; 4 | use std::error::Error; 5 | 6 | use db::models::Phrase; 7 | use dotenv::dotenv; 8 | use handler_tree::handler_tree; 9 | use handlers::*; 10 | use resources::{handler_tree, keyboards, text}; 11 | use teloxide::{ 12 | dispatching::dialogue::{serializer::Cbor, Dialogue, ErasedStorage, RedisStorage, Storage}, 13 | prelude::*, 14 | }; 15 | 16 | pub type MyDialogue = Dialogue>; 17 | pub type HandlerResult = Result<(), Box>; 18 | pub type MyStorage = std::sync::Arc>; 19 | 20 | #[derive(Clone, PartialEq, Debug, Default, serde::Serialize, serde::Deserialize)] 21 | pub enum State { 22 | #[default] 23 | Start, 24 | ChangeNickname, 25 | WhatToDoWithPhrases, 26 | WhatIsNewPhraseEmoji, 27 | WhatIsNewPhraseText { 28 | emoji: String, 29 | }, 30 | WhatIsNewPhraseBotText { 31 | emoji: String, 32 | text: String, 33 | }, 34 | WhatPhraseToDelete { 35 | phrases: Vec, 36 | }, 37 | } 38 | 39 | pub async fn get_bot_storage() -> MyStorage { 40 | let storage: MyStorage = RedisStorage::open(&dotenv::var("REDIS_URL").unwrap(), Cbor) 41 | // For reasons unknown to me, Binary serializer doesn't accept json-like objects, 42 | // so im using it. If you want to use InMemStorage, just change 43 | // ErasedStorage to InMemStorage (dont forget to do it in the resources/handler_tree.rs), 44 | // and make this function return InMemStorage::::new() 45 | .await 46 | .unwrap() 47 | .erase(); 48 | storage 49 | } 50 | 51 | #[tokio::main] 52 | async fn main() { 53 | pretty_env_logger::init(); 54 | dotenv().ok(); 55 | 56 | let bot = Bot::from_env(); 57 | 58 | Dispatcher::builder(bot, handler_tree()) 59 | .dependencies(dptree::deps![get_bot_storage().await]) 60 | .build() 61 | .dispatch() 62 | .await; 63 | } 64 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: ["master"] 4 | pull_request: 5 | branches: ["*"] 6 | 7 | name: Continuous integration 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | CI: 1 12 | 13 | jobs: 14 | ci-pass: 15 | name: CI succeeded 16 | runs-on: ubuntu-latest 17 | if: always() 18 | 19 | needs: 20 | - build 21 | - check-examples 22 | 23 | steps: 24 | - name: Check whether the needed jobs succeeded or failed 25 | uses: re-actors/alls-green@release/v1 26 | with: 27 | jobs: ${{ toJSON(needs) }} 28 | 29 | build: 30 | runs-on: ubuntu-latest 31 | strategy: 32 | matrix: 33 | rust: 34 | - stable 35 | - beta 36 | - nightly 37 | 38 | steps: 39 | - uses: actions/checkout@v4 40 | 41 | - name: Cache Dependencies 42 | uses: Swatinem/rust-cache@v2 43 | 44 | - run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} 45 | 46 | - name: Build 47 | run: cargo build --verbose 48 | 49 | - name: Run tests 50 | run: cargo test --verbose 51 | 52 | check-examples: 53 | runs-on: ubuntu-latest 54 | 55 | steps: 56 | - uses: actions/checkout@v4 57 | 58 | - name: Cache Dependencies 59 | uses: Swatinem/rust-cache@v2 60 | 61 | - name: Check examples 62 | run: cargo check --manifest-path=./examples/Cargo.toml 63 | 64 | check-formatting: 65 | runs-on: ubuntu-latest 66 | 67 | steps: 68 | - uses: actions/checkout@v4 69 | 70 | - name: Cache Dependencies 71 | uses: Swatinem/rust-cache@v2 72 | 73 | - name: Check formatting 74 | run: cargo fmt --all -- --check 75 | 76 | check-clippy: 77 | runs-on: ubuntu-latest 78 | 79 | steps: 80 | - uses: actions/checkout@v4 81 | 82 | - name: Cache Dependencies 83 | uses: Swatinem/rust-cache@v2 84 | 85 | - name: Check clippy 86 | run: cargo clippy --all-targets -- -D warnings 87 | -------------------------------------------------------------------------------- /examples/calculator_bot/src/main.rs: -------------------------------------------------------------------------------- 1 | mod handler_tree; 2 | mod handlers; 3 | #[cfg(test)] 4 | mod tests; 5 | pub mod text; 6 | 7 | use std::error::Error; 8 | 9 | use dotenv::dotenv; 10 | use handler_tree::handler_tree; 11 | use teloxide::{ 12 | dispatching::dialogue::{serializer::Cbor, Dialogue, ErasedStorage, RedisStorage, Storage}, 13 | prelude::*, 14 | }; 15 | 16 | pub type MyDialogue = Dialogue>; 17 | pub type HandlerResult = Result<(), Box>; 18 | pub type MyStorage = std::sync::Arc>; 19 | 20 | #[derive(Clone, PartialEq, Debug, Default, serde::Serialize, serde::Deserialize)] 21 | pub enum State { 22 | #[default] 23 | Start, // The default state, from which you can send '/start' 24 | WhatDoYouWant, // We ask, what do you want, to add or subtract 25 | GetFirstNumber { 26 | // We got what the user wants to do, and we ask for the first number 27 | operation: String, 28 | }, 29 | GetSecondNumber { 30 | // Now ask for the second number 31 | first_number: i32, 32 | operation: String, 33 | }, 34 | } 35 | 36 | pub async fn get_bot_storage() -> MyStorage { 37 | let storage: MyStorage = RedisStorage::open(&dotenv::var("REDIS_URL").unwrap(), Cbor) 38 | // For reasons unknown to me, Binary serializer doesn't accept json-like objects, 39 | // so im using it. If you want to use InMemStorage, just change 40 | // ErasedStorage to InMemStorage (dont forget to do it in the handler_tree.rs), 41 | // and make this function return InMemStorage::::new() 42 | .await 43 | .unwrap() 44 | .erase(); 45 | storage 46 | } 47 | 48 | #[tokio::main] 49 | async fn main() { 50 | pretty_env_logger::init(); 51 | dotenv().ok(); 52 | 53 | let bot = Bot::from_env(); 54 | 55 | Dispatcher::builder(bot, handler_tree()) 56 | .dependencies(dptree::deps![get_bot_storage().await]) 57 | .build() 58 | .dispatch() 59 | .await; 60 | } 61 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/send_dice.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{error::ErrorBadRequest, web, Responder}; 4 | use serde::Deserialize; 5 | use teloxide::types::{BusinessConnectionId, DiceEmoji, ReplyMarkup, ReplyParameters}; 6 | 7 | use super::{make_telegram_result, BodyChatId}; 8 | use crate::{ 9 | server::{routes::check_if_message_exists, SentMessageDice}, 10 | state::State, 11 | MockMessageDice, 12 | }; 13 | 14 | #[derive(Debug, Deserialize, Clone)] 15 | pub struct SendMessageDiceBody { 16 | pub chat_id: BodyChatId, 17 | pub message_thread_id: Option, 18 | pub emoji: Option, 19 | pub disable_notification: Option, 20 | pub protect_content: Option, 21 | pub message_effect_id: Option, 22 | pub reply_markup: Option, 23 | pub reply_parameters: Option, 24 | pub business_connection_id: Option, 25 | } 26 | 27 | pub async fn send_dice( 28 | state: web::Data>, 29 | body: web::Json, 30 | ) -> impl Responder { 31 | let mut lock = state.lock().unwrap(); 32 | let chat = body.chat_id.chat(); 33 | let mut message = // Creates the message, which will be mutated to fit the needed shape 34 | MockMessageDice::new().chat(chat); 35 | message.emoji = body.emoji.clone().unwrap_or(MockMessageDice::EMOJI); 36 | // Random from 1 to 5 because it fits all the emoji 37 | message.value = (1 + rand::random::() % 5) as u8; 38 | if let Some(reply_parameters) = &body.reply_parameters { 39 | check_if_message_exists!(lock, reply_parameters.message_id.0); 40 | } 41 | 42 | let last_id = lock.messages.max_message_id(); 43 | let message = lock.messages.add_message(message.id(last_id + 1).build()); 44 | 45 | lock.responses.sent_messages.push(message.clone()); 46 | lock.responses.sent_messages_dice.push(SentMessageDice { 47 | message: message.clone(), 48 | bot_request: body.into_inner(), 49 | }); 50 | 51 | make_telegram_result(message) 52 | } 53 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/edit_message_reply_markup.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{error::ErrorBadRequest, web, Responder}; 4 | use serde::Deserialize; 5 | use teloxide::types::{BusinessConnectionId, ReplyMarkup}; 6 | 7 | use super::BodyChatId; 8 | use crate::{ 9 | server::{ 10 | routes::{check_if_message_exists, make_telegram_result}, 11 | EditedMessageReplyMarkup, 12 | }, 13 | state::State, 14 | }; 15 | 16 | #[derive(Debug, Deserialize, Clone)] 17 | pub struct EditMessageReplyMarkupBody { 18 | pub chat_id: Option, 19 | pub message_id: Option, 20 | pub inline_message_id: Option, 21 | pub reply_markup: Option, 22 | pub business_connection_id: Option, 23 | } 24 | 25 | pub async fn edit_message_reply_markup( 26 | body: web::Json, 27 | state: web::Data>, 28 | ) -> impl Responder { 29 | match ( 30 | body.chat_id.clone(), 31 | body.message_id, 32 | body.inline_message_id.clone(), 33 | ) { 34 | (Some(_), Some(message_id), None) => { 35 | let mut lock = state.lock().unwrap(); 36 | check_if_message_exists!(lock, message_id); 37 | 38 | let message = match body.reply_markup.clone() { 39 | Some(reply_markup) => lock 40 | .messages 41 | .edit_message_field(message_id, "reply_markup", reply_markup) 42 | .unwrap(), 43 | None => lock 44 | .messages 45 | .edit_message_field(message_id, "reply_markup", None::<()>) 46 | .unwrap(), 47 | }; 48 | 49 | lock.responses 50 | .edited_messages_reply_markup 51 | .push(EditedMessageReplyMarkup { 52 | message: message.clone(), 53 | bot_request: body.into_inner(), 54 | }); 55 | 56 | make_telegram_result(message) 57 | } 58 | (None, None, Some(_)) => make_telegram_result(true), 59 | _ => ErrorBadRequest("No message_id or inline_message_id were provided").into(), 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /teloxide_tests/src/listener.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | pin::Pin, 3 | sync::Mutex, 4 | task::{Context, Poll}, 5 | thread::sleep, 6 | time::Duration, 7 | }; 8 | 9 | use futures_util::Stream; 10 | use teloxide::{ 11 | stop::StopToken, 12 | types::Update, 13 | update_listeners::{AsUpdateStream, UpdateListener}, 14 | Bot, RequestError, 15 | }; 16 | 17 | // It isn't really a listener, it just takes the updates and feeds them one by one to the 18 | // dispather, until there is no more. 19 | pub(crate) struct InsertingListener { 20 | pub updates: Vec, 21 | } 22 | 23 | pub(crate) struct InsertingListenerStream { 24 | updates: Mutex>, 25 | } 26 | 27 | impl Stream for InsertingListenerStream { 28 | type Item = Result; 29 | 30 | fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { 31 | if self.updates.lock().unwrap().len() == 0 { 32 | // A small wait to make sure the state is setteled in?.. 33 | // No idea, but it fixes a bug with test_erased_state... 34 | sleep(Duration::from_millis(10)); 35 | // Returning Poll::Ready(None) means that there is nothing more to poll, and the 36 | // dispatcher closes. 37 | return Poll::Ready(None); 38 | } 39 | // Returns updates one by one 40 | let update = self.updates.lock().unwrap().remove(0); 41 | Poll::Ready(Some(Ok(update))) 42 | } 43 | } 44 | 45 | impl UpdateListener for InsertingListener { 46 | type Err = RequestError; 47 | 48 | fn stop_token(&mut self) -> StopToken { 49 | // This is a workaround, StopToken fields are private 50 | let token = "1234567890:QWERTYUIOPASDFGHJKLZXCVBNMQWERTYUIO"; 51 | let bot = Bot::new(token); 52 | teloxide::update_listeners::Polling::builder(bot) 53 | .build() 54 | .stop_token() 55 | } 56 | } 57 | 58 | impl<'a> AsUpdateStream<'a> for InsertingListener { 59 | type StreamErr = RequestError; 60 | type Stream = InsertingListenerStream; 61 | 62 | fn as_stream(&'a mut self) -> Self::Stream { 63 | InsertingListenerStream { 64 | updates: self.updates.clone().into(), 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /examples/file_download_bot/src/main.rs: -------------------------------------------------------------------------------- 1 | use teloxide::{ 2 | dispatching::{UpdateFilterExt, UpdateHandler}, 3 | net::Download, 4 | prelude::*, 5 | }; 6 | 7 | type HandlerResult = Result<(), Box>; 8 | 9 | async fn download_document(bot: Bot, message: Message) -> HandlerResult { 10 | if let Some(document) = message.document() { 11 | let file = bot.get_file(document.file.id.clone()).await?; // Get the file 12 | 13 | // Make the destination file 14 | let mut dest = tokio::fs::File::create("test.txt").await?; 15 | 16 | // Download the file (its always a dummy, but it works as a check) 17 | bot.download_file(&file.path, &mut dest).await?; 18 | 19 | // Just a check that the file was downloaded 20 | assert!(tokio::fs::read_to_string("test.txt").await.is_ok()); 21 | 22 | bot.send_message(message.chat.id, "Downloaded!").await?; 23 | 24 | tokio::fs::remove_file("test.txt").await?; // Just a cleanup 25 | } else { 26 | bot.send_message(message.chat.id, "Not a document").await?; 27 | } 28 | Ok(()) 29 | } 30 | 31 | fn handler_tree() -> UpdateHandler> { 32 | // A simple handler. But you need to make it into a separate thing! 33 | dptree::entry().branch(Update::filter_message().endpoint(download_document)) 34 | } 35 | 36 | #[tokio::main] 37 | async fn main() { 38 | dotenv::dotenv().ok(); // Loads the .env file 39 | 40 | let bot = Bot::from_env(); 41 | 42 | Dispatcher::builder(bot, handler_tree()) 43 | .enable_ctrlc_handler() 44 | .build() 45 | .dispatch() 46 | .await; 47 | } 48 | 49 | #[cfg(test)] 50 | mod tests { 51 | use teloxide_tests::{MockBot, MockMessageDocument, MockMessageText}; 52 | 53 | use super::*; 54 | 55 | #[tokio::test] 56 | async fn test_not_a_document() { 57 | let mut bot = MockBot::new(MockMessageText::new().text("Hi!"), handler_tree()); 58 | bot.dispatch_and_check_last_text("Not a document").await; 59 | } 60 | 61 | #[tokio::test] 62 | async fn test_download_document_and_check() { 63 | let mut bot = MockBot::new(MockMessageDocument::new(), handler_tree()); 64 | bot.dispatch_and_check_last_text("Downloaded!").await; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /examples/deep_linking_bot/src/handlers.rs: -------------------------------------------------------------------------------- 1 | use teloxide::{prelude::*, types::Me}; 2 | 3 | use crate::{add_deep_link, text, HandlerResult, MyDialogue, StartCommand, State}; 4 | 5 | pub async fn start( 6 | bot: Bot, 7 | msg: Message, 8 | dialogue: MyDialogue, 9 | command: StartCommand, 10 | me: Me, 11 | ) -> HandlerResult { 12 | let chat_id = msg.chat.id; 13 | // If you have multiple commands, this will need to become if let 14 | let StartCommand::Start(arg) = command; 15 | if arg.is_empty() { 16 | // This means that it is just a regular link like https://t.me/some_bot 17 | bot.send_message(chat_id, add_deep_link(text::START, me, chat_id)) 18 | .await?; 19 | dialogue.update(State::default()).await?; 20 | } else { 21 | // And this means that the link is like this: https://t.me/some_bot?start=123456789 22 | if let Ok(id) = arg.parse::() { 23 | bot.send_message(chat_id, text::SEND_YOUR_MESSAGE).await?; 24 | dialogue.update(State::WriteToSomeone { id }).await?; 25 | } else { 26 | bot.send_message(chat_id, text::WRONG_LINK).await?; 27 | dialogue.update(State::default()).await?; 28 | } 29 | } 30 | 31 | Ok(()) 32 | } 33 | 34 | pub async fn send_message( 35 | bot: Bot, 36 | msg: Message, 37 | dialogue: MyDialogue, 38 | state: State, 39 | me: Me, 40 | ) -> HandlerResult { 41 | let State::WriteToSomeone { id } = state else { 42 | // Shouldn't ever happen 43 | return Ok(()); 44 | }; 45 | 46 | if let Some(text) = msg.text() { 47 | // Trying to send a message to the user 48 | let sent_result = bot 49 | .send_message( 50 | ChatId(id), 51 | text::YOU_HAVE_A_NEW_MESSAGE.replace("{message}", text), 52 | ) 53 | .parse_mode(teloxide::types::ParseMode::Html) 54 | .await; 55 | 56 | // And if no error is returned, success! 57 | if sent_result.is_ok() { 58 | bot.send_message( 59 | msg.chat.id, 60 | add_deep_link(text::MESSAGE_SENT, me, msg.chat.id), 61 | ) 62 | .await?; 63 | } else { 64 | bot.send_message(msg.chat.id, text::ERROR_SENDING_MESSAGE) 65 | .await?; 66 | } 67 | dialogue.update(State::default()).await?; 68 | } else { 69 | // You can add support for more messages yourself! 70 | bot.send_message(msg.chat.id, text::SEND_TEXT).await?; 71 | } 72 | Ok(()) 73 | } 74 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/edit_message_caption.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{error::ErrorBadRequest, web, Responder}; 4 | use serde::Deserialize; 5 | use teloxide::types::{BusinessConnectionId, MessageEntity, ParseMode, ReplyMarkup}; 6 | 7 | use super::{check_if_message_exists, BodyChatId}; 8 | use crate::{ 9 | server::{routes::make_telegram_result, EditedMessageCaption}, 10 | state::State, 11 | }; 12 | 13 | #[derive(Debug, Deserialize, Clone)] 14 | pub struct EditMessageCaptionBody { 15 | pub chat_id: Option, 16 | pub message_id: Option, 17 | pub inline_message_id: Option, 18 | pub caption: String, 19 | pub parse_mode: Option, 20 | pub caption_entities: Option>, 21 | pub show_caption_above_media: Option, 22 | pub reply_markup: Option, 23 | pub business_connection_id: Option, 24 | } 25 | 26 | pub async fn edit_message_caption( 27 | state: web::Data>, 28 | body: web::Json, 29 | ) -> impl Responder { 30 | match ( 31 | body.chat_id.clone(), 32 | body.message_id, 33 | body.inline_message_id.clone(), 34 | ) { 35 | (Some(_), Some(message_id), None) => { 36 | let mut lock = state.lock().unwrap(); 37 | check_if_message_exists!(lock, message_id); 38 | lock.messages 39 | .edit_message_field(message_id, "caption", body.caption.clone()); 40 | lock.messages.edit_message_field( 41 | message_id, 42 | "caption_entities", 43 | body.caption_entities.clone().unwrap_or_default(), 44 | ); 45 | lock.messages.edit_message_field( 46 | message_id, 47 | "show_caption_above_media", 48 | body.show_caption_above_media.unwrap_or(false), 49 | ); 50 | 51 | let message = lock 52 | .messages 53 | .edit_message_reply_markup(message_id, body.reply_markup.clone()) 54 | .unwrap(); 55 | 56 | lock.responses 57 | .edited_messages_caption 58 | .push(EditedMessageCaption { 59 | message: message.clone(), 60 | bot_request: body.into_inner(), 61 | }); 62 | 63 | make_telegram_result(message) 64 | } 65 | (None, None, Some(_)) => make_telegram_result(true), 66 | _ => ErrorBadRequest("No message_id or inline_message_id were provided").into(), 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First of all, thanks! I really want to make this crate as good as possible, as i think that testing the bot is very important and helps a lot with debugging, and if you share my thoughts, and want to contribute, im very thankful! 4 | 5 | ### Here are the rules for writing code i consider important for PRs: 6 | 7 | 1. Add code comments and docstrings. I take an example from teloxide source, and they have documented everything in their code very nicely. 8 | 2. Add tests for the source code. Not for every single function, but if you add a dataset item, or a new endpoint, add a test for it. (endpoint tests are in the teloxide_tests/src/tests.rs) 9 | 3. The teloxide bot testing for the users of this crate should be very intuitive and easy. That is the reason i made so that the tests can be run without serial_test crate, it adds unnecessary boilerplate. 10 | 4. The bot should handle the test failiure with grace. After all, the tests are made to fail, so the error messages and panics should be clear. Mutex poison errors because of a one failed test are not good at all, as well as server errors. If one test fails, no others should. 11 | 5. Users have to have many options for testing their bot. For that very reason i save bot requests to the fake server, as well as making some fields in the MockBot public. 12 | 6. Write the code that is similar to the one that already exists. Not identical, but similar. Also, i hate boilerplate, as you could've seen by proc macros and regular macros. If you know, how to avoid boilerplate, please do. 13 | 7. Be VERY careful when modifying the existing MockBot code. I am very very sorry if you come across stupid race condition bugs, they have caused way too much pain, and i do not want the same happening to you. 14 | 15 | These aren't super strict rules (unless you are modifying MockBot code), and you can step away a little from them, just write good working code! 16 | 17 | ### And here are the rules for issues: 18 | 19 | Just write a clear description of what is the problem. A bug, a reasonable feature request, a documentation issue, etc. are all valid problems. 20 | 21 | # What to contribute 22 | 23 | The main two things that need to be done: add all of dataset items and add all of the endpoints. It doesn't matter, what and in what order, just write what you consider important. Or you can just look at the TODO field in the README.md 24 | 25 | You can also try cleaning up the code, writing more tests, adding more comments, more examples, more syntactic sugar, whatever your heart desires and will be useful for that crate! 26 | 27 | Thanks once again, i am very grateful for any contributions that improve this crate! 28 | -------------------------------------------------------------------------------- /teloxide_tests/src/utils.rs: -------------------------------------------------------------------------------- 1 | use serde_json::Value; 2 | use teloxide::{prelude::*, types::FileMeta}; 3 | 4 | macro_rules! assert_eqn { 5 | ($actual:expr, $expected:expr $(,)?) => { 6 | match (&$actual, &$expected) { 7 | (actual, expected) => { 8 | if !(*actual == *expected) { 9 | panic!("assertion `actual == expected` failed: 10 | actual: {actual:?} 11 | expected: {expected:?}", actual=&*actual, expected=&*expected) 12 | 13 | } 14 | } 15 | } 16 | }; 17 | ($actual:expr, $expected:expr, $($arg:tt)+) => { 18 | match (&$actual, &$expected) { 19 | (actual, expected) => { 20 | if !(*actual == *expected) { 21 | panic!("assertion `actual == expected` failed: {message} 22 | actual: {actual:?} 23 | expected: {expected:?}", message=$($arg)+, actual=&*actual, expected=&*expected) 24 | 25 | } 26 | } 27 | } 28 | }; 29 | } 30 | 31 | pub(crate) use assert_eqn; 32 | 33 | pub fn find_file(value: Value) -> Option { 34 | // Recursively searches for file meta 35 | let mut file_id = None; 36 | let mut file_unique_id = None; 37 | let mut file_size = None; 38 | if let Value::Object(map) = value { 39 | for (k, v) in map { 40 | if k == "file_id" { 41 | file_id = Some(v.as_str().unwrap().to_string().into()); 42 | } else if k == "file_unique_id" { 43 | file_unique_id = Some(v.as_str().unwrap().to_string().into()); 44 | } else if k == "file_size" { 45 | file_size = Some(v.as_u64().unwrap() as u32); 46 | } else if let Some(found) = find_file(v) { 47 | return Some(found); 48 | } 49 | } 50 | } 51 | if let (Some(id), Some(unique_id)) = (file_id, file_unique_id) { 52 | return Some(FileMeta { 53 | id, 54 | unique_id, 55 | size: file_size.unwrap_or(0), 56 | }); 57 | } 58 | None 59 | } 60 | 61 | pub fn find_chat_id(value: Value) -> Option { 62 | // Recursively searches for chat id 63 | if let Value::Object(map) = value { 64 | for (k, v) in map { 65 | if k == "chat" { 66 | return v["id"].as_i64(); 67 | } else if let Some(found) = find_chat_id(v) { 68 | return Some(found); 69 | } 70 | } 71 | } 72 | None 73 | } 74 | 75 | /// A key that defines the parallelism of updates 76 | #[derive(Debug, Hash, PartialEq, Eq, Clone)] 77 | pub struct DistributionKey(pub ChatId); 78 | 79 | pub(crate) fn default_distribution_function(update: &Update) -> Option { 80 | update.chat().map(|c| c.id).map(DistributionKey) 81 | } 82 | -------------------------------------------------------------------------------- /examples/calculator_bot/src/handler_tree.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | use dptree::case; 4 | use teloxide::{ 5 | dispatching::{ 6 | dialogue::{self, ErasedStorage, GetChatId}, 7 | UpdateFilterExt, UpdateHandler, 8 | }, 9 | prelude::*, 10 | types::Update, 11 | }; 12 | 13 | use crate::{ 14 | get_bot_storage, 15 | handlers::{StartCommand, *}, 16 | text, MyDialogue, State, 17 | }; 18 | 19 | async fn check_if_the_state_is_ok(update: Update) -> bool { 20 | // This function doesn't have anything to do with tests, but i thought i would put it here, 21 | // because i've encountered that if you update the state, and the user is on that 22 | // state, it just errors out, softlocking the user. Very bad. 23 | let chat_id = match update.chat_id() { 24 | Some(chat_id) => chat_id, 25 | None => return true, 26 | }; 27 | let dialogue = MyDialogue::new(get_bot_storage().await, chat_id); 28 | match dialogue.get().await { 29 | Ok(_) => true, 30 | Err(_) => { 31 | // This error happens if redis has a state saved for the user, but that state 32 | // doesn't fit into anything that State has, so it just errors out. Very bad. 33 | let bot = Bot::from_env(); 34 | bot.send_message(chat_id, text::SORRY_BOT_UPDATED) 35 | .await 36 | .unwrap(); 37 | dialogue.update(State::default()).await.unwrap(); 38 | false 39 | } 40 | } 41 | } 42 | 43 | pub fn handler_tree() -> UpdateHandler> { 44 | // Just a schema, nothing extraordinary 45 | let normal_branch = dialogue::enter::, State, _>() 46 | .branch( 47 | Update::filter_message() 48 | .filter_command::() 49 | .branch(case![StartCommand::Start].endpoint(start)), 50 | ) 51 | .branch( 52 | Update::filter_callback_query() 53 | .branch(case![State::WhatDoYouWant].endpoint(what_is_the_first_number)), 54 | ) 55 | .branch( 56 | Update::filter_message() 57 | .branch( 58 | case![State::GetFirstNumber { operation }].endpoint(what_is_the_second_number), 59 | ) 60 | .branch( 61 | case![State::GetSecondNumber { 62 | first_number, 63 | operation 64 | }] 65 | .endpoint(get_result), 66 | ), 67 | ); 68 | 69 | // If the dialogue errors out - do not go further 70 | let catch_updated_dialogue_branch = dptree::entry() 71 | .filter_async(check_if_the_state_is_ok) 72 | .branch(normal_branch); 73 | 74 | catch_updated_dialogue_branch 75 | } 76 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/send_message.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{error::ErrorBadRequest, web, Responder}; 4 | use serde::Deserialize; 5 | use teloxide::types::{ 6 | BusinessConnectionId, EffectId, LinkPreviewOptions, Me, MessageEntity, ParseMode, ReplyMarkup, 7 | ReplyParameters, 8 | }; 9 | 10 | use super::{make_telegram_result, BodyChatId}; 11 | use crate::{ 12 | dataset::message_common::MockMessageText, 13 | server::{routes::check_if_message_exists, SentMessageText}, 14 | state::State, 15 | }; 16 | 17 | #[derive(Debug, Deserialize, Clone)] 18 | pub struct SendMessageTextBody { 19 | pub chat_id: BodyChatId, 20 | pub text: String, 21 | pub message_thread_id: Option, 22 | pub parse_mode: Option, 23 | pub entities: Option>, 24 | pub link_preview_options: Option, 25 | pub disable_notification: Option, 26 | pub protect_content: Option, 27 | pub message_effect_id: Option, 28 | pub reply_markup: Option, 29 | pub reply_parameters: Option, 30 | pub business_connection_id: Option, 31 | } 32 | 33 | pub async fn send_message( 34 | body: web::Json, 35 | me: web::Data, 36 | state: web::Data>, 37 | ) -> impl Responder { 38 | let mut lock = state.lock().unwrap(); 39 | let chat = body.chat_id.chat(); 40 | let mut message = // Creates the message, which will be mutated to fit the needed shape 41 | MockMessageText::new().text(&body.text).chat(chat); 42 | message.from = Some(me.user.clone()); 43 | message.has_protected_content = body.protect_content.unwrap_or(false); 44 | message.effect_id = body.message_effect_id.clone(); 45 | message.business_connection_id = body.business_connection_id.clone(); 46 | 47 | message.entities = body.entities.clone().unwrap_or_default(); 48 | if let Some(reply_parameters) = &body.reply_parameters { 49 | check_if_message_exists!(lock, reply_parameters.message_id.0); 50 | let reply_to_message = lock 51 | .messages 52 | .get_message(reply_parameters.message_id.0) 53 | .unwrap(); 54 | message.reply_to_message = Some(Box::new(reply_to_message.clone())); 55 | } 56 | if let Some(ReplyMarkup::InlineKeyboard(markup)) = body.reply_markup.clone() { 57 | message.reply_markup = Some(markup); 58 | } 59 | 60 | let last_id = lock.messages.max_message_id(); 61 | let message = lock.messages.add_message(message.id(last_id + 1).build()); 62 | 63 | lock.responses.sent_messages.push(message.clone()); 64 | lock.responses.sent_messages_text.push(SentMessageText { 65 | message: message.clone(), 66 | bot_request: body.into_inner(), 67 | }); 68 | 69 | make_telegram_result(message) 70 | } 71 | -------------------------------------------------------------------------------- /examples/phrase_bot/src/db/mod.rs: -------------------------------------------------------------------------------- 1 | //! A sepatate file for simplicity. This file has all the database related functions 2 | //! that are used in the bot. 3 | pub mod models; 4 | pub mod schema; 5 | use diesel::prelude::*; 6 | use models::*; 7 | 8 | pub fn establish_connection() -> PgConnection { 9 | dotenv::dotenv().ok(); 10 | 11 | let database_url = dotenv::var("DATABASE_URL").expect("DATABASE_URL must be set"); 12 | PgConnection::establish(&database_url) 13 | .unwrap_or_else(|_| panic!("Error connecting to {}", database_url)) 14 | } 15 | 16 | pub fn create_user(id: i64) -> Result { 17 | let conn = &mut establish_connection(); 18 | 19 | let new_user = NewUser { id }; 20 | 21 | diesel::insert_into(schema::users::table) 22 | .values(&new_user) 23 | .returning(User::as_returning()) 24 | .get_result(conn) 25 | } 26 | 27 | pub fn delete_user(id: i64) -> Result { 28 | let conn = &mut establish_connection(); 29 | 30 | diesel::delete(schema::users::table.find(id)).execute(conn) 31 | } 32 | 33 | pub fn full_user_redeletion(id: i64, nickname: Option) { 34 | // For tests, to fully reset the user 35 | let _ = delete_user(id).unwrap(); 36 | 37 | create_user(id).unwrap(); 38 | if let Some(nickname) = nickname { 39 | change_user_nickname(id, nickname).unwrap(); 40 | } 41 | } 42 | 43 | pub fn change_user_nickname(id: i64, nickname: String) -> Result { 44 | let conn = &mut establish_connection(); 45 | 46 | diesel::update(schema::users::table.find(id)) 47 | .set(schema::users::nickname.eq(nickname)) 48 | .get_result(conn) 49 | } 50 | 51 | pub fn get_user(id: i64) -> Result { 52 | let conn = &mut establish_connection(); 53 | 54 | schema::users::table.find(id).first(conn) 55 | } 56 | 57 | pub fn get_user_phrases(id: i64) -> Result, diesel::result::Error> { 58 | let conn = &mut establish_connection(); 59 | 60 | schema::phrases::table 61 | .filter(schema::phrases::user_id.eq(id)) 62 | .load(conn) 63 | } 64 | 65 | pub fn create_phrase( 66 | user_id: i64, 67 | emoji: String, 68 | text: String, 69 | bot_text: String, 70 | ) -> Result { 71 | let conn = &mut establish_connection(); 72 | 73 | let new_phrase = NewPhrase { 74 | user_id, 75 | emoji, 76 | text, 77 | bot_text, 78 | }; 79 | 80 | diesel::insert_into(schema::phrases::table) 81 | .values(&new_phrase) 82 | .returning(Phrase::as_returning()) 83 | .get_result(conn) 84 | } 85 | 86 | pub fn delete_phrase(id: i32) -> Result { 87 | let conn = &mut establish_connection(); 88 | 89 | diesel::delete(schema::phrases::table.find(id)).execute(conn) 90 | } 91 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/send_contact.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{error::ErrorBadRequest, web, Responder}; 4 | use serde::Deserialize; 5 | use teloxide::types::{BusinessConnectionId, EffectId, Me, ReplyMarkup, ReplyParameters}; 6 | 7 | use super::{make_telegram_result, BodyChatId}; 8 | use crate::{ 9 | server::{routes::check_if_message_exists, SentMessageContact}, 10 | state::State, 11 | MockMessageContact, 12 | }; 13 | 14 | #[derive(Debug, Deserialize, Clone)] 15 | pub struct SendMessageContactBody { 16 | pub chat_id: BodyChatId, 17 | pub message_thread_id: Option, 18 | pub phone_number: String, 19 | pub first_name: String, 20 | pub last_name: Option, 21 | pub vcard: Option, 22 | pub disable_notification: Option, 23 | pub protect_content: Option, 24 | pub message_effect_id: Option, 25 | pub reply_markup: Option, 26 | pub reply_parameters: Option, 27 | pub business_connection_id: Option, 28 | } 29 | 30 | pub async fn send_contact( 31 | body: web::Json, 32 | me: web::Data, 33 | state: web::Data>, 34 | ) -> impl Responder { 35 | let mut lock = state.lock().unwrap(); 36 | let chat = body.chat_id.chat(); 37 | let mut message = // Creates the message, which will be mutated to fit the needed shape 38 | MockMessageContact::new().chat(chat); 39 | message.from = Some(me.user.clone()); 40 | message.phone_number = body.phone_number.clone(); 41 | message.first_name = body.first_name.clone(); 42 | message.last_name = body.last_name.clone(); 43 | message.vcard = body.vcard.clone(); 44 | message.has_protected_content = body.protect_content.unwrap_or(false); 45 | message.effect_id = body.message_effect_id.clone(); 46 | message.business_connection_id = body.business_connection_id.clone(); 47 | 48 | if let Some(reply_parameters) = &body.reply_parameters { 49 | check_if_message_exists!(lock, reply_parameters.message_id.0); 50 | let reply_to_message = lock 51 | .messages 52 | .get_message(reply_parameters.message_id.0) 53 | .unwrap(); 54 | message.reply_to_message = Some(Box::new(reply_to_message.clone())); 55 | } 56 | if let Some(ReplyMarkup::InlineKeyboard(markup)) = body.reply_markup.clone() { 57 | message.reply_markup = Some(markup); 58 | } 59 | 60 | let last_id = lock.messages.max_message_id(); 61 | let message = lock.messages.add_message(message.id(last_id + 1).build()); 62 | 63 | lock.responses.sent_messages.push(message.clone()); 64 | lock.responses 65 | .sent_messages_contact 66 | .push(SentMessageContact { 67 | message: message.clone(), 68 | bot_request: body.into_inner(), 69 | }); 70 | 71 | make_telegram_result(message) 72 | } 73 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/edit_message_text.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{error::ErrorBadRequest, web, Responder, ResponseError}; 4 | use serde::Deserialize; 5 | use teloxide::{ 6 | types::{BusinessConnectionId, LinkPreviewOptions, MessageEntity, ParseMode, ReplyMarkup}, 7 | ApiError, 8 | }; 9 | 10 | use super::{BodyChatId, BotApiError}; 11 | use crate::{ 12 | server::{routes::make_telegram_result, EditedMessageText}, 13 | state::State, 14 | }; 15 | 16 | #[derive(Debug, Deserialize, Clone)] 17 | pub struct EditMessageTextBody { 18 | pub chat_id: Option, 19 | pub message_id: Option, 20 | pub inline_message_id: Option, 21 | pub text: String, 22 | pub parse_mode: Option, 23 | pub entities: Option>, 24 | pub link_preview_options: Option, 25 | pub reply_markup: Option, 26 | pub business_connection_id: Option, 27 | } 28 | 29 | pub async fn edit_message_text( 30 | body: web::Json, 31 | state: web::Data>, 32 | ) -> impl Responder { 33 | match ( 34 | body.chat_id.clone(), 35 | body.message_id, 36 | body.inline_message_id.clone(), 37 | ) { 38 | (Some(_), Some(message_id), None) => { 39 | let mut lock = state.lock().unwrap(); 40 | let Some(old_message) = lock.messages.get_message(message_id) else { 41 | return BotApiError::new(ApiError::MessageToEditNotFound).error_response(); 42 | }; 43 | 44 | let old_reply_markup = old_message 45 | .reply_markup() 46 | .map(|kb| ReplyMarkup::InlineKeyboard(kb.clone())); 47 | if old_message.text() == Some(&body.text) && old_reply_markup == body.reply_markup { 48 | return BotApiError::new(ApiError::MessageNotModified).error_response(); 49 | } 50 | 51 | lock.messages 52 | .edit_message_field(message_id, "text", body.text.clone()); 53 | lock.messages.edit_message_field( 54 | message_id, 55 | "entities", 56 | body.entities.clone().unwrap_or(vec![]), 57 | ); 58 | let message = lock 59 | .messages 60 | .edit_message_reply_markup(message_id, body.reply_markup.clone()) 61 | .unwrap(); 62 | 63 | lock.responses.edited_messages_text.push(EditedMessageText { 64 | message: message.clone(), 65 | bot_request: body.into_inner(), 66 | }); 67 | 68 | make_telegram_result(message) 69 | } 70 | // No implementation for inline messages yet, so just return success 71 | (None, None, Some(_)) => make_telegram_result(true), 72 | _ => ErrorBadRequest("No message_id or inline_message_id were provided").into(), 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/forward_message.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{error::ErrorBadRequest, web, Responder}; 4 | use serde::Deserialize; 5 | use teloxide::types::{Me, MessageId, MessageKind, MessageOrigin}; 6 | 7 | use super::{make_telegram_result, BodyChatId}; 8 | use crate::{ 9 | server::{routes::check_if_message_exists, ForwardedMessage}, 10 | state::State, 11 | }; 12 | 13 | #[derive(Debug, Deserialize, Clone)] 14 | pub struct ForwardMessageBody { 15 | pub chat_id: BodyChatId, 16 | pub from_chat_id: BodyChatId, 17 | pub message_id: i32, 18 | pub message_thread_id: Option, 19 | pub disable_notification: Option, 20 | pub protect_content: Option, 21 | } 22 | 23 | pub async fn forward_message( 24 | body: web::Json, 25 | me: web::Data, 26 | state: web::Data>, 27 | ) -> impl Responder { 28 | let mut lock = state.lock().unwrap(); 29 | 30 | check_if_message_exists!(lock, body.message_id); 31 | let mut message = lock.messages.get_message(body.message_id).unwrap(); 32 | 33 | if message.has_protected_content() { 34 | return ErrorBadRequest("Message has protected content").into(); 35 | } 36 | 37 | let message_clone = message.clone(); 38 | if let MessageKind::Common(ref mut common) = message.kind { 39 | common.forward_origin = Some(if message.chat.is_channel() { 40 | MessageOrigin::Channel { 41 | date: message_clone.date, 42 | chat: message_clone.chat, 43 | message_id: message_clone.id, 44 | author_signature: None, 45 | } 46 | } else if let Some(sender_chat) = &message.sender_chat { 47 | MessageOrigin::Chat { 48 | date: message_clone.date, 49 | sender_chat: sender_chat.clone(), 50 | author_signature: None, 51 | } 52 | } else if let Some(user) = &message.from { 53 | MessageOrigin::User { 54 | date: message_clone.date, 55 | sender_user: user.clone(), 56 | } 57 | } else { 58 | // This is probably unreachable. 59 | MessageOrigin::HiddenUser { 60 | date: message_clone.date, 61 | sender_user_name: "Unknown user".to_string(), 62 | } 63 | }); 64 | common.has_protected_content = body.protect_content.unwrap_or(false); 65 | } 66 | 67 | let last_id = lock.messages.max_message_id(); 68 | message.id = MessageId(last_id + 1); 69 | message.chat = body.chat_id.chat(); 70 | message.from = Some(me.user.clone()); 71 | let message = lock.messages.add_message(message); 72 | 73 | lock.responses.sent_messages.push(message.clone()); 74 | lock.responses.forwarded_messages.push(ForwardedMessage { 75 | message: message.clone(), 76 | bot_request: body.into_inner(), 77 | }); 78 | 79 | make_telegram_result(message) 80 | } 81 | -------------------------------------------------------------------------------- /teloxide_tests/src/state.rs: -------------------------------------------------------------------------------- 1 | use teloxide::{ 2 | prelude::*, 3 | types::{File, MessageId, MessageKind}, 4 | }; 5 | 6 | use crate::{server::messages::Messages, utils::find_file, MockMessageText, Responses}; 7 | 8 | #[derive(Default)] 9 | pub(crate) struct State { 10 | pub files: Vec, 11 | pub responses: Responses, 12 | pub messages: Messages, 13 | } 14 | 15 | impl State { 16 | pub fn reset(&mut self) { 17 | self.responses = Responses::default(); 18 | } 19 | 20 | pub(crate) fn add_message(&mut self, message: &mut Message) { 21 | let max_id = self.messages.max_message_id(); 22 | let maybe_message = self.messages.get_message(message.id.0); 23 | 24 | // If message exists in the database, and it isn't a default, 25 | // let it be, the user knows best 26 | if maybe_message.is_some() && message.id != MessageId(MockMessageText::ID) { 27 | log::debug!( 28 | "Not inserting message with id {}, this id exists in the database.", 29 | message.id 30 | ); 31 | return; 32 | } 33 | 34 | if message.id.0 <= max_id || maybe_message.is_some() { 35 | message.id = MessageId(max_id + 1); 36 | } 37 | 38 | if let Some(file_meta) = find_file(serde_json::to_value(&message).unwrap()) { 39 | let file = File { 40 | meta: file_meta, 41 | path: "some_path.txt".to_string(), // This doesn't really matter 42 | }; 43 | self.files.push(file); 44 | } 45 | if let MessageKind::Common(ref mut message_kind) = message.kind { 46 | if let Some(ref mut reply_message) = message_kind.reply_to_message { 47 | self.add_message(reply_message); 48 | } 49 | } 50 | log::debug!("Inserted message with {}.", message.id); 51 | self.messages.add_message(message.clone()); 52 | } 53 | 54 | pub(crate) fn edit_message(&mut self, message: &mut Message) { 55 | let old_message = self.messages.get_message(message.id.0); 56 | 57 | if old_message.is_none() { 58 | log::error!( 59 | "Not editing message with id {}, this id does not exist in the database.", 60 | message.id 61 | ); 62 | return; 63 | } 64 | 65 | if let Some(file_meta) = find_file(serde_json::to_value(&message).unwrap()) { 66 | if self 67 | .files 68 | .iter() 69 | .all(|f| f.meta.unique_id != file_meta.unique_id) 70 | { 71 | let file = File { 72 | meta: file_meta, 73 | path: "some_path.txt".to_string(), // This doesn't really matter 74 | }; 75 | self.files.push(file); 76 | } 77 | } 78 | log::debug!("Edited message with {}.", message.id); 79 | self.messages.edit_message(message.clone()); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/send_location.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{error::ErrorBadRequest, web, Responder}; 4 | use serde::Deserialize; 5 | use teloxide::types::{ 6 | BusinessConnectionId, EffectId, LivePeriod, Me, ReplyMarkup, ReplyParameters, 7 | }; 8 | 9 | use super::{make_telegram_result, BodyChatId}; 10 | use crate::{ 11 | server::{routes::check_if_message_exists, SentMessageLocation}, 12 | state::State, 13 | MockMessageLocation, 14 | }; 15 | 16 | #[derive(Debug, Deserialize, Clone)] 17 | pub struct SendMessageLocationBody { 18 | pub chat_id: BodyChatId, 19 | pub latitude: f64, 20 | pub longitude: f64, 21 | pub horizontal_accuracy: Option, 22 | pub live_period: Option, 23 | pub heading: Option, 24 | pub proximity_alert_radius: Option, 25 | pub message_thread_id: Option, 26 | pub disable_notification: Option, 27 | pub protect_content: Option, 28 | pub message_effect_id: Option, 29 | pub reply_markup: Option, 30 | pub reply_parameters: Option, 31 | pub business_connection_id: Option, 32 | } 33 | 34 | pub async fn send_location( 35 | body: web::Json, 36 | me: web::Data, 37 | state: web::Data>, 38 | ) -> impl Responder { 39 | let mut lock = state.lock().unwrap(); 40 | 41 | let chat = body.chat_id.chat(); 42 | let mut message = // Creates the message, which will be mutated to fit the needed shape 43 | MockMessageLocation::new().chat(chat).latitude(body.latitude).longitude(body.longitude); 44 | message.from = Some(me.user.clone()); 45 | message.horizontal_accuracy = body.horizontal_accuracy; 46 | message.live_period = body.live_period; 47 | message.heading = body.heading; 48 | message.proximity_alert_radius = body.proximity_alert_radius; 49 | message.has_protected_content = body.protect_content.unwrap_or(false); 50 | message.effect_id = body.message_effect_id.clone(); 51 | message.business_connection_id = body.business_connection_id.clone(); 52 | 53 | if let Some(reply_parameters) = &body.reply_parameters { 54 | check_if_message_exists!(lock, reply_parameters.message_id.0); 55 | let reply_to_message = lock 56 | .messages 57 | .get_message(reply_parameters.message_id.0) 58 | .unwrap(); 59 | message.reply_to_message = Some(Box::new(reply_to_message.clone())); 60 | } 61 | if let Some(ReplyMarkup::InlineKeyboard(markup)) = body.reply_markup.clone() { 62 | message.reply_markup = Some(markup); 63 | } 64 | 65 | let last_id = lock.messages.max_message_id(); 66 | let message = lock.messages.add_message(message.id(last_id + 1).build()); 67 | 68 | lock.responses.sent_messages.push(message.clone()); 69 | lock.responses 70 | .sent_messages_location 71 | .push(SentMessageLocation { 72 | message: message.clone(), 73 | bot_request: body.into_inner(), 74 | }); 75 | 76 | make_telegram_result(message) 77 | } 78 | -------------------------------------------------------------------------------- /teloxide_tests/src/dataset/update.rs: -------------------------------------------------------------------------------- 1 | use std::sync::atomic::Ordering; 2 | 3 | use chrono::{DateTime, Utc}; 4 | use teloxide::types::{ 5 | MessageEntity, Poll, PollId, PollOption, PollType, Seconds, Update, UpdateId, UpdateKind, 6 | }; 7 | use teloxide_tests_macros::Changeable; 8 | 9 | use super::{IntoUpdate, MockMessagePoll}; 10 | 11 | #[derive(Changeable, Clone)] 12 | pub struct MockUpdatePoll { 13 | pub poll_id: PollId, 14 | pub question: String, 15 | pub question_entities: Option>, 16 | pub options: Vec, 17 | pub is_closed: bool, 18 | pub total_voter_count: u32, 19 | pub is_anonymous: bool, 20 | pub poll_type: PollType, 21 | pub allows_multiple_answers: bool, 22 | pub correct_option_id: Option, 23 | pub explanation: Option, 24 | pub explanation_entities: Option>, 25 | pub open_period: Option, 26 | pub close_date: Option>, 27 | } 28 | 29 | impl MockUpdatePoll { 30 | /// Creates a new easily changable poll update builder 31 | /// 32 | /// # Example 33 | /// ``` 34 | /// let update = teloxide_tests::MockUpdatePoll::new() 35 | /// .poll_id("123456".into()); 36 | /// 37 | /// assert_eq!(update.poll_id, "123456".into()); 38 | /// ``` 39 | pub fn new() -> Self { 40 | let poll = MockMessagePoll::new(); 41 | Self { 42 | poll_id: poll.poll_id, 43 | question: poll.question, 44 | question_entities: poll.question_entities, 45 | options: poll.options, 46 | is_closed: poll.is_closed, 47 | total_voter_count: poll.total_voter_count, 48 | is_anonymous: poll.is_anonymous, 49 | poll_type: poll.poll_type, 50 | allows_multiple_answers: poll.allows_multiple_answers, 51 | correct_option_id: poll.correct_option_id, 52 | explanation: poll.explanation, 53 | explanation_entities: poll.explanation_entities, 54 | open_period: poll.open_period, 55 | close_date: poll.close_date, 56 | } 57 | } 58 | } 59 | 60 | impl IntoUpdate for MockUpdatePoll { 61 | fn into_update(self, id: &std::sync::atomic::AtomicI32) -> Vec { 62 | vec![Update { 63 | id: UpdateId(id.fetch_add(1, Ordering::Relaxed) as u32), 64 | kind: UpdateKind::Poll(Poll { 65 | id: self.poll_id, 66 | question: self.question, 67 | question_entities: self.question_entities, 68 | options: self.options, 69 | is_closed: self.is_closed, 70 | total_voter_count: self.total_voter_count, 71 | is_anonymous: self.is_anonymous, 72 | poll_type: self.poll_type, 73 | allows_multiple_answers: self.allows_multiple_answers, 74 | correct_option_id: self.correct_option_id, 75 | explanation: self.explanation, 76 | explanation_entities: self.explanation_entities, 77 | open_period: self.open_period, 78 | close_date: self.close_date, 79 | }), 80 | }] 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/send_venue.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{error::ErrorBadRequest, web, Responder}; 4 | use serde::Deserialize; 5 | use teloxide::types::{BusinessConnectionId, EffectId, Me, ReplyMarkup, ReplyParameters}; 6 | 7 | use super::{make_telegram_result, BodyChatId}; 8 | use crate::{ 9 | server::{routes::check_if_message_exists, SentMessageVenue}, 10 | state::State, 11 | MockLocation, MockMessageVenue, 12 | }; 13 | 14 | #[derive(Debug, Deserialize, Clone)] 15 | pub struct SendMessageVenueBody { 16 | pub chat_id: BodyChatId, 17 | pub message_thread_id: Option, 18 | pub latitude: f64, 19 | pub longitude: f64, 20 | pub title: String, 21 | pub address: String, 22 | pub foursquare_id: Option, 23 | pub foursquare_type: Option, 24 | pub google_place_id: Option, 25 | pub google_place_type: Option, 26 | pub disable_notification: Option, 27 | pub protect_content: Option, 28 | pub message_effect_id: Option, 29 | pub reply_markup: Option, 30 | pub reply_parameters: Option, 31 | pub business_connection_id: Option, 32 | } 33 | 34 | pub async fn send_venue( 35 | body: web::Json, 36 | me: web::Data, 37 | state: web::Data>, 38 | ) -> impl Responder { 39 | let mut lock = state.lock().unwrap(); 40 | let chat = body.chat_id.chat(); 41 | let mut message = // Creates the message, which will be mutated to fit the needed shape 42 | MockMessageVenue::new().chat(chat); 43 | message.from = Some(me.user.clone()); 44 | message.has_protected_content = body.protect_content.unwrap_or(false); 45 | message.location = MockLocation::new() 46 | .latitude(body.latitude) 47 | .longitude(body.longitude) 48 | .build(); 49 | message.title = body.title.clone(); 50 | message.address = body.address.clone(); 51 | message.foursquare_id = body.foursquare_id.clone(); 52 | message.foursquare_type = body.foursquare_type.clone(); 53 | message.google_place_id = body.google_place_id.clone(); 54 | message.google_place_type = body.google_place_type.clone(); 55 | message.effect_id = body.message_effect_id.clone(); 56 | message.business_connection_id = body.business_connection_id.clone(); 57 | 58 | if let Some(reply_parameters) = &body.reply_parameters { 59 | check_if_message_exists!(lock, reply_parameters.message_id.0); 60 | let reply_to_message = lock 61 | .messages 62 | .get_message(reply_parameters.message_id.0) 63 | .unwrap(); 64 | message.reply_to_message = Some(Box::new(reply_to_message.clone())); 65 | } 66 | if let Some(ReplyMarkup::InlineKeyboard(markup)) = body.reply_markup.clone() { 67 | message.reply_markup = Some(markup); 68 | } 69 | 70 | let last_id = lock.messages.max_message_id(); 71 | let message = lock.messages.add_message(message.id(last_id + 1).build()); 72 | 73 | lock.responses.sent_messages.push(message.clone()); 74 | lock.responses.sent_messages_venue.push(SentMessageVenue { 75 | message: message.clone(), 76 | bot_request: body.into_inner(), 77 | }); 78 | 79 | make_telegram_result(message) 80 | } 81 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/send_sticker.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, sync::Mutex}; 2 | 3 | use actix_multipart::Multipart; 4 | use actix_web::{error::ErrorBadRequest, web, Responder}; 5 | use serde::Deserialize; 6 | use teloxide::types::{BusinessConnectionId, EffectId, Me, ReplyMarkup, ReplyParameters}; 7 | 8 | use super::{get_raw_multipart_fields, make_telegram_result, BodyChatId}; 9 | use crate::{ 10 | proc_macros::SerializeRawFields, 11 | server::{ 12 | routes::{check_if_message_exists, Attachment, FileType, SerializeRawFields}, 13 | SentMessageSticker, 14 | }, 15 | state::State, 16 | MockMessageSticker, 17 | }; 18 | 19 | pub async fn send_sticker( 20 | mut payload: Multipart, 21 | me: web::Data, 22 | state: web::Data>, 23 | ) -> impl Responder { 24 | let (fields, attachments) = get_raw_multipart_fields(&mut payload).await; 25 | let mut lock = state.lock().unwrap(); 26 | let body = 27 | SendMessageStickerBody::serialize_raw_fields(&fields, &attachments, FileType::Sticker) 28 | .unwrap(); 29 | let chat = body.chat_id.chat(); 30 | 31 | let mut message = MockMessageSticker::new().chat(chat); 32 | message.from = Some(me.user.clone()); 33 | message.has_protected_content = body.protect_content.unwrap_or(false); 34 | message.emoji = body.emoji.clone(); 35 | message.effect_id = body.message_effect_id.clone(); 36 | message.business_connection_id = body.business_connection_id.clone(); 37 | 38 | // Idk how to get sticker kind and sticker format from this, sooooooooooo im not doing it, 39 | // ain't nobody testing that 40 | 41 | if let Some(reply_parameters) = &body.reply_parameters { 42 | check_if_message_exists!(lock, reply_parameters.message_id.0); 43 | let reply_to_message = lock 44 | .messages 45 | .get_message(reply_parameters.message_id.0) 46 | .unwrap(); 47 | message.reply_to_message = Some(Box::new(reply_to_message.clone())); 48 | } 49 | if let Some(ReplyMarkup::InlineKeyboard(markup)) = body.reply_markup.clone() { 50 | message.reply_markup = Some(markup); 51 | } 52 | 53 | let last_id = lock.messages.max_message_id(); 54 | let message = lock.messages.add_message(message.id(last_id + 1).build()); 55 | 56 | lock.files.push(teloxide::types::File { 57 | meta: message.sticker().unwrap().file.clone(), 58 | path: body.file_name.to_owned(), 59 | }); 60 | lock.responses.sent_messages.push(message.clone()); 61 | lock.responses 62 | .sent_messages_sticker 63 | .push(SentMessageSticker { 64 | message: message.clone(), 65 | bot_request: body, 66 | }); 67 | 68 | make_telegram_result(message) 69 | } 70 | 71 | #[derive(Debug, Clone, Deserialize, SerializeRawFields)] 72 | pub struct SendMessageStickerBody { 73 | pub chat_id: BodyChatId, 74 | pub file_name: String, 75 | pub file_data: String, 76 | pub message_thread_id: Option, 77 | pub emoji: Option, 78 | pub disable_notification: Option, 79 | pub protect_content: Option, 80 | pub message_effect_id: Option, 81 | pub reply_markup: Option, 82 | pub reply_parameters: Option, 83 | pub business_connection_id: Option, 84 | } 85 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/send_invoice.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{web, Responder}; 4 | use serde::Deserialize; 5 | use teloxide::types::{LabeledPrice, Me, ReplyMarkup, ReplyParameters}; 6 | 7 | use super::{make_telegram_result, BodyChatId}; 8 | use crate::{server::SentMessageInvoice, state::State, MockMessageInvoice}; 9 | 10 | #[derive(Debug, Deserialize, Clone)] 11 | pub struct SendMessageInvoiceBody { 12 | pub chat_id: BodyChatId, 13 | pub message_thread_id: Option, 14 | pub title: String, 15 | pub description: String, 16 | pub payload: String, 17 | pub provider_token: Option, 18 | pub currency: String, 19 | pub prices: Vec, 20 | pub max_tip_amount: Option, 21 | pub suggested_tip_amounts: Option>, 22 | pub start_parameter: Option, 23 | pub provider_data: Option, 24 | pub photo_url: Option, 25 | pub photo_size: Option, 26 | pub photo_width: Option, 27 | pub photo_height: Option, 28 | pub need_name: Option, 29 | pub need_phone_number: Option, 30 | pub need_email: Option, 31 | pub need_shipping_address: Option, 32 | pub send_phone_number_to_provider: Option, 33 | pub send_email_to_provider: Option, 34 | pub is_flexible: Option, 35 | pub disable_notification: Option, 36 | pub protect_content: Option, 37 | pub message_effect_id: Option, 38 | pub reply_parameters: Option, 39 | pub reply_markup: Option, 40 | } 41 | 42 | pub async fn send_invoice( 43 | body: web::Json, 44 | me: web::Data, 45 | state: web::Data>, 46 | ) -> impl Responder { 47 | let mut lock = state.lock().unwrap(); 48 | 49 | let chat = body.chat_id.chat(); 50 | let mut message = MockMessageInvoice::new() 51 | .chat(chat) 52 | .title(body.title.clone()) 53 | .description(body.description.clone()) 54 | .start_parameter(body.start_parameter.clone().unwrap_or("".to_owned())) 55 | .total_amount(body.prices.first().unwrap().amount); 56 | message.from = Some(me.user.clone()); 57 | 58 | // Commented until teloxides new release 59 | // message.has_protected_content = body.protect_content.unwrap_or(false); 60 | 61 | // if let Some(reply_parameters) = &body.reply_parameters { 62 | // check_if_message_exists!(lock, reply_parameters.message_id.0); 63 | // let reply_to_message = lock 64 | // .messages 65 | // .get_message(reply_parameters.message_id.0) 66 | // .unwrap(); 67 | // message.reply_to_message = Some(Box::new(reply_to_message.clone())); 68 | // } 69 | // if let Some(ReplyMarkup::InlineKeyboard(markup)) = body.reply_markup.clone() { 70 | // message.reply_markup = Some(markup); 71 | // } 72 | 73 | let last_id = lock.messages.max_message_id(); 74 | let message = lock.messages.add_message(message.id(last_id + 1).build()); 75 | 76 | lock.responses.sent_messages.push(message.clone()); 77 | lock.responses 78 | .sent_messages_invoice 79 | .push(SentMessageInvoice { 80 | message: message.clone(), 81 | bot_request: body.into_inner(), 82 | }); 83 | 84 | make_telegram_result(message) 85 | } 86 | -------------------------------------------------------------------------------- /examples/hello_world_bot/src/main.rs: -------------------------------------------------------------------------------- 1 | use teloxide::{ 2 | dispatching::{UpdateFilterExt, UpdateHandler}, 3 | prelude::*, 4 | }; 5 | 6 | type HandlerResult = Result<(), Box>; 7 | 8 | async fn hello_world(bot: Bot, message: Message) -> HandlerResult { 9 | bot.send_message(message.chat.id, "Hello World!").await?; 10 | Ok(()) 11 | } 12 | 13 | fn handler_tree() -> UpdateHandler> { 14 | // A simple handler. But you need to make it into a separate thing! 15 | dptree::entry().branch(Update::filter_message().endpoint(hello_world)) 16 | } 17 | 18 | #[tokio::main] 19 | async fn main() { 20 | dotenv::dotenv().ok(); // Loads the .env file 21 | 22 | let bot = Bot::from_env(); 23 | 24 | Dispatcher::builder(bot, handler_tree()) 25 | .enable_ctrlc_handler() 26 | .build() 27 | .dispatch() 28 | .await; 29 | } 30 | 31 | #[cfg(test)] 32 | mod tests { 33 | use teloxide_tests::{MockBot, MockMessageText}; 34 | 35 | use super::*; 36 | 37 | #[tokio::test] 38 | async fn test_hello_world() { 39 | // This is a message builder. You can check the docs for more info about mocked types 40 | let mock_message = MockMessageText::new().text("Hi!"); 41 | // This creates a fake bot that will send the mock_message after we dispatch it as if it was sent by the user 42 | // If you wanted, you could've made vec![MockMessageText::new().text("Hi!"), MockMessageText::new().text("Hello!")], 43 | // and both updates would've been sent one after the other. You also can make a MockMessagePhoto, MockMessageDocument, etc 44 | let mut bot = MockBot::new(mock_message, handler_tree()); 45 | // This will dispatch the update 46 | bot.dispatch().await; 47 | 48 | // We can now check the sent messages 49 | let responses = bot.get_responses(); // This returns a struct that has all of the recieved 50 | // updates and requests. You can treat that function like a variable, because it basically is. 51 | let message = responses 52 | .sent_messages // This is a list of all sent messages. Be warned, editing or deleting 53 | // messages do not affect this list! 54 | .last() 55 | .expect("No sent messages were detected!"); 56 | assert_eq!(message.text(), Some("Hello World!")); 57 | 58 | // There is also a more specialized field, sent_messages_text: 59 | let message_text = responses 60 | .sent_messages_text // This has a list request bodies and sent messages of only text messages, no photo, audio, etc. 61 | // messages 62 | .last() 63 | .expect("No sent messages were detected!"); 64 | assert_eq!(message_text.message.text(), Some("Hello World!")); 65 | // The 'bot_request' field is what the bot sent to the fake server. It has some fields that 66 | // can't be accessed by looking only at the resulted message. For example, drop-down style keyboards can't 67 | // be seen in the regular message, like the parse_mode. 68 | assert_eq!(message_text.bot_request.parse_mode, None); 69 | // Also, it is highly discouraged to use the raw bot fields like bot.updates and bot.bot, 70 | // abstractions exist for a reason!!! Do not use them unless you know what you are doing! 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /examples/deep_linking_bot/src/tests.rs: -------------------------------------------------------------------------------- 1 | use teloxide::{dispatching::dialogue::InMemStorage, dptree::deps}; 2 | use teloxide_tests::{MockBot, MockMessagePhoto, MockMessageText}; 3 | 4 | use crate::{add_deep_link, handler_tree::handler_tree, text, State}; 5 | 6 | #[tokio::test] 7 | async fn test_start() { 8 | // Just a regular start 9 | let mock_message = MockMessageText::new().text("/start"); 10 | let mut bot = MockBot::new(mock_message.clone(), handler_tree()); 11 | 12 | bot.dependencies(deps![InMemStorage::::new()]); 13 | let me = bot.me.clone(); // Yeah, we can access the default 'me' like that 14 | 15 | bot.dispatch_and_check_last_text_and_state( 16 | &add_deep_link(text::START, me, mock_message.chat.id), 17 | State::Start, 18 | ) 19 | .await; 20 | } 21 | 22 | #[tokio::test] 23 | async fn test_with_deep_link() { 24 | // Because https://t.me/some_bot?start=987654321 is the same as sending "/start 987654321", 25 | // we can simulate it with this 26 | let mock_message = MockMessageText::new().text("/start 987654321"); 27 | let mut bot = MockBot::new(mock_message, handler_tree()); 28 | 29 | bot.dependencies(deps![InMemStorage::::new()]); 30 | 31 | bot.dispatch_and_check_last_text_and_state( 32 | text::SEND_YOUR_MESSAGE, 33 | State::WriteToSomeone { id: 987654321 }, 34 | ) 35 | .await; 36 | } 37 | 38 | #[tokio::test] 39 | async fn test_send_message() { 40 | // The text we want to send to a 987654321 user 41 | let mock_message = MockMessageText::new().text("I love you!"); 42 | let mut bot = MockBot::new(mock_message.clone(), handler_tree()); 43 | 44 | let me = bot.me.clone(); 45 | bot.dependencies(deps![InMemStorage::::new()]); 46 | bot.set_state(State::WriteToSomeone { id: 987654321 }).await; 47 | 48 | // Just checking that the state returned to normal 49 | bot.dispatch_and_check_state(State::Start).await; 50 | 51 | let responses = bot.get_responses(); 52 | 53 | // This is the message that was sent to 987654321. It is always first 54 | let sent_message = responses.sent_messages[0].clone(); 55 | // And this is the message that was sent to the default user 56 | let response_message = responses.sent_messages[1].clone(); 57 | 58 | assert_eq!( 59 | sent_message.text().unwrap(), 60 | text::YOU_HAVE_A_NEW_MESSAGE.replace("{message}", "I love you!") 61 | ); // Just checking that the text and sender are correct 62 | assert_eq!(sent_message.chat.id.0, 987654321); 63 | 64 | assert_eq!( 65 | response_message.text().unwrap(), 66 | add_deep_link(text::MESSAGE_SENT, me, mock_message.chat.id) 67 | ); 68 | assert_eq!(response_message.chat.id, mock_message.chat.id); 69 | } 70 | 71 | #[tokio::test] 72 | async fn test_wrong_link() { 73 | let mock_message = MockMessageText::new().text("/start not_id"); 74 | let mut bot = MockBot::new(mock_message, handler_tree()); 75 | bot.dependencies(deps![InMemStorage::::new()]); 76 | 77 | bot.dispatch_and_check_last_text(text::WRONG_LINK).await; 78 | } 79 | 80 | #[tokio::test] 81 | async fn test_not_a_text() { 82 | let mock_message = MockMessagePhoto::new(); 83 | let mut bot = MockBot::new(mock_message, handler_tree()); 84 | bot.dependencies(deps![InMemStorage::::new()]); 85 | 86 | bot.set_state(State::WriteToSomeone { id: 987654321 }).await; 87 | 88 | bot.dispatch_and_check_last_text(text::SEND_TEXT).await; 89 | } 90 | -------------------------------------------------------------------------------- /examples/phrase_bot/src/resources/text.rs: -------------------------------------------------------------------------------- 1 | use crate::db::models; 2 | 3 | pub const NO_SUCH_PHRASE: &str = "There is no such phrase!"; 4 | 5 | pub const PLEASE_SEND_NUMBER: &str = "Please send a number!"; 6 | 7 | pub const DELETED_PHRASE: &str = "Your phrase has been deleted!"; 8 | 9 | pub const CHANGED_NICKNAME: &str = "Your nickname has been changed! 10 | 11 | New nickname: "; 12 | pub const START: &str = "Hello there, welcome to the phrase bot! 13 | 14 | What do you want to do?"; 15 | 16 | pub const MENU: &str = "What do you want to do?"; 17 | pub const SORRY_BOT_UPDATED: &str = 18 | "Sorry, bot updated and we lost where you were. Please try again."; 19 | pub const PLEASE_SEND_TEXT: &str = "Please send text!"; 20 | pub const NO_MORE_CHARACTERS: &str = "Your message must not be more than 3 characters!"; 21 | 22 | pub const CHANGE_NICKNAME: &str = "Send me new nickname! 23 | 24 | If you want to return, send /cancel"; 25 | 26 | pub const CANCELED: &str = "Canceled."; 27 | 28 | pub fn delete_phrase(all_phrases: &[models::Phrase]) -> String { 29 | format!( 30 | "These are your phrases: 31 | 32 | {} 33 | 34 | Send me a number of a phrase you want to delete! 35 | 36 | If you want to return, press /cancel", 37 | list_all_phrases(all_phrases) 38 | ) 39 | } 40 | 41 | pub fn phrase_progress(emoji: Option<&str>, text: Option<&str>, bot_text: Option<&str>) -> String { 42 | format!( 43 | "😀Emoji: {} 44 | 📝Text: {} 45 | 🗂Bot text: {}", 46 | emoji.unwrap_or("Not set🚫"), 47 | text.unwrap_or("Not set🚫"), 48 | bot_text.unwrap_or("Not set🚫") 49 | ) 50 | } 51 | 52 | pub fn what_is_new_phrase_emoji() -> String { 53 | format!( 54 | "Send an emoji for your phrase💬: 55 | 56 | To cancel at any time, send /cancel 57 | 58 | {}", 59 | phrase_progress(None, None, None) 60 | ) 61 | } 62 | 63 | pub fn what_is_new_phrase_text(emoji: &str) -> String { 64 | format!( 65 | "Now send a text that will trigger a phrase: 66 | 67 | {}", 68 | phrase_progress(Some(emoji), None, None) 69 | ) 70 | } 71 | 72 | pub fn what_is_new_phrase_bot_text(emoji: &str, text: &str) -> String { 73 | format!( 74 | "And finally, send a text that the bot will send. To mention yourself, add `(me)` in the text, and to mention someone you replied to, add `(reply)`. 75 | 76 | Example: (me) hugged (reply) 🤗 77 | 78 | {}", 79 | phrase_progress(Some(emoji), Some(text), None) 80 | ) 81 | } 82 | 83 | pub fn added_phrase(emoji: &str, text: &str, bot_text: &str) -> String { 84 | format!( 85 | "A new phrase was added! 86 | 87 | {}", 88 | phrase_progress(Some(emoji), Some(text), Some(bot_text)) 89 | ) 90 | } 91 | 92 | pub fn make_link(name: String, id: u64) -> String { 93 | format!("{}", id, name) 94 | } 95 | 96 | pub fn make_phrase_string(phrase: &models::Phrase) -> String { 97 | format!("{} - {} | {}", phrase.text, phrase.emoji, phrase.bot_text) 98 | } 99 | 100 | pub fn list_all_phrases(phrases: &[models::Phrase]) -> String { 101 | phrases 102 | .iter() 103 | .map(make_phrase_string) 104 | .enumerate() 105 | .map(|(i, phrase)| format!("{}. {}", i + 1, phrase)) 106 | .collect::>() 107 | .join("\n\n") 108 | } 109 | 110 | pub fn profile(nickname: Option, phrases: &[models::Phrase]) -> String { 111 | format!( 112 | "Your nickname📜: {} 113 | 114 | Your phrases: 115 | 116 | {}", 117 | nickname.unwrap_or("Not set🚫".to_string()), 118 | list_all_phrases(phrases) 119 | ) 120 | } 121 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/send_video_note.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, sync::Mutex}; 2 | 3 | use actix_multipart::Multipart; 4 | use actix_web::{error::ErrorBadRequest, web, Responder}; 5 | use rand::distr::{Alphanumeric, SampleString}; 6 | use serde::Deserialize; 7 | use teloxide::types::{ 8 | BusinessConnectionId, EffectId, FileId, FileUniqueId, Me, ReplyMarkup, ReplyParameters, Seconds, 9 | }; 10 | 11 | use super::{get_raw_multipart_fields, make_telegram_result, BodyChatId}; 12 | use crate::{ 13 | proc_macros::SerializeRawFields, 14 | server::{ 15 | routes::{check_if_message_exists, Attachment, FileType, SerializeRawFields}, 16 | SentMessageVideoNote, 17 | }, 18 | state::State, 19 | MockMessageVideoNote, 20 | }; 21 | 22 | pub async fn send_video_note( 23 | mut payload: Multipart, 24 | me: web::Data, 25 | state: web::Data>, 26 | ) -> impl Responder { 27 | let (fields, attachments) = get_raw_multipart_fields(&mut payload).await; 28 | let mut lock = state.lock().unwrap(); 29 | let body = 30 | SendMessageVideoNoteBody::serialize_raw_fields(&fields, &attachments, FileType::Voice) 31 | .unwrap(); 32 | let chat = body.chat_id.chat(); 33 | 34 | let mut message = MockMessageVideoNote::new().chat(chat.clone()); 35 | message.from = Some(me.user.clone()); 36 | message.has_protected_content = body.protect_content.unwrap_or(false); 37 | 38 | if let Some(reply_parameters) = &body.reply_parameters { 39 | check_if_message_exists!(lock, reply_parameters.message_id.0); 40 | let reply_to_message = lock 41 | .messages 42 | .get_message(reply_parameters.message_id.0) 43 | .unwrap(); 44 | message.reply_to_message = Some(Box::new(reply_to_message.clone())); 45 | } 46 | if let Some(ReplyMarkup::InlineKeyboard(markup)) = body.reply_markup.clone() { 47 | message.reply_markup = Some(markup); 48 | } 49 | 50 | let file_id = FileId(Alphanumeric.sample_string(&mut rand::rng(), 16)); 51 | let file_unique_id = FileUniqueId(Alphanumeric.sample_string(&mut rand::rng(), 8)); 52 | 53 | message.file_id = file_id; 54 | message.file_unique_id = file_unique_id; 55 | message.duration = body.duration.unwrap_or(Seconds::from_seconds(0)); 56 | message.length = body.length.unwrap_or(100); 57 | message.file_size = body.file_data.bytes().len() as u32; 58 | message.effect_id = body.message_effect_id.clone(); 59 | message.business_connection_id = body.business_connection_id.clone(); 60 | 61 | let last_id = lock.messages.max_message_id(); 62 | let message = lock.messages.add_message(message.id(last_id + 1).build()); 63 | 64 | lock.files.push(teloxide::types::File { 65 | meta: message.video_note().unwrap().file.clone(), 66 | path: body.file_name.to_owned(), 67 | }); 68 | lock.responses.sent_messages.push(message.clone()); 69 | lock.responses 70 | .sent_messages_video_note 71 | .push(SentMessageVideoNote { 72 | message: message.clone(), 73 | bot_request: body, 74 | }); 75 | 76 | make_telegram_result(message) 77 | } 78 | 79 | #[derive(Debug, Clone, Deserialize, SerializeRawFields)] 80 | pub struct SendMessageVideoNoteBody { 81 | pub chat_id: BodyChatId, 82 | pub message_thread_id: Option, 83 | pub file_name: String, 84 | pub file_data: String, 85 | pub duration: Option, 86 | pub length: Option, 87 | pub disable_notification: Option, 88 | pub protect_content: Option, 89 | pub message_effect_id: Option, 90 | pub reply_parameters: Option, 91 | pub reply_markup: Option, 92 | pub business_connection_id: Option, 93 | } 94 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/copy_message.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{error::ErrorBadRequest, web, Responder}; 4 | use serde::Deserialize; 5 | use serde_json::json; 6 | use teloxide::types::{ 7 | Me, MediaAnimation, MediaAudio, MediaDocument, MediaKind, MediaPhoto, MediaVideo, MediaVoice, 8 | MessageEntity, MessageId, MessageKind, ParseMode, ReplyMarkup, 9 | }; 10 | 11 | use super::{make_telegram_result, BodyChatId}; 12 | use crate::{ 13 | server::{routes::check_if_message_exists, CopiedMessage}, 14 | state::State, 15 | }; 16 | 17 | #[derive(Debug, Deserialize, Clone)] 18 | pub struct CopyMessageBody { 19 | pub chat_id: BodyChatId, 20 | pub message_thread_id: Option, 21 | pub from_chat_id: BodyChatId, 22 | pub message_id: i32, 23 | pub caption: Option, 24 | pub parse_mode: Option, 25 | pub caption_entities: Option>, 26 | pub show_caption_above_media: Option, 27 | pub disable_notification: Option, 28 | pub protect_content: Option, 29 | pub reply_markup: Option, 30 | } 31 | 32 | pub async fn copy_message( 33 | body: web::Json, 34 | me: web::Data, 35 | state: web::Data>, 36 | ) -> impl Responder { 37 | let mut lock = state.lock().unwrap(); 38 | let chat = body.chat_id.chat(); 39 | check_if_message_exists!(lock, body.message_id); 40 | let mut message = lock.messages.get_message(body.message_id).unwrap(); 41 | message.chat = chat; 42 | message.from = Some(me.user.clone()); 43 | 44 | // FIXME: Use show_caption_above_media 45 | if let MessageKind::Common(ref mut common) = message.kind { 46 | common.forward_origin = None; 47 | common.external_reply = None; 48 | match common.media_kind { 49 | MediaKind::Animation(MediaAnimation { 50 | ref mut caption, 51 | ref mut caption_entities, 52 | .. 53 | }) 54 | | MediaKind::Audio(MediaAudio { 55 | ref mut caption, 56 | ref mut caption_entities, 57 | .. 58 | }) 59 | | MediaKind::Document(MediaDocument { 60 | ref mut caption, 61 | ref mut caption_entities, 62 | .. 63 | }) 64 | | MediaKind::Photo(MediaPhoto { 65 | ref mut caption, 66 | ref mut caption_entities, 67 | .. 68 | }) 69 | | MediaKind::Video(MediaVideo { 70 | ref mut caption, 71 | ref mut caption_entities, 72 | .. 73 | }) 74 | | MediaKind::Voice(MediaVoice { 75 | ref mut caption, 76 | ref mut caption_entities, 77 | .. 78 | }) => { 79 | *caption = body.caption.clone(); 80 | *caption_entities = body.caption_entities.clone().unwrap_or_default(); 81 | } 82 | _ => {} 83 | }; 84 | if let Some(ReplyMarkup::InlineKeyboard(markup)) = body.reply_markup.clone() { 85 | common.reply_markup = Some(markup); 86 | } 87 | common.has_protected_content = body.protect_content.unwrap_or(false); 88 | } 89 | 90 | let last_id = lock.messages.max_message_id(); 91 | message.id = MessageId(last_id + 1); 92 | message.chat = body.chat_id.chat(); 93 | let message = lock.messages.add_message(message); 94 | 95 | lock.responses.sent_messages.push(message.clone()); 96 | lock.responses.copied_messages.push(CopiedMessage { 97 | message_id: message.id, 98 | bot_request: body.into_inner(), 99 | }); 100 | 101 | make_telegram_result(json!({ 102 | "message_id": message.id.0 103 | })) 104 | } 105 | -------------------------------------------------------------------------------- /examples/calculator_bot/src/handlers.rs: -------------------------------------------------------------------------------- 1 | use teloxide::{ 2 | dispatching::dialogue::GetChatId, 3 | macros::BotCommands, 4 | prelude::*, 5 | types::{InlineKeyboardButton, InlineKeyboardMarkup}, 6 | }; 7 | 8 | use crate::{text, HandlerResult, MyDialogue, State}; 9 | 10 | #[derive(BotCommands, Clone)] 11 | #[command(rename_rule = "lowercase")] 12 | pub enum StartCommand { 13 | #[command()] 14 | Start, 15 | } 16 | 17 | /* 18 | Just some simple example handlers to test 19 | */ 20 | 21 | pub async fn start(bot: Bot, dialogue: MyDialogue, msg: Message) -> HandlerResult { 22 | let keyboard = InlineKeyboardMarkup::new([[ 23 | InlineKeyboardButton::callback("Add", "add"), 24 | InlineKeyboardButton::callback("Subtract", "subtract"), 25 | ]]); 26 | bot.send_message(msg.chat.id, text::WHAT_DO_YOU_WANT) 27 | .reply_markup(keyboard) 28 | .await?; 29 | dialogue.update(State::WhatDoYouWant).await?; 30 | Ok(()) 31 | } 32 | 33 | pub async fn what_is_the_first_number( 34 | bot: Bot, 35 | dialogue: MyDialogue, 36 | call: CallbackQuery, 37 | ) -> HandlerResult { 38 | let chat_id = call.clone().chat_id().unwrap(); 39 | bot.edit_message_reply_markup(chat_id, call.regular_message().unwrap().id) 40 | .await?; 41 | bot.send_message(chat_id, text::ENTER_THE_FIRST_NUMBER) 42 | .await?; 43 | dialogue 44 | .update(State::GetFirstNumber { 45 | operation: call.data.unwrap(), 46 | }) 47 | .await?; 48 | Ok(()) 49 | } 50 | 51 | pub async fn what_is_the_second_number( 52 | bot: Bot, 53 | dialogue: MyDialogue, 54 | message: Message, 55 | state_data: String, 56 | ) -> HandlerResult { 57 | let message_text = match message.text() { 58 | // Just extracting the text from the message 59 | Some(text) => text, 60 | None => { 61 | bot.send_message(message.chat.id, text::PLEASE_SEND_TEXT) 62 | .await?; 63 | return Ok(()); 64 | } 65 | }; 66 | let first_number = match message_text.parse::() { 67 | // And then parsing it 68 | Ok(number) => number, 69 | Err(_) => { 70 | bot.send_message(message.chat.id, text::PLEASE_ENTER_A_NUMBER) 71 | .await?; 72 | return Ok(()); 73 | } 74 | }; 75 | bot.send_message(message.chat.id, text::ENTER_THE_SECOND_NUMBER) 76 | .await?; 77 | dialogue 78 | .update(State::GetSecondNumber { 79 | first_number, 80 | operation: state_data, 81 | }) 82 | .await?; 83 | Ok(()) 84 | } 85 | 86 | pub async fn get_result( 87 | bot: Bot, 88 | dialogue: MyDialogue, 89 | message: Message, 90 | state_data: (i32, String), 91 | ) -> HandlerResult { 92 | let message_text = match message.text() { 93 | // Who cares about DRY anyway 94 | Some(text) => text, 95 | None => { 96 | bot.send_message(message.chat.id, text::PLEASE_SEND_TEXT) 97 | .await?; 98 | return Ok(()); 99 | } 100 | }; 101 | let second_number = match message_text.parse::() { 102 | Ok(number) => number, 103 | Err(_) => { 104 | bot.send_message(message.chat.id, text::PLEASE_ENTER_A_NUMBER) 105 | .await?; 106 | return Ok(()); 107 | } 108 | }; 109 | 110 | let (first_number, operation) = state_data; 111 | let result = match operation.as_str() { 112 | "add" => first_number + second_number, 113 | "subtract" => first_number - second_number, 114 | _ => unreachable!(), 115 | }; 116 | 117 | bot.send_message( 118 | message.chat.id, 119 | text::YOUR_RESULT.to_owned() + result.to_string().as_str(), 120 | ) 121 | .await?; 122 | dialogue.update(State::default()).await?; 123 | Ok(()) 124 | } 125 | -------------------------------------------------------------------------------- /examples/calculator_bot/src/tests.rs: -------------------------------------------------------------------------------- 1 | use teloxide::dptree::deps; 2 | use teloxide_tests::{MockBot, MockCallbackQuery, MockMessagePhoto, MockMessageText}; 3 | 4 | use crate::{get_bot_storage, handler_tree::handler_tree, text, State}; 5 | 6 | #[tokio::test] 7 | async fn test_start() { 8 | let mut bot = MockBot::new(MockMessageText::new().text("/start"), handler_tree()); 9 | 10 | bot.dependencies(deps![get_bot_storage().await]); 11 | bot.set_state(State::Start).await; 12 | 13 | bot.dispatch_and_check_last_text_and_state(text::WHAT_DO_YOU_WANT, State::WhatDoYouWant) 14 | .await; 15 | // This is a shortcut for: 16 | // 17 | // bot.dispatch().await; 18 | // 19 | // let state: State = bot.get_state().await; 20 | // assert_eq!(state, State::WhatDoYouWant); 21 | // 22 | // let responses = bot.get_responses(); 23 | // let last_message = responses.sent_messages.last().unwrap(); 24 | // assert_eq!(last_message.text().unwrap(), text::WHAT_DO_YOU_WANT); 25 | // 26 | } 27 | 28 | #[tokio::test] 29 | async fn test_what_is_the_first_number() { 30 | let mut bot = MockBot::new(MockCallbackQuery::new().data("add"), handler_tree()); 31 | 32 | bot.dependencies(deps![get_bot_storage().await]); 33 | bot.set_state(State::WhatDoYouWant).await; 34 | 35 | bot.dispatch_and_check_last_text_and_state( 36 | text::ENTER_THE_FIRST_NUMBER, 37 | State::GetFirstNumber { 38 | operation: "add".to_owned(), 39 | }, 40 | ) 41 | .await; 42 | } 43 | 44 | #[tokio::test] 45 | async fn test_message_errors() { 46 | let mut bot = MockBot::new(MockMessageText::new().text("not a number"), handler_tree()); 47 | 48 | bot.dependencies(deps![get_bot_storage().await]); 49 | bot.set_state(State::GetFirstNumber { 50 | operation: "add".to_owned(), 51 | }) 52 | .await; 53 | 54 | bot.dispatch_and_check_last_text(text::PLEASE_ENTER_A_NUMBER) 55 | .await; 56 | 57 | // This makes a new update into the same bot instance, so we can check the second error type 58 | // using the same state and storage 59 | bot.update(MockMessagePhoto::new()); 60 | bot.dispatch_and_check_last_text(text::PLEASE_SEND_TEXT) 61 | .await; 62 | } 63 | 64 | #[tokio::test] 65 | async fn test_what_is_the_second_number() { 66 | let mut bot = MockBot::new(MockMessageText::new().text("5"), handler_tree()); 67 | 68 | bot.dependencies(deps![get_bot_storage().await]); 69 | bot.set_state(State::GetFirstNumber { 70 | operation: "add".to_owned(), 71 | }) 72 | .await; 73 | 74 | bot.dispatch_and_check_last_text_and_state( 75 | text::ENTER_THE_SECOND_NUMBER, 76 | State::GetSecondNumber { 77 | first_number: 5, 78 | operation: "add".to_owned(), 79 | }, 80 | ) 81 | .await; 82 | } 83 | 84 | #[tokio::test] 85 | async fn test_add_result() { 86 | let mut bot = MockBot::new(MockMessageText::new().text("4"), handler_tree()); 87 | 88 | bot.dependencies(deps![get_bot_storage().await]); 89 | bot.set_state(State::GetSecondNumber { 90 | first_number: 5, 91 | operation: "add".to_owned(), 92 | }) 93 | .await; 94 | 95 | bot.dispatch_and_check_last_text_and_state( 96 | &(text::YOUR_RESULT.to_owned() + "9"), 97 | State::default(), 98 | ) 99 | .await; 100 | } 101 | 102 | #[tokio::test] 103 | async fn test_subtract_result() { 104 | let mut bot = MockBot::new(MockMessageText::new().text("4"), handler_tree()); 105 | 106 | bot.dependencies(deps![get_bot_storage().await]); 107 | bot.set_state(State::GetSecondNumber { 108 | first_number: 5, 109 | operation: "subtract".to_owned(), 110 | }) 111 | .await; 112 | 113 | bot.dispatch_and_check_last_text_and_state( 114 | &(text::YOUR_RESULT.to_owned() + "1"), 115 | State::default(), 116 | ) 117 | .await; 118 | } 119 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/send_voice.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, str::FromStr, sync::Mutex}; 2 | 3 | use actix_multipart::Multipart; 4 | use actix_web::{error::ErrorBadRequest, web, Responder}; 5 | use mime::Mime; 6 | use rand::distr::{Alphanumeric, SampleString}; 7 | use serde::Deserialize; 8 | use teloxide::types::{ 9 | BusinessConnectionId, EffectId, FileId, FileUniqueId, Me, MessageEntity, ParseMode, 10 | ReplyMarkup, ReplyParameters, Seconds, 11 | }; 12 | 13 | use super::{get_raw_multipart_fields, make_telegram_result, BodyChatId}; 14 | use crate::{ 15 | proc_macros::SerializeRawFields, 16 | server::{ 17 | routes::{check_if_message_exists, Attachment, FileType, SerializeRawFields}, 18 | SentMessageVoice, 19 | }, 20 | state::State, 21 | MockMessageVoice, 22 | }; 23 | 24 | pub async fn send_voice( 25 | mut payload: Multipart, 26 | me: web::Data, 27 | state: web::Data>, 28 | ) -> impl Responder { 29 | let (fields, attachments) = get_raw_multipart_fields(&mut payload).await; 30 | let mut lock = state.lock().unwrap(); 31 | let body = 32 | SendMessageVoiceBody::serialize_raw_fields(&fields, &attachments, FileType::Voice).unwrap(); 33 | let chat = body.chat_id.chat(); 34 | 35 | let mut message = MockMessageVoice::new().chat(chat.clone()); 36 | message.from = Some(me.user.clone()); 37 | message.has_protected_content = body.protect_content.unwrap_or(false); 38 | message.caption = body.caption.clone(); 39 | message.caption_entities = body.caption_entities.clone().unwrap_or_default(); 40 | message.business_connection_id = body.business_connection_id.clone(); 41 | 42 | if let Some(reply_parameters) = &body.reply_parameters { 43 | check_if_message_exists!(lock, reply_parameters.message_id.0); 44 | let reply_to_message = lock 45 | .messages 46 | .get_message(reply_parameters.message_id.0) 47 | .unwrap(); 48 | message.reply_to_message = Some(Box::new(reply_to_message.clone())); 49 | } 50 | if let Some(ReplyMarkup::InlineKeyboard(markup)) = body.reply_markup.clone() { 51 | message.reply_markup = Some(markup); 52 | } 53 | 54 | let file_id = FileId(Alphanumeric.sample_string(&mut rand::rng(), 16)); 55 | let file_unique_id = FileUniqueId(Alphanumeric.sample_string(&mut rand::rng(), 8)); 56 | 57 | message.file_id = file_id; 58 | message.file_unique_id = file_unique_id; 59 | message.duration = body.duration.unwrap_or(Seconds::from_seconds(0)); 60 | message.file_size = body.file_data.bytes().len() as u32; 61 | message.mime_type = Some(Mime::from_str("audio/mp3").unwrap()); 62 | message.effect_id = body.message_effect_id.clone(); 63 | 64 | let last_id = lock.messages.max_message_id(); 65 | let message = lock.messages.add_message(message.id(last_id + 1).build()); 66 | 67 | lock.files.push(teloxide::types::File { 68 | meta: message.voice().unwrap().file.clone(), 69 | path: body.file_name.to_owned(), 70 | }); 71 | lock.responses.sent_messages.push(message.clone()); 72 | lock.responses.sent_messages_voice.push(SentMessageVoice { 73 | message: message.clone(), 74 | bot_request: body, 75 | }); 76 | 77 | make_telegram_result(message) 78 | } 79 | 80 | #[derive(Debug, Clone, Deserialize, SerializeRawFields)] 81 | pub struct SendMessageVoiceBody { 82 | pub chat_id: BodyChatId, 83 | pub message_thread_id: Option, 84 | pub file_name: String, 85 | pub file_data: String, 86 | pub duration: Option, 87 | pub caption: Option, 88 | pub parse_mode: Option, 89 | pub caption_entities: Option>, 90 | pub disable_notification: Option, 91 | pub protect_content: Option, 92 | pub message_effect_id: Option, 93 | pub reply_parameters: Option, 94 | pub reply_markup: Option, 95 | pub business_connection_id: Option, 96 | } 97 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/send_photo.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, sync::Mutex}; 2 | 3 | use actix_multipart::Multipart; 4 | use actix_web::{error::ErrorBadRequest, web, Responder}; 5 | use rand::distr::{Alphanumeric, SampleString}; 6 | use serde::Deserialize; 7 | use teloxide::types::{ 8 | BusinessConnectionId, EffectId, FileId, FileUniqueId, LinkPreviewOptions, Me, MessageEntity, 9 | ParseMode, ReplyMarkup, ReplyParameters, 10 | }; 11 | 12 | use super::{get_raw_multipart_fields, make_telegram_result, BodyChatId}; 13 | use crate::{ 14 | dataset::{MockMessagePhoto, MockPhotoSize}, 15 | proc_macros::SerializeRawFields, 16 | server::{ 17 | routes::{check_if_message_exists, Attachment, FileType, SerializeRawFields}, 18 | SentMessagePhoto, 19 | }, 20 | state::State, 21 | }; 22 | 23 | pub async fn send_photo( 24 | mut payload: Multipart, 25 | me: web::Data, 26 | state: web::Data>, 27 | ) -> impl Responder { 28 | let (fields, attachments) = get_raw_multipart_fields(&mut payload).await; 29 | let mut lock = state.lock().unwrap(); 30 | let body = 31 | SendMessagePhotoBody::serialize_raw_fields(&fields, &attachments, FileType::Photo).unwrap(); 32 | let chat = body.chat_id.chat(); 33 | 34 | let mut message = // Creates the message, which will be mutated to fit the needed shape 35 | MockMessagePhoto::new().chat(chat); 36 | message.from = Some(me.user.clone()); 37 | message.has_protected_content = body.protect_content.unwrap_or(false); 38 | message.caption = body.caption.clone(); 39 | message.caption_entities = body.caption_entities.clone().unwrap_or_default(); 40 | message.show_caption_above_media = body.show_caption_above_media.unwrap_or(false); 41 | message.effect_id = body.message_effect_id.clone(); 42 | message.business_connection_id = body.business_connection_id.clone(); 43 | 44 | if let Some(reply_parameters) = &body.reply_parameters { 45 | check_if_message_exists!(lock, reply_parameters.message_id.0); 46 | let reply_to_message = lock 47 | .messages 48 | .get_message(reply_parameters.message_id.0) 49 | .unwrap(); 50 | message.reply_to_message = Some(Box::new(reply_to_message.clone())); 51 | } 52 | if let Some(ReplyMarkup::InlineKeyboard(markup)) = body.reply_markup.clone() { 53 | message.reply_markup = Some(markup); 54 | } 55 | 56 | let file_id = FileId(Alphanumeric.sample_string(&mut rand::rng(), 16)); 57 | let file_unique_id = FileUniqueId(Alphanumeric.sample_string(&mut rand::rng(), 8)); 58 | 59 | message.photo = vec![MockPhotoSize::new() 60 | .file_id(file_id) 61 | .file_unique_id(file_unique_id) 62 | .file_size(body.file_data.bytes().len() as u32) 63 | .build()]; 64 | 65 | let last_id = lock.messages.max_message_id(); 66 | let message = lock.messages.add_message(message.id(last_id + 1).build()); 67 | 68 | lock.files.push(teloxide::types::File { 69 | meta: message.photo().unwrap()[0].file.clone(), 70 | path: body.file_name.to_owned(), 71 | }); 72 | lock.responses.sent_messages.push(message.clone()); 73 | lock.responses.sent_messages_photo.push(SentMessagePhoto { 74 | message: message.clone(), 75 | bot_request: body, 76 | }); 77 | 78 | make_telegram_result(message) 79 | } 80 | 81 | #[derive(Debug, Clone, Deserialize, SerializeRawFields)] 82 | pub struct SendMessagePhotoBody { 83 | pub chat_id: BodyChatId, 84 | pub file_name: String, 85 | pub file_data: String, 86 | pub caption: Option, 87 | pub message_thread_id: Option, 88 | pub parse_mode: Option, 89 | pub caption_entities: Option>, 90 | pub link_preview_options: Option, 91 | pub disable_notification: Option, 92 | pub protect_content: Option, 93 | pub show_caption_above_media: Option, 94 | pub message_effect_id: Option, 95 | pub reply_markup: Option, 96 | pub reply_parameters: Option, 97 | pub business_connection_id: Option, 98 | } 99 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/send_poll.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use actix_web::{error::ErrorBadRequest, web, Responder}; 4 | use chrono::DateTime; 5 | use serde::Deserialize; 6 | use teloxide::types::{ 7 | BusinessConnectionId, EffectId, InputPollOption, Me, MessageEntity, ParseMode, PollOption, 8 | PollType, ReplyMarkup, ReplyParameters, Seconds, 9 | }; 10 | 11 | use super::{make_telegram_result, BodyChatId}; 12 | use crate::{ 13 | server::{routes::check_if_message_exists, SentMessagePoll}, 14 | state::State, 15 | MockMessagePoll, 16 | }; 17 | 18 | #[derive(Debug, Deserialize, Clone)] 19 | pub struct SendMessagePollBody { 20 | pub chat_id: BodyChatId, 21 | pub message_thread_id: Option, 22 | pub question: String, 23 | pub question_parse_mode: Option, 24 | pub question_entities: Option>, 25 | pub options: Vec, 26 | pub is_anonymous: Option, 27 | pub r#type: Option, 28 | pub allows_multiple_answers: Option, 29 | pub correct_option_id: Option, 30 | pub explanation: Option, 31 | pub explanation_parse_mode: Option, 32 | pub explanation_entities: Option>, 33 | pub open_period: Option, 34 | pub close_date: Option, 35 | pub is_closed: Option, 36 | pub disable_notification: Option, 37 | pub protect_content: Option, 38 | pub message_effect_id: Option, 39 | pub reply_markup: Option, 40 | pub reply_parameters: Option, 41 | pub business_connection_id: Option, 42 | } 43 | 44 | pub async fn send_poll( 45 | state: web::Data>, 46 | body: web::Json, 47 | me: web::Data, 48 | ) -> impl Responder { 49 | let mut lock = state.lock().unwrap(); 50 | let chat = body.chat_id.chat(); 51 | let mut message = // Creates the message, which will be mutated to fit the needed shape 52 | MockMessagePoll::new().chat(chat); 53 | message.from = Some(me.user.clone()); 54 | message.has_protected_content = body.protect_content.unwrap_or(false); 55 | message.business_connection_id = body.business_connection_id.clone(); 56 | 57 | message.question = body.question.clone(); 58 | let mut options = vec![]; 59 | for option in body.options.iter() { 60 | options.push(PollOption { 61 | text: option.text.clone(), 62 | text_entities: None, 63 | voter_count: 0, 64 | }); 65 | } 66 | message.options = options; 67 | message.is_anonymous = body.is_anonymous.unwrap_or(false); 68 | message.poll_type = body.r#type.clone().unwrap_or(PollType::Regular); 69 | message.allows_multiple_answers = body.allows_multiple_answers.unwrap_or(false); 70 | message.correct_option_id = body.correct_option_id; 71 | message.explanation = body.explanation.clone(); 72 | message.explanation_entities = body.explanation_entities.clone(); 73 | message.open_period = body.open_period; 74 | message.close_date = DateTime::from_timestamp(body.close_date.unwrap_or(0) as i64, 0); 75 | message.effect_id = body.message_effect_id.clone(); 76 | message.question_entities = body.question_entities.clone(); 77 | 78 | if let Some(reply_parameters) = &body.reply_parameters { 79 | check_if_message_exists!(lock, reply_parameters.message_id.0); 80 | let reply_to_message = lock 81 | .messages 82 | .get_message(reply_parameters.message_id.0) 83 | .unwrap(); 84 | message.reply_to_message = Some(Box::new(reply_to_message.clone())); 85 | } 86 | if let Some(ReplyMarkup::InlineKeyboard(markup)) = body.reply_markup.clone() { 87 | message.reply_markup = Some(markup); 88 | } 89 | 90 | let last_id = lock.messages.max_message_id(); 91 | let message = lock.messages.add_message(message.id(last_id + 1).build()); 92 | 93 | lock.responses.sent_messages.push(message.clone()); 94 | lock.responses.sent_messages_poll.push(SentMessagePoll { 95 | message: message.clone(), 96 | bot_request: body.into_inner(), 97 | }); 98 | 99 | make_telegram_result(message) 100 | } 101 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/send_audio.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, str::FromStr, sync::Mutex}; 2 | 3 | use actix_multipart::Multipart; 4 | use actix_web::{error::ErrorBadRequest, web, Responder}; 5 | use mime::Mime; 6 | use rand::distr::{Alphanumeric, SampleString}; 7 | use serde::Deserialize; 8 | use teloxide::types::{ 9 | BusinessConnectionId, EffectId, FileId, FileUniqueId, Me, MessageEntity, ParseMode, 10 | ReplyMarkup, ReplyParameters, Seconds, 11 | }; 12 | 13 | use super::{get_raw_multipart_fields, make_telegram_result, BodyChatId}; 14 | use crate::{ 15 | proc_macros::SerializeRawFields, 16 | server::{ 17 | routes::{check_if_message_exists, Attachment, FileType, SerializeRawFields}, 18 | SentMessageAudio, 19 | }, 20 | state::State, 21 | MockMessageAudio, 22 | }; 23 | 24 | pub async fn send_audio( 25 | mut payload: Multipart, 26 | me: web::Data, 27 | state: web::Data>, 28 | ) -> impl Responder { 29 | let (fields, attachments) = get_raw_multipart_fields(&mut payload).await; 30 | let mut lock = state.lock().unwrap(); 31 | let body = 32 | SendMessageAudioBody::serialize_raw_fields(&fields, &attachments, FileType::Audio).unwrap(); 33 | let chat = body.chat_id.chat(); 34 | 35 | let mut message = MockMessageAudio::new().chat(chat.clone()); 36 | message.has_protected_content = body.protect_content.unwrap_or(false); 37 | message.from = Some(me.user.clone()); 38 | message.caption = body.caption.clone(); 39 | message.caption_entities = body.caption_entities.clone().unwrap_or_default(); 40 | message.effect_id = body.message_effect_id.clone(); 41 | message.business_connection_id = body.business_connection_id.clone(); 42 | 43 | if let Some(reply_parameters) = &body.reply_parameters { 44 | check_if_message_exists!(lock, reply_parameters.message_id.0); 45 | let reply_to_message = lock 46 | .messages 47 | .get_message(reply_parameters.message_id.0) 48 | .unwrap(); 49 | message.reply_to_message = Some(Box::new(reply_to_message.clone())); 50 | } 51 | if let Some(ReplyMarkup::InlineKeyboard(markup)) = body.reply_markup.clone() { 52 | message.reply_markup = Some(markup); 53 | } 54 | 55 | let file_id = FileId(Alphanumeric.sample_string(&mut rand::rng(), 16)); 56 | let file_unique_id = FileUniqueId(Alphanumeric.sample_string(&mut rand::rng(), 8)); 57 | 58 | message.file_id = file_id; 59 | message.file_unique_id = file_unique_id; 60 | message.performer = body.performer.clone(); 61 | message.title = body.title.clone(); 62 | message.duration = body.duration.unwrap_or(Seconds::from_seconds(0)); 63 | message.file_size = body.file_data.bytes().len() as u32; 64 | message.mime_type = Some(Mime::from_str("audio/mp3").unwrap()); 65 | message.file_name = Some(body.file_name.clone()); 66 | 67 | let last_id = lock.messages.max_message_id(); 68 | let message = lock.messages.add_message(message.id(last_id + 1).build()); 69 | 70 | lock.files.push(teloxide::types::File { 71 | meta: message.audio().unwrap().file.clone(), 72 | path: body.file_name.to_owned(), 73 | }); 74 | lock.responses.sent_messages.push(message.clone()); 75 | lock.responses.sent_messages_audio.push(SentMessageAudio { 76 | message: message.clone(), 77 | bot_request: body, 78 | }); 79 | 80 | make_telegram_result(message) 81 | } 82 | 83 | #[derive(Debug, Clone, Deserialize, SerializeRawFields)] 84 | pub struct SendMessageAudioBody { 85 | pub chat_id: BodyChatId, 86 | pub message_thread_id: Option, 87 | pub file_name: String, 88 | pub file_data: String, 89 | pub duration: Option, 90 | pub caption: Option, 91 | pub parse_mode: Option, 92 | pub caption_entities: Option>, 93 | pub performer: Option, 94 | pub title: Option, 95 | pub disable_notification: Option, 96 | pub protect_content: Option, 97 | pub message_effect_id: Option, 98 | pub reply_parameters: Option, 99 | pub reply_markup: Option, 100 | pub business_connection_id: Option, 101 | } 102 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/send_document.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, str::FromStr, sync::Mutex}; 2 | 3 | use actix_multipart::Multipart; 4 | use actix_web::{error::ErrorBadRequest, web, Responder}; 5 | use mime::Mime; 6 | use rand::distr::{Alphanumeric, SampleString}; 7 | use serde::Deserialize; 8 | use teloxide::types::{ 9 | BusinessConnectionId, EffectId, FileId, FileUniqueId, Me, MessageEntity, ParseMode, 10 | ReplyMarkup, ReplyParameters, 11 | }; 12 | 13 | use super::{get_raw_multipart_fields, make_telegram_result, BodyChatId}; 14 | use crate::{ 15 | dataset::MockMessageDocument, 16 | proc_macros::SerializeRawFields, 17 | server::{ 18 | routes::{check_if_message_exists, Attachment, FileType, SerializeRawFields}, 19 | SentMessageDocument, 20 | }, 21 | state::State, 22 | }; 23 | 24 | pub async fn send_document( 25 | mut payload: Multipart, 26 | me: web::Data, 27 | state: web::Data>, 28 | ) -> impl Responder { 29 | let (fields, attachments) = get_raw_multipart_fields(&mut payload).await; 30 | let mut lock = state.lock().unwrap(); 31 | let body = 32 | SendMessageDocumentBody::serialize_raw_fields(&fields, &attachments, FileType::Document) 33 | .unwrap(); 34 | let chat = body.chat_id.chat(); 35 | 36 | let mut message = // Creates the message, which will be mutated to fit the needed shape 37 | MockMessageDocument::new().chat(chat); 38 | message.from = Some(me.user.clone()); 39 | message.caption = body.caption.clone(); 40 | message.caption_entities = body.caption_entities.clone().unwrap_or_default(); 41 | message.effect_id = body.message_effect_id.clone(); 42 | message.business_connection_id = body.business_connection_id.clone(); 43 | 44 | if let Some(reply_parameters) = &body.reply_parameters { 45 | check_if_message_exists!(lock, reply_parameters.message_id.0); 46 | let reply_to_message = lock 47 | .messages 48 | .get_message(reply_parameters.message_id.0) 49 | .unwrap(); 50 | message.reply_to_message = Some(Box::new(reply_to_message.clone())); 51 | } 52 | if let Some(ReplyMarkup::InlineKeyboard(markup)) = body.reply_markup.clone() { 53 | message.reply_markup = Some(markup); 54 | } 55 | 56 | let file_id = FileId(Alphanumeric.sample_string(&mut rand::rng(), 16)); 57 | let file_unique_id = FileUniqueId(Alphanumeric.sample_string(&mut rand::rng(), 8)); 58 | 59 | message.file_name = Some(body.file_name.clone()); 60 | message.file_id = file_id; 61 | message.file_unique_id = file_unique_id; 62 | message.file_size = body.file_data.bytes().len() as u32; 63 | message.mime_type = Some( 64 | mime_guess::from_path(body.file_name.clone()) 65 | .first() 66 | .unwrap_or(Mime::from_str("text/plain").unwrap()), 67 | ); 68 | message.has_protected_content = body.protect_content.unwrap_or(false); 69 | 70 | let last_id = lock.messages.max_message_id(); 71 | let message = lock.messages.add_message(message.id(last_id + 1).build()); 72 | 73 | lock.files.push(teloxide::types::File { 74 | meta: message.document().unwrap().file.clone(), 75 | path: body.file_name.to_owned(), 76 | }); 77 | lock.responses.sent_messages.push(message.clone()); 78 | lock.responses 79 | .sent_messages_document 80 | .push(SentMessageDocument { 81 | message: message.clone(), 82 | bot_request: body, 83 | }); 84 | 85 | make_telegram_result(message) 86 | } 87 | 88 | #[derive(Debug, Clone, Deserialize, SerializeRawFields)] 89 | pub struct SendMessageDocumentBody { 90 | pub chat_id: BodyChatId, 91 | pub file_name: String, 92 | pub file_data: String, 93 | pub caption: Option, 94 | pub message_thread_id: Option, 95 | pub parse_mode: Option, 96 | pub caption_entities: Option>, 97 | pub disable_content_type_detection: Option, 98 | pub disable_notification: Option, 99 | pub protect_content: Option, 100 | pub message_effect_id: Option, 101 | pub reply_markup: Option, 102 | pub reply_parameters: Option, 103 | pub business_connection_id: Option, 104 | } 105 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/send_video.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, str::FromStr, sync::Mutex}; 2 | 3 | use actix_multipart::Multipart; 4 | use actix_web::{error::ErrorBadRequest, web, Responder}; 5 | use mime::Mime; 6 | use rand::distr::{Alphanumeric, SampleString}; 7 | use serde::Deserialize; 8 | use teloxide::types::{ 9 | BusinessConnectionId, EffectId, FileId, FileUniqueId, Me, MessageEntity, ParseMode, 10 | ReplyMarkup, ReplyParameters, Seconds, 11 | }; 12 | 13 | use super::{get_raw_multipart_fields, make_telegram_result, BodyChatId}; 14 | use crate::{ 15 | dataset::{MockMessageVideo, MockVideo}, 16 | proc_macros::SerializeRawFields, 17 | server::{ 18 | routes::{check_if_message_exists, Attachment, FileType, SerializeRawFields}, 19 | SentMessageVideo, 20 | }, 21 | state::State, 22 | }; 23 | 24 | pub async fn send_video( 25 | mut payload: Multipart, 26 | me: web::Data, 27 | state: web::Data>, 28 | ) -> impl Responder { 29 | let (fields, attachments) = get_raw_multipart_fields(&mut payload).await; 30 | let mut lock = state.lock().unwrap(); 31 | let body = 32 | SendMessageVideoBody::serialize_raw_fields(&fields, &attachments, FileType::Video).unwrap(); 33 | let chat = body.chat_id.chat(); 34 | 35 | let mut message = MockMessageVideo::new().chat(chat.clone()); 36 | message.from = Some(me.user.clone()); 37 | message.has_protected_content = body.protect_content.unwrap_or(false); 38 | message.caption = body.caption.clone(); 39 | message.caption_entities = body.caption_entities.clone().unwrap_or_default(); 40 | message.show_caption_above_media = body.show_caption_above_media.unwrap_or(false); 41 | message.effect_id = body.message_effect_id.clone(); 42 | message.business_connection_id = body.business_connection_id.clone(); 43 | 44 | if let Some(reply_parameters) = &body.reply_parameters { 45 | check_if_message_exists!(lock, reply_parameters.message_id.0); 46 | let reply_to_message = lock 47 | .messages 48 | .get_message(reply_parameters.message_id.0) 49 | .unwrap(); 50 | message.reply_to_message = Some(Box::new(reply_to_message.clone())); 51 | } 52 | 53 | if let Some(ReplyMarkup::InlineKeyboard(markup)) = body.reply_markup.clone() { 54 | message.reply_markup = Some(markup); 55 | } 56 | 57 | let file_id = FileId(Alphanumeric.sample_string(&mut rand::rng(), 16)); 58 | let file_unique_id = FileUniqueId(Alphanumeric.sample_string(&mut rand::rng(), 8)); 59 | 60 | message.video = MockVideo::new() 61 | .file_id(file_id) 62 | .file_unique_id(file_unique_id) 63 | .file_size(body.file_data.bytes().len() as u32) 64 | .file_name(body.file_name.clone()) 65 | .width(body.width.unwrap_or(100)) 66 | .height(body.height.unwrap_or(100)) 67 | .duration(body.duration.unwrap_or(Seconds::from_seconds(1))) 68 | .mime_type(Mime::from_str("video/mp4").unwrap()) 69 | .build(); 70 | 71 | let last_id = lock.messages.max_message_id(); 72 | let message = lock.messages.add_message(message.id(last_id + 1).build()); 73 | 74 | lock.files.push(teloxide::types::File { 75 | meta: message.video().unwrap().file.clone(), 76 | path: body.file_name.to_owned(), 77 | }); 78 | lock.responses.sent_messages.push(message.clone()); 79 | lock.responses.sent_messages_video.push(SentMessageVideo { 80 | message: message.clone(), 81 | bot_request: body, 82 | }); 83 | 84 | make_telegram_result(message) 85 | } 86 | 87 | #[derive(Debug, Clone, Deserialize, SerializeRawFields)] 88 | pub struct SendMessageVideoBody { 89 | pub chat_id: BodyChatId, 90 | pub message_thread_id: Option, 91 | pub file_name: String, 92 | pub file_data: String, 93 | pub duration: Option, 94 | pub width: Option, 95 | pub height: Option, 96 | pub caption: Option, 97 | pub parse_mode: Option, 98 | pub caption_entities: Option>, 99 | pub show_caption_above_media: Option, 100 | pub has_spoiler: Option, 101 | pub supports_streaming: Option, 102 | pub disable_notification: Option, 103 | pub protect_content: Option, 104 | pub message_effect_id: Option, 105 | pub reply_markup: Option, 106 | pub reply_parameters: Option, 107 | pub business_connection_id: Option, 108 | } 109 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/send_animation.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, str::FromStr, sync::Mutex}; 2 | 3 | use actix_multipart::Multipart; 4 | use actix_web::{error::ErrorBadRequest, web, Responder}; 5 | use mime::Mime; 6 | use rand::distr::{Alphanumeric, SampleString}; 7 | use serde::Deserialize; 8 | use teloxide::types::{ 9 | BusinessConnectionId, EffectId, FileId, FileUniqueId, Me, MessageEntity, ParseMode, 10 | ReplyMarkup, ReplyParameters, Seconds, 11 | }; 12 | 13 | use super::{get_raw_multipart_fields, make_telegram_result, BodyChatId}; 14 | use crate::{ 15 | proc_macros::SerializeRawFields, 16 | server::{ 17 | routes::{check_if_message_exists, Attachment, FileType, SerializeRawFields}, 18 | SentMessageAnimation, 19 | }, 20 | state::State, 21 | MockMessageAnimation, 22 | }; 23 | 24 | pub async fn send_animation( 25 | mut payload: Multipart, 26 | me: web::Data, 27 | state: web::Data>, 28 | ) -> impl Responder { 29 | let (fields, attachments) = get_raw_multipart_fields(&mut payload).await; 30 | let mut lock = state.lock().unwrap(); 31 | let body = 32 | SendMessageAnimationBody::serialize_raw_fields(&fields, &attachments, FileType::Animation) 33 | .unwrap(); 34 | let chat = body.chat_id.chat(); 35 | 36 | let mut message = // Creates the message, which will be mutated to fit the needed shape 37 | MockMessageAnimation::new().chat(chat); 38 | message.from = Some(me.user.clone()); 39 | message.has_protected_content = body.protect_content.unwrap_or(false); 40 | message.caption = body.caption.clone(); 41 | message.caption_entities = body.caption_entities.clone().unwrap_or_default(); 42 | message.has_media_spoiler = body.has_spoiler.unwrap_or_default(); 43 | message.effect_id = body.message_effect_id.clone(); 44 | message.show_caption_above_media = body.show_caption_above_media.unwrap_or(false); 45 | message.business_connection_id = body.business_connection_id.clone(); 46 | 47 | if let Some(reply_parameters) = &body.reply_parameters { 48 | check_if_message_exists!(lock, reply_parameters.message_id.0); 49 | let reply_to_message = lock 50 | .messages 51 | .get_message(reply_parameters.message_id.0) 52 | .unwrap(); 53 | message.reply_to_message = Some(Box::new(reply_to_message.clone())); 54 | } 55 | if let Some(ReplyMarkup::InlineKeyboard(markup)) = body.reply_markup.clone() { 56 | message.reply_markup = Some(markup); 57 | } 58 | 59 | let file_id = FileId(Alphanumeric.sample_string(&mut rand::rng(), 16)); 60 | let file_unique_id = FileUniqueId(Alphanumeric.sample_string(&mut rand::rng(), 8)); 61 | 62 | message.file_name = Some(body.file_name.clone()); 63 | message.file_id = file_id; 64 | message.file_unique_id = file_unique_id; 65 | message.file_size = body.file_data.bytes().len() as u32; 66 | message.duration = body.duration.unwrap_or(Seconds::from_seconds(0)); 67 | message.width = body.width.unwrap_or(100); 68 | message.height = body.height.unwrap_or(100); 69 | message.mime_type = Some( 70 | mime_guess::from_path(body.file_name.clone()) 71 | .first() 72 | .unwrap_or(Mime::from_str("image/gif").unwrap()), 73 | ); 74 | 75 | let last_id = lock.messages.max_message_id(); 76 | let message = lock.messages.add_message(message.id(last_id + 1).build()); 77 | 78 | lock.files.push(teloxide::types::File { 79 | meta: message.animation().unwrap().file.clone(), 80 | path: body.file_name.to_owned(), 81 | }); 82 | lock.responses.sent_messages.push(message.clone()); 83 | lock.responses 84 | .sent_messages_animation 85 | .push(SentMessageAnimation { 86 | message: message.clone(), 87 | bot_request: body, 88 | }); 89 | 90 | make_telegram_result(message) 91 | } 92 | 93 | #[derive(Debug, Clone, Deserialize, SerializeRawFields)] 94 | pub struct SendMessageAnimationBody { 95 | pub chat_id: BodyChatId, 96 | pub file_name: String, 97 | pub file_data: String, 98 | pub duration: Option, 99 | pub width: Option, 100 | pub height: Option, 101 | pub caption: Option, 102 | pub message_thread_id: Option, 103 | pub parse_mode: Option, 104 | pub caption_entities: Option>, 105 | pub show_caption_above_media: Option, 106 | pub has_spoiler: Option, 107 | pub disable_notification: Option, 108 | pub protect_content: Option, 109 | pub message_effect_id: Option, 110 | pub reply_markup: Option, 111 | pub reply_parameters: Option, 112 | pub business_connection_id: Option, 113 | } 114 | -------------------------------------------------------------------------------- /examples/phrase_bot/src/resources/handler_tree.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | use dptree::{case, entry, filter}; 4 | use teloxide::{ 5 | dispatching::{ 6 | dialogue::{self, ErasedStorage, GetChatId}, 7 | UpdateFilterExt, UpdateHandler, 8 | }, 9 | prelude::*, 10 | types::Update, 11 | }; 12 | 13 | use crate::{ 14 | get_bot_storage, keyboards, 15 | private::{StartCommand, *}, 16 | public::*, 17 | text, MyDialogue, State, 18 | }; 19 | 20 | async fn check_if_the_state_is_ok(update: Update) -> bool { 21 | // This function doesn't have anything to do with tests, but i thought i would put it here, 22 | // because i've encountered that if you update the state, and the user is on that 23 | // state, it just errors out, softlocking the user. Very bad. 24 | let chat_id = match update.chat_id() { 25 | Some(chat_id) => chat_id, 26 | None => return true, 27 | }; 28 | let dialogue = MyDialogue::new(get_bot_storage().await, chat_id); 29 | match dialogue.get().await { 30 | Ok(_) => true, 31 | Err(_) => { 32 | // This error happens if redis has a state saved for the user, but that state 33 | // doesn't fit into anything that State has, so it just errors out. Very bad. 34 | let bot = Bot::from_env(); 35 | bot.send_message(chat_id, text::SORRY_BOT_UPDATED) 36 | .await 37 | .unwrap(); 38 | dialogue.update(State::default()).await.unwrap(); 39 | false 40 | } 41 | } 42 | } 43 | 44 | pub fn handler_tree() -> UpdateHandler> { 45 | // Just a schema, nothing extraordinary 46 | let private_branch = dialogue::enter::, State, _>() 47 | .branch( 48 | Update::filter_message() 49 | .filter_command::() 50 | .branch(case![StartCommand::Start].endpoint(start)) 51 | .branch(case![StartCommand::Cancel].endpoint(cancel)), 52 | ) 53 | .branch( 54 | case![State::Start].branch( 55 | Update::filter_message() 56 | .branch( 57 | filter(|msg: Message| msg.text() == Some(keyboards::PROFILE_BUTTON)) 58 | .endpoint(profile), 59 | ) 60 | .branch( 61 | filter(|msg: Message| { 62 | msg.text() == Some(keyboards::CHANGE_NICKNAME_BUTTON) 63 | }) 64 | .endpoint(change_nickname), 65 | ) 66 | .branch( 67 | filter(|msg: Message| msg.text() == Some(keyboards::REMOVE_PHRASE_BUTTON)) 68 | .endpoint(delete_phrase), 69 | ) 70 | .branch( 71 | filter(|msg: Message| msg.text() == Some(keyboards::ADD_PHRASE_BUTTON)) 72 | .endpoint(add_phrase), 73 | ), 74 | ), 75 | ) 76 | .branch( 77 | case![State::ChangeNickname] 78 | .branch(Update::filter_message().endpoint(changed_nickname)), 79 | ) 80 | .branch( 81 | case![State::WhatPhraseToDelete { phrases }] 82 | .branch(Update::filter_message().endpoint(deleted_phrase)), 83 | ) 84 | .branch( 85 | entry() 86 | .branch( 87 | case![State::WhatIsNewPhraseEmoji] 88 | .branch(Update::filter_message().endpoint(what_is_new_phrase_text)), 89 | ) 90 | .branch( 91 | case![State::WhatIsNewPhraseText { emoji }] 92 | .branch(Update::filter_message().endpoint(what_is_new_phrase_bot_text)), 93 | ) 94 | .branch( 95 | case![State::WhatIsNewPhraseBotText { emoji, text }] 96 | .branch(Update::filter_message().endpoint(added_phrase)), 97 | ), 98 | ); 99 | 100 | let public_branch = Update::filter_message().endpoint(bot_phrase); 101 | 102 | let normal_branch = entry() 103 | .branch( 104 | filter(|update: Update| update.chat().is_some() && update.chat().unwrap().is_private()) 105 | .filter_async(check_if_the_state_is_ok) 106 | .branch(private_branch), 107 | ) 108 | .branch( 109 | filter(|update: Update| { 110 | update.chat().is_some() 111 | && (update.chat().unwrap().is_group() || update.chat().unwrap().is_supergroup()) 112 | }) 113 | .branch(public_branch), 114 | ); 115 | 116 | // If the dialogue errors out - do not go further 117 | 118 | normal_branch 119 | } 120 | -------------------------------------------------------------------------------- /teloxide_tests/src/dataset/queries.rs: -------------------------------------------------------------------------------- 1 | use std::sync::atomic::{AtomicI32, Ordering}; 2 | 3 | use teloxide::types::*; 4 | 5 | use super::{MockMessageText, MockUser}; 6 | use crate::proc_macros::Changeable; 7 | 8 | #[derive(Changeable, Clone)] 9 | pub struct MockCallbackQuery { 10 | pub id: CallbackQueryId, 11 | pub from: User, 12 | pub message: Option, 13 | pub inline_message_id: Option, 14 | pub chat_instance: String, 15 | pub data: Option, 16 | pub game_short_name: Option, 17 | make_message_inaccessible: bool, 18 | } 19 | 20 | impl MockCallbackQuery { 21 | pub const ID: &'static str = "id"; 22 | pub const CHAT_INSTANCE: &'static str = "chat_instance"; 23 | 24 | /// Creates a new easily changable callback query builder 25 | /// 26 | /// # Examples 27 | /// ``` 28 | /// let callback_query = teloxide_tests::MockCallbackQuery::new() 29 | /// .id("id".into()) 30 | /// .build(); 31 | /// assert_eq!(callback_query.id, "id".into()); 32 | /// ``` 33 | /// 34 | pub fn new() -> Self { 35 | Self { 36 | id: Self::ID.into(), 37 | from: MockUser::new().build(), 38 | message: Some( 39 | MockMessageText::new() 40 | .text("This is the callback message") 41 | .build(), 42 | ), 43 | inline_message_id: None, 44 | chat_instance: Self::CHAT_INSTANCE.to_string(), 45 | data: None, 46 | game_short_name: None, 47 | make_message_inaccessible: false, 48 | } 49 | } 50 | 51 | /// Converts the message from MaybeInaccessibleMessage::Regular to MaybeInaccessibleMessage::Inaccessible in the final build 52 | /// 53 | /// # Example 54 | /// ```rust 55 | /// use teloxide_tests::{MockCallbackQuery, MockMessageText}; 56 | /// 57 | /// let message_in_query = MockMessageText::new().build(); 58 | /// let callback_query = MockCallbackQuery::new() 59 | /// .message(message_in_query.clone()) 60 | /// .make_message_inaccessible() 61 | /// .build(); 62 | /// 63 | /// match callback_query.message.unwrap() { 64 | /// teloxide::types::MaybeInaccessibleMessage::Inaccessible(msg) => assert_eq!(msg.message_id, message_in_query.id), 65 | /// teloxide::types::MaybeInaccessibleMessage::Regular(msg) => panic!("Message should be inaccessible"), 66 | /// } 67 | /// ``` 68 | pub fn make_message_inaccessible(mut self) -> Self { 69 | self.make_message_inaccessible = true; 70 | self 71 | } 72 | 73 | /// Builds the callback query 74 | /// 75 | /// # Example 76 | /// ``` 77 | /// let mock_callback_query = teloxide_tests::MockCallbackQuery::new(); 78 | /// let callback_query = mock_callback_query.build(); 79 | /// assert_eq!( 80 | /// callback_query.id, 81 | /// teloxide_tests::MockCallbackQuery::ID.into() 82 | /// ); // ID is a default value 83 | /// ``` 84 | /// 85 | pub fn build(self) -> CallbackQuery { 86 | CallbackQuery { 87 | id: self.id, 88 | from: self.from, 89 | message: self.message.map(|message| { 90 | if !self.make_message_inaccessible { 91 | MaybeInaccessibleMessage::Regular(Box::new(message)) 92 | } else { 93 | MaybeInaccessibleMessage::Inaccessible(InaccessibleMessage { 94 | chat: message.chat, 95 | message_id: message.id, 96 | }) 97 | } 98 | }), 99 | inline_message_id: self.inline_message_id, 100 | chat_instance: self.chat_instance, 101 | data: self.data, 102 | game_short_name: self.game_short_name, 103 | } 104 | } 105 | } 106 | 107 | impl crate::dataset::IntoUpdate for MockCallbackQuery { 108 | /// Converts the MockCallbackQuery into an updates vector 109 | /// 110 | /// # Example 111 | /// ``` 112 | /// use teloxide_tests::IntoUpdate; 113 | /// use teloxide::types::{UpdateId, UpdateKind::CallbackQuery}; 114 | /// use std::sync::atomic::AtomicI32; 115 | /// 116 | /// let mock_callback_query = teloxide_tests::MockCallbackQuery::new(); 117 | /// let update = mock_callback_query.clone().into_update(&AtomicI32::new(42))[0].clone(); 118 | /// 119 | /// assert_eq!(update.id, UpdateId(42)); 120 | /// assert_eq!(update.kind, CallbackQuery(mock_callback_query.build())); 121 | /// ``` 122 | /// 123 | fn into_update(self, id: &AtomicI32) -> Vec { 124 | vec![Update { 125 | id: UpdateId(id.fetch_add(1, Ordering::Relaxed) as u32), 126 | kind: UpdateKind::CallbackQuery(self.build()), 127 | }] 128 | } 129 | } 130 | 131 | // Add more queries here like ShippingQuery, PreCheckoutQuery etc. 132 | -------------------------------------------------------------------------------- /examples/phrase_bot/src/handlers/public.rs: -------------------------------------------------------------------------------- 1 | use teloxide::{prelude::*, types::ParseMode}; 2 | 3 | use crate::{db, text, HandlerResult}; 4 | 5 | pub async fn bot_phrase(bot: Bot, msg: Message) -> HandlerResult { 6 | if let Some(reply) = msg.reply_to_message() { 7 | if let Some(text) = msg.text() { 8 | let user_from = msg.from.clone().unwrap(); 9 | let reply_from = reply.from.clone().unwrap(); 10 | let user_from_id = user_from.clone().id.0 as i64; 11 | let reply_from_id = reply_from.clone().id.0 as i64; 12 | let user_phrases = db::get_user_phrases(user_from_id).unwrap(); 13 | // Gets all the phrases and tries to find a matching one in the db 14 | let phrase = user_phrases 15 | .iter() 16 | .find(|phrase| phrase.text.to_lowercase() == text.to_lowercase()); 17 | 18 | if let Some(phrase) = phrase { 19 | // If successfull, start making the test string 20 | let raw_text = format!("{} | {}", phrase.emoji, phrase.bot_text); 21 | 22 | let me_user = db::get_user(user_from_id); 23 | let reply_user = db::get_user(reply_from_id); 24 | 25 | let me_nickname = match me_user { 26 | Ok(user) => user.nickname.unwrap_or(user_from.full_name()), 27 | Err(_) => user_from.full_name(), 28 | }; 29 | 30 | let reply_nickname = match reply_user { 31 | Ok(user) => user.nickname.unwrap_or(reply_from.full_name()), 32 | Err(_) => reply_from.full_name(), 33 | }; 34 | 35 | let me_link = text::make_link(me_nickname, user_from_id as u64); 36 | let reply_link = text::make_link(reply_nickname, reply_from_id as u64); 37 | 38 | bot.send_message( 39 | msg.chat.id, 40 | raw_text 41 | .replace("(me)", &me_link) 42 | .replace("(reply)", &reply_link), 43 | ) 44 | .parse_mode(ParseMode::Html) 45 | .await?; 46 | } 47 | } 48 | } 49 | Ok(()) 50 | } 51 | 52 | #[cfg(test)] 53 | mod tests { 54 | use teloxide_tests::{MockBot, MockGroupChat, MockMessageText, MockUser}; 55 | 56 | use crate::{db, dptree::deps, get_bot_storage, handler_tree::handler_tree, text}; 57 | 58 | #[tokio::test] 59 | async fn test_phrase() { 60 | let chat = MockGroupChat::new().build(); 61 | 62 | let reply_message = MockMessageText::new() 63 | .text("some reply message") 64 | .chat(chat.clone()) 65 | .from(MockUser::new().first_name("reply").id(5678).build()); 66 | 67 | let me_message = MockMessageText::new() 68 | .text("hug") 69 | .chat(chat.clone()) 70 | .from(MockUser::new().first_name("me").id(1234).build()) 71 | .reply_to_message(reply_message.build()); 72 | 73 | let mut bot = MockBot::new(me_message, handler_tree()); 74 | bot.dependencies(deps![get_bot_storage().await]); 75 | // !!! IMPORTANT !!! same as in test_delete_phrase in private handlers, do all db stuff 76 | // after creating the bot 77 | db::full_user_redeletion(1234, Some("nick1".to_string())); 78 | db::full_user_redeletion(5678, Some("nick2".to_string())); 79 | db::create_phrase( 80 | 1234, 81 | "🤗".to_string(), 82 | "hug".to_string(), 83 | "(me) hugged (reply)".to_string(), 84 | ) 85 | .unwrap(); 86 | 87 | // Parse mode doesn't yet work, so it still has link text. But that isn't a problem! 88 | bot.dispatch_and_check_last_text( 89 | &format!( 90 | "🤗 | {} hugged {}", 91 | text::make_link("nick1".to_string(), 1234), 92 | text::make_link("nick2".to_string(), 5678) 93 | ) 94 | .to_string(), 95 | ) 96 | .await; 97 | } 98 | 99 | #[tokio::test] 100 | async fn test_no_phrase() { 101 | let chat = MockGroupChat::new().build(); 102 | 103 | let me_message = MockMessageText::new() 104 | .text("hug") 105 | .chat(chat.clone()) 106 | .from(MockUser::new().first_name("me").id(1234).build()); 107 | 108 | let mut bot = MockBot::new(me_message.clone(), handler_tree()); 109 | bot.dependencies(deps![get_bot_storage().await]); 110 | db::full_user_redeletion(1234, None); 111 | db::create_phrase( 112 | 1234, 113 | "🤗".to_string(), 114 | "hug".to_string(), 115 | "(me) hugged (reply)".to_string(), 116 | ) 117 | .unwrap(); 118 | 119 | // No text should be sent 120 | bot.dispatch().await; 121 | assert!(bot.get_responses().sent_messages.is_empty()) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /teloxide_tests/src/dataset/chat.rs: -------------------------------------------------------------------------------- 1 | use teloxide::types::*; 2 | 3 | use super::MockUser; 4 | use crate::proc_macros::Changeable; 5 | 6 | macro_rules! Chat { 7 | ( 8 | #[derive($($derive:meta),*)] 9 | $pub:vis struct $name:ident { 10 | $($fpub:vis $field:ident : $type:ty,)* 11 | } 12 | ) => { 13 | #[derive($($derive),*)] 14 | $pub struct $name { // This is basically a template 15 | pub id: ChatId, 16 | $($fpub $field : $type,)* 17 | } 18 | impl $name { 19 | pub const ID: i64 = -12345678; // Make them into a constant cuz why not 20 | 21 | pub(crate) fn new_chat($($field:$type,)*) -> Self { 22 | Self { // To not repeat this over and over again 23 | id: ChatId(Self::ID), 24 | $($field,)* 25 | } 26 | } 27 | 28 | pub(crate) fn build_chat(self, chat_kind: ChatKind) -> Chat { 29 | Chat { 30 | id: self.id, 31 | kind: chat_kind, 32 | } 33 | } 34 | } 35 | } 36 | } 37 | 38 | macro_rules! ChatPublic { // A specialization of Chat!, again, to not repeat myself 39 | ( 40 | #[derive($($derive:meta),*)] 41 | $pub:vis struct $name:ident { 42 | $($fpub:vis $field:ident : $type:ty,)* 43 | } 44 | ) => { 45 | Chat! { 46 | #[derive($($derive),*)] 47 | $pub struct $name { 48 | pub title: Option, 49 | $($fpub $field : $type,)* 50 | } 51 | } 52 | impl $name { 53 | pub(crate) fn new_chat_public($($field:$type,)*) -> Self { 54 | $name::new_chat( 55 | None, 56 | $($field,)* 57 | ) 58 | } 59 | 60 | pub(crate) fn build_chat_public(self, chat_public_kind: PublicChatKind) -> Chat { 61 | self.clone().build_chat(ChatKind::Public(ChatPublic { 62 | title: self.title, 63 | kind: chat_public_kind, 64 | })) 65 | } 66 | } 67 | } 68 | } 69 | 70 | ChatPublic! { 71 | #[derive(Changeable, Clone)] 72 | pub struct MockGroupChat { } 73 | } 74 | 75 | impl MockGroupChat { 76 | /// Creates a new easily changable group chat builder 77 | /// 78 | /// Example: 79 | /// ``` 80 | /// let chat = teloxide_tests::MockGroupChat::new() 81 | /// .id(-1234) 82 | /// .build(); 83 | /// assert_eq!(chat.id.0, -1234); 84 | /// ``` 85 | /// 86 | pub fn new() -> Self { 87 | Self::new_chat_public() 88 | } 89 | 90 | /// Builds the group chat 91 | /// 92 | /// Example: 93 | /// ``` 94 | /// let mock_chat = teloxide_tests::MockGroupChat::new(); 95 | /// let chat = mock_chat.build(); 96 | /// assert_eq!(chat.id.0, teloxide_tests::MockGroupChat::ID); // ID is a default value 97 | /// ``` 98 | /// 99 | pub fn build(self) -> Chat { 100 | self.clone().build_chat_public(PublicChatKind::Group) 101 | } 102 | } 103 | 104 | ChatPublic! { 105 | #[derive(Changeable, Clone)] 106 | pub struct MockChannelChat { 107 | pub username: Option, 108 | } 109 | } 110 | 111 | impl MockChannelChat { 112 | /// Creates a new easily changable channel builder 113 | /// 114 | /// Example: 115 | /// ``` 116 | /// let chat = teloxide_tests::MockChannelChat::new() 117 | /// .id(-1234) 118 | /// .username("test_channel") 119 | /// .build(); 120 | /// assert_eq!(chat.id.0, -1234); 121 | /// assert_eq!(chat.username(), Some("test_channel")); 122 | /// ``` 123 | /// 124 | pub fn new() -> Self { 125 | Self::new_chat_public(None) 126 | } 127 | 128 | /// Builds the channel chat 129 | /// 130 | /// Example: 131 | /// ``` 132 | /// let mock_chat = teloxide_tests::MockChannelChat::new(); 133 | /// let chat = mock_chat.build(); 134 | /// assert_eq!(chat.id.0, teloxide_tests::MockChannelChat::ID); // ID is a default value 135 | /// assert_eq!(chat.username(), None); 136 | /// ``` 137 | /// 138 | pub fn build(self) -> Chat { 139 | self.clone() 140 | .build_chat_public(PublicChatKind::Channel(PublicChatChannel { 141 | username: self.username, 142 | })) 143 | } 144 | } 145 | 146 | ChatPublic! { 147 | #[derive(Changeable, Clone)] 148 | pub struct MockSupergroupChat { 149 | pub username: Option, 150 | pub is_forum: bool, 151 | } 152 | } 153 | 154 | impl MockSupergroupChat { 155 | pub const IS_FORUM: bool = false; 156 | 157 | /// Creates a new easily changable supergroup chat full info builder 158 | /// 159 | /// Example: 160 | /// ``` 161 | /// let chat = teloxide_tests::MockSupergroupChat::new() 162 | /// .id(-1234) 163 | /// .build(); 164 | /// assert_eq!(chat.id.0, -1234); 165 | /// ``` 166 | /// 167 | pub fn new() -> Self { 168 | Self::new_chat_public(None, Self::IS_FORUM) 169 | } 170 | 171 | /// Builds the supergroup chat 172 | /// 173 | /// Example: 174 | /// ``` 175 | /// let mock_chat = teloxide_tests::MockSupergroupChat::new(); 176 | /// let chat = mock_chat.build(); 177 | /// assert_eq!(chat.id.0, teloxide_tests::MockSupergroupChat::ID); // ID is a default value 178 | /// ``` 179 | /// 180 | pub fn build(self) -> Chat { 181 | self.clone() 182 | .build_chat_public(PublicChatKind::Supergroup(PublicChatSupergroup { 183 | username: self.username, 184 | is_forum: self.is_forum, 185 | })) 186 | } 187 | } 188 | 189 | Chat! { 190 | #[derive(Changeable, Clone)] 191 | pub struct MockPrivateChat { 192 | pub username: Option, 193 | pub first_name: Option, 194 | pub last_name: Option, 195 | } 196 | } 197 | 198 | impl MockPrivateChat { 199 | /// Creates a new easily changable private chat builder 200 | /// 201 | /// Example: 202 | /// ``` 203 | /// let chat = teloxide_tests::MockPrivateChat::new() 204 | /// .id(-1234) 205 | /// .build(); 206 | /// assert_eq!(chat.id.0, -1234); 207 | /// ``` 208 | /// 209 | pub fn new() -> Self { 210 | Self::new_chat(None, None, None).id(MockUser::ID as i64) 211 | } 212 | 213 | /// Builds the private chat 214 | /// 215 | /// Example: 216 | /// ``` 217 | /// let mock_chat = teloxide_tests::MockPrivateChat::new(); 218 | /// let chat = mock_chat.build(); 219 | /// assert_eq!(chat.id.0 as u64, teloxide_tests::MockUser::ID); // Private chats have the id of users 220 | /// ``` 221 | /// 222 | pub fn build(self) -> Chat { 223 | self.clone().build_chat(ChatKind::Private(ChatPrivate { 224 | username: self.username, 225 | first_name: self.first_name, 226 | last_name: self.last_name, 227 | })) 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /teloxide_tests/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! This crate aims to make a mocked bot for unit testing teloxide bots with an actual fake server! 2 | //! 3 | //! [[`examples/hello_world`](https://github.com/LasterAlex/teloxide_tests/tree/master/examples/hello_world_bot)] 4 | //! ```no_run 5 | //! use teloxide::{ 6 | //! dispatching::{UpdateFilterExt, UpdateHandler}, 7 | //! prelude::*, 8 | //! }; 9 | //! 10 | //! type HandlerResult = Result<(), Box>; 11 | //! 12 | //! async fn hello_world(bot: Bot, message: Message) -> HandlerResult { 13 | //! bot.send_message(message.chat.id, "Hello World!").await?; 14 | //! Ok(()) 15 | //! } 16 | //! 17 | //! fn handler_tree() -> UpdateHandler> { 18 | //! dptree::entry().branch(Update::filter_message().endpoint(hello_world)) 19 | //! } 20 | //! 21 | //! #[tokio::main] 22 | //! async fn main() { // A regular bot dispatch 23 | //! dotenv::dotenv().ok(); 24 | //! let bot = Bot::from_env(); 25 | //! Dispatcher::builder(bot, handler_tree()) 26 | //! .enable_ctrlc_handler() 27 | //! .build() 28 | //! .dispatch() 29 | //! .await; 30 | //! } 31 | //! 32 | //! #[cfg(test)] 33 | //! mod tests { 34 | //! use super::*; 35 | //! use teloxide_tests::{MockBot, MockMessageText}; 36 | //! 37 | //! #[tokio::test] 38 | //! async fn test_hello_world() { // A testing bot dispatch 39 | //! let mut bot = MockBot::new(MockMessageText::new().text("Hi!"), handler_tree()); 40 | //! bot.dispatch().await; 41 | //! let message = bot.get_responses().sent_messages.last().unwrap(); 42 | //! // This is a regular teloxide::types::Message! 43 | //! assert_eq!(message.text(), Some("Hello World!")); 44 | //! } 45 | //! } 46 | //! ``` 47 | //! 48 | //! To run the tests, just run `cargo test` in the terminal! It's that easy! No internet connection required! 49 | //! 50 | //! 51 | //! I haven't seen telegram bot testing tools that are up to my standards (for any bot api wrapper, not just teloxide), 52 | //! so I decided to write this. This crate tries to give as much tooling for testing as reasonably possible, 53 | //! while keeping it simple to work with and implement. 54 | //! The goal of this crate is to test most of the teloxide and telegram features. This crate is not yet 55 | //! complete, but you still can use it for what it has! 56 | //! 57 | //! ## Supported Endpoints 58 | //! 59 | //! - /AnswerCallbackQuery 60 | //! - /DeleteMessage 61 | //! - /DeleteMessages 62 | //! - /EditMessageText 63 | //! - /EditMessageReplyMarkup 64 | //! - /EditMessageCaption 65 | //! - /GetFile 66 | //! - /SendMessage 67 | //! - /SendDocument 68 | //! - /SendPhoto 69 | //! - /SendVideo 70 | //! - /SendAudio 71 | //! - /SendVoice 72 | //! - /SendVideoNote 73 | //! - /SendAnimation 74 | //! - /SendLocation 75 | //! - /SendVenue 76 | //! - /SendContact 77 | //! - /SendDice 78 | //! - /SendPoll 79 | //! - /SendSticker 80 | //! - /SendChatAction 81 | //! - /SendMediaGroup 82 | //! - /SendInvoice 83 | //! - /PinChatMessage 84 | //! - /UnpinChatMessage 85 | //! - /UnpinAllChatMessages 86 | //! - /ForwardMessage 87 | //! - /CopyMessage 88 | //! - /BanChatMember 89 | //! - /UnbanChatMember 90 | //! - /RestrictChatMember 91 | //! - /SetMessageReaction 92 | //! - /SetMyCommands 93 | //! - /GetMe 94 | //! 95 | //! More endpoints will be added as time goes on! 96 | //! 97 | //! (/GetUpdates and /GetWebhookInfo exist, but they are dummies) 98 | //! 99 | //! And also fake file downloading! 100 | //! 101 | //! ## Why even use unit tests? 102 | //! 103 | //! I've always found manual bot testing to be very time consuming and unreliable, especially when 104 | //! the bot becomes large and very complex. This crate can solve this problem! 105 | //! 106 | //! As an example, here is a bot that i did not run once before i have written all of the code: 107 | //! [`examples/phrase_bot`](https://github.com/LasterAlex/teloxide_tests/tree/master/examples/phrase_bot) 108 | //! (dont forget to read the README.md in the examples directory!) 109 | //! 110 | //! ## Other 111 | //! 112 | //! If you see something that works in teloxide, but doesn't work in this crate, while it should 113 | //! (a missing endpoint doesn't qualify as a bug), please open an issue on the [GitHub repo!](https://github.com/LasterAlex/teloxide_tests) 114 | //! All feedback and suggestions are very welcome! 115 | //! Or you can write to the [@teloxide_tests](https://t.me/teloxide_tests) group! 116 | //! 117 | //! And huge thanks to the teloxide team, their code was amazing to work with! Without all of the 118 | //! code comments, i would've gone insane. The crate itself is also just amazing! 119 | //! 120 | //! To start, i recommend you look at the [[`examples github`]](https://github.com/LasterAlex/teloxide_tests/tree/master/examples) folder 121 | //! 122 | //! Or [MockBot struct documentation](https://docs.rs/teloxide_tests/latest/teloxide_tests/mock_bot/struct.MockBot.html) 123 | //! 124 | //! The only thing you need to change in your existing bots is to shift your dptree (handler tree) to some function, because the 125 | //! sole reason for this crates existance is to test that tree, and we need easy access to it! 126 | //! Just follow the examples! 127 | //! 128 | //! And try to not use the raw bot fields unless you know what you are doing! They are public only 129 | //! to give more options to those who seek it. 130 | //! 131 | //! ## **!!! IMPORTANT !!!** 132 | //! 133 | //! If you want to use the database or 134 | //! something that is shared across all tests, DO IT __AFTER__ THE `MockBot::new()`!!!!! 135 | //! The creation of the bot creates a safe lock that prevents other tests from starting, before 136 | //! this bot becomes out of scope. 137 | //! If you encounter issues regarding this, try to manually add `drop(bot);` at the end of your 138 | //! tests! 139 | //! Or use the [serial_test](https://crates.io/crates/serial_test) crate 140 | //! 141 | //! Please refer to the [phrase_bot example](https://github.com/LasterAlex/teloxide_tests/tree/master/examples/phrase_bot) for more information 142 | //! 143 | #![doc( 144 | html_logo_url = "https://github.com/user-attachments/assets/627beca8-5852-4c70-97e0-5f4fcb5e2040", 145 | html_favicon_url = "https://github.com/user-attachments/assets/627beca8-5852-4c70-97e0-5f4fcb5e2040" 146 | )] 147 | #![allow(clippy::too_long_first_doc_paragraph)] 148 | #![allow(clippy::to_string_in_format_args)] 149 | #![allow(clippy::new_without_default)] 150 | #![allow(clippy::too_many_arguments)] 151 | #![allow(clippy::map_flatten)] 152 | #![allow(clippy::unnecessary_unwrap)] 153 | #![allow(clippy::needless_question_mark)] 154 | #![allow(clippy::borrow_interior_mutable_const)] 155 | #![allow(clippy::declare_interior_mutable_const)] 156 | #![allow(clippy::clone_on_copy)] 157 | #![allow(clippy::needless_borrows_for_generic_args)] 158 | #![allow(clippy::search_is_some)] 159 | #![allow(clippy::unwrap_or_default)] 160 | #![allow(clippy::enum_variant_names)] 161 | #![allow(clippy::needless_return)] 162 | #![allow(clippy::bool_assert_comparison)] 163 | 164 | mod dataset; 165 | pub(crate) mod listener; 166 | pub mod mock_bot; 167 | pub mod server; 168 | pub(crate) mod state; 169 | #[cfg(test)] 170 | mod tests; 171 | pub(crate) mod utils; 172 | 173 | pub use dataset::*; 174 | pub use mock_bot::MockBot; 175 | pub use server::Responses; 176 | use teloxide_tests_macros as proc_macros; 177 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/mod.rs: -------------------------------------------------------------------------------- 1 | //! A fake telegram bot API for testing purposes. Read more in teloxide_tests crate. 2 | mod routes; 3 | use std::{ 4 | error::Error, 5 | io, 6 | net::TcpListener, 7 | sync::{Arc, Mutex}, 8 | }; 9 | 10 | use actix_web::{ 11 | web::{self, get, post, scope, Data, ServiceConfig}, 12 | App, HttpResponse, HttpServer, Responder, 13 | }; 14 | pub use responses::*; 15 | use routes::{ 16 | answer_callback_query::*, ban_chat_member::*, copy_message::*, delete_message::*, 17 | delete_messages::*, download_file::download_file, edit_message_caption::*, 18 | edit_message_reply_markup::*, edit_message_text::*, forward_message::*, get_file::*, get_me::*, 19 | get_updates::*, get_webhook_info::*, pin_chat_message::*, restrict_chat_member::*, 20 | send_animation::*, send_audio::*, send_chat_action::*, send_contact::*, send_dice::*, 21 | send_document::*, send_invoice::*, send_location::*, send_media_group::*, send_message::*, 22 | send_photo::*, send_poll::*, send_sticker::*, send_venue::*, send_video::*, send_video_note::*, 23 | send_voice::*, set_message_reaction::*, set_my_commands::*, unban_chat_member::*, 24 | unpin_all_chat_messages::*, unpin_chat_message::*, 25 | }; 26 | pub use routes::{ 27 | copy_message::CopyMessageBody, delete_message::DeleteMessageBody, 28 | delete_messages::DeleteMessagesBody, edit_message_caption::EditMessageCaptionBody, 29 | edit_message_reply_markup::EditMessageReplyMarkupBody, edit_message_text::EditMessageTextBody, 30 | forward_message::ForwardMessageBody, send_animation::SendMessageAnimationBody, 31 | send_audio::SendMessageAudioBody, send_contact::SendMessageContactBody, 32 | send_dice::SendMessageDiceBody, send_document::SendMessageDocumentBody, 33 | send_invoice::SendMessageInvoiceBody, send_location::SendMessageLocationBody, 34 | send_media_group::SendMediaGroupBody, send_message::SendMessageTextBody, 35 | send_photo::SendMessagePhotoBody, send_poll::SendMessagePollBody, 36 | send_sticker::SendMessageStickerBody, send_venue::SendMessageVenueBody, 37 | send_video::SendMessageVideoBody, send_video_note::SendMessageVideoNoteBody, 38 | }; 39 | use teloxide::types::Me; 40 | use tokio::{ 41 | sync::mpsc::{channel, Sender}, 42 | task::{JoinError, JoinHandle}, 43 | }; 44 | use tokio_util::sync::CancellationToken; 45 | 46 | use crate::state::State; 47 | 48 | pub mod messages; 49 | pub mod responses; 50 | 51 | pub(crate) struct ServerManager { 52 | pub port: u16, 53 | server: JoinHandle<()>, 54 | cancel_token: CancellationToken, 55 | } 56 | 57 | #[warn(clippy::unwrap_used)] 58 | impl ServerManager { 59 | pub(crate) async fn start(me: Me, state: Arc>) -> Result> { 60 | let listener = TcpListener::bind("127.0.0.1:0")?; 61 | let port = listener.local_addr()?.port(); 62 | 63 | let cancel_token = CancellationToken::new(); 64 | let (tx, mut rx) = channel::<()>(100); 65 | 66 | let server = tokio::spawn(run_server( 67 | listener, 68 | me, 69 | state.clone(), 70 | cancel_token.clone(), 71 | tx, 72 | )); 73 | // Waits until the server is ready 74 | rx.recv().await; 75 | 76 | Ok(Self { 77 | port, 78 | cancel_token, 79 | server, 80 | }) 81 | } 82 | 83 | pub(crate) async fn stop(self) -> Result<(), JoinError> { 84 | self.cancel_token.cancel(); 85 | self.server.await 86 | } 87 | } 88 | 89 | async fn run_server( 90 | listener: TcpListener, 91 | me: Me, 92 | state: Arc>, 93 | cancel_token: CancellationToken, 94 | tx: Sender<()>, 95 | ) { 96 | let server = create_server(listener, me, state).unwrap(); 97 | tx.send(()).await.unwrap(); 98 | let server_handle = server.handle(); 99 | 100 | tokio::spawn(async move { 101 | cancel_token.cancelled().await; 102 | server_handle.stop(false).await; 103 | }); 104 | 105 | server.await.unwrap(); 106 | } 107 | 108 | fn create_server( 109 | listener: TcpListener, 110 | me: Me, 111 | state: Arc>, 112 | ) -> io::Result { 113 | Ok(HttpServer::new(move || { 114 | App::new() 115 | .app_data(Data::new(me.clone())) 116 | .app_data(Data::from(state.clone())) 117 | .configure(set_routes) 118 | }) 119 | .listen(listener)? 120 | .run()) 121 | } 122 | 123 | fn set_routes(cfg: &mut ServiceConfig) { 124 | cfg.route("/file/bot{token}/{file_name}", get().to(download_file)) 125 | .service(scope("/bot{token}").configure(set_bot_routes)); 126 | } 127 | 128 | fn set_bot_routes(cfg: &mut ServiceConfig) { 129 | cfg.route("/GetFile", post().to(get_file)) 130 | .route("/SendMessage", post().to(send_message)) 131 | .route("/GetWebhookInfo", post().to(get_webhook_info)) 132 | .route("/GetMe", post().to(get_me)) 133 | .route("/GetUpdates", post().to(get_updates)) 134 | .route("/SendPhoto", post().to(send_photo)) 135 | .route("/SendVideo", post().to(send_video)) 136 | .route("/SendVoice", post().to(send_voice)) 137 | .route("/SendAudio", post().to(send_audio)) 138 | .route("/SendVideoNote", post().to(send_video_note)) 139 | .route("/SendDocument", post().to(send_document)) 140 | .route("/SendAnimation", post().to(send_animation)) 141 | .route("/SendLocation", post().to(send_location)) 142 | .route("/SendVenue", post().to(send_venue)) 143 | .route("/SendContact", post().to(send_contact)) 144 | .route("/SendSticker", post().to(send_sticker)) 145 | .route("/SendChatAction", post().to(send_chat_action)) 146 | .route("/SendDice", post().to(send_dice)) 147 | .route("/SendPoll", post().to(send_poll)) 148 | .route("/SendMediaGroup", post().to(send_media_group)) 149 | .route("/SendInvoice", post().to(send_invoice)) 150 | .route("/EditMessageText", post().to(edit_message_text)) 151 | .route("/EditMessageCaption", post().to(edit_message_caption)) 152 | .route( 153 | "/EditMessageReplyMarkup", 154 | post().to(edit_message_reply_markup), 155 | ) 156 | .route("/DeleteMessage", post().to(delete_message)) 157 | .route("/DeleteMessages", post().to(delete_messages)) 158 | .route("/ForwardMessage", post().to(forward_message)) 159 | .route("/CopyMessage", post().to(copy_message)) 160 | .route("/AnswerCallbackQuery", post().to(answer_callback_query)) 161 | .route("/PinChatMessage", post().to(pin_chat_message)) 162 | .route("/UnpinChatMessage", post().to(unpin_chat_message)) 163 | .route("/UnpinAllChatMessages", post().to(unpin_all_chat_messages)) 164 | .route("/BanChatMember", post().to(ban_chat_member)) 165 | .route("/UnbanChatMember", post().to(unban_chat_member)) 166 | .route("/RestrictChatMember", post().to(restrict_chat_member)) 167 | .route("/SetMessageReaction", post().to(set_message_reaction)) 168 | .route("/SetMyCommands", post().to(set_my_commands)) 169 | .route("/{unknown_endpoint}", post().to(unknown_endpoint)); 170 | } 171 | 172 | async fn unknown_endpoint(path: web::Path<(String, String)>) -> impl Responder { 173 | HttpResponse::InternalServerError().message_body(format!("Endpoint \"{}\" is not yet implemented! Please make an issue to https://github.com/LasterAlex/teloxide_tests/issues/new?assignees=&labels=no+endpoint&projects=&template=add-endpoint-template.md&title=", path.1)) 174 | } 175 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

teloxide_tests

4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | A crate that allows you to unit test your teloxide bots easily! No internet, accounts or anything required! 21 |
22 | 23 | ## What this crate has 24 | 25 | - Easy testing of handlers with access to raw bot requests (see [hello_world_bot](https://github.com/LasterAlex/teloxide_tests/blob/master/examples/hello_world_bot/src/main.rs)) 26 | - Support of dependencies, changes of `me`, distribution_function and multiple updates (see [album_bot](https://github.com/LasterAlex/teloxide_tests/blob/master/examples/album_bot/src/main.rs)) 27 | - Syntactic sugar and native support for storage, dialogue and states (see [calculator_bot](https://github.com/LasterAlex/teloxide_tests/blob/master/examples/calculator_bot/src/tests.rs)) 28 | - Fake file getting and downloading (see [file_download_bot](https://github.com/LasterAlex/teloxide_tests/blob/master/examples/file_download_bot/src/main.rs)) 29 | - Ability to be used with databases (see [phrase_bot](https://github.com/LasterAlex/teloxide_tests/blob/master/examples/phrase_bot/src/main.rs)) 30 | 31 | ## Examples 32 | 33 | Simplified [[`hello_world_bot`]](https://github.com/LasterAlex/teloxide_tests/blob/master/examples/hello_world_bot/src/main.rs) 34 | ```rust,ignore 35 | #[tokio::test] 36 | async fn test_hello_world() { 37 | let message = MockMessageText::new().text("Hi!"); 38 | let mut bot = MockBot::new(message, handler_tree()); 39 | // Sends the message as if it was from a user 40 | bot.dispatch().await; 41 | 42 | let responses = bot.get_responses(); 43 | let message = responses 44 | .sent_messages 45 | .last() 46 | .expect("No sent messages were detected!"); 47 | assert_eq!(message.text(), Some("Hello World!")); 48 | } 49 | ``` 50 | 51 | [[`file_download_bot`]](https://github.com/LasterAlex/teloxide_tests/blob/master/examples/file_download_bot/src/main.rs) 52 | ```rust,ignore 53 | #[tokio::test] 54 | async fn test_not_a_document() { 55 | let mut bot = MockBot::new(MockMessageText::new().text("Hi!"), handler_tree()); 56 | // Syntactic sugar 57 | bot.dispatch_and_check_last_text("Not a document").await; 58 | } 59 | 60 | #[tokio::test] 61 | async fn test_download_document_and_check() { 62 | let mut bot = MockBot::new(MockMessageDocument::new(), handler_tree()); 63 | bot.dispatch_and_check_last_text("Downloaded!").await; 64 | } 65 | ``` 66 | 67 | [[`calculator_bot`]](https://github.com/LasterAlex/teloxide_tests/blob/master/examples/calculator_bot/src/tests.rs) 68 | ```rust,ignore 69 | #[tokio::test] 70 | async fn test_what_is_the_first_number() { 71 | let mut bot = MockBot::new(MockCallbackQuery::new().data("add"), handler_tree()); 72 | 73 | bot.dependencies(deps![get_bot_storage().await]); 74 | bot.set_state(State::WhatDoYouWant).await; 75 | 76 | bot.dispatch_and_check_last_text_and_state( 77 | text::ENTER_THE_FIRST_NUMBER, 78 | State::GetFirstNumber { 79 | operation: "add".to_owned(), 80 | }, 81 | ) 82 | .await; 83 | } 84 | ``` 85 | 86 | You can see more useful examples at [examples/](https://github.com/LasterAlex/teloxide_tests/tree/master/examples) and the docs at [docs.rs](https://docs.rs/teloxide_tests) 87 | 88 | It is highly reccomended you read at least [`hello_world_bot`](https://github.com/LasterAlex/teloxide_tests/blob/master/examples/hello_world_bot/src/main.rs) (there is a lot of comments that explain how to use this crate which i removed in the README) and [`calculator_bot`](https://github.com/LasterAlex/teloxide_tests/blob/master/examples/calculator_bot/src/tests.rs) (it teaches about the syntactic sugar and working with dialogue) 89 | 90 | ## How to implement it? 91 | 92 | Hopefully it is as easy as doing what happens in `./examples` 93 | 94 | 1. Import the `teloxide_tests` 95 | 2. Make your handler tree into a separate function (we are going to test it, after all) 96 | 3. Create a mocked bot with something that can be turned into an update, like MockMessageText or MockMessagePhoto 97 | 4. Add dependencies and/or a different bot using .dependencies(deps![]) and .me(MockedMe::new().build()) 98 | 5. Dispatch it with .dispatch().await 99 | 6. Get the responses with .get_responses() 100 | 7. Do the testing with the gotten responses 101 | 8. If you want to re-use the current bot and state with a new update, just call .update(MockMessageText::new()) and follow from the 5th step! 102 | 103 | **Do NOT** use raw MockBot fields like bot.updates or bot.me to mutate the bot, unless you know what you are doing. Use given abstractions, and if some feature is missing, you can mention it in the github repo (or write it in the telegram group [@teloxide_tests](https://t.me/teloxide_tests)) 104 | 105 | ## Pitfalls 106 | 107 | 1. Race conditions. They are, to my knowledge, the most difficult. 108 | 109 | 2. And also when you use a method that is still not supported by this crate. Please refer to the docs to see, what endpoints are implemented in the latest release (or look at [server/routes](https://github.com/LasterAlex/teloxide_tests/tree/master/teloxide_tests/src/server/routes) files to look at the current endpoints) 110 | 111 | 3. Maybe also the fact that the fake server actually checks the messages and files that are present, and it starts with a clean state. You can't just send a file by file_id or forward a message by an arbitrary message_id that was sent long ago, the bot wouldn't know what to do with it, so you need to separately add it by dispatching the bot with that update, so that it gets added as the user message to memory (you can change file_id and message_id in the mocked structs to anything you need). 112 | 113 | ### Some errors associated with these race conditions: 114 | 115 | - trait `Send` is not implemented for `std::sync::MutexGuard<'static, ()>` 116 | 117 | This means you can't share the bot between any threads, as you should not in any circumstance. 118 | 119 | - PoisonError(...) 120 | 121 | You shouldn't see it, i tried to mitigate it, but if you do, it's not the problem, it just means that something else panicked and now the bot doesn't know, what to do. Just fix whatever was causing the problem, and poison errors should be gone. 122 | 123 | - Stupid bugs that change every time you run a test 124 | 125 | You can use the crate [serial_test](https://crates.io/crates/serial_test), or try to add `drop(bot);` at the end of every test, and do everything AFTER calling `MockBot::new()`, as the bot creation makes a safe lock that prevent any race conditions. 126 | 127 | ## Contributing 128 | 129 | Please see [CONTRIBUTING.md](https://github.com/LasterAlex/teloxide_tests/blob/master/CONTRIBUTING.md) 130 | 131 | ## Todo 132 | 133 | - [x] Add dataset 134 | - [x] Add dataset of chats 135 | - [x] Add dataset of common messages 136 | - [ ] Add dataset of queries (low priority) 137 | - [ ] Add dataset of messages (low priority) 138 | - [ ] Add structs without a category (low priority) 139 | - [x] Add fake server 140 | - [x] Add most common endpoints 141 | - [x] Add all common messages 142 | - [ ] Add inline queries (low priority) 143 | - [ ] Add all queries (low priority) 144 | - [ ] Add all messages (super low priority) 145 | - [ ] Add everything else (may never be done) 146 | - [x] Make mocked bot that sends requests to fake server 147 | - [x] Add tests to that bot 148 | - [x] Make it into a library 149 | - [x] Publish it when it is ready 150 | 151 | ## Special thanks to 152 | 153 | The teloxide team! They made an absolutely incredible library with amazing internal documentation, which helped me a lot during development! It is an amazing project, and i'm happy i'm able to add to it something useful! 154 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/messages.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use serde::Serialize; 4 | use teloxide::types::{Message, ReplyMarkup}; 5 | 6 | #[derive(Default)] 7 | pub struct Messages { 8 | pub messages: Vec, 9 | last_message_id: i32, 10 | } 11 | 12 | impl Messages { 13 | pub fn max_message_id(&self) -> i32 { 14 | self.last_message_id 15 | } 16 | 17 | pub fn edit_message(&mut self, message: Message) -> Option { 18 | self.messages.iter().find(|m| m.id == message.id)?; // Find the message (return None if not found) 19 | 20 | self.messages.retain(|m| m.id != message.id); // Remove the old message 21 | self.messages.push(message.clone()); // Add the new message 22 | Some(message) // Profit! 23 | } 24 | 25 | pub fn edit_message_field( 26 | &mut self, 27 | message_id: i32, 28 | field: &str, 29 | value: T, 30 | ) -> Option 31 | where 32 | T: Serialize, 33 | { 34 | let message = self.messages.iter().find(|m| m.id.0 == message_id)?; // Find the message 35 | // (return None if not found) 36 | 37 | let mut json = serde_json::to_value(message).ok()?; // Convert the message to JSON 38 | json[field] = serde_json::to_value(value).ok()?; // Edit the field 39 | let new_message: Message = serde_json::from_value(json).ok()?; // Convert back to Message 40 | 41 | self.messages.retain(|m| m.id.0 != message_id); // Remove the old message 42 | self.messages.push(new_message.clone()); // Add the new message 43 | Some(new_message) // Profit! 44 | } 45 | 46 | pub fn edit_message_reply_markup( 47 | &mut self, 48 | message_id: i32, 49 | reply_markup: Option, 50 | ) -> Option { 51 | match reply_markup { 52 | None => { 53 | // Telegram deletes reply markup when `editMessageText` is called without any. 54 | self.edit_message_field(message_id, "reply_markup", None::<()>) 55 | } 56 | // Only the inline keyboard can be inside of a message 57 | Some(ReplyMarkup::InlineKeyboard(reply_markup)) => { 58 | self.edit_message_field(message_id, "reply_markup", reply_markup) 59 | } 60 | _ => unreachable!("Only InlineKeyboard is allowed"), 61 | } 62 | } 63 | 64 | pub fn add_message(&mut self, message: Message) -> Message { 65 | self.messages.push(message.clone()); 66 | self.last_message_id += 1; 67 | message 68 | } 69 | 70 | pub fn get_message(&self, message_id: i32) -> Option { 71 | self.messages.iter().find(|m| m.id.0 == message_id).cloned() 72 | } 73 | 74 | pub fn delete_message(&mut self, message_id: i32) -> Option { 75 | let message = self 76 | .messages 77 | .iter() 78 | .find(|m| m.id.0 == message_id) 79 | .cloned()?; 80 | self.messages.retain(|m| m.id.0 != message_id); 81 | Some(message) 82 | } 83 | 84 | pub fn delete_messages(&mut self, message_ids: &[i32]) -> Vec { 85 | let message_ids: HashSet = message_ids.iter().cloned().collect(); 86 | let deleted = self 87 | .messages 88 | .iter() 89 | .filter(|m| message_ids.contains(&m.id.0)) 90 | .cloned() 91 | .collect(); 92 | self.messages.retain(|m| !message_ids.contains(&m.id.0)); 93 | deleted 94 | } 95 | } 96 | 97 | #[cfg(test)] 98 | mod tests { 99 | use chrono::{TimeZone, Utc}; 100 | use serial_test::serial; 101 | use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, MessageId}; 102 | 103 | use super::*; 104 | use crate::dataset::*; 105 | 106 | #[test] 107 | #[serial] 108 | fn test_add_messages() { 109 | let mut messages = Messages::default(); 110 | messages.add_message( 111 | message_common::MockMessageText::new() 112 | .text("123") 113 | .id(1) 114 | .build(), 115 | ); 116 | messages.add_message( 117 | message_common::MockMessageText::new() 118 | .text("123") 119 | .id(2) 120 | .build(), 121 | ); 122 | messages.add_message( 123 | message_common::MockMessageText::new() 124 | .text("123") 125 | .id(3) 126 | .build(), 127 | ); 128 | assert_eq!(messages.max_message_id(), 3); 129 | } 130 | 131 | #[test] 132 | #[serial] 133 | fn test_edit_message() { 134 | let mut messages = Messages::default(); 135 | let date = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(); 136 | messages.add_message( 137 | message_common::MockMessageText::new() 138 | .text("123") 139 | .id(1) 140 | .build(), 141 | ); 142 | messages.edit_message( 143 | message_common::MockMessageText::new() 144 | .text("321") 145 | .edit_date(date) 146 | .id(1) 147 | .build(), 148 | ); 149 | let message = messages.get_message(1).unwrap(); 150 | assert_eq!(message.text().unwrap(), "321"); 151 | assert_eq!(message.edit_date().unwrap(), &date); 152 | } 153 | 154 | #[test] 155 | #[serial] 156 | fn test_edit_message_field() { 157 | let mut messages = Messages::default(); 158 | messages.add_message( 159 | message_common::MockMessageText::new() 160 | .text("123") 161 | .id(1) 162 | .build(), 163 | ); 164 | messages.edit_message_field(1, "text", "1234"); 165 | assert_eq!(messages.get_message(1).unwrap().text().unwrap(), "1234"); 166 | } 167 | 168 | #[test] 169 | #[serial] 170 | fn test_get_messages() { 171 | let mut messages = Messages::default(); 172 | messages.add_message( 173 | message_common::MockMessageText::new() 174 | .text("123") 175 | .id(1) 176 | .build(), 177 | ); 178 | assert_eq!(messages.get_message(1).unwrap().text().unwrap(), "123"); 179 | } 180 | 181 | #[test] 182 | #[serial] 183 | fn test_delete_message() { 184 | let mut messages = Messages::default(); 185 | messages.add_message( 186 | message_common::MockMessageText::new() 187 | .text("123") 188 | .id(1) 189 | .build(), 190 | ); 191 | messages.delete_message(1); 192 | assert_eq!(messages.get_message(1), None); 193 | } 194 | 195 | #[test] 196 | #[serial] 197 | fn test_delete_messages() { 198 | let mut messages = Messages::default(); 199 | for id in 1..=5 { 200 | messages.add_message( 201 | message_common::MockMessageText::new() 202 | .text(format!("Message {}", id)) 203 | .id(id) 204 | .build(), 205 | ); 206 | } 207 | 208 | let deleted = messages.delete_messages(&[2, 3]); 209 | 210 | assert_eq!(deleted.len(), 2); 211 | assert_eq!(deleted[0].id, MessageId(2)); 212 | assert_eq!(deleted[1].id, MessageId(3)); 213 | 214 | assert!(messages.get_message(1).is_some()); 215 | assert_eq!(messages.get_message(2), None); 216 | assert_eq!(messages.get_message(3), None); 217 | assert!(messages.get_message(4).is_some()); 218 | assert!(messages.get_message(5).is_some()); 219 | } 220 | 221 | #[test] 222 | #[serial] 223 | fn test_edit_message_reply_markup() { 224 | let mut messages = Messages::default(); 225 | messages.add_message( 226 | message_common::MockMessageText::new() 227 | .text("123") 228 | .id(1) 229 | .build(), 230 | ); 231 | messages.edit_message_reply_markup( 232 | 1, 233 | Some(ReplyMarkup::InlineKeyboard(InlineKeyboardMarkup::new( 234 | vec![vec![InlineKeyboardButton::callback("123", "123")]], 235 | ))), 236 | ); 237 | assert_eq!( 238 | messages 239 | .get_message(1) 240 | .unwrap() 241 | .reply_markup() 242 | .unwrap() 243 | .inline_keyboard[0][0] 244 | .text, 245 | "123" 246 | ); 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /teloxide_tests/src/server/routes/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, str::from_utf8}; 2 | 3 | use actix_web::{error::ResponseError, http::header::ContentType, HttpResponse}; 4 | use futures_util::{stream::StreamExt as _, TryStreamExt}; 5 | use rand::distr::{Alphanumeric, SampleString}; 6 | use serde::{Deserialize, Serialize}; 7 | use serde_json::json; 8 | use teloxide::{ 9 | types::{Chat, MessageEntity, ParseMode, Seconds}, 10 | ApiError, 11 | }; 12 | 13 | use crate::dataset::{MockPrivateChat, MockSupergroupChat}; 14 | 15 | pub mod answer_callback_query; 16 | pub mod ban_chat_member; 17 | pub mod copy_message; 18 | pub mod delete_message; 19 | pub mod delete_messages; 20 | pub mod download_file; 21 | pub mod edit_message_caption; 22 | pub mod edit_message_reply_markup; 23 | pub mod edit_message_text; 24 | pub mod forward_message; 25 | pub mod get_file; 26 | pub mod get_me; 27 | pub mod get_updates; 28 | pub mod get_webhook_info; 29 | pub mod pin_chat_message; 30 | pub mod restrict_chat_member; 31 | pub mod send_animation; 32 | pub mod send_audio; 33 | pub mod send_chat_action; 34 | pub mod send_contact; 35 | pub mod send_dice; 36 | pub mod send_document; 37 | pub mod send_invoice; 38 | pub mod send_location; 39 | pub mod send_media_group; 40 | pub mod send_message; 41 | pub mod send_photo; 42 | pub mod send_poll; 43 | pub mod send_sticker; 44 | pub mod send_venue; 45 | pub mod send_video; 46 | pub mod send_video_note; 47 | pub mod send_voice; 48 | pub mod set_message_reaction; 49 | pub mod set_my_commands; 50 | pub mod unban_chat_member; 51 | pub mod unpin_all_chat_messages; 52 | pub mod unpin_chat_message; 53 | 54 | /// Telegram accepts both `i64` and `String` for chat_id, 55 | /// so it is a wrapper for both 56 | #[derive(Debug, Deserialize, Clone)] 57 | #[serde(untagged)] 58 | pub enum BodyChatId { 59 | Text(String), 60 | Id(i64), 61 | } 62 | 63 | impl BodyChatId { 64 | /// Returns the ID of the chat 65 | pub fn id(&self) -> i64 { 66 | match self { 67 | BodyChatId::Text(_) => 123456789, 68 | BodyChatId::Id(id) => *id, 69 | } 70 | } 71 | 72 | /// Returns the chat 73 | pub fn chat(&self) -> Chat { 74 | let chat_id: i64 = self.id(); 75 | if chat_id < 0 { 76 | MockSupergroupChat::new().id(chat_id).build() 77 | } else { 78 | MockPrivateChat::new().id(chat_id).build() 79 | } 80 | } 81 | } 82 | 83 | #[derive(Debug, Deserialize, Clone)] 84 | #[serde(untagged)] 85 | pub enum MediaGroupInputMedia { 86 | InputMediaAudio(MediaGroupInputMediaAudio), 87 | InputMediaDocument(MediaGroupInputMediaDocument), 88 | InputMediaPhoto(MediaGroupInputMediaPhoto), 89 | InputMediaVideo(MediaGroupInputMediaVideo), 90 | } 91 | 92 | #[derive(Debug, Deserialize, Clone)] 93 | pub struct MediaGroupInputMediaAudio { 94 | pub r#type: String, 95 | pub file_name: String, 96 | pub file_data: String, 97 | pub caption: Option, 98 | pub parse_mode: Option, 99 | pub caption_entities: Option>, 100 | pub duration: Option, 101 | pub performer: Option, 102 | pub title: Option, 103 | } 104 | 105 | #[derive(Debug, Deserialize, Clone)] 106 | pub struct MediaGroupInputMediaDocument { 107 | pub r#type: String, 108 | pub file_name: String, 109 | pub file_data: String, 110 | pub caption: Option, 111 | pub parse_mode: Option, 112 | pub caption_entities: Option>, 113 | pub disable_content_type_detection: Option, 114 | } 115 | 116 | #[derive(Debug, Deserialize, Clone)] 117 | pub struct MediaGroupInputMediaPhoto { 118 | pub r#type: String, 119 | pub file_name: String, 120 | pub file_data: String, 121 | pub caption: Option, 122 | pub parse_mode: Option, 123 | pub caption_entities: Option>, 124 | pub show_caption_above_media: Option, 125 | pub has_spoiler: Option, 126 | } 127 | 128 | #[derive(Debug, Deserialize, Clone)] 129 | pub struct MediaGroupInputMediaVideo { 130 | pub r#type: String, 131 | pub file_name: String, 132 | pub file_data: String, 133 | pub caption: Option, 134 | pub parse_mode: Option, 135 | pub caption_entities: Option>, 136 | pub show_caption_above_media: Option, 137 | pub width: Option, 138 | pub height: Option, 139 | pub duration: Option, 140 | pub supports_streaming: Option, 141 | pub has_spoiler: Option, 142 | } 143 | 144 | #[derive(Debug, Clone)] 145 | #[allow(dead_code)] 146 | pub enum FileType { 147 | Photo, 148 | Video, 149 | Audio, 150 | Document, 151 | Sticker, 152 | Voice, 153 | VideoNote, 154 | Animation, 155 | } 156 | 157 | pub struct Attachment { 158 | pub raw_name: String, 159 | pub file_name: String, 160 | pub file_data: String, 161 | } 162 | 163 | pub trait SerializeRawFields { 164 | fn serialize_raw_fields( 165 | fields: &HashMap, 166 | attachments: &HashMap, 167 | file_type: FileType, 168 | ) -> Option 169 | where 170 | Self: Sized; 171 | } 172 | 173 | #[derive(Debug, Serialize)] 174 | struct TelegramResponse { 175 | ok: bool, 176 | description: String, 177 | } 178 | 179 | #[derive(Debug, PartialEq, Hash, Eq, Clone)] 180 | struct BotApiError { 181 | error: ApiError, 182 | } 183 | 184 | impl BotApiError { 185 | pub fn new(error: ApiError) -> Self { 186 | Self { error } 187 | } 188 | } 189 | 190 | impl std::fmt::Display for BotApiError { 191 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 192 | self.error.fmt(f) 193 | } 194 | } 195 | 196 | impl ResponseError for BotApiError { 197 | fn status_code(&self) -> actix_web::http::StatusCode { 198 | actix_web::http::StatusCode::BAD_REQUEST 199 | } 200 | 201 | fn error_response(&self) -> HttpResponse { 202 | let response = TelegramResponse { 203 | ok: false, 204 | description: self.error.to_string(), 205 | }; 206 | HttpResponse::build(self.status_code()) 207 | .insert_header(ContentType::json()) 208 | .body(serde_json::to_string(&response).unwrap()) 209 | } 210 | } 211 | 212 | // TODO: replace usages with appropriate error values from teloxide::ApiError. 213 | macro_rules! check_if_message_exists { 214 | ($lock:expr, $msg_id:expr) => { 215 | if $lock.messages.get_message($msg_id).is_none() { 216 | return ErrorBadRequest("Message not found").into(); 217 | } 218 | }; 219 | } 220 | 221 | pub(crate) use check_if_message_exists; 222 | 223 | pub async fn get_raw_multipart_fields( 224 | payload: &mut actix_multipart::Multipart, 225 | ) -> (HashMap, HashMap) { 226 | let mut raw_fields: HashMap> = HashMap::new(); 227 | let mut raw_attachments: HashMap)> = HashMap::new(); 228 | 229 | while let Ok(Some(mut field)) = payload.try_next().await { 230 | let content_disposition = field.content_disposition().unwrap(); 231 | let name = content_disposition.get_name().unwrap().to_string(); 232 | let filename = content_disposition.get_filename().map(|s| s.to_string()); 233 | 234 | let mut field_data = Vec::new(); 235 | while let Some(chunk) = field.next().await { 236 | let data = chunk.unwrap(); 237 | field_data.extend_from_slice(&data); 238 | } 239 | 240 | if let Some(fname) = filename { 241 | // Treat raw_fields with filenames as raw_attachments 242 | let mut attachment_key = fname.clone(); 243 | if raw_attachments.contains_key(&fname) { 244 | // If two files have the same name, add a random string to the filename 245 | attachment_key = fname 246 | .split('.') 247 | .enumerate() 248 | .map(|(i, s)| { 249 | if i == 0 { 250 | format!("{s}{}", Alphanumeric.sample_string(&mut rand::rng(), 5)) 251 | } else { 252 | s.to_string() 253 | } 254 | }) 255 | .collect::>() 256 | .join("."); 257 | } 258 | raw_attachments.insert(attachment_key, (name, field_data)); 259 | } else { 260 | raw_fields.insert(name, field_data); 261 | } 262 | } 263 | 264 | // Now `raw_fields` contains all the regular fields and `raw_attachments` contains all the attachments. 265 | // Process the raw_fields as needed. 266 | let mut fields = HashMap::new(); 267 | for (name, data) in raw_fields { 268 | fields.insert( 269 | name.to_string(), 270 | from_utf8(data.as_slice()).unwrap().to_string(), 271 | ); 272 | } 273 | 274 | let mut attachments = HashMap::new(); 275 | for (filename, data) in raw_attachments { 276 | attachments.insert( 277 | filename.to_string(), 278 | Attachment { 279 | raw_name: data.0.to_string(), 280 | file_name: filename.to_string(), 281 | file_data: from_utf8(&data.1) 282 | .unwrap_or("error_getting_data") 283 | .to_string(), 284 | }, 285 | ); 286 | } 287 | 288 | (fields, attachments) 289 | } 290 | 291 | pub fn make_telegram_result(result: T) -> HttpResponse 292 | where 293 | T: Serialize, 294 | { 295 | HttpResponse::Ok().body( 296 | json!({ 297 | "ok": true, 298 | "result": result, 299 | }) 300 | .to_string(), 301 | ) 302 | } 303 | --------------------------------------------------------------------------------