From 9ad562a4b243dfe5498b943bdfeb0af7e8ba2d0c Mon Sep 17 00:00:00 2001 From: Dylan Knutson Date: Wed, 3 Sep 2025 00:28:46 +0000 Subject: [PATCH] basic scaffold for placing bids --- Cargo.lock | 1 + Cargo.toml | 1 + src/bidding/keyboards.rs | 1 + src/bidding/mod.rs | 212 ++++++++++++++++++++ src/bot_result.rs | 5 + src/commands/my_listings/mod.rs | 134 ++++++++----- src/commands/new_listing/callbacks.rs | 9 +- src/commands/new_listing/handler_factory.rs | 28 +-- src/commands/new_listing/handlers.rs | 45 +++-- src/commands/new_listing/mod.rs | 2 +- src/commands/start.rs | 24 ++- src/db/dao/listing_dao.rs | 39 ++-- src/db/dao/user_dao.rs | 92 ++++----- src/db/models/listing.rs | 56 ++---- src/handle_error.rs | 5 +- src/handler_utils.rs | 57 ++++-- src/main.rs | 47 ++--- src/message_utils.rs | 11 + src/start_command_data.rs | 75 +++++++ src/test_utils.rs | 16 ++ src/wrap_endpoint.rs | 3 + 21 files changed, 602 insertions(+), 261 deletions(-) create mode 100644 src/bidding/keyboards.rs create mode 100644 src/bidding/mod.rs create mode 100644 src/start_command_data.rs diff --git a/Cargo.lock b/Cargo.lock index 2ac9037..da22746 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1616,6 +1616,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "base64", "chrono", "dotenvy", "dptree", diff --git a/Cargo.toml b/Cargo.toml index f950db1..049b036 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ regex = "1.11.2" paste = "1.0" dptree = "0.5.1" seq-macro = "0.3.6" +base64 = "0.22.1" [dev-dependencies] rstest = "0.26.1" diff --git a/src/bidding/keyboards.rs b/src/bidding/keyboards.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/bidding/keyboards.rs @@ -0,0 +1 @@ + diff --git a/src/bidding/mod.rs b/src/bidding/mod.rs new file mode 100644 index 0000000..025e4c8 --- /dev/null +++ b/src/bidding/mod.rs @@ -0,0 +1,212 @@ +mod keyboards; + +use crate::{ + case, + commands::new_listing::validations::{validate_price, SetFieldError}, + db::{ + listing::{ListingFields, PersistedListing}, + user::PersistedUser, + ListingDbId, MoneyAmount, UserDAO, + }, + handle_error::with_error_handler, + handler_utils::find_listing_by_id, + message_utils::{send_message, MessageTarget}, + start_command_data::StartCommandData, + BotError, BotHandler, BotResult, DialogueRootState, RootDialogue, +}; +use anyhow::{anyhow, Context}; +use log::info; +use serde::{Deserialize, Serialize}; +use teloxide::{ + dispatching::UpdateFilterExt, + types::{CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, Update}, + Bot, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum BiddingState { + AwaitingBidAmount(ListingDbId), + AwaitingConfirmBidAmount(ListingDbId, MoneyAmount), +} +impl From for DialogueRootState { + fn from(state: BiddingState) -> Self { + DialogueRootState::Bidding(state) + } +} + +pub fn bidding_handler() -> BotHandler { + dptree::entry() + .branch( + Update::filter_message() + .filter_map(StartCommandData::get_from_update) + .filter_map(StartCommandData::get_place_bid_on_listing_start_command) + .filter_map_async(find_listing_by_id) + .endpoint(with_error_handler(handle_place_bid_on_listing)), + ) + .branch( + Update::filter_message() + .chain(case![DialogueRootState::Bidding( + BiddingState::AwaitingBidAmount(listing_id) + )]) + .filter_map_async(find_listing_by_id) + .endpoint(with_error_handler(handle_awaiting_bid_amount_input)), + ) + .branch( + Update::filter_callback_query() + .chain(case![DialogueRootState::Bidding( + BiddingState::AwaitingConfirmBidAmount(listing_id, bid_amount) + )]) + .filter_map_async( + async |listing_dao, (listing_id, _): (ListingDbId, MoneyAmount)| { + find_listing_by_id(listing_dao, listing_id).await + }, + ) + .endpoint(with_error_handler( + handle_awaiting_confirm_bid_amount_callback, + )), + ) +} + +async fn handle_place_bid_on_listing( + bot: Bot, + user_dao: UserDAO, + target: MessageTarget, + user: PersistedUser, + listing: PersistedListing, + dialogue: RootDialogue, +) -> BotResult { + info!("Handling place bid on listing for listing {listing:?} for user {user:?}"); + let seller = user_dao + .find_by_id(listing.base.seller_id) + .await? + .ok_or(BotError::UserVisibleError("Seller not found".to_string()))?; + + let fields = match &listing.fields { + ListingFields::BasicAuction(fields) => fields, + _ => { + return Err(BotError::UserVisibleError( + "Unsupported listing type".to_string(), + )) + } + }; + + dialogue + .update(BiddingState::AwaitingBidAmount(listing.persisted.id)) + .await + .context("failed to update dialogue")?; + + let mut response_lines = vec![]; + response_lines.push(format!( + "Place bid on listing for listing {}, ran by {}", + listing.base.title, + seller + .username + .clone() + .unwrap_or_else(|| seller.telegram_id.to_string()) + )); + response_lines.push(format!("You are bidding on this listing as: {user:?}")); + response_lines.push(format!("Minimum bid: {}", fields.min_increment)); + + let keyboard = InlineKeyboardMarkup::default() + .append_row([InlineKeyboardButton::callback("Bid $1", "cancel")]); + + send_message(&bot, target, response_lines.join("\n"), Some(keyboard)).await?; + + Ok(()) +} + +async fn handle_awaiting_bid_amount_input( + bot: Bot, + listing: PersistedListing, + target: MessageTarget, + dialogue: RootDialogue, + msg: Message, +) -> BotResult { + // parse the bid amount into a MoneyAmount + let text = msg + .text() + .ok_or(BotError::user_visible("Please enter a valid bid amount"))?; + let bid_amount = match validate_price(text) { + Ok(bid_amount) => bid_amount, + Err(SetFieldError::ValidationFailed(e)) => { + return Err(BotError::user_visible(e)); + } + Err(other) => { + return Err(anyhow!("Error validating bid amount: {other:?}").into()); + } + }; + + send_message( + &bot, + target, + format!("Confirm bid amount: {bid_amount} - this cannot be undone!"), + Some(InlineKeyboardMarkup::default().append_row([ + InlineKeyboardButton::callback( + format!("Confirm bid amount: {bid_amount}"), + "confirm_bid", + ), + InlineKeyboardButton::callback("Cancel", "cancel_bid"), + ])), + ) + .await?; + + dialogue + .update(BiddingState::AwaitingConfirmBidAmount( + listing.persisted.id, + bid_amount, + )) + .await + .context("failed to update dialogue")?; + + Ok(()) +} + +async fn handle_awaiting_confirm_bid_amount_callback( + bot: Bot, + listing: PersistedListing, + (_, bid_amount): (ListingDbId, MoneyAmount), + target: MessageTarget, + dialogue: RootDialogue, + callback_query: CallbackQuery, +) -> BotResult { + let callback_data = callback_query + .data + .as_deref() + .ok_or(BotError::user_visible("Missing data in callback query"))?; + + let bid_amount = match callback_data { + "confirm_bid" => bid_amount, + "cancel_bid" => { + dialogue.exit().await.context("failed to exit dialogue")?; + send_message(&bot, target, "Bid cancelled", None).await?; + return Ok(()); + } + _ => { + return Err(BotError::user_visible(format!( + "Invalid response {callback_data}" + ))) + } + }; + + dialogue.exit().await.context("failed to exit dialogue")?; + + send_message( + &bot, + target.only_chat_id(), + format!( + "Bid placed for {}{} on {}", + listing.base.currency_type.symbol(), + bid_amount, + listing.base.title + ), + None, + ) + .await?; + + // TODO - keyboard with buttons to: + // - be notified if they are outbid + // - be notified when the auction ends + // - view details about the auction + + Ok(()) +} diff --git a/src/bot_result.rs b/src/bot_result.rs index e78e1d7..3d58529 100644 --- a/src/bot_result.rs +++ b/src/bot_result.rs @@ -7,6 +7,11 @@ pub enum BotError { #[error(transparent)] InternalError(#[from] anyhow::Error), } +impl BotError { + pub fn user_visible(msg: impl Into) -> Self { + Self::UserVisibleError(msg.into()) + } +} pub type BotResult = Result; pub type BotHandler = dptree::Handler<'static, BotResult, DpHandlerDescription>; diff --git a/src/commands/my_listings/mod.rs b/src/commands/my_listings/mod.rs index c80c52f..1e4ccde 100644 --- a/src/commands/my_listings/mod.rs +++ b/src/commands/my_listings/mod.rs @@ -7,7 +7,7 @@ use crate::{ my_listings::keyboard::{ManageListingButtons, MyListingsButtons}, new_listing::{ enter_edit_listing_draft, enter_select_new_listing_type, keyboard::NavKeyboardButtons, - ListingDraft, + messages::steps_for_listing_type, ListingDraft, }, }, db::{ @@ -15,17 +15,16 @@ use crate::{ user::PersistedUser, ListingDAO, ListingDbId, ListingType, }, - handler_utils::{ - callback_query_into_message_target, find_or_create_db_user_from_callback_query, - find_or_create_db_user_from_message, message_into_message_target, - }, + 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, send_message, MessageTarget}, + start_command_data::StartCommandData, BotError, BotResult, Command, DialogueRootState, RootDialogue, }; use anyhow::{anyhow, Context}; +use base64::{prelude::BASE64_URL_SAFE, Engine}; use log::info; use serde::{Deserialize, Serialize}; -use sqlx::SqlitePool; use teloxide::{ dispatching::{DpHandlerDescription, UpdateFilterExt}, prelude::*, @@ -57,46 +56,54 @@ pub fn my_listings_handler() -> Handler<'static, BotResult, DpHandlerDescription dptree::entry() .branch( Update::filter_message() - .filter_command::() - .map(message_into_message_target) - .branch( - dptree::case![Command::MyListings] - .filter_map_async(find_or_create_db_user_from_message) - .endpoint(handle_my_listings_command_input), - ), + .filter_map(StartCommandData::get_from_update) + .filter_map(StartCommandData::get_view_listing_details_start_command) + .filter_map_async(find_listing_by_id) + .endpoint(with_error_handler(handle_view_listing_details)), + ) + .branch( + Update::filter_message().filter_command::().branch( + dptree::case![Command::MyListings] + .filter_map_async(find_or_create_db_user_from_update) + .endpoint(with_error_handler(handle_my_listings_command_input)), + ), ) .branch( Update::filter_callback_query() - .filter_map(callback_query_into_message_target) .branch( // Callback when user taps a listing ID button to manage that listing case![DialogueRootState::MyListings( MyListingsState::ViewingListings )] - .filter_map_async(find_or_create_db_user_from_callback_query) - .endpoint(handle_viewing_listings_callback), + .endpoint(with_error_handler(handle_viewing_listings_callback)), ) .branch( case![DialogueRootState::MyListings( MyListingsState::ManagingListing(listing_id) )] - .filter_map_async(find_or_create_db_user_from_callback_query) - .endpoint(handle_managing_listing_callback), + .endpoint(with_error_handler(handle_managing_listing_callback)), ), ) } +async fn handle_view_listing_details( + bot: Bot, + listing: PersistedListing, + target: MessageTarget, +) -> BotResult { + send_listing_details_message(&bot, target, listing, None).await?; + Ok(()) +} + async fn inline_query_extract_forward_listing( - db_pool: SqlitePool, + listing_dao: ListingDAO, inline_query: InlineQuery, ) -> Option { let query = &inline_query.query; info!("Try to extract forward listing from query: {query}"); let listing_id_str = query.split("forward_listing:").nth(1)?; let listing_id = ListingDbId::new(listing_id_str.parse::().ok()?); - let listing = ListingDAO::find_by_id(&db_pool, listing_id) - .await - .unwrap_or(None)?; + let listing = listing_dao.find_by_id(listing_id).await.unwrap_or(None)?; Some(listing) } @@ -121,9 +128,16 @@ async fn handle_forward_listing( // Create inline keyboard with auction interaction buttons let keyboard = InlineKeyboardMarkup::default() .append_row([ - InlineKeyboardButton::callback( - "💰 Place Bid", - format!("inline_bid:{}", listing.persisted.id), + InlineKeyboardButton::url( + "💰 Place Bid?", + format!( + "tg://resolve?domain={}&start={}", + bot_username, + BASE64_URL_SAFE + .encode(format!("place_bid_on_listing:{}", listing.persisted.id)) + ) + .parse() + .unwrap(), ), InlineKeyboardButton::callback( "👀 Watch", @@ -133,8 +147,9 @@ async fn handle_forward_listing( .append_row([InlineKeyboardButton::url( "🔗 View Full Details", format!( - "https://t.me/{}?start=listing:{}", - bot_username, listing.persisted.id + "tg://resolve?domain={}&start={}", + bot_username, + BASE64_URL_SAFE.encode(format!("view_listing_details:{}", listing.persisted.id)) ) .parse() .unwrap(), @@ -204,18 +219,18 @@ fn get_listing_current_price(listing: &PersistedListing) -> String { } async fn handle_my_listings_command_input( - db_pool: SqlitePool, + listing_dao: ListingDAO, bot: Bot, dialogue: RootDialogue, user: PersistedUser, target: MessageTarget, ) -> BotResult { - enter_my_listings(db_pool, bot, dialogue, user, target, None).await?; + enter_my_listings(listing_dao, bot, dialogue, user, target, None).await?; Ok(()) } pub async fn enter_my_listings( - db_pool: SqlitePool, + listing_dao: ListingDAO, bot: Bot, dialogue: RootDialogue, user: PersistedUser, @@ -228,7 +243,7 @@ pub async fn enter_my_listings( .await .context("failed to update dialogue")?; - let listings = ListingDAO::find_by_seller(&db_pool, user.persisted.id).await?; + let listings = listing_dao.find_by_seller(user.persisted.id).await?; // Create keyboard with buttons for each listing let mut keyboard = teloxide::types::InlineKeyboardMarkup::default(); for listing in &listings { @@ -267,7 +282,7 @@ pub async fn enter_my_listings( } async fn handle_viewing_listings_callback( - db_pool: SqlitePool, + listing_dao: ListingDAO, bot: Bot, dialogue: RootDialogue, callback_query: CallbackQuery, @@ -284,7 +299,7 @@ async fn handle_viewing_listings_callback( let button = MyListingsButtons::try_from(data.as_str())?; match button { MyListingsButtons::SelectListing(listing_id) => { - let listing = get_listing_for_user(&db_pool, user, listing_id).await?; + let listing = get_listing_for_user(&listing_dao, user, listing_id).await?; enter_show_listing_details(&bot, dialogue, listing, target).await?; } @@ -302,19 +317,7 @@ async fn enter_show_listing_details( listing: PersistedListing, target: MessageTarget, ) -> BotResult { - let listing_type = Into::::into(&listing.fields); let listing_id = listing.persisted.id; - let response = format!( - "🔍 {listing_type} Details\n\n\ - Title: {}\n\ - Description: {}\n", - listing.base.title, - listing - .base - .description - .as_deref() - .unwrap_or("No description"), - ); dialogue .update(MyListingsState::ManagingListing(listing_id)) .await @@ -332,12 +335,34 @@ async fn enter_show_listing_details( ManageListingButtons::Delete.to_button(), ]) .append_row([ManageListingButtons::Back.to_button()]); - send_message(bot, target, response, Some(keyboard)).await?; + send_listing_details_message(bot, target, listing, Some(keyboard)).await?; + Ok(()) +} + +async fn send_listing_details_message( + bot: &Bot, + target: MessageTarget, + listing: PersistedListing, + keyboard: Option, +) -> BotResult { + let listing_type = Into::::into(&listing.fields); + let mut response_lines = vec![format!("🔍 {listing_type} Details")]; + response_lines.push("".to_string()); + + let draft = ListingDraft::from_persisted(listing); + for step in steps_for_listing_type(listing_type) { + let field_value = match (step.get_field_value)(&draft) { + Ok(value) => value.unwrap_or_else(|| "(none)".to_string()), + Err(_) => continue, + }; + response_lines.push(format!("{}: {}", step.field_name, field_value)); + } + send_message(bot, target, response_lines.join("\n"), keyboard).await?; Ok(()) } async fn handle_managing_listing_callback( - db_pool: SqlitePool, + listing_dao: ListingDAO, bot: Bot, dialogue: RootDialogue, callback_query: CallbackQuery, @@ -350,7 +375,8 @@ async fn handle_managing_listing_callback( match ManageListingButtons::try_from(data.as_str())? { ManageListingButtons::PreviewMessage => { - let listing = ListingDAO::find_by_id(&db_pool, listing_id) + let listing = listing_dao + .find_by_id(listing_id) .await? .ok_or(anyhow::anyhow!("Listing not found"))?; send_preview_listing_message(&bot, listing, from).await?; @@ -359,14 +385,14 @@ async fn handle_managing_listing_callback( unimplemented!("Forward listing not implemented"); } ManageListingButtons::Edit => { - let listing = get_listing_for_user(&db_pool, user, listing_id).await?; + let listing = get_listing_for_user(&listing_dao, user, listing_id).await?; let draft = ListingDraft::from_persisted(listing); enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?; } ManageListingButtons::Delete => { - ListingDAO::delete_listing(&db_pool, listing_id).await?; + listing_dao.delete_listing(listing_id).await?; enter_my_listings( - db_pool, + listing_dao, bot, dialogue, user, @@ -376,7 +402,7 @@ async fn handle_managing_listing_callback( .await?; } ManageListingButtons::Back => { - enter_my_listings(db_pool, bot, dialogue, user, target, None).await?; + enter_my_listings(listing_dao, bot, dialogue, user, target, None).await?; } } @@ -435,11 +461,11 @@ async fn send_preview_listing_message( } async fn get_listing_for_user( - db_pool: &SqlitePool, + listing_dao: &ListingDAO, user: PersistedUser, listing_id: ListingDbId, ) -> BotResult { - let listing = match ListingDAO::find_by_id(db_pool, listing_id).await? { + let listing = match listing_dao.find_by_id(listing_id).await? { Some(listing) => listing, None => { return Err(BotError::UserVisibleError("❌ Listing not found.".into())); diff --git a/src/commands/new_listing/callbacks.rs b/src/commands/new_listing/callbacks.rs index b3f57e7..bdd5d43 100644 --- a/src/commands/new_listing/callbacks.rs +++ b/src/commands/new_listing/callbacks.rs @@ -15,17 +15,18 @@ use crate::{ ui::enter_confirm_save_listing, }, }, - db::{user::PersistedUser, CurrencyType, ListingDuration, ListingType, MoneyAmount}, + db::{ + user::PersistedUser, CurrencyType, ListingDAO, ListingDuration, ListingType, MoneyAmount, + }, message_utils::*, BotResult, RootDialogue, }; use log::{error, info}; -use sqlx::SqlitePool; use teloxide::{types::CallbackQuery, Bot}; /// Handle callbacks during the listing type selection phase pub async fn handle_selecting_listing_type_callback( - db_pool: SqlitePool, + listing_dao: ListingDAO, bot: Bot, dialogue: RootDialogue, user: PersistedUser, @@ -36,7 +37,7 @@ pub async fn handle_selecting_listing_type_callback( info!("User {target:?} selected listing type: {data:?}"); if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) { - return enter_my_listings(db_pool, bot, dialogue, user, target, None).await; + return enter_my_listings(listing_dao, bot, dialogue, user, target, None).await; } // Parse the listing type from callback data diff --git a/src/commands/new_listing/handler_factory.rs b/src/commands/new_listing/handler_factory.rs index e86db88..b3ade88 100644 --- a/src/commands/new_listing/handler_factory.rs +++ b/src/commands/new_listing/handler_factory.rs @@ -1,12 +1,5 @@ use super::{callbacks::*, handlers::*, types::*}; -use crate::{ - case, - handler_utils::{ - callback_query_into_message_target, find_or_create_db_user_from_callback_query, - message_into_message_target, - }, - BotHandler, Command, DialogueRootState, -}; +use crate::{case, handle_error::with_error_handler, BotHandler, Command, DialogueRootState}; use teloxide::{dptree, prelude::*, types::Update}; // Create the dialogue handler tree for new listing wizard @@ -14,59 +7,56 @@ pub fn new_listing_handler() -> BotHandler { dptree::entry() .branch( Update::filter_message() - .map(message_into_message_target) .branch( dptree::entry() .filter_command::() .chain(case![Command::NewListing]) - .endpoint(handle_new_listing_command), + .endpoint(with_error_handler(handle_new_listing_command)), ) .branch( case![DialogueRootState::NewListing( NewListingState::AwaitingDraftField { field, draft } )] - .endpoint(handle_awaiting_draft_field_input), + .endpoint(with_error_handler(handle_awaiting_draft_field_input)), ) .branch( case![DialogueRootState::NewListing( NewListingState::EditingDraftField { field, draft } )] - .endpoint(handle_editing_field_input), + .endpoint(with_error_handler(handle_editing_field_input)), ), ) .branch( Update::filter_callback_query() - .filter_map(callback_query_into_message_target) - .filter_map_async(find_or_create_db_user_from_callback_query) .branch( case![DialogueRootState::NewListing( NewListingState::SelectingListingType )] - .endpoint(handle_selecting_listing_type_callback), + .endpoint(with_error_handler(handle_selecting_listing_type_callback)), ) .branch( case![DialogueRootState::NewListing( NewListingState::AwaitingDraftField { field, draft } )] - .endpoint(handle_awaiting_draft_field_callback), + .endpoint(with_error_handler(handle_awaiting_draft_field_callback)), ) .branch( case![DialogueRootState::NewListing( NewListingState::ViewingDraft(draft) )] - .endpoint(handle_viewing_draft_callback), + .endpoint(with_error_handler(handle_viewing_draft_callback)), ) .branch( case![DialogueRootState::NewListing( NewListingState::EditingDraft(draft) )] - .endpoint(handle_editing_draft_callback), + .endpoint(with_error_handler(handle_editing_draft_callback)), ) .branch( case![DialogueRootState::NewListing( NewListingState::EditingDraftField { field, draft } )] - .endpoint(handle_editing_draft_field_callback), + .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 0156a26..dca8efd 100644 --- a/src/commands/new_listing/handlers.rs +++ b/src/commands/new_listing/handlers.rs @@ -28,11 +28,10 @@ use crate::{ ListingDAO, }, message_utils::*, - BotResult, DialogueRootState, RootDialogue, + BotError, BotResult, DialogueRootState, RootDialogue, }; use anyhow::{anyhow, Context}; use log::info; -use sqlx::SqlitePool; use teloxide::{prelude::*, types::*, Bot}; /// Handle the /newlisting command - starts the dialogue @@ -80,8 +79,7 @@ pub async fn handle_awaiting_draft_field_input( match update_field_on_draft(field, &mut draft, msg.text()) { Ok(()) => (), Err(SetFieldError::ValidationFailed(e)) => { - send_message(&bot, target, e.clone(), None).await?; - return Ok(()); + return Err(BotError::user_visible(e)); } Err(SetFieldError::UnsupportedFieldForListingType) => { return Err(anyhow!("Cannot update field {field:?} for listing type").into()); @@ -121,8 +119,7 @@ pub async fn handle_editing_field_input( match update_field_on_draft(field, &mut draft, msg.text()) { Ok(()) => (), Err(SetFieldError::ValidationFailed(e)) => { - send_message(&bot, target, e.clone(), None).await?; - return Ok(()); + return Err(BotError::user_visible(e)); } Err(SetFieldError::UnsupportedFieldForListingType) => { return Err(anyhow!("Cannot update field {field:?} for listing type").into()); @@ -139,7 +136,7 @@ pub async fn handle_editing_field_input( /// Handle viewing draft confirmation callbacks pub async fn handle_viewing_draft_callback( - db_pool: SqlitePool, + listing_dao: ListingDAO, bot: Bot, dialogue: RootDialogue, draft: ListingDraft, @@ -152,8 +149,16 @@ pub async fn handle_viewing_draft_callback( match ConfirmationKeyboardButtons::try_from(data.as_str())? { ConfirmationKeyboardButtons::Create | ConfirmationKeyboardButtons::Save => { info!("User {target:?} confirmed listing creation"); - let success_message = save_listing(&db_pool, draft).await?; - enter_my_listings(db_pool, bot, dialogue, user, target, Some(success_message)).await?; + let success_message = save_listing(&listing_dao, draft).await?; + enter_my_listings( + listing_dao, + bot, + dialogue, + user, + target, + Some(success_message), + ) + .await?; } ConfirmationKeyboardButtons::Cancel => { info!("User {target:?} cancelled listing update"); @@ -272,28 +277,24 @@ pub async fn enter_edit_listing_draft( } /// Save the listing to the database -async fn save_listing(db_pool: &SqlitePool, draft: ListingDraft) -> BotResult { +async fn save_listing(listing_dao: &ListingDAO, draft: ListingDraft) -> BotResult { let (listing, success_message) = if let Some(fields) = draft.persisted { - let listing = ListingDAO::update_listing( - db_pool, - PersistedListing { + let listing = listing_dao + .update_listing(PersistedListing { persisted: fields, base: draft.base, fields: draft.fields, - }, - ) - .await?; + }) + .await?; (listing, "Listing updated!") } else { - let listing = ListingDAO::insert_listing( - db_pool, - NewListing { + let listing = listing_dao + .insert_listing(NewListing { persisted: (), base: draft.base, fields: draft.fields, - }, - ) - .await?; + }) + .await?; (listing, "Listing created!") }; diff --git a/src/commands/new_listing/mod.rs b/src/commands/new_listing/mod.rs index 67cb1a2..a552be4 100644 --- a/src/commands/new_listing/mod.rs +++ b/src/commands/new_listing/mod.rs @@ -22,7 +22,7 @@ pub mod messages; mod tests; mod types; mod ui; -mod validations; +pub mod validations; // Re-export the main handler for external use pub use handler_factory::new_listing_handler; diff --git a/src/commands/start.rs b/src/commands/start.rs index 557eb11..85d90f0 100644 --- a/src/commands/start.rs +++ b/src/commands/start.rs @@ -1,12 +1,14 @@ use anyhow::Context; use log::info; -use teloxide::{types::CallbackQuery, utils::command::BotCommands, Bot}; - -use sqlx::SqlitePool; +use teloxide::{ + types::{CallbackQuery, Update}, + utils::command::BotCommands, + Bot, +}; use crate::{ commands::my_listings::enter_my_listings, - db::user::PersistedUser, + db::{user::PersistedUser, ListingDAO}, keyboard_buttons, message_utils::{extract_callback_data, send_message, MessageTarget}, BotResult, Command, DialogueRootState, RootDialogue, @@ -26,7 +28,7 @@ keyboard_buttons! { } /// Get the main menu welcome message -pub fn get_main_menu_message() -> &'static str { +fn get_main_menu_message() -> &'static str { "🎯 Welcome to Pawctioneer Bot! 🎯\n\n\ This bot helps you participate in various types of auctions:\n\ • Standard auctions with anti-sniping protection\n\ @@ -36,7 +38,13 @@ pub fn get_main_menu_message() -> &'static str { Choose an option below to get started! 🚀" } -pub async fn handle_start(bot: Bot, dialogue: RootDialogue, target: MessageTarget) -> BotResult { +pub async fn handle_start( + bot: Bot, + dialogue: RootDialogue, + target: MessageTarget, + update: Update, +) -> BotResult { + info!("got start message: {update:?}"); enter_main_menu(bot, dialogue, target).await?; Ok(()) } @@ -60,7 +68,7 @@ pub async fn enter_main_menu(bot: Bot, dialogue: RootDialogue, target: MessageTa } pub async fn handle_main_menu_callback( - db_pool: SqlitePool, + listing_dao: ListingDAO, bot: Bot, dialogue: RootDialogue, user: PersistedUser, @@ -74,7 +82,7 @@ pub async fn handle_main_menu_callback( match button { MainMenuButtons::MyListings => { // Call show_listings_for_user directly - enter_my_listings(db_pool, bot, dialogue, user, target, None).await?; + enter_my_listings(listing_dao, bot, dialogue, user, target, None).await?; } MainMenuButtons::MyBids => { send_message( diff --git a/src/db/dao/listing_dao.rs b/src/db/dao/listing_dao.rs index 2aff9e0..7705a72 100644 --- a/src/db/dao/listing_dao.rs +++ b/src/db/dao/listing_dao.rs @@ -19,7 +19,8 @@ use crate::db::{ }; /// Data Access Object for Listing operations -pub struct ListingDAO; +#[derive(Clone)] +pub struct ListingDAO(SqlitePool); const LISTING_RETURN_FIELDS: &[&str] = &[ "id", @@ -40,11 +41,12 @@ const LISTING_RETURN_FIELDS: &[&str] = &[ ]; impl ListingDAO { + pub fn new(pool: SqlitePool) -> Self { + Self(pool) + } + /// Insert a new listing into the database - pub async fn insert_listing( - pool: &SqlitePool, - listing: NewListing, - ) -> Result { + pub async fn insert_listing(&self, listing: NewListing) -> Result { let now = Utc::now(); let binds = binds_for_listing(&listing) @@ -66,15 +68,12 @@ impl ListingDAO { let row = binds .bind_to_query(sqlx::query(&query_str)) - .fetch_one(pool) + .fetch_one(&self.0) .await?; Ok(FromRow::from_row(&row)?) } - pub async fn update_listing( - pool: &SqlitePool, - listing: PersistedListing, - ) -> Result { + pub async fn update_listing(&self, listing: PersistedListing) -> Result { let now = Utc::now(); let binds = binds_for_listing(&listing).push("updated_at", &now); @@ -97,47 +96,41 @@ impl ListingDAO { .bind_to_query(sqlx::query(&query_str)) .bind(listing.persisted.id) .bind(listing.base.seller_id) - .fetch_one(pool) + .fetch_one(&self.0) .await?; Ok(FromRow::from_row(&row)?) } /// Find a listing by its ID - pub async fn find_by_id( - pool: &SqlitePool, - listing_id: ListingDbId, - ) -> Result> { + pub async fn find_by_id(&self, listing_id: ListingDbId) -> Result> { let result = sqlx::query_as(&format!( "SELECT {} FROM listings WHERE id = ?", LISTING_RETURN_FIELDS.join(", ") )) .bind(listing_id) - .fetch_optional(pool) + .fetch_optional(&self.0) .await?; Ok(result) } /// Find all listings by a seller - pub async fn find_by_seller( - pool: &SqlitePool, - seller_id: UserDbId, - ) -> Result> { + pub async fn find_by_seller(&self, seller_id: UserDbId) -> Result> { let rows = sqlx::query_as(&format!( "SELECT {} FROM listings WHERE seller_id = ? ORDER BY created_at DESC", LISTING_RETURN_FIELDS.join(", ") )) .bind(seller_id) - .fetch_all(pool) + .fetch_all(&self.0) .await?; Ok(rows) } /// Delete a listing - pub async fn delete_listing(pool: &SqlitePool, listing_id: ListingDbId) -> Result<()> { + pub async fn delete_listing(&self, listing_id: ListingDbId) -> Result<()> { sqlx::query("DELETE FROM listings WHERE id = ?") .bind(listing_id) - .execute(pool) + .execute(&self.0) .await?; Ok(()) diff --git a/src/db/dao/user_dao.rs b/src/db/dao/user_dao.rs index 77dd32e..cea754c 100644 --- a/src/db/dao/user_dao.rs +++ b/src/db/dao/user_dao.rs @@ -14,7 +14,8 @@ use crate::db::{ }; /// Data Access Object for User operations -pub struct UserDAO; +#[derive(Clone)] +pub struct UserDAO(SqlitePool); const USER_RETURN_FIELDS: &[&str] = &[ "id", @@ -29,8 +30,12 @@ const USER_RETURN_FIELDS: &[&str] = &[ #[allow(unused)] impl UserDAO { + pub fn new(pool: SqlitePool) -> Self { + Self(pool) + } + /// Insert a new user into the database - pub async fn insert_user(pool: &SqlitePool, new_user: &NewUser) -> Result { + pub async fn insert_user(&self, new_user: &NewUser) -> Result { let binds = BindFields::default() .push("telegram_id", &new_user.telegram_id) .push("first_name", &new_user.first_name) @@ -48,13 +53,13 @@ impl UserDAO { USER_RETURN_FIELDS.join(", ") ); let query = sqlx::query(&query_str); - let row = binds.bind_to_query(query).fetch_one(pool).await?; + let row = binds.bind_to_query(query).fetch_one(&self.0).await?; Ok(FromRow::from_row(&row)?) } /// Find a user by their ID - pub async fn find_by_id(pool: &SqlitePool, user_id: UserDbId) -> Result> { + pub async fn find_by_id(&self, user_id: UserDbId) -> Result> { Ok(sqlx::query_as::<_, PersistedUser>( r#" SELECT id, telegram_id, first_name, last_name, username, is_banned, created_at, updated_at @@ -63,13 +68,13 @@ impl UserDAO { "#, ) .bind(user_id) - .fetch_optional(pool) + .fetch_optional(&self.0) .await?) } /// Find a user by their Telegram ID pub async fn find_by_telegram_id( - pool: &SqlitePool, + &self, telegram_id: impl Into, ) -> Result> { let telegram_id = telegram_id.into(); @@ -81,12 +86,12 @@ impl UserDAO { "#, ) .bind(telegram_id) - .fetch_optional(pool) + .fetch_optional(&self.0) .await?) } pub async fn find_or_create_by_telegram_user( - pool: &SqlitePool, + &self, user: teloxide::types::User, ) -> Result { let binds = BindFields::default() @@ -112,7 +117,7 @@ impl UserDAO { let row = binds .bind_to_query(sqlx::query(&query_str)) - .fetch_one(pool) + .fetch_one(&self.0) .await?; let user = FromRow::from_row(&row)?; @@ -121,7 +126,7 @@ impl UserDAO { } /// Update a user's information - pub async fn update_user(pool: &SqlitePool, user: &PersistedUser) -> Result { + pub async fn update_user(&self, user: &PersistedUser) -> Result { let updated_user = sqlx::query_as::<_, PersistedUser>( r#" UPDATE users @@ -135,32 +140,28 @@ impl UserDAO { .bind(&user.last_name) .bind(user.is_banned) // sqlx automatically converts bool to INTEGER for SQLite .bind(user.persisted.id) - .fetch_one(pool) + .fetch_one(&self.0) .await?; Ok(updated_user) } /// Set a user's ban status - pub async fn set_ban_status( - pool: &SqlitePool, - user_id: UserDbId, - is_banned: bool, - ) -> Result<()> { + pub async fn set_ban_status(&self, user_id: UserDbId, is_banned: bool) -> Result<()> { sqlx::query("UPDATE users SET is_banned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") .bind(is_banned) // sqlx automatically converts bool to INTEGER for SQLite .bind(user_id) - .execute(pool) + .execute(&self.0) .await?; Ok(()) } /// Delete a user (soft delete by setting is_banned = true might be better in production) - pub async fn delete_user(pool: &SqlitePool, user_id: UserDbId) -> Result<()> { + pub async fn delete_user(&self, user_id: UserDbId) -> Result<()> { sqlx::query("DELETE FROM users WHERE id = ?") .bind(user_id) - .execute(pool) + .execute(&self.0) .await?; Ok(()) @@ -194,7 +195,7 @@ mod tests { use teloxide::types::UserId; /// Create test database for UserDAO tests - async fn create_test_pool() -> SqlitePool { + async fn create_test_dao() -> UserDAO { let pool = SqlitePool::connect("sqlite::memory:") .await .expect("Failed to create in-memory database"); @@ -205,12 +206,12 @@ mod tests { .await .expect("Failed to run database migrations"); - pool + UserDAO::new(pool) } #[tokio::test] async fn test_insert_and_find_user() { - let pool = create_test_pool().await; + let dao = create_test_dao().await; let new_user = NewUser { persisted: (), @@ -222,7 +223,8 @@ mod tests { }; // Insert user - let inserted_user = UserDAO::insert_user(&pool, &new_user) + let inserted_user = dao + .insert_user(&new_user) .await .expect("Failed to insert user"); @@ -232,7 +234,8 @@ mod tests { assert!(!inserted_user.is_banned); // Find by ID - let found_user = UserDAO::find_by_id(&pool, inserted_user.persisted.id) + let found_user = dao + .find_by_id(inserted_user.persisted.id) .await .expect("Failed to find user by id") .expect("User should be found"); @@ -241,7 +244,8 @@ mod tests { assert_eq!(found_user.telegram_id, inserted_user.telegram_id); // Find by telegram ID - let found_by_telegram = UserDAO::find_by_telegram_id(&pool, UserId(12345)) + let found_by_telegram = dao + .find_by_telegram_id(UserId(12345)) .await .expect("Failed to find user by telegram_id") .expect("User should be found"); @@ -252,12 +256,11 @@ mod tests { #[tokio::test] async fn test_get_or_create_user() { - let pool = create_test_pool().await; + let dao = create_test_dao().await; // First call should create the user - let user1 = UserDAO::find_or_create_by_telegram_user( - &pool, - teloxide::types::User { + let user1 = dao + .find_or_create_by_telegram_user(teloxide::types::User { id: UserId(67890), is_bot: false, first_name: "New User".to_string(), @@ -266,18 +269,16 @@ mod tests { language_code: None, is_premium: false, added_to_attachment_menu: false, - }, - ) - .await - .expect("Failed to get or create user"); + }) + .await + .expect("Failed to get or create user"); assert_eq!(user1.telegram_id, 67890.into()); assert_eq!(user1.username, Some("newuser".to_string())); // Second call should return the same user - let user2 = UserDAO::find_or_create_by_telegram_user( - &pool, - teloxide::types::User { + let user2 = dao + .find_or_create_by_telegram_user(teloxide::types::User { id: UserId(67890), is_bot: false, first_name: "New User".to_string(), @@ -286,10 +287,9 @@ mod tests { language_code: None, is_premium: false, added_to_attachment_menu: false, - }, - ) - .await - .expect("Failed to get or create user"); + }) + .await + .expect("Failed to get or create user"); assert_eq!(user1.persisted.id, user2.persisted.id); assert_eq!(user2.username, Some("newuser".to_string())); // Original username preserved @@ -297,7 +297,7 @@ mod tests { #[tokio::test] async fn test_update_user() { - let pool = create_test_pool().await; + let pool = create_test_dao().await; let new_user = NewUser { persisted: (), @@ -328,7 +328,7 @@ mod tests { #[tokio::test] async fn test_delete_user() { - let pool = create_test_pool().await; + let pool = create_test_dao().await; let new_user = NewUser { persisted: (), @@ -358,7 +358,7 @@ mod tests { #[tokio::test] async fn test_find_nonexistent_user() { - let pool = create_test_pool().await; + let pool = create_test_dao().await; // Try to find a user that doesn't exist let not_found = UserDAO::find_by_id(&pool, UserDbId::new(99999)) @@ -390,7 +390,7 @@ mod tests { #[case] first_name: Option<&str>, #[case] last_name: Option<&str>, ) { - let pool = create_test_pool().await; + let pool = create_test_dao().await; let user_id = UserId(12345); let initial = teloxide::types::User { @@ -440,7 +440,7 @@ mod tests { #[tokio::test] async fn test_multiple_users_separate() { - let pool = create_test_pool().await; + let pool = create_test_dao().await; let user1 = teloxide::types::User { id: UserId(111), @@ -477,7 +477,7 @@ mod tests { #[tokio::test] async fn test_upsert_preserves_id_and_timestamps() { - let pool = create_test_pool().await; + let pool = create_test_dao().await; let user = teloxide::types::User { id: UserId(333), diff --git a/src/db/models/listing.rs b/src/db/models/listing.rs index fe152d9..5c40650 100644 --- a/src/db/models/listing.rs +++ b/src/db/models/listing.rs @@ -36,14 +36,14 @@ pub struct Listing { pub type ListingBaseFields<'a> = (&'a ListingBase, &'a ListingFields); pub type ListingBaseFieldsMut<'a> = (&'a mut ListingBase, &'a mut ListingFields); -impl<'a, P: Debug + Clone> Into> for &'a Listing

{ - fn into(self) -> ListingBaseFields<'a> { - (&self.base, &self.fields) +impl<'a, P: Debug + Clone> From<&'a Listing

> for ListingBaseFields<'a> { + fn from(value: &'a Listing

) -> Self { + (&value.base, &value.fields) } } -impl<'a, P: Debug + Clone> Into> for &'a mut Listing

{ - fn into(self) -> ListingBaseFieldsMut<'a> { - (&mut self.base, &mut self.fields) +impl<'a, P: Debug + Clone> From<&'a mut Listing

> for ListingBaseFieldsMut<'a> { + fn from(value: &'a mut Listing

) -> Self { + (&mut value.base, &mut value.fields) } } @@ -135,40 +135,17 @@ impl From<&ListingFields> for ListingType { #[cfg(test)] mod tests { use super::*; - use crate::db::{ListingDAO, TelegramUserDbId}; + use crate::db::{TelegramUserDbId, UserDAO}; use chrono::Duration; use rstest::rstest; - use sqlx::SqlitePool; - - /// Test utilities for creating an in-memory database with migrations - async fn create_test_pool() -> SqlitePool { - // Create an in-memory SQLite database for testing - let pool = SqlitePool::connect("sqlite::memory:") - .await - .expect("Failed to create in-memory database"); - - // Run the migration - apply_test_migrations(&pool).await; - - pool - } - - /// Apply the database migrations for testing - async fn apply_test_migrations(pool: &SqlitePool) { - // Run the actual migrations from the migrations directory - sqlx::migrate!("./migrations") - .run(pool) - .await - .expect("Failed to run database migrations"); - } /// Create a test user using UserDAO and return their ID async fn create_test_user( - pool: &SqlitePool, + user_dao: &UserDAO, telegram_id: TelegramUserDbId, username: Option<&str>, ) -> UserDbId { - use crate::db::{models::user::NewUser, UserDAO}; + use crate::db::models::user::NewUser; let new_user = NewUser { persisted: (), @@ -179,7 +156,8 @@ mod tests { is_banned: false, }; - let user = UserDAO::insert_user(pool, &new_user) + let user = user_dao + .insert_user(&new_user) .await .expect("Failed to create test user"); user.persisted.id @@ -208,8 +186,12 @@ mod tests { #[case(ListingFields::FixedPriceListing(FixedPriceListingFields { buy_now_price: MoneyAmount::from_str("100.00").unwrap(), slots_available: 10 }))] #[tokio::test] async fn test_blind_auction_crud(#[case] fields: ListingFields) { + use crate::{db::ListingDAO, test_utils::create_test_pool}; + let pool = create_test_pool().await; - let seller_id = create_test_user(&pool, 99999.into(), Some("testuser")).await; + let user_dao = UserDAO::new(pool.clone()); + let listing_dao = ListingDAO::new(pool.clone()); + let seller_id = create_test_user(&user_dao, 99999.into(), Some("testuser")).await; let new_listing = build_base_listing( seller_id, "Test Auction", @@ -219,14 +201,16 @@ mod tests { .with_fields(fields); // Insert using DAO - let created_listing = ListingDAO::insert_listing(&pool, new_listing.clone()) + let created_listing = listing_dao + .insert_listing(new_listing.clone()) .await .expect("Failed to insert listing"); assert_eq!(created_listing.base, new_listing.base); assert_eq!(created_listing.fields, new_listing.fields); - let read_listing = ListingDAO::find_by_id(&pool, created_listing.persisted.id) + let read_listing = listing_dao + .find_by_id(created_listing.persisted.id) .await .expect("Failed to find listing") .expect("Listing should exist"); diff --git a/src/handle_error.rs b/src/handle_error.rs index a351659..adac690 100644 --- a/src/handle_error.rs +++ b/src/handle_error.rs @@ -7,11 +7,10 @@ use std::{future::Future, pin::Pin}; use teloxide::Bot; pub async fn handle_error(bot: Bot, target: MessageTarget, error: BotError) -> BotResult { - log::error!("Error in handler: {error}"); + log::error!("Error in handler: {error:?}"); match error { BotError::UserVisibleError(message) => send_message(&bot, target, message, None).await?, - BotError::InternalError(error) => { - log::error!("Internal error: {error}"); + BotError::InternalError(_) => { send_message( &bot, target, diff --git a/src/handler_utils.rs b/src/handler_utils.rs index e6e1c15..4736ad7 100644 --- a/src/handler_utils.rs +++ b/src/handler_utils.rs @@ -1,32 +1,24 @@ -use sqlx::SqlitePool; -use teloxide::types::{CallbackQuery, Message}; +use log::warn; +use teloxide::types::Update; use crate::{ - db::{user::PersistedUser, UserDAO}, + db::{listing::PersistedListing, user::PersistedUser, ListingDAO, ListingDbId, UserDAO}, message_utils::MessageTarget, }; -pub async fn find_or_create_db_user_from_message( - db_pool: SqlitePool, - message: Message, +pub async fn find_or_create_db_user_from_update( + user_dao: UserDAO, + update: Update, ) -> Option { - let user = message.from?; - find_or_create_db_user(db_pool, user).await -} - -pub async fn find_or_create_db_user_from_callback_query( - db_pool: SqlitePool, - callback_query: CallbackQuery, -) -> Option { - let user = callback_query.from; - find_or_create_db_user(db_pool, user).await + let user = update.from()?.clone(); + find_or_create_db_user(user_dao, user).await } pub async fn find_or_create_db_user( - db_pool: SqlitePool, + user_dao: UserDAO, user: teloxide::types::User, ) -> Option { - match UserDAO::find_or_create_by_telegram_user(&db_pool, user).await { + match user_dao.find_or_create_by_telegram_user(user).await { Ok(user) => { log::debug!("loaded user from db: {user:?}"); Some(user) @@ -38,10 +30,31 @@ pub async fn find_or_create_db_user( } } -pub fn message_into_message_target(message: Message) -> MessageTarget { - message.chat.id.into() +pub async fn find_listing_by_id( + listing_dao: ListingDAO, + listing_id: ListingDbId, +) -> Option { + listing_dao.find_by_id(listing_id).await.unwrap_or(None) } -pub fn callback_query_into_message_target(callback_query: CallbackQuery) -> Option { - (&callback_query).try_into().ok() +pub fn update_into_message_target(update: Update) -> Option { + match update.kind { + teloxide::types::UpdateKind::Message(message) => Some(message.chat.into()), + teloxide::types::UpdateKind::InlineQuery(inline_query) => Some(inline_query.from.into()), + teloxide::types::UpdateKind::CallbackQuery(callback_query) => { + (&callback_query).try_into().ok() + } + _ => { + warn!("Received unexpected update kind: {update:?}"); + None + } + } } + +// pub fn message_into_message_target(message: Message) -> MessageTarget { +// message.chat.id.into() +// } + +// pub fn callback_query_into_message_target(callback_query: CallbackQuery) -> Option { +// (&callback_query).try_into().ok() +// } diff --git a/src/main.rs b/src/main.rs index 3dd5e3b..f8ab6b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod bidding; mod bot_result; mod commands; mod config; @@ -8,20 +9,20 @@ mod handler_utils; mod keyboard_utils; mod message_utils; mod sqlite_storage; +mod start_command_data; #[cfg(test)] mod test_utils; mod wrap_endpoint; -use crate::handle_error::with_error_handler; -use crate::handler_utils::{callback_query_into_message_target, message_into_message_target}; -use crate::sqlite_storage::SqliteStorage; -use crate::{ - commands::{ - my_listings::{my_listings_handler, my_listings_inline_handler, MyListingsState}, - new_listing::{new_listing_handler, NewListingState}, - }, - handler_utils::find_or_create_db_user_from_callback_query, +use crate::bidding::{bidding_handler, BiddingState}; +use crate::commands::{ + my_listings::{my_listings_handler, my_listings_inline_handler, MyListingsState}, + new_listing::{new_listing_handler, NewListingState}, }; +use crate::db::{ListingDAO, UserDAO}; +use crate::handle_error::with_error_handler; +use crate::handler_utils::{find_or_create_db_user_from_update, update_into_message_target}; +use crate::sqlite_storage::SqliteStorage; use anyhow::Result; pub use bot_result::*; use commands::*; @@ -73,6 +74,7 @@ enum DialogueRootState { MainMenu, NewListing(NewListingState), MyListings(MyListingsState), + Bidding(BiddingState), } type RootDialogue = Dialogue>; @@ -97,24 +99,23 @@ async fn main() -> Result<()> { 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() - .filter_map(callback_query_into_message_target) - .branch( - dptree::case![DialogueRootState::MainMenu] - .filter_map_async(find_or_create_db_user_from_callback_query) - .endpoint(with_error_handler(handle_main_menu_callback)), - ), + Update::filter_callback_query().branch( + dptree::case![DialogueRootState::MainMenu] + .endpoint(with_error_handler(handle_main_menu_callback)), + ), ) .branch( Update::filter_message() - .map(message_into_message_target) .filter_command::() .branch( dptree::case![Command::Start] @@ -134,13 +135,13 @@ async fn main() -> Result<()> { ), ), ) - .branch( - Update::filter_message() - .map(message_into_message_target) - .endpoint(with_error_handler(unknown_message_handler)), - ), + .branch(Update::filter_message().endpoint(with_error_handler(unknown_message_handler))), ) - .dependencies(dptree::deps![db_pool, dialog_storage]) + .dependencies(dptree::deps![ + dialog_storage, + ListingDAO::new(db_pool.clone()), + UserDAO::new(db_pool.clone()) + ]) .enable_ctrlc_handler() .worker_queue_size(1) .build() diff --git a/src/message_utils.rs b/src/message_utils.rs index 39e6a76..fa58f5e 100644 --- a/src/message_utils.rs +++ b/src/message_utils.rs @@ -57,6 +57,15 @@ pub struct MessageTarget { pub message_id: Option, } +impl MessageTarget { + pub fn only_chat_id(self) -> MessageTarget { + MessageTarget { + chat_id: self.chat_id, + message_id: None, + } + } +} + impl From for MessageTarget { fn from(val: ChatId) -> Self { MessageTarget { @@ -120,6 +129,7 @@ pub async fn send_message( 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); @@ -128,6 +138,7 @@ pub async fn send_message( } 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); diff --git a/src/start_command_data.rs b/src/start_command_data.rs new file mode 100644 index 0000000..47d3525 --- /dev/null +++ b/src/start_command_data.rs @@ -0,0 +1,75 @@ +use base64::{prelude::BASE64_URL_SAFE, Engine}; +use log::info; +use teloxide::types::{MediaKind, MessageKind, UpdateKind}; + +use crate::db::ListingDbId; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StartCommandData { + PlaceBidOnListing(ListingDbId), + ViewListingDetails(ListingDbId), +} + +impl From for String { + fn from(value: StartCommandData) -> Self { + match value { + StartCommandData::PlaceBidOnListing(listing_id) => { + format!("place_bid_on_listing:{listing_id}") + } + StartCommandData::ViewListingDetails(listing_id) => { + format!("view_listing_details:{listing_id}") + } + } + } +} + +impl StartCommandData { + pub fn get_from_update(update: teloxide::types::Update) -> Option { + let message = match update.kind { + UpdateKind::Message(message) => Some(message), + _ => None, + }?; + let message = match message.kind { + MessageKind::Common(message) => Some(message), + _ => None, + }?; + let message = match message.media_kind { + MediaKind::Text(media_text) => Some(media_text), + _ => None, + }?; + let message = message.text.strip_prefix("/start ")?; + let decoded = BASE64_URL_SAFE.decode(message).ok()?; + let decoded = String::from_utf8(decoded).ok()?; + let parts = decoded.split(":").map(|s| s.trim()).collect::>(); + info!("command parts: {parts:?}"); + match parts.first()?.trim() { + "place_bid_on_listing" => Some(StartCommandData::PlaceBidOnListing(ListingDbId::new( + parts.get(1)?.parse::().ok()?, + ))), + "view_listing_details" => Some(StartCommandData::ViewListingDetails(ListingDbId::new( + parts.get(1)?.parse::().ok()?, + ))), + _ => None, + } + } + + pub fn get_place_bid_on_listing_start_command( + command: StartCommandData, + ) -> Option { + if let StartCommandData::PlaceBidOnListing(listing_id) = command { + Some(listing_id) + } else { + None + } + } + + pub fn get_view_listing_details_start_command( + command: StartCommandData, + ) -> Option { + if let StartCommandData::ViewListingDetails(listing_id) = command { + Some(listing_id) + } else { + None + } + } +} diff --git a/src/test_utils.rs b/src/test_utils.rs index 3fc2cd9..792feef 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -1,5 +1,7 @@ //! Test utilities including timestamp comparison macros +use sqlx::SqlitePool; + /// Assert that two timestamps are approximately equal within a given epsilon tolerance. /// /// This macro is useful for testing timestamps that may have small variations due to @@ -87,6 +89,20 @@ macro_rules! assert_timestamps_approx_eq_default { }; } +pub async fn create_test_pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:") + .await + .expect("Failed to create in-memory database"); + + // Run migrations + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("Failed to run database migrations"); + + pool +} + #[cfg(test)] mod tests { use chrono::{Duration, Utc}; diff --git a/src/wrap_endpoint.rs b/src/wrap_endpoint.rs index 00cbf21..3058b50 100644 --- a/src/wrap_endpoint.rs +++ b/src/wrap_endpoint.rs @@ -75,6 +75,7 @@ generate_wrapped!([T1, T2, T3], []); generate_wrapped!([T1, T2, T3, T4], []); generate_wrapped!([T1, T2, T3, T4, T5], []); generate_wrapped!([T1, T2, T3, T4, T5, T6], []); +generate_wrapped!([T1, T2, T3, T4, T5, T6, T7], []); generate_wrapped!([], [E1]); generate_wrapped!([T1], [E1]); @@ -83,6 +84,7 @@ generate_wrapped!([T1, T2, T3], [E1]); generate_wrapped!([T1, T2, T3, T4], [E1]); generate_wrapped!([T1, T2, T3, T4, T5], [E1]); generate_wrapped!([T1, T2, T3, T4, T5, T6], [E1]); +generate_wrapped!([T1, T2, T3, T4, T5, T6, T7], [E1]); generate_wrapped!([], [E1, E2]); generate_wrapped!([T1], [E1, E2]); @@ -91,6 +93,7 @@ generate_wrapped!([T1, T2, T3], [E1, E2]); generate_wrapped!([T1, T2, T3, T4], [E1, E2]); generate_wrapped!([T1, T2, T3, T4, T5], [E1, E2]); generate_wrapped!([T1, T2, T3, T4, T5, T6], [E1, E2]); +generate_wrapped!([T1, T2, T3, T4, T5, T6, T7], [E1, E2]); pub fn wrap_endpoint( fn_base: FnBase,