mocakble message sender trait

This commit is contained in:
Dylan Knutson
2025-09-05 21:48:52 +00:00
parent da7e59fe0f
commit af5b8883af
17 changed files with 461 additions and 191 deletions

71
Cargo.lock generated
View File

@@ -529,6 +529,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1"
[[package]] [[package]]
name = "dptree" name = "dptree"
version = "0.5.1" version = "0.5.1"
@@ -678,6 +684,12 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fragile"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619"
[[package]] [[package]]
name = "funty" name = "funty"
version = "2.0.0" version = "2.0.0"
@@ -1396,6 +1408,32 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "native-tls" name = "native-tls"
version = "0.2.14" version = "0.2.14"
@@ -1625,6 +1663,7 @@ dependencies = [
"itertools 0.14.0", "itertools 0.14.0",
"lazy_static", "lazy_static",
"log", "log",
"mockall",
"num", "num",
"paste", "paste",
"regex", "regex",
@@ -1752,6 +1791,32 @@ dependencies = [
"zerocopy", "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]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.3.0" version = "3.3.0"
@@ -2819,6 +2884,12 @@ dependencies = [
"windows-sys 0.60.2", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.16" version = "2.0.16"

View File

@@ -32,6 +32,7 @@ paste = "1.0"
dptree = "0.5.1" dptree = "0.5.1"
seq-macro = "0.3.6" seq-macro = "0.3.6"
base64 = "0.22.1" base64 = "0.22.1"
mockall = "0.13.1"
[dev-dependencies] [dev-dependencies]
rstest = "0.26.1" rstest = "0.26.1"

View File

@@ -7,12 +7,12 @@ use crate::{
bid::NewBid, bid::NewBid,
listing::{ListingFields, PersistedListing}, listing::{ListingFields, PersistedListing},
user::PersistedUser, user::PersistedUser,
BidDAO, ListingDbId, MoneyAmount, UserDAO, ListingDbId, MoneyAmount, UserDAO,
}, },
dptree_utils::MapTwo, dptree_utils::MapTwo,
handle_error::with_error_handler, handle_error::with_error_handler,
handler_utils::find_listing_by_id, handler_utils::find_listing_by_id,
message_utils::{MessageTarget, SendHtmlMessage}, message_utils::MessageTarget,
start_command_data::StartCommandData, start_command_data::StartCommandData,
App, BotError, BotHandler, BotResult, DialogueRootState, RootDialogue, App, BotError, BotHandler, BotResult, DialogueRootState, RootDialogue,
}; };
@@ -173,7 +173,6 @@ async fn handle_awaiting_confirm_bid_amount_callback(
app: App, app: App,
listing: PersistedListing, listing: PersistedListing,
user: PersistedUser, user: PersistedUser,
bid_dao: BidDAO,
bid_amount: MoneyAmount, bid_amount: MoneyAmount,
target: MessageTarget, target: MessageTarget,
dialogue: RootDialogue, dialogue: RootDialogue,
@@ -189,7 +188,7 @@ async fn handle_awaiting_confirm_bid_amount_callback(
"cancel_bid" => { "cancel_bid" => {
dialogue.exit().await.context("failed to exit dialogue")?; dialogue.exit().await.context("failed to exit dialogue")?;
app.bot app.bot
.send_html_message(target, "Bid cancelled", None) .send_html_message(target, "Bid cancelled".to_string(), None)
.await?; .await?;
return Ok(()); 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); 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")?; dialogue.exit().await.context("failed to exit dialogue")?;

View File

@@ -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; use teloxide::utils::command::BotCommands;
pub async fn handle_help(app: App, target: MessageTarget) -> BotResult { pub async fn handle_help(app: App, target: MessageTarget) -> BotResult {

View File

@@ -1,4 +1,4 @@
use crate::{message_utils::{MessageTarget, SendHtmlMessage}, App, BotResult}; use crate::{message_utils::MessageTarget, App, BotResult};
use log::info; use log::info;
use teloxide::types::Message; 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\ • Bid status (winning/outbid)\n\
• Proxy bid settings\n\ • Proxy bid settings\n\
• Auction end times\n\n\ • Auction end times\n\n\
Feature in development! 🏗️"; Feature in development! 🏗️"
.to_string();
info!( info!(
"User {} ({}) checked their bids", "User {} ({}) checked their bids",

View File

@@ -1,5 +1,7 @@
mod keyboard; mod keyboard;
use std::ops::Deref;
use crate::{ use crate::{
case, case,
commands::{ commands::{
@@ -17,7 +19,7 @@ use crate::{
}, },
handle_error::with_error_handler, handle_error::with_error_handler,
handler_utils::{find_listing_by_id, find_or_create_db_user_from_update}, 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, start_command_data::StartCommandData,
App, BotError, BotResult, Command, DialogueRootState, RootDialogue, App, BotError, BotResult, Command, DialogueRootState, RootDialogue,
}; };
@@ -32,7 +34,6 @@ use teloxide::{
InlineKeyboardButton, InlineKeyboardMarkup, InlineQueryResult, InlineQueryResultArticle, InlineKeyboardButton, InlineKeyboardMarkup, InlineQueryResult, InlineQueryResultArticle,
InputMessageContent, InputMessageContentText, ParseMode, User, InputMessageContent, InputMessageContentText, ParseMode, User,
}, },
Bot,
}; };
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -184,7 +185,7 @@ async fn handle_forward_listing(
app.bot app.bot
.answer_inline_query( .answer_inline_query(
inline_query.id, inline_query.id,
[InlineQueryResult::Article( vec![InlineQueryResult::Article(
InlineQueryResultArticle::new( InlineQueryResultArticle::new(
listing.persisted.id.to_string(), listing.persisted.id.to_string(),
format!("💰 {} - ${}", listing.base.title, current_price), format!("💰 {} - ${}", listing.base.title, current_price),
@@ -264,7 +265,8 @@ pub async fn enter_my_listings(
.send_html_message( .send_html_message(
target, target,
"📋 <b>My Listings</b>\n\n\ "📋 <b>My Listings</b>\n\n\
You don't have any listings yet.", You don't have any listings yet."
.to_string(),
Some(keyboard), Some(keyboard),
) )
.await?; .await?;
@@ -295,7 +297,7 @@ async fn handle_viewing_listings_callback(
user: PersistedUser, user: PersistedUser,
target: MessageTarget, target: MessageTarget,
) -> BotResult { ) -> 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()) { if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) {
return enter_main_menu(app, dialogue, target).await; return enter_main_menu(app, dialogue, target).await;

View File

@@ -76,7 +76,8 @@ pub async fn handle_selecting_listing_type_callback(
pub async fn handle_awaiting_draft_field_callback( pub async fn handle_awaiting_draft_field_callback(
app: App, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
(field, draft): (ListingField, ListingDraft), field: ListingField,
draft: ListingDraft,
callback_query: CallbackQuery, callback_query: CallbackQuery,
target: MessageTarget, target: MessageTarget,
) -> BotResult { ) -> BotResult {
@@ -182,7 +183,7 @@ async fn handle_slots_callback(
app.bot app.bot
.send_html_message( .send_html_message(
target, target,
&response, response,
get_keyboard_for_field(ListingField::StartTime), get_keyboard_for_field(ListingField::StartTime),
) )
.await?; .await?;
@@ -217,7 +218,7 @@ async fn handle_start_time_callback(
app.bot app.bot
.send_html_message( .send_html_message(
target, target,
&response, response,
get_keyboard_for_field(ListingField::EndTime), get_keyboard_for_field(ListingField::EndTime),
) )
.await?; .await?;
@@ -304,7 +305,7 @@ async fn handle_currency_type_callback(
); );
transition_to_field(dialogue, next_field, draft).await?; transition_to_field(dialogue, next_field, draft).await?;
app.bot 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?; .await?;
Ok(()) Ok(())
} }

View File

@@ -1,5 +1,10 @@
use super::{callbacks::*, handlers::*, types::*}; 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}; use teloxide::{dptree, prelude::*, types::Update};
// Create the dialogue handler tree for new listing wizard // 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)), .endpoint(with_error_handler(handle_new_listing_command)),
) )
.branch( .branch(
case![DialogueRootState::NewListing( dptree::entry()
NewListingState::AwaitingDraftField { field, draft } .chain(case![DialogueRootState::NewListing(state)])
)] .branch(
.endpoint(with_error_handler(handle_awaiting_draft_field_input)), case![NewListingState::AwaitingDraftField { field, draft }]
) .map2(identity::<(ListingField, ListingDraft)>)
.branch( .endpoint(with_error_handler(handle_awaiting_draft_field_input)),
case![DialogueRootState::NewListing( )
NewListingState::EditingDraftField { field, draft } .branch(
)] case![NewListingState::EditingDraftField { field, draft }]
.endpoint(with_error_handler(handle_editing_field_input)), .map2(identity::<(ListingField, ListingDraft)>)
.endpoint(with_error_handler(handle_editing_field_input)),
),
), ),
) )
.branch( .branch(
Update::filter_callback_query() Update::filter_callback_query()
.chain(case![DialogueRootState::NewListing(state)])
.branch( .branch(
case![DialogueRootState::NewListing( case![NewListingState::SelectingListingType]
NewListingState::SelectingListingType .endpoint(with_error_handler(handle_selecting_listing_type_callback)),
)]
.endpoint(with_error_handler(handle_selecting_listing_type_callback)),
) )
.branch( .branch(
case![DialogueRootState::NewListing( case![NewListingState::AwaitingDraftField { field, draft }]
NewListingState::AwaitingDraftField { field, draft } .map2(identity::<(ListingField, ListingDraft)>)
)] .endpoint(with_error_handler(handle_awaiting_draft_field_callback)),
.endpoint(with_error_handler(handle_awaiting_draft_field_callback)),
) )
.branch( .branch(
case![DialogueRootState::NewListing( case![NewListingState::ViewingDraft(draft)]
NewListingState::ViewingDraft(draft) .endpoint(with_error_handler(handle_viewing_draft_callback)),
)]
.endpoint(with_error_handler(handle_viewing_draft_callback)),
) )
.branch( .branch(
case![DialogueRootState::NewListing( case![NewListingState::EditingDraft(draft)]
NewListingState::EditingDraft(draft) .endpoint(with_error_handler(handle_editing_draft_callback)),
)]
.endpoint(with_error_handler(handle_editing_draft_callback)),
) )
.branch( .branch(
case![DialogueRootState::NewListing( case![NewListingState::EditingDraftField { field, draft }]
NewListingState::EditingDraftField { field, draft } .map2(identity::<(ListingField, ListingDraft)>)
)] .endpoint(with_error_handler(handle_editing_draft_field_callback)),
.endpoint(with_error_handler(handle_editing_draft_field_callback)),
), ),
) )
} }

View File

@@ -58,7 +58,7 @@ pub async fn enter_select_new_listing_type(
app.bot app.bot
.send_html_message( .send_html_message(
target, target,
get_listing_type_selection_message(), get_listing_type_selection_message().to_string(),
Some(get_listing_type_keyboard()), Some(get_listing_type_keyboard()),
) )
.await?; .await?;
@@ -69,7 +69,8 @@ pub async fn enter_select_new_listing_type(
pub async fn handle_awaiting_draft_field_input( pub async fn handle_awaiting_draft_field_input(
app: App, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
(field, mut draft): (ListingField, ListingDraft), field: ListingField,
mut draft: ListingDraft,
target: MessageTarget, target: MessageTarget,
msg: Message, msg: Message,
) -> BotResult { ) -> BotResult {
@@ -111,7 +112,8 @@ pub async fn handle_awaiting_draft_field_input(
pub async fn handle_editing_field_input( pub async fn handle_editing_field_input(
app: App, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
(field, mut draft): (ListingField, ListingDraft), field: ListingField,
mut draft: ListingDraft,
target: MessageTarget, target: MessageTarget,
msg: Message, msg: Message,
) -> BotResult { ) -> BotResult {
@@ -156,8 +158,9 @@ pub async fn handle_viewing_draft_callback(
ConfirmationKeyboardButtons::Cancel => { ConfirmationKeyboardButtons::Cancel => {
info!("User {target:?} cancelled listing update"); info!("User {target:?} cancelled listing update");
let response = "🗑️ <b>Changes Discarded</b>\n\n\ let response = "🗑️ <b>Changes Discarded</b>\n\n\
Your changes have been discarded and not saved."; Your changes have been discarded and not saved."
app.bot.send_html_message(target, &response, None).await?; .to_string();
app.bot.send_html_message(target, response, None).await?;
dialogue.exit().await.context("failed to exit dialogue")?; dialogue.exit().await.context("failed to exit dialogue")?;
} }
ConfirmationKeyboardButtons::Discard => { ConfirmationKeyboardButtons::Discard => {
@@ -165,8 +168,9 @@ pub async fn handle_viewing_draft_callback(
let response = "🗑️ <b>Listing Discarded</b>\n\n\ let response = "🗑️ <b>Listing Discarded</b>\n\n\
Your listing has been discarded and not created.\n\ Your listing has been discarded and not created.\n\
You can start a new listing anytime with /newlisting."; You can start a new listing anytime with /newlisting."
app.bot.send_html_message(target, &response, None).await?; .to_string();
app.bot.send_html_message(target, response, None).await?;
dialogue.exit().await.context("failed to exit dialogue")?; dialogue.exit().await.context("failed to exit dialogue")?;
} }
ConfirmationKeyboardButtons::Edit => { ConfirmationKeyboardButtons::Edit => {
@@ -228,7 +232,8 @@ pub async fn handle_editing_draft_callback(
pub async fn handle_editing_draft_field_callback( pub async fn handle_editing_draft_field_callback(
app: App, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
(field, draft): (ListingField, ListingDraft), field: ListingField,
draft: ListingDraft,
callback_query: CallbackQuery, callback_query: CallbackQuery,
target: MessageTarget, target: MessageTarget,
) -> BotResult { ) -> BotResult {

View File

@@ -1,7 +1,4 @@
use crate::{ use crate::{message_utils::MessageTarget, App, BotResult};
message_utils::{MessageTarget, SendHtmlMessage},
App, BotResult,
};
use log::info; use log::info;
use teloxide::types::Message; use teloxide::types::Message;
@@ -12,7 +9,8 @@ pub async fn handle_settings(app: App, msg: Message, target: MessageTarget) -> B
• Language settings\n\ • Language settings\n\
• Default bid increments\n\ • Default bid increments\n\
• Outbid alerts\n\n\ • Outbid alerts\n\n\
Feature in development! 🛠️"; Feature in development! 🛠️"
.to_string();
info!( info!(
"User {} ({}) accessed settings", "User {} ({}) accessed settings",

View File

@@ -9,7 +9,7 @@ use crate::{
commands::my_listings::enter_my_listings, commands::my_listings::enter_my_listings,
db::user::PersistedUser, db::user::PersistedUser,
keyboard_buttons, keyboard_buttons,
message_utils::{extract_callback_data, MessageTarget, SendHtmlMessage as _}, message_utils::{extract_callback_data, MessageTarget},
App, BotResult, Command, DialogueRootState, RootDialogue, App, BotResult, Command, DialogueRootState, RootDialogue,
}; };
@@ -58,7 +58,7 @@ pub async fn enter_main_menu(app: App, dialogue: RootDialogue, target: MessageTa
app.bot app.bot
.send_html_message( .send_html_message(
target, target,
get_main_menu_message(), get_main_menu_message().to_string(),
Some(MainMenuButtons::to_keyboard()), Some(MainMenuButtons::to_keyboard()),
) )
.await?; .await?;
@@ -92,7 +92,8 @@ pub async fn handle_main_menu_callback(
• Bid history\n\ • Bid history\n\
• Won/lost auctions\n\ • Won/lost auctions\n\
• Outbid notifications\n\n\ • Outbid notifications\n\n\
Feature in development! 🛠️", Feature in development! 🛠️"
.to_string(),
Some(MainMenuButtons::to_keyboard()), Some(MainMenuButtons::to_keyboard()),
) )
.await?; .await?;
@@ -107,7 +108,8 @@ pub async fn handle_main_menu_callback(
• Language settings\n\ • Language settings\n\
• Default bid increments\n\ • Default bid increments\n\
• Outbid alerts\n\n\ • Outbid alerts\n\n\
Feature in development! 🛠️", Feature in development! 🛠️"
.to_string(),
Some(MainMenuButtons::to_keyboard()), Some(MainMenuButtons::to_keyboard()),
) )
.await?; .await?;

View File

@@ -135,6 +135,10 @@ where
} }
} }
pub fn identity<T>(t: T) -> T {
t
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::ops::ControlFlow; use std::ops::ControlFlow;

View File

@@ -1,40 +1,41 @@
use crate::{ use crate::{
message_utils::{MessageTarget, SendHtmlMessage}, message_utils::MessageTarget, wrap_endpoint, App, BotError, BotResult, WrappedAsyncFn,
wrap_endpoint, BotError, BotResult, WrappedAsyncFn,
}; };
use futures::future::BoxFuture; 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:?}"); log::error!("Error in handler: {error:?}");
match 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(_) => { BotError::InternalError(_) => {
bot.send_html_message( app.bot
target, .send_html_message(
"An internal error occurred. Please try again later.", target,
None, "An internal error occurred. Please try again later.".to_string(),
) None,
.await?; )
.await?;
} }
} }
Ok(()) Ok(())
} }
fn boxed_handle_error( fn boxed_handle_error(
bot: Bot, app: App,
target: MessageTarget, target: MessageTarget,
error: BotError, error: BotError,
) -> BoxFuture<'static, BotResult> { ) -> BoxFuture<'static, BotResult> {
Box::pin(handle_error(bot, target, error)) Box::pin(handle_error(app, target, error))
} }
pub type ErrorHandlerWrapped<FnBase, FnBaseArgs> = WrappedAsyncFn< pub type ErrorHandlerWrapped<FnBase, FnBaseArgs> = WrappedAsyncFn<
FnBase, FnBase,
fn(Bot, MessageTarget, BotError) -> BoxFuture<'static, BotResult>, fn(App, MessageTarget, BotError) -> BoxFuture<'static, BotResult>,
BotError, BotError,
FnBaseArgs, FnBaseArgs,
(Bot, MessageTarget), (App, MessageTarget),
>; >;
pub fn with_error_handler<FnBase, FnBaseArgs>( pub fn with_error_handler<FnBase, FnBaseArgs>(

View File

@@ -7,6 +7,7 @@ mod dptree_utils;
mod handle_error; mod handle_error;
mod handler_utils; mod handler_utils;
mod keyboard_utils; mod keyboard_utils;
mod message_sender;
mod message_utils; mod message_utils;
mod sqlite_storage; mod sqlite_storage;
mod start_command_data; mod start_command_data;
@@ -14,6 +15,8 @@ mod start_command_data;
mod test_utils; mod test_utils;
mod wrap_endpoint; mod wrap_endpoint;
use std::sync::Arc;
use crate::bidding::{bidding_handler, BiddingState}; use crate::bidding::{bidding_handler, BiddingState};
use crate::commands::{ use crate::commands::{
my_listings::{my_listings_handler, my_listings_inline_handler, MyListingsState}, my_listings::{my_listings_handler, my_listings_inline_handler, MyListingsState},
@@ -22,6 +25,7 @@ use crate::commands::{
use crate::db::DAOs; use crate::db::DAOs;
use crate::handle_error::with_error_handler; use crate::handle_error::with_error_handler;
use crate::handler_utils::{find_or_create_db_user_from_update, update_into_message_target}; 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 crate::sqlite_storage::SqliteStorage;
use anyhow::Result; use anyhow::Result;
pub use bot_result::*; pub use bot_result::*;
@@ -35,13 +39,16 @@ pub use wrap_endpoint::*;
#[derive(Clone)] #[derive(Clone)]
pub struct App { pub struct App {
pub bot: Bot, pub bot: Arc<BoxMessageSender>,
pub daos: DAOs, pub daos: DAOs,
} }
impl App { impl App {
pub fn new(bot: Bot, daos: DAOs) -> Self { pub fn new(bot: BoxMessageSender, daos: DAOs) -> Self {
Self { bot, daos } Self {
bot: Arc::new(bot),
daos,
}
} }
} }
@@ -91,6 +98,50 @@ enum DialogueRootState {
type RootDialogue = Dialogue<DialogueRootState, SqliteStorage<Json>>; type RootDialogue = Dialogue<DialogueRootState, SqliteStorage<Json>>;
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::<Update, SqliteStorage<Json>, 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::<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] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
// Load and validate configuration from environment/.env file // Load and validate configuration from environment/.env file
@@ -100,7 +151,7 @@ async fn main() -> Result<()> {
let db_pool = config.create_database_pool().await?; let db_pool = config.create_database_pool().await?;
info!("Starting Pawctioneer Bot..."); 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 // Set up the bot's command menu
setup_bot_commands(&bot).await?; setup_bot_commands(&bot).await?;
@@ -110,53 +161,20 @@ async fn main() -> Result<()> {
let app = App::new(bot.clone(), daos.clone()); let app = App::new(bot.clone(), daos.clone());
// Create dispatcher with dialogue system // Create dispatcher with dialogue system
Dispatcher::builder( Dispatcher::builder(bot, main_handler())
bot, .dependencies(dptree::deps![
dptree::entry() dialog_storage,
.filter_map(update_into_message_target) daos,
.filter_map_async(find_or_create_db_user_from_update) app.daos.user.clone(),
.branch(my_listings_inline_handler()) app.daos.listing.clone(),
.branch( app.daos.bid.clone(),
dptree::entry() app
.enter_dialogue::<Update, SqliteStorage<Json>, DialogueRootState>() ])
.branch(new_listing_handler()) .enable_ctrlc_handler()
.branch(my_listings_handler()) .worker_queue_size(1)
.branch(bidding_handler()) .build()
.branch( .dispatch()
Update::filter_callback_query().branch( .await;
dptree::case![DialogueRootState::MainMenu]
.endpoint(with_error_handler(handle_main_menu_callback)),
),
)
.branch(
Update::filter_message()
.filter_command::<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;
Ok(()) Ok(())
} }
@@ -169,3 +187,24 @@ async fn unknown_message_handler(msg: Message) -> BotResult {
msg.text().unwrap_or("") 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);
}
}

109
src/message_sender.rs Normal file
View File

@@ -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<InlineKeyboardMarkup>,
) -> BotResult;
async fn answer_inline_query(
&self,
inline_query_id: InlineQueryId,
results: Vec<InlineQueryResult>,
) -> BotResult;
async fn answer_callback_query(&self, query_id: CallbackQueryId) -> BotResult;
async fn get_me(&self) -> BotResult<Me>;
}
pub type BoxMessageSender = Box<dyn MessageSender + Send + Sync>;
#[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<InlineKeyboardMarkup>,
) -> BotResult;
async fn answer_inline_query(
&self,
inline_query_id: InlineQueryId,
results: Vec<InlineQueryResult>,
) -> BotResult;
async fn answer_callback_query(&self, query_id: CallbackQueryId) -> BotResult;
async fn get_me(&self) -> BotResult<Me>;
}
}
#[async_trait]
impl MessageSender for Bot {
async fn send_html_message(
&self,
target: MessageTarget,
text: String,
keyboard: Option<InlineKeyboardMarkup>,
) -> 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<InlineQueryResult>,
) -> 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<Me> {
teloxide::prelude::Requester::get_me(self)
.await
.map_err(|err| BotError::InternalError(err.into()))
}
}

View File

@@ -1,16 +1,11 @@
use crate::BotResult; use crate::{message_sender::BoxMessageSender, BotResult};
use anyhow::{anyhow, Context}; use anyhow::anyhow;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use num::One; use num::One;
use std::fmt::Display; use std::fmt::Display;
use teloxide::{ use teloxide::types::{
payloads::{EditMessageTextSetters as _, SendMessageSetters as _}, CallbackQuery, Chat, ChatId, InlineKeyboardButton, InlineKeyboardMarkup,
prelude::Requester as _, MaybeInaccessibleMessage, MessageId, User,
types::{
CallbackQuery, Chat, ChatId, InlineKeyboardButton, InlineKeyboardMarkup,
MaybeInaccessibleMessage, MessageId, ParseMode, User,
},
Bot,
}; };
#[derive(Debug, Clone, Copy)] #[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<str>,
keyboard: Option<InlineKeyboardMarkup>,
) -> BotResult;
}
impl SendHtmlMessage for Bot {
async fn send_html_message(
&self,
target: MessageTarget,
text: impl AsRef<str>,
keyboard: Option<InlineKeyboardMarkup>,
) -> 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<str>,
keyboard: Option<InlineKeyboardMarkup>,
) -> 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 // 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 // Extract callback data and answer callback query
pub async fn extract_callback_data(bot: &Bot, callback_query: CallbackQuery) -> BotResult<String> { pub async fn extract_callback_data(
bot: &BoxMessageSender,
callback_query: CallbackQuery,
) -> BotResult<String> {
let data = match callback_query.data { let data = match callback_query.data {
Some(data) => data, Some(data) => data,
None => return Err(anyhow!("Missing data in callback query"))?, None => return Err(anyhow!("Missing data in callback query"))?,

View File

@@ -1,6 +1,12 @@
//! Test utilities including timestamp comparison macros //! Test utilities including timestamp comparison macros
use chrono::Utc;
use dptree::di::DependencyMap;
use sqlx::SqlitePool; 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. /// Assert that two timestamps are approximately equal within a given epsilon tolerance.
/// ///
@@ -103,6 +109,88 @@ pub async fn create_test_pool() -> SqlitePool {
pool 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)] #[cfg(test)]
mod tests { mod tests {
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};