From af5b8883af68a61710f331a7af3e413f13c31dc7 Mon Sep 17 00:00:00 2001 From: Dylan Knutson Date: Fri, 5 Sep 2025 21:48:52 +0000 Subject: [PATCH] mocakble message sender trait --- Cargo.lock | 71 ++++++++++ Cargo.toml | 1 + src/bidding/mod.rs | 9 +- src/commands/help.rs | 2 +- src/commands/my_bids.rs | 5 +- src/commands/my_listings/mod.rs | 12 +- src/commands/new_listing/callbacks.rs | 9 +- src/commands/new_listing/handler_factory.rs | 62 ++++----- src/commands/new_listing/handlers.rs | 21 +-- src/commands/settings.rs | 8 +- src/commands/start.rs | 10 +- src/dptree_utils.rs | 4 + src/handle_error.rs | 31 ++--- src/main.rs | 141 +++++++++++++------- src/message_sender.rs | 109 +++++++++++++++ src/message_utils.rs | 69 ++-------- src/test_utils.rs | 88 ++++++++++++ 17 files changed, 461 insertions(+), 191 deletions(-) create mode 100644 src/message_sender.rs diff --git a/Cargo.lock b/Cargo.lock index da22746..21d484d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -529,6 +529,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "dptree" version = "0.5.1" @@ -678,6 +684,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + [[package]] name = "funty" version = "2.0.0" @@ -1396,6 +1408,32 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "mockall" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -1625,6 +1663,7 @@ dependencies = [ "itertools 0.14.0", "lazy_static", "log", + "mockall", "num", "paste", "regex", @@ -1752,6 +1791,32 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro-crate" version = "3.3.0" @@ -2819,6 +2884,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "2.0.16" diff --git a/Cargo.toml b/Cargo.toml index 049b036..acfc036 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ paste = "1.0" dptree = "0.5.1" seq-macro = "0.3.6" base64 = "0.22.1" +mockall = "0.13.1" [dev-dependencies] rstest = "0.26.1" diff --git a/src/bidding/mod.rs b/src/bidding/mod.rs index f331c12..29b8564 100644 --- a/src/bidding/mod.rs +++ b/src/bidding/mod.rs @@ -7,12 +7,12 @@ use crate::{ bid::NewBid, listing::{ListingFields, PersistedListing}, user::PersistedUser, - BidDAO, ListingDbId, MoneyAmount, UserDAO, + ListingDbId, MoneyAmount, UserDAO, }, dptree_utils::MapTwo, handle_error::with_error_handler, handler_utils::find_listing_by_id, - message_utils::{MessageTarget, SendHtmlMessage}, + message_utils::MessageTarget, start_command_data::StartCommandData, App, BotError, BotHandler, BotResult, DialogueRootState, RootDialogue, }; @@ -173,7 +173,6 @@ async fn handle_awaiting_confirm_bid_amount_callback( app: App, listing: PersistedListing, user: PersistedUser, - bid_dao: BidDAO, bid_amount: MoneyAmount, target: MessageTarget, dialogue: RootDialogue, @@ -189,7 +188,7 @@ async fn handle_awaiting_confirm_bid_amount_callback( "cancel_bid" => { dialogue.exit().await.context("failed to exit dialogue")?; app.bot - .send_html_message(target, "Bid cancelled", None) + .send_html_message(target, "Bid cancelled".to_string(), None) .await?; return Ok(()); } @@ -201,7 +200,7 @@ async fn handle_awaiting_confirm_bid_amount_callback( }; let bid = NewBid::new_basic(listing.persisted.id, user.persisted.id, bid_amount); - bid_dao.insert_bid(bid).await?; + app.daos.bid.insert_bid(bid).await?; dialogue.exit().await.context("failed to exit dialogue")?; diff --git a/src/commands/help.rs b/src/commands/help.rs index 7dd08d7..d8de6b1 100644 --- a/src/commands/help.rs +++ b/src/commands/help.rs @@ -1,4 +1,4 @@ -use crate::{message_utils::{MessageTarget, SendHtmlMessage}, App, BotResult, Command}; +use crate::{message_utils::MessageTarget, App, BotResult, Command}; use teloxide::utils::command::BotCommands; pub async fn handle_help(app: App, target: MessageTarget) -> BotResult { diff --git a/src/commands/my_bids.rs b/src/commands/my_bids.rs index df370db..f69f9d5 100644 --- a/src/commands/my_bids.rs +++ b/src/commands/my_bids.rs @@ -1,4 +1,4 @@ -use crate::{message_utils::{MessageTarget, SendHtmlMessage}, App, BotResult}; +use crate::{message_utils::MessageTarget, App, BotResult}; use log::info; use teloxide::types::Message; @@ -9,7 +9,8 @@ pub async fn handle_my_bids(app: App, msg: Message, target: MessageTarget) -> Bo • Bid status (winning/outbid)\n\ • Proxy bid settings\n\ • Auction end times\n\n\ - Feature in development! 🏗️"; + Feature in development! 🏗️" + .to_string(); info!( "User {} ({}) checked their bids", diff --git a/src/commands/my_listings/mod.rs b/src/commands/my_listings/mod.rs index fd368ea..7674147 100644 --- a/src/commands/my_listings/mod.rs +++ b/src/commands/my_listings/mod.rs @@ -1,5 +1,7 @@ mod keyboard; +use std::ops::Deref; + use crate::{ case, commands::{ @@ -17,7 +19,7 @@ use crate::{ }, handle_error::with_error_handler, handler_utils::{find_listing_by_id, find_or_create_db_user_from_update}, - message_utils::{extract_callback_data, pluralize_with_count, MessageTarget, SendHtmlMessage}, + message_utils::{extract_callback_data, pluralize_with_count, MessageTarget}, start_command_data::StartCommandData, App, BotError, BotResult, Command, DialogueRootState, RootDialogue, }; @@ -32,7 +34,6 @@ use teloxide::{ InlineKeyboardButton, InlineKeyboardMarkup, InlineQueryResult, InlineQueryResultArticle, InputMessageContent, InputMessageContentText, ParseMode, User, }, - Bot, }; #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -184,7 +185,7 @@ async fn handle_forward_listing( app.bot .answer_inline_query( inline_query.id, - [InlineQueryResult::Article( + vec![InlineQueryResult::Article( InlineQueryResultArticle::new( listing.persisted.id.to_string(), format!("💰 {} - ${}", listing.base.title, current_price), @@ -264,7 +265,8 @@ pub async fn enter_my_listings( .send_html_message( target, "📋 My Listings\n\n\ - You don't have any listings yet.", + You don't have any listings yet." + .to_string(), Some(keyboard), ) .await?; @@ -295,7 +297,7 @@ async fn handle_viewing_listings_callback( user: PersistedUser, target: MessageTarget, ) -> BotResult { - let data = extract_callback_data(&app.bot, callback_query).await?; + let data = extract_callback_data(app.bot.deref(), callback_query).await?; if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) { return enter_main_menu(app, dialogue, target).await; diff --git a/src/commands/new_listing/callbacks.rs b/src/commands/new_listing/callbacks.rs index 1b85667..59f723c 100644 --- a/src/commands/new_listing/callbacks.rs +++ b/src/commands/new_listing/callbacks.rs @@ -76,7 +76,8 @@ pub async fn handle_selecting_listing_type_callback( pub async fn handle_awaiting_draft_field_callback( app: App, dialogue: RootDialogue, - (field, draft): (ListingField, ListingDraft), + field: ListingField, + draft: ListingDraft, callback_query: CallbackQuery, target: MessageTarget, ) -> BotResult { @@ -182,7 +183,7 @@ async fn handle_slots_callback( app.bot .send_html_message( target, - &response, + response, get_keyboard_for_field(ListingField::StartTime), ) .await?; @@ -217,7 +218,7 @@ async fn handle_start_time_callback( app.bot .send_html_message( target, - &response, + response, get_keyboard_for_field(ListingField::EndTime), ) .await?; @@ -304,7 +305,7 @@ async fn handle_currency_type_callback( ); transition_to_field(dialogue, next_field, draft).await?; app.bot - .send_html_message(target, &response, get_keyboard_for_field(next_field)) + .send_html_message(target, response, get_keyboard_for_field(next_field)) .await?; Ok(()) } diff --git a/src/commands/new_listing/handler_factory.rs b/src/commands/new_listing/handler_factory.rs index b3ade88..4dc59b3 100644 --- a/src/commands/new_listing/handler_factory.rs +++ b/src/commands/new_listing/handler_factory.rs @@ -1,5 +1,10 @@ use super::{callbacks::*, handlers::*, types::*}; -use crate::{case, handle_error::with_error_handler, BotHandler, Command, DialogueRootState}; +use crate::{ + dptree_utils::{identity, MapTwo}, + handle_error::with_error_handler, + BotHandler, Command, DialogueRootState, +}; +use dptree::case; use teloxide::{dptree, prelude::*, types::Update}; // Create the dialogue handler tree for new listing wizard @@ -14,49 +19,44 @@ pub fn new_listing_handler() -> BotHandler { .endpoint(with_error_handler(handle_new_listing_command)), ) .branch( - case![DialogueRootState::NewListing( - NewListingState::AwaitingDraftField { field, draft } - )] - .endpoint(with_error_handler(handle_awaiting_draft_field_input)), - ) - .branch( - case![DialogueRootState::NewListing( - NewListingState::EditingDraftField { field, draft } - )] - .endpoint(with_error_handler(handle_editing_field_input)), + dptree::entry() + .chain(case![DialogueRootState::NewListing(state)]) + .branch( + case![NewListingState::AwaitingDraftField { field, draft }] + .map2(identity::<(ListingField, ListingDraft)>) + .endpoint(with_error_handler(handle_awaiting_draft_field_input)), + ) + .branch( + case![NewListingState::EditingDraftField { field, draft }] + .map2(identity::<(ListingField, ListingDraft)>) + .endpoint(with_error_handler(handle_editing_field_input)), + ), ), ) .branch( Update::filter_callback_query() + .chain(case![DialogueRootState::NewListing(state)]) .branch( - case![DialogueRootState::NewListing( - NewListingState::SelectingListingType - )] - .endpoint(with_error_handler(handle_selecting_listing_type_callback)), + case![NewListingState::SelectingListingType] + .endpoint(with_error_handler(handle_selecting_listing_type_callback)), ) .branch( - case![DialogueRootState::NewListing( - NewListingState::AwaitingDraftField { field, draft } - )] - .endpoint(with_error_handler(handle_awaiting_draft_field_callback)), + case![NewListingState::AwaitingDraftField { field, draft }] + .map2(identity::<(ListingField, ListingDraft)>) + .endpoint(with_error_handler(handle_awaiting_draft_field_callback)), ) .branch( - case![DialogueRootState::NewListing( - NewListingState::ViewingDraft(draft) - )] - .endpoint(with_error_handler(handle_viewing_draft_callback)), + case![NewListingState::ViewingDraft(draft)] + .endpoint(with_error_handler(handle_viewing_draft_callback)), ) .branch( - case![DialogueRootState::NewListing( - NewListingState::EditingDraft(draft) - )] - .endpoint(with_error_handler(handle_editing_draft_callback)), + case![NewListingState::EditingDraft(draft)] + .endpoint(with_error_handler(handle_editing_draft_callback)), ) .branch( - case![DialogueRootState::NewListing( - NewListingState::EditingDraftField { field, draft } - )] - .endpoint(with_error_handler(handle_editing_draft_field_callback)), + case![NewListingState::EditingDraftField { field, draft }] + .map2(identity::<(ListingField, ListingDraft)>) + .endpoint(with_error_handler(handle_editing_draft_field_callback)), ), ) } diff --git a/src/commands/new_listing/handlers.rs b/src/commands/new_listing/handlers.rs index f6f600c..e46efb8 100644 --- a/src/commands/new_listing/handlers.rs +++ b/src/commands/new_listing/handlers.rs @@ -58,7 +58,7 @@ pub async fn enter_select_new_listing_type( app.bot .send_html_message( target, - get_listing_type_selection_message(), + get_listing_type_selection_message().to_string(), Some(get_listing_type_keyboard()), ) .await?; @@ -69,7 +69,8 @@ pub async fn enter_select_new_listing_type( pub async fn handle_awaiting_draft_field_input( app: App, dialogue: RootDialogue, - (field, mut draft): (ListingField, ListingDraft), + field: ListingField, + mut draft: ListingDraft, target: MessageTarget, msg: Message, ) -> BotResult { @@ -111,7 +112,8 @@ pub async fn handle_awaiting_draft_field_input( pub async fn handle_editing_field_input( app: App, dialogue: RootDialogue, - (field, mut draft): (ListingField, ListingDraft), + field: ListingField, + mut draft: ListingDraft, target: MessageTarget, msg: Message, ) -> BotResult { @@ -156,8 +158,9 @@ pub async fn handle_viewing_draft_callback( ConfirmationKeyboardButtons::Cancel => { info!("User {target:?} cancelled listing update"); let response = "🗑️ Changes Discarded\n\n\ - Your changes have been discarded and not saved."; - app.bot.send_html_message(target, &response, None).await?; + Your changes have been discarded and not saved." + .to_string(); + app.bot.send_html_message(target, response, None).await?; dialogue.exit().await.context("failed to exit dialogue")?; } ConfirmationKeyboardButtons::Discard => { @@ -165,8 +168,9 @@ pub async fn handle_viewing_draft_callback( let response = "🗑️ Listing Discarded\n\n\ Your listing has been discarded and not created.\n\ - You can start a new listing anytime with /newlisting."; - app.bot.send_html_message(target, &response, None).await?; + You can start a new listing anytime with /newlisting." + .to_string(); + app.bot.send_html_message(target, response, None).await?; dialogue.exit().await.context("failed to exit dialogue")?; } ConfirmationKeyboardButtons::Edit => { @@ -228,7 +232,8 @@ pub async fn handle_editing_draft_callback( pub async fn handle_editing_draft_field_callback( app: App, dialogue: RootDialogue, - (field, draft): (ListingField, ListingDraft), + field: ListingField, + draft: ListingDraft, callback_query: CallbackQuery, target: MessageTarget, ) -> BotResult { diff --git a/src/commands/settings.rs b/src/commands/settings.rs index fdec02b..51523d3 100644 --- a/src/commands/settings.rs +++ b/src/commands/settings.rs @@ -1,7 +1,4 @@ -use crate::{ - message_utils::{MessageTarget, SendHtmlMessage}, - App, BotResult, -}; +use crate::{message_utils::MessageTarget, App, BotResult}; use log::info; use teloxide::types::Message; @@ -12,7 +9,8 @@ pub async fn handle_settings(app: App, msg: Message, target: MessageTarget) -> B • Language settings\n\ • Default bid increments\n\ • Outbid alerts\n\n\ - Feature in development! 🛠️"; + Feature in development! 🛠️" + .to_string(); info!( "User {} ({}) accessed settings", diff --git a/src/commands/start.rs b/src/commands/start.rs index af48d37..ef8b87c 100644 --- a/src/commands/start.rs +++ b/src/commands/start.rs @@ -9,7 +9,7 @@ use crate::{ commands::my_listings::enter_my_listings, db::user::PersistedUser, keyboard_buttons, - message_utils::{extract_callback_data, MessageTarget, SendHtmlMessage as _}, + message_utils::{extract_callback_data, MessageTarget}, App, BotResult, Command, DialogueRootState, RootDialogue, }; @@ -58,7 +58,7 @@ pub async fn enter_main_menu(app: App, dialogue: RootDialogue, target: MessageTa app.bot .send_html_message( target, - get_main_menu_message(), + get_main_menu_message().to_string(), Some(MainMenuButtons::to_keyboard()), ) .await?; @@ -92,7 +92,8 @@ pub async fn handle_main_menu_callback( • Bid history\n\ • Won/lost auctions\n\ • Outbid notifications\n\n\ - Feature in development! 🛠️", + Feature in development! 🛠️" + .to_string(), Some(MainMenuButtons::to_keyboard()), ) .await?; @@ -107,7 +108,8 @@ pub async fn handle_main_menu_callback( • Language settings\n\ • Default bid increments\n\ • Outbid alerts\n\n\ - Feature in development! 🛠️", + Feature in development! 🛠️" + .to_string(), Some(MainMenuButtons::to_keyboard()), ) .await?; diff --git a/src/dptree_utils.rs b/src/dptree_utils.rs index 7430548..dfd09ab 100644 --- a/src/dptree_utils.rs +++ b/src/dptree_utils.rs @@ -135,6 +135,10 @@ where } } +pub fn identity(t: T) -> T { + t +} + #[cfg(test)] mod tests { use std::ops::ControlFlow; diff --git a/src/handle_error.rs b/src/handle_error.rs index 6ff3d13..c9b5b94 100644 --- a/src/handle_error.rs +++ b/src/handle_error.rs @@ -1,40 +1,41 @@ use crate::{ - message_utils::{MessageTarget, SendHtmlMessage}, - wrap_endpoint, BotError, BotResult, WrappedAsyncFn, + message_utils::MessageTarget, wrap_endpoint, App, BotError, BotResult, WrappedAsyncFn, }; use futures::future::BoxFuture; -use teloxide::Bot; -pub async fn handle_error(bot: Bot, target: MessageTarget, error: BotError) -> BotResult { +pub async fn handle_error(app: App, target: MessageTarget, error: BotError) -> BotResult { log::error!("Error in handler: {error:?}"); match error { - BotError::UserVisibleError(message) => bot.send_html_message(target, message, None).await?, + BotError::UserVisibleError(message) => { + app.bot.send_html_message(target, message, None).await? + } BotError::InternalError(_) => { - bot.send_html_message( - target, - "An internal error occurred. Please try again later.", - None, - ) - .await?; + app.bot + .send_html_message( + target, + "An internal error occurred. Please try again later.".to_string(), + None, + ) + .await?; } } Ok(()) } fn boxed_handle_error( - bot: Bot, + app: App, target: MessageTarget, error: BotError, ) -> BoxFuture<'static, BotResult> { - Box::pin(handle_error(bot, target, error)) + Box::pin(handle_error(app, target, error)) } pub type ErrorHandlerWrapped = WrappedAsyncFn< FnBase, - fn(Bot, MessageTarget, BotError) -> BoxFuture<'static, BotResult>, + fn(App, MessageTarget, BotError) -> BoxFuture<'static, BotResult>, BotError, FnBaseArgs, - (Bot, MessageTarget), + (App, MessageTarget), >; pub fn with_error_handler( diff --git a/src/main.rs b/src/main.rs index 3097e42..9bc1909 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod dptree_utils; mod handle_error; mod handler_utils; mod keyboard_utils; +mod message_sender; mod message_utils; mod sqlite_storage; mod start_command_data; @@ -14,6 +15,8 @@ mod start_command_data; mod test_utils; mod wrap_endpoint; +use std::sync::Arc; + use crate::bidding::{bidding_handler, BiddingState}; use crate::commands::{ my_listings::{my_listings_handler, my_listings_inline_handler, MyListingsState}, @@ -22,6 +25,7 @@ use crate::commands::{ use crate::db::DAOs; use crate::handle_error::with_error_handler; use crate::handler_utils::{find_or_create_db_user_from_update, update_into_message_target}; +use crate::message_sender::BoxMessageSender; use crate::sqlite_storage::SqliteStorage; use anyhow::Result; pub use bot_result::*; @@ -35,13 +39,16 @@ pub use wrap_endpoint::*; #[derive(Clone)] pub struct App { - pub bot: Bot, + pub bot: Arc, pub daos: DAOs, } impl App { - pub fn new(bot: Bot, daos: DAOs) -> Self { - Self { bot, daos } + pub fn new(bot: BoxMessageSender, daos: DAOs) -> Self { + Self { + bot: Arc::new(bot), + daos, + } } } @@ -91,6 +98,50 @@ enum DialogueRootState { type RootDialogue = Dialogue>; +pub fn main_handler() -> BotHandler { + dptree::entry() + .map(|app: App| app.daos.clone()) + .map(|daos: DAOs| daos.user.clone()) + .map(|daos: DAOs| daos.listing.clone()) + .map(|daos: DAOs| daos.bid.clone()) + .filter_map(update_into_message_target) + .filter_map_async(find_or_create_db_user_from_update) + .branch(my_listings_inline_handler()) + .branch( + dptree::entry() + .enter_dialogue::, DialogueRootState>() + .branch(new_listing_handler()) + .branch(my_listings_handler()) + .branch(bidding_handler()) + .branch( + Update::filter_callback_query().branch( + dptree::case![DialogueRootState::MainMenu] + .endpoint(with_error_handler(handle_main_menu_callback)), + ), + ) + .branch( + Update::filter_message() + .filter_command::() + .branch( + dptree::case![Command::Start] + .endpoint(with_error_handler(handle_start)), + ) + .branch( + dptree::case![Command::Help].endpoint(with_error_handler(handle_help)), + ) + .branch( + dptree::case![Command::MyBids] + .endpoint(with_error_handler(handle_my_bids)), + ) + .branch( + dptree::case![Command::Settings] + .endpoint(with_error_handler(handle_settings)), + ), + ), + ) + .branch(Update::filter_message().endpoint(with_error_handler(unknown_message_handler))) +} + #[tokio::main] async fn main() -> Result<()> { // Load and validate configuration from environment/.env file @@ -100,7 +151,7 @@ async fn main() -> Result<()> { let db_pool = config.create_database_pool().await?; info!("Starting Pawctioneer Bot..."); - let bot = Bot::new(&config.telegram_token); + let bot = Box::new(Bot::new(&config.telegram_token)); // Set up the bot's command menu setup_bot_commands(&bot).await?; @@ -110,53 +161,20 @@ async fn main() -> Result<()> { let app = App::new(bot.clone(), daos.clone()); // Create dispatcher with dialogue system - Dispatcher::builder( - bot, - dptree::entry() - .filter_map(update_into_message_target) - .filter_map_async(find_or_create_db_user_from_update) - .branch(my_listings_inline_handler()) - .branch( - dptree::entry() - .enter_dialogue::, DialogueRootState>() - .branch(new_listing_handler()) - .branch(my_listings_handler()) - .branch(bidding_handler()) - .branch( - Update::filter_callback_query().branch( - dptree::case![DialogueRootState::MainMenu] - .endpoint(with_error_handler(handle_main_menu_callback)), - ), - ) - .branch( - Update::filter_message() - .filter_command::() - .branch( - dptree::case![Command::Start] - .endpoint(with_error_handler(handle_start)), - ) - .branch( - dptree::case![Command::Help] - .endpoint(with_error_handler(handle_help)), - ) - .branch( - dptree::case![Command::MyBids] - .endpoint(with_error_handler(handle_my_bids)), - ) - .branch( - dptree::case![Command::Settings] - .endpoint(with_error_handler(handle_settings)), - ), - ), - ) - .branch(Update::filter_message().endpoint(with_error_handler(unknown_message_handler))), - ) - .dependencies(dptree::deps![dialog_storage, daos, app]) - .enable_ctrlc_handler() - .worker_queue_size(1) - .build() - .dispatch() - .await; + Dispatcher::builder(bot, main_handler()) + .dependencies(dptree::deps![ + dialog_storage, + daos, + app.daos.user.clone(), + app.daos.listing.clone(), + app.daos.bid.clone(), + app + ]) + .enable_ctrlc_handler() + .worker_queue_size(1) + .build() + .dispatch() + .await; Ok(()) } @@ -169,3 +187,24 @@ async fn unknown_message_handler(msg: Message) -> BotResult { msg.text().unwrap_or("") ))) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::message_sender::MockMessageSender; + use crate::test_utils::create_deps; + + #[tokio::test] + async fn test_main_handler() { + let mut bot = MockMessageSender::new(); + bot.expect_send_html_message() + .times(1) + .returning(|_, _, _| Ok(())); + let deps = create_deps(bot).await; + let handler = main_handler(); + dptree::type_check(handler.sig(), &deps, &[]); + + let result = handler.dispatch(deps).await; + assert!(matches!(result, ControlFlow::Break(Ok(()))), "{:?}", result); + } +} diff --git a/src/message_sender.rs b/src/message_sender.rs new file mode 100644 index 0000000..8fb1e65 --- /dev/null +++ b/src/message_sender.rs @@ -0,0 +1,109 @@ +use crate::{message_utils::MessageTarget, BotError, BotResult}; +use anyhow::Context; +use async_trait::async_trait; +use teloxide::{ + payloads::{EditMessageTextSetters, SendMessageSetters}, + prelude::Requester, + types::{ + CallbackQueryId, InlineKeyboardMarkup, InlineQueryId, InlineQueryResult, Me, ParseMode, + }, + Bot, +}; + +#[async_trait] +pub trait MessageSender { + async fn send_html_message( + &self, + target: MessageTarget, + text: String, + keyboard: Option, + ) -> BotResult; + async fn answer_inline_query( + &self, + inline_query_id: InlineQueryId, + results: Vec, + ) -> BotResult; + async fn answer_callback_query(&self, query_id: CallbackQueryId) -> BotResult; + async fn get_me(&self) -> BotResult; +} + +pub type BoxMessageSender = Box; + +#[cfg(test)] +mockall::mock! { + pub MessageSender {} + impl Clone for MessageSender { + fn clone(&self) -> Self; + } + #[async_trait] + impl MessageSender for MessageSender { + async fn send_html_message( + &self, + target: MessageTarget, + text: String, + keyboard: Option, + ) -> BotResult; + async fn answer_inline_query( + &self, + inline_query_id: InlineQueryId, + results: Vec, + ) -> BotResult; + async fn answer_callback_query(&self, query_id: CallbackQueryId) -> BotResult; + async fn get_me(&self) -> BotResult; + } +} + +#[async_trait] +impl MessageSender for Bot { + async fn send_html_message( + &self, + target: MessageTarget, + text: String, + keyboard: Option, + ) -> BotResult { + if let Some(message_id) = target.message_id { + log::info!("Editing message in chat: {target:?}"); + let mut message = self + .edit_message_text(target.chat_id, message_id, &text) + .parse_mode(ParseMode::Html); + if let Some(kb) = keyboard { + message = message.reply_markup(kb); + } + message.await.context("failed to edit message")?; + } else { + log::info!("Sending message to chat: {target:?}"); + let mut message = self + .send_message(target.chat_id, &text) + .parse_mode(ParseMode::Html); + if let Some(kb) = keyboard { + message = message.reply_markup(kb); + } + message.await.context("failed to send message")?; + } + Ok(()) + } + + async fn answer_inline_query( + &self, + inline_query_id: InlineQueryId, + results: Vec, + ) -> BotResult { + teloxide::prelude::Requester::answer_inline_query(self, inline_query_id, results) + .await + .map(|_| ()) + .map_err(|err| BotError::InternalError(err.into())) + } + + async fn answer_callback_query(&self, query_id: CallbackQueryId) -> BotResult { + teloxide::prelude::Requester::answer_callback_query(self, query_id) + .await + .map(|_| ()) + .map_err(|err| BotError::InternalError(err.into())) + } + + async fn get_me(&self) -> BotResult { + teloxide::prelude::Requester::get_me(self) + .await + .map_err(|err| BotError::InternalError(err.into())) + } +} diff --git a/src/message_utils.rs b/src/message_utils.rs index 3e5faae..371ad50 100644 --- a/src/message_utils.rs +++ b/src/message_utils.rs @@ -1,16 +1,11 @@ -use crate::BotResult; -use anyhow::{anyhow, Context}; +use crate::{message_sender::BoxMessageSender, BotResult}; +use anyhow::anyhow; use chrono::{DateTime, Utc}; use num::One; use std::fmt::Display; -use teloxide::{ - payloads::{EditMessageTextSetters as _, SendMessageSetters as _}, - prelude::Requester as _, - types::{ - CallbackQuery, Chat, ChatId, InlineKeyboardButton, InlineKeyboardMarkup, - MaybeInaccessibleMessage, MessageId, ParseMode, User, - }, - Bot, +use teloxide::types::{ + CallbackQuery, Chat, ChatId, InlineKeyboardButton, InlineKeyboardMarkup, + MaybeInaccessibleMessage, MessageId, User, }; #[derive(Debug, Clone, Copy)] @@ -121,55 +116,6 @@ impl TryFrom<&CallbackQuery> for MessageTarget { } } -pub trait SendHtmlMessage { - async fn send_html_message( - &self, - target: MessageTarget, - text: impl AsRef, - keyboard: Option, - ) -> BotResult; -} - -impl SendHtmlMessage for Bot { - async fn send_html_message( - &self, - target: MessageTarget, - text: impl AsRef, - keyboard: Option, - ) -> BotResult { - send_html_message(self, target, text, keyboard).await - } -} - -// Unified HTML message sending utility -async fn send_html_message( - bot: &Bot, - target: MessageTarget, - text: impl AsRef, - keyboard: Option, -) -> BotResult { - if let Some(message_id) = target.message_id { - log::info!("Editing message in chat: {target:?}"); - let mut message = bot - .edit_message_text(target.chat_id, message_id, text.as_ref()) - .parse_mode(ParseMode::Html); - if let Some(kb) = keyboard { - message = message.reply_markup(kb); - } - message.await.context("failed to edit message")?; - } else { - log::info!("Sending message to chat: {target:?}"); - let mut message = bot - .send_message(target.chat_id, text.as_ref()) - .parse_mode(ParseMode::Html); - if let Some(kb) = keyboard { - message = message.reply_markup(kb); - } - message.await.context("failed to send message")?; - } - Ok(()) -} - // ============================================================================ // KEYBOARD CREATION UTILITIES // ============================================================================ @@ -180,7 +126,10 @@ pub fn create_single_button_keyboard(text: &str, callback_data: &str) -> InlineK } // Extract callback data and answer callback query -pub async fn extract_callback_data(bot: &Bot, callback_query: CallbackQuery) -> BotResult { +pub async fn extract_callback_data( + bot: &BoxMessageSender, + callback_query: CallbackQuery, +) -> BotResult { let data = match callback_query.data { Some(data) => data, None => return Err(anyhow!("Missing data in callback query"))?, diff --git a/src/test_utils.rs b/src/test_utils.rs index 792feef..0f952c1 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -1,6 +1,12 @@ //! Test utilities including timestamp comparison macros +use chrono::Utc; +use dptree::di::DependencyMap; use sqlx::SqlitePool; +use teloxide::dispatching::dialogue::serializer::Json; +use teloxide::types::*; + +use crate::{db::DAOs, message_sender::MockMessageSender, sqlite_storage::SqliteStorage, App}; /// Assert that two timestamps are approximately equal within a given epsilon tolerance. /// @@ -103,6 +109,88 @@ pub async fn create_test_pool() -> SqlitePool { pool } +pub fn create_tele_user(username: &str) -> User { + User { + id: UserId(1), + username: Some(username.to_string()), + first_name: username.to_string(), + last_name: Some("lastname".to_string()), + is_bot: false, + language_code: Some("en".to_string()), + is_premium: false, + added_to_attachment_menu: false, + } +} + +pub fn create_tele_private_chat(user: &User) -> Chat { + Chat { + id: ChatId(1), + kind: ChatKind::Private(ChatPrivate { + username: user.username.clone(), + first_name: Some(user.first_name.clone()), + last_name: user.last_name.clone(), + }), + } +} + +pub fn create_tele_update() -> Update { + let user = create_tele_user("sender"); + let chat = create_tele_private_chat(&user); + Update { + id: UpdateId(1), + kind: UpdateKind::Message(Message { + id: MessageId(1), + thread_id: None, + from: Some(user), + sender_chat: None, + date: Utc::now(), + chat, + is_topic_message: false, + via_bot: None, + sender_business_bot: None, + kind: MessageKind::Common(MessageCommon { + media_kind: MediaKind::Text(MediaText { + text: "test".to_string(), + entities: vec![], + link_preview_options: None, + }), + author_signature: None, + paid_star_count: None, + effect_id: None, + forward_origin: None, + reply_to_message: None, + external_reply: None, + quote: None, + reply_to_story: None, + sender_boost_count: None, + edit_date: None, + reply_markup: None, + is_automatic_forward: false, + has_protected_content: false, + is_from_offline: false, + business_connection_id: None, + }), + }), + } +} + +pub async fn create_deps(mock_bot: MockMessageSender) -> DependencyMap { + let update = create_tele_update(); + let pool = create_test_pool().await; + let dialog_storage = SqliteStorage::new(pool.clone(), Json).await.unwrap(); + let app = App::new(Box::new(mock_bot), DAOs::new(pool)); + let me_user = create_tele_user("me"); + let me = Me { + user: me_user, + can_join_groups: true, + can_read_all_group_messages: true, + supports_inline_queries: true, + can_connect_to_business: true, + has_main_web_app: true, + }; + dptree::deps![update, dialog_storage, app, me] +} + #[cfg(test)] mod tests { use chrono::{Duration, Utc};