From da7e59fe0f5d6f8e729d3f9e2ce4e82c20be4d3d Mon Sep 17 00:00:00 2001 From: Dylan Knutson Date: Fri, 5 Sep 2025 03:50:09 +0000 Subject: [PATCH] create App struct --- src/bidding/mod.rs | 95 ++++++----- src/commands/help.rs | 13 +- src/commands/my_bids.rs | 11 +- src/commands/my_listings/mod.rs | 151 ++++++++--------- src/commands/new_listing/callbacks.rs | 107 ++++++------ src/commands/new_listing/handlers.rs | 81 +++++----- src/commands/new_listing/ui.rs | 13 +- src/commands/settings.rs | 10 +- src/commands/start.rs | 72 ++++----- src/db/dao/bid_dao.rs | 188 ++++++++++++++++++++++ src/db/dao/listing_dao.rs | 47 ++---- src/db/dao/mod.rs | 24 ++- src/db/dao/user_dao.rs | 31 +--- src/db/models/bid.rs | 57 ++++--- src/db/models/listing.rs | 7 - src/db/types/db_id.rs | 93 +++++++++++ src/db/types/db_id/listing_db_id.rs | 71 -------- src/db/types/db_id/mod.rs | 7 - src/db/types/db_id/telegram_user_db_id.rs | 78 --------- src/db/types/db_id/user_db_id.rs | 71 -------- src/dptree_utils.rs | 63 ++++++++ src/handle_error.rs | 10 +- src/main.rs | 22 ++- src/message_utils.rs | 22 ++- src/wrap_endpoint.rs | 52 +++--- 25 files changed, 769 insertions(+), 627 deletions(-) create mode 100644 src/db/dao/bid_dao.rs create mode 100644 src/db/types/db_id.rs delete mode 100644 src/db/types/db_id/listing_db_id.rs delete mode 100644 src/db/types/db_id/mod.rs delete mode 100644 src/db/types/db_id/telegram_user_db_id.rs delete mode 100644 src/db/types/db_id/user_db_id.rs diff --git a/src/bidding/mod.rs b/src/bidding/mod.rs index 025e4c8..f331c12 100644 --- a/src/bidding/mod.rs +++ b/src/bidding/mod.rs @@ -4,15 +4,17 @@ use crate::{ case, commands::new_listing::validations::{validate_price, SetFieldError}, db::{ + bid::NewBid, listing::{ListingFields, PersistedListing}, user::PersistedUser, - ListingDbId, MoneyAmount, UserDAO, + BidDAO, ListingDbId, MoneyAmount, UserDAO, }, + dptree_utils::MapTwo, handle_error::with_error_handler, handler_utils::find_listing_by_id, - message_utils::{send_message, MessageTarget}, + message_utils::{MessageTarget, SendHtmlMessage}, start_command_data::StartCommandData, - BotError, BotHandler, BotResult, DialogueRootState, RootDialogue, + App, BotError, BotHandler, BotResult, DialogueRootState, RootDialogue, }; use anyhow::{anyhow, Context}; use log::info; @@ -20,7 +22,6 @@ use serde::{Deserialize, Serialize}; use teloxide::{ dispatching::UpdateFilterExt, types::{CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, Update}, - Bot, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -40,8 +41,11 @@ pub fn bidding_handler() -> BotHandler { 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( + dptree::entry() + .filter_map_async(find_listing_by_id) + .endpoint(with_error_handler(handle_place_bid_on_listing)), + ), ) .branch( Update::filter_message() @@ -56,11 +60,12 @@ pub fn bidding_handler() -> BotHandler { .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 - }, - ) + .map2(|(listing_id, bid_amount): (ListingDbId, MoneyAmount)| { + (listing_id, bid_amount) + }) + .filter_map_async(async |listing_dao, listing_id| { + find_listing_by_id(listing_dao, listing_id).await + }) .endpoint(with_error_handler( handle_awaiting_confirm_bid_amount_callback, )), @@ -68,7 +73,7 @@ pub fn bidding_handler() -> BotHandler { } async fn handle_place_bid_on_listing( - bot: Bot, + app: App, user_dao: UserDAO, target: MessageTarget, user: PersistedUser, @@ -110,13 +115,15 @@ async fn handle_place_bid_on_listing( let keyboard = InlineKeyboardMarkup::default() .append_row([InlineKeyboardButton::callback("Bid $1", "cancel")]); - send_message(&bot, target, response_lines.join("\n"), Some(keyboard)).await?; + app.bot + .send_html_message(target, response_lines.join("\n"), Some(keyboard)) + .await?; Ok(()) } async fn handle_awaiting_bid_amount_input( - bot: Bot, + app: App, listing: PersistedListing, target: MessageTarget, dialogue: RootDialogue, @@ -136,19 +143,20 @@ async fn handle_awaiting_bid_amount_input( } }; - 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?; + let bid_amount_str = format!("{}{}", listing.base.currency_type.symbol(), bid_amount); + app.bot + .send_html_message( + target, + format!("Confirm bid amount: {bid_amount_str} - this cannot be undone!"), + Some(InlineKeyboardMarkup::default().append_row([ + InlineKeyboardButton::callback( + format!("Confirm bid amount: {bid_amount_str}"), + "confirm_bid", + ), + InlineKeyboardButton::callback("Cancel", "cancel_bid"), + ])), + ) + .await?; dialogue .update(BiddingState::AwaitingConfirmBidAmount( @@ -162,9 +170,11 @@ async fn handle_awaiting_bid_amount_input( } async fn handle_awaiting_confirm_bid_amount_callback( - bot: Bot, + app: App, listing: PersistedListing, - (_, bid_amount): (ListingDbId, MoneyAmount), + user: PersistedUser, + bid_dao: BidDAO, + bid_amount: MoneyAmount, target: MessageTarget, dialogue: RootDialogue, callback_query: CallbackQuery, @@ -178,7 +188,9 @@ async fn handle_awaiting_confirm_bid_amount_callback( "confirm_bid" => bid_amount, "cancel_bid" => { dialogue.exit().await.context("failed to exit dialogue")?; - send_message(&bot, target, "Bid cancelled", None).await?; + app.bot + .send_html_message(target, "Bid cancelled", None) + .await?; return Ok(()); } _ => { @@ -188,20 +200,19 @@ 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?; + 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?; + let bid_amount_str = format!("{}{}", listing.base.currency_type.symbol(), bid_amount); + app.bot + .send_html_message( + target.only_chat_id(), + format!("Bid placed for {bid_amount_str} on {}", listing.base.title), + None, + ) + .await?; // TODO - keyboard with buttons to: // - be notified if they are outbid diff --git a/src/commands/help.rs b/src/commands/help.rs index 0f9455a..7dd08d7 100644 --- a/src/commands/help.rs +++ b/src/commands/help.rs @@ -1,10 +1,7 @@ -use crate::{ - message_utils::{send_message, MessageTarget}, - BotResult, Command, -}; -use teloxide::{utils::command::BotCommands, Bot}; +use crate::{message_utils::{MessageTarget, SendHtmlMessage}, App, BotResult, Command}; +use teloxide::utils::command::BotCommands; -pub async fn handle_help(bot: Bot, target: MessageTarget) -> BotResult { +pub async fn handle_help(app: App, target: MessageTarget) -> BotResult { let help_message = format!( "📋 Available Commands:\n\n{}\n\n\ 📧 Support: Contact @admin for help\n\ @@ -12,6 +9,8 @@ pub async fn handle_help(bot: Bot, target: MessageTarget) -> BotResult { Command::descriptions() ); - send_message(&bot, target, help_message, None).await?; + app.bot + .send_html_message(target, help_message, None) + .await?; Ok(()) } diff --git a/src/commands/my_bids.rs b/src/commands/my_bids.rs index 39d08d3..df370db 100644 --- a/src/commands/my_bids.rs +++ b/src/commands/my_bids.rs @@ -1,11 +1,8 @@ -use crate::{ - message_utils::{send_message, MessageTarget}, - BotResult, -}; +use crate::{message_utils::{MessageTarget, SendHtmlMessage}, App, BotResult}; use log::info; -use teloxide::{types::Message, Bot}; +use teloxide::types::Message; -pub async fn handle_my_bids(bot: Bot, msg: Message, target: MessageTarget) -> BotResult { +pub async fn handle_my_bids(app: App, msg: Message, target: MessageTarget) -> BotResult { let response = "🎯 My Bids (Coming Soon)\n\n\ Here you'll be able to view:\n\ • Your active bids\n\ @@ -20,6 +17,6 @@ pub async fn handle_my_bids(bot: Bot, msg: Message, target: MessageTarget) -> Bo msg.chat.id ); - send_message(&bot, target, response, None).await?; + app.bot.send_html_message(target, response, None).await?; Ok(()) } diff --git a/src/commands/my_listings/mod.rs b/src/commands/my_listings/mod.rs index 1e4ccde..fd368ea 100644 --- a/src/commands/my_listings/mod.rs +++ b/src/commands/my_listings/mod.rs @@ -13,13 +13,13 @@ use crate::{ db::{ listing::{ListingFields, PersistedListing}, user::PersistedUser, - ListingDAO, ListingDbId, ListingType, + DAOs, ListingDbId, ListingType, }, 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}, + message_utils::{extract_callback_data, pluralize_with_count, MessageTarget, SendHtmlMessage}, start_command_data::StartCommandData, - BotError, BotResult, Command, DialogueRootState, RootDialogue, + App, BotError, BotResult, Command, DialogueRootState, RootDialogue, }; use anyhow::{anyhow, Context}; use base64::{prelude::BASE64_URL_SAFE, Engine}; @@ -87,34 +87,40 @@ pub fn my_listings_handler() -> Handler<'static, BotResult, DpHandlerDescription } async fn handle_view_listing_details( - bot: Bot, + app: App, listing: PersistedListing, target: MessageTarget, ) -> BotResult { - send_listing_details_message(&bot, target, listing, None).await?; + send_listing_details_message(app, target, listing, None).await?; Ok(()) } async fn inline_query_extract_forward_listing( - listing_dao: ListingDAO, + app: App, 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 = listing_dao.find_by_id(listing_id).await.unwrap_or(None)?; + let listing = app + .daos + .listing + .find_by_id(listing_id) + .await + .unwrap_or(None)?; Some(listing) } async fn handle_forward_listing( - bot: Bot, + app: App, inline_query: InlineQuery, listing: PersistedListing, ) -> BotResult { info!("Handling forward listing inline query for listing {listing:?}"); - let bot_username = match bot + let bot_username = match app + .bot .get_me() .await .context("failed to get bot username")? @@ -175,22 +181,23 @@ async fn handle_forward_listing( listing.base.ends_at.format("%b %d, %Y at %H:%M UTC") ); - bot.answer_inline_query( - inline_query.id, - [InlineQueryResult::Article( - InlineQueryResultArticle::new( - listing.persisted.id.to_string(), - format!("💰 {} - ${}", listing.base.title, current_price), - InputMessageContent::Text( - InputMessageContentText::new(message_content).parse_mode(ParseMode::Html), - ), - ) - .description(&listing.base.title) - .reply_markup(keyboard), // Add the inline keyboard here! - )], - ) - .await - .map_err(|e| anyhow::anyhow!("Error answering inline query: {e:?}"))?; + app.bot + .answer_inline_query( + inline_query.id, + [InlineQueryResult::Article( + InlineQueryResultArticle::new( + listing.persisted.id.to_string(), + format!("💰 {} - ${}", listing.base.title, current_price), + InputMessageContent::Text( + InputMessageContentText::new(message_content).parse_mode(ParseMode::Html), + ), + ) + .description(&listing.base.title) + .reply_markup(keyboard), // Add the inline keyboard here! + )], + ) + .await + .map_err(|e| anyhow::anyhow!("Error answering inline query: {e:?}"))?; Ok(()) } @@ -219,19 +226,17 @@ fn get_listing_current_price(listing: &PersistedListing) -> String { } async fn handle_my_listings_command_input( - listing_dao: ListingDAO, - bot: Bot, + app: App, dialogue: RootDialogue, user: PersistedUser, target: MessageTarget, ) -> BotResult { - enter_my_listings(listing_dao, bot, dialogue, user, target, None).await?; + enter_my_listings(app, dialogue, user, target, None).await?; Ok(()) } pub async fn enter_my_listings( - listing_dao: ListingDAO, - bot: Bot, + app: App, dialogue: RootDialogue, user: PersistedUser, target: MessageTarget, @@ -243,7 +248,7 @@ pub async fn enter_my_listings( .await .context("failed to update dialogue")?; - let listings = listing_dao.find_by_seller(user.persisted.id).await?; + let listings = app.daos.listing.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 { @@ -255,14 +260,14 @@ pub async fn enter_my_listings( ]); if listings.is_empty() { - send_message( - &bot, - target, - "📋 My Listings\n\n\ + app.bot + .send_html_message( + target, + "📋 My Listings\n\n\ You don't have any listings yet.", - Some(keyboard), - ) - .await?; + Some(keyboard), + ) + .await?; return Ok(()); } @@ -277,34 +282,34 @@ pub async fn enter_my_listings( response = format!("{flash}\n\n{response}"); } - send_message(&bot, target, response, Some(keyboard)).await?; + app.bot + .send_html_message(target, response, Some(keyboard)) + .await?; Ok(()) } async fn handle_viewing_listings_callback( - listing_dao: ListingDAO, - bot: Bot, + app: App, dialogue: RootDialogue, callback_query: CallbackQuery, user: PersistedUser, target: MessageTarget, ) -> BotResult { - let data = extract_callback_data(&bot, callback_query).await?; + let data = extract_callback_data(&app.bot, callback_query).await?; if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) { - return enter_main_menu(bot, dialogue, target).await; + return enter_main_menu(app, dialogue, target).await; } // Check if it's the back to menu button let button = MyListingsButtons::try_from(data.as_str())?; match button { MyListingsButtons::SelectListing(listing_id) => { - let listing = get_listing_for_user(&listing_dao, user, listing_id).await?; - - enter_show_listing_details(&bot, dialogue, listing, target).await?; + let listing = get_listing_for_user(&app.daos, user, listing_id).await?; + enter_show_listing_details(app, dialogue, listing, target).await?; } MyListingsButtons::NewListing => { - enter_select_new_listing_type(bot, dialogue, target).await?; + enter_select_new_listing_type(app, dialogue, target).await?; } } @@ -312,7 +317,7 @@ async fn handle_viewing_listings_callback( } async fn enter_show_listing_details( - bot: &Bot, + app: App, dialogue: RootDialogue, listing: PersistedListing, target: MessageTarget, @@ -335,12 +340,12 @@ async fn enter_show_listing_details( ManageListingButtons::Delete.to_button(), ]) .append_row([ManageListingButtons::Back.to_button()]); - send_listing_details_message(bot, target, listing, Some(keyboard)).await?; + send_listing_details_message(app, target, listing, Some(keyboard)).await?; Ok(()) } async fn send_listing_details_message( - bot: &Bot, + app: App, target: MessageTarget, listing: PersistedListing, keyboard: Option, @@ -357,13 +362,14 @@ async fn send_listing_details_message( }; response_lines.push(format!("{}: {}", step.field_name, field_value)); } - send_message(bot, target, response_lines.join("\n"), keyboard).await?; + app.bot + .send_html_message(target, response_lines.join("\n"), keyboard) + .await?; Ok(()) } async fn handle_managing_listing_callback( - listing_dao: ListingDAO, - bot: Bot, + app: App, dialogue: RootDialogue, callback_query: CallbackQuery, user: PersistedUser, @@ -371,29 +377,30 @@ async fn handle_managing_listing_callback( target: MessageTarget, ) -> BotResult { let from = callback_query.from.clone(); - let data = extract_callback_data(&bot, callback_query).await?; + let data = extract_callback_data(&app.bot, callback_query).await?; match ManageListingButtons::try_from(data.as_str())? { ManageListingButtons::PreviewMessage => { - let listing = listing_dao + let listing = app + .daos + .listing .find_by_id(listing_id) .await? .ok_or(anyhow::anyhow!("Listing not found"))?; - send_preview_listing_message(&bot, listing, from).await?; + send_preview_listing_message(app, listing, from).await?; } ManageListingButtons::ForwardListing => { unimplemented!("Forward listing not implemented"); } ManageListingButtons::Edit => { - let listing = get_listing_for_user(&listing_dao, user, listing_id).await?; + let listing = get_listing_for_user(&app.daos, user, listing_id).await?; let draft = ListingDraft::from_persisted(listing); - enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?; + enter_edit_listing_draft(app, target, draft, dialogue, None).await?; } ManageListingButtons::Delete => { - listing_dao.delete_listing(listing_id).await?; + app.daos.listing.delete_listing(listing_id).await?; enter_my_listings( - listing_dao, - bot, + app, dialogue, user, target, @@ -402,7 +409,7 @@ async fn handle_managing_listing_callback( .await?; } ManageListingButtons::Back => { - enter_my_listings(listing_dao, bot, dialogue, user, target, None).await?; + enter_my_listings(app, dialogue, user, target, None).await?; } } @@ -441,7 +448,7 @@ fn keyboard_for_listing(listing: &PersistedListing) -> InlineKeyboardMarkup { } async fn send_preview_listing_message( - bot: &Bot, + app: App, listing: PersistedListing, from: User, ) -> BotResult { @@ -450,22 +457,22 @@ async fn send_preview_listing_message( if let Some(description) = &listing.base.description { response_lines.push(description.to_owned()); } - send_message( - bot, - from.into(), - response_lines.join("\n\n"), - Some(keyboard_for_listing(&listing)), - ) - .await?; + app.bot + .send_html_message( + from.into(), + response_lines.join("\n\n"), + Some(keyboard_for_listing(&listing)), + ) + .await?; Ok(()) } async fn get_listing_for_user( - listing_dao: &ListingDAO, + daos: &DAOs, user: PersistedUser, listing_id: ListingDbId, ) -> BotResult { - let listing = match listing_dao.find_by_id(listing_id).await? { + let listing = match daos.listing.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 bdd5d43..1b85667 100644 --- a/src/commands/new_listing/callbacks.rs +++ b/src/commands/new_listing/callbacks.rs @@ -15,29 +15,26 @@ use crate::{ ui::enter_confirm_save_listing, }, }, - db::{ - user::PersistedUser, CurrencyType, ListingDAO, ListingDuration, ListingType, MoneyAmount, - }, + db::{user::PersistedUser, CurrencyType, ListingDuration, ListingType, MoneyAmount}, message_utils::*, - BotResult, RootDialogue, + App, BotResult, RootDialogue, }; use log::{error, info}; -use teloxide::{types::CallbackQuery, Bot}; +use teloxide::types::CallbackQuery; /// Handle callbacks during the listing type selection phase pub async fn handle_selecting_listing_type_callback( - listing_dao: ListingDAO, - bot: Bot, + app: App, dialogue: RootDialogue, user: PersistedUser, callback_query: CallbackQuery, target: MessageTarget, ) -> BotResult { - let data = extract_callback_data(&bot, callback_query).await?; + let data = extract_callback_data(&app.bot, callback_query).await?; info!("User {target:?} selected listing type: {data:?}"); if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) { - return enter_my_listings(listing_dao, bot, dialogue, user, target, None).await; + return enter_my_listings(app, dialogue, user, target, None).await; } // Parse the listing type from callback data @@ -64,38 +61,38 @@ pub async fn handle_selecting_listing_type_callback( get_step_message(ListingField::Title, listing_type) ); - send_message( - &bot, - target, - response, - get_keyboard_for_field(ListingField::Title), - ) - .await?; + app.bot + .send_html_message( + target, + response, + get_keyboard_for_field(ListingField::Title), + ) + .await?; Ok(()) } /// Handle callbacks during the field input phase pub async fn handle_awaiting_draft_field_callback( - bot: Bot, + app: App, dialogue: RootDialogue, (field, draft): (ListingField, ListingDraft), callback_query: CallbackQuery, target: MessageTarget, ) -> BotResult { - let data = extract_callback_data(&bot, callback_query).await?; + let data = extract_callback_data(&app.bot, callback_query).await?; info!("User {target:?} selected callback: {data:?}"); if let Ok(button) = NavKeyboardButtons::try_from(data.as_str()) { match button { NavKeyboardButtons::Back => { - return enter_select_new_listing_type(bot, dialogue, target).await; + return enter_select_new_listing_type(app, dialogue, target).await; } NavKeyboardButtons::Skip => { - return handle_skip_field(&bot, dialogue, field, draft, target).await; + return handle_skip_field(app, dialogue, field, draft, target).await; } NavKeyboardButtons::Cancel => { - return cancel_wizard(bot, dialogue, target).await; + return cancel_wizard(app, dialogue, target).await; } } } @@ -104,23 +101,23 @@ pub async fn handle_awaiting_draft_field_callback( match field { ListingField::Slots => { let button = SlotsKeyboardButtons::try_from(data.as_str())?; - handle_slots_callback(&bot, dialogue, draft, button, target).await + handle_slots_callback(app, dialogue, draft, button, target).await } ListingField::StartTime => { let button = StartTimeKeyboardButtons::try_from(data.as_str())?; - handle_start_time_callback(&bot, dialogue, draft, button, target).await + handle_start_time_callback(app, dialogue, draft, button, target).await } ListingField::EndTime => { let button = DurationKeyboardButtons::try_from(data.as_str())?; - handle_duration_callback(&bot, dialogue, draft, button, target).await + handle_duration_callback(app, dialogue, draft, button, target).await } ListingField::MinBidIncrement => { let button = EditMinimumBidIncrementKeyboardButtons::try_from(data.as_str())?; - handle_starting_bid_amount_callback(&bot, dialogue, draft, button, target).await + handle_starting_bid_amount_callback(app, dialogue, draft, button, target).await } ListingField::CurrencyType => { let button = CurrencyTypeKeyboardButtons::try_from(data.as_str())?; - handle_currency_type_callback(&bot, dialogue, draft, button, target).await + handle_currency_type_callback(app, dialogue, draft, button, target).await } _ => { error!("Unknown callback data for field {field:?}: {data}"); @@ -130,7 +127,7 @@ pub async fn handle_awaiting_draft_field_callback( } async fn handle_skip_field( - bot: &Bot, + app: App, dialogue: RootDialogue, current_field: ListingField, draft: ListingDraft, @@ -146,16 +143,18 @@ async fn handle_skip_field( get_step_message(next_field, draft.listing_type()) ); transition_to_field(dialogue, next_field, draft).await?; - send_message(bot, target, response, get_keyboard_for_field(next_field)).await?; + app.bot + .send_html_message(target, response, get_keyboard_for_field(next_field)) + .await?; } else { - enter_confirm_save_listing(bot, dialogue, target, draft, Some(flash)).await?; + enter_confirm_save_listing(app, dialogue, target, draft, Some(flash)).await?; } Ok(()) } /// Handle slots selection callback async fn handle_slots_callback( - bot: &Bot, + app: App, dialogue: RootDialogue, mut draft: ListingDraft, button: SlotsKeyboardButtons, @@ -180,19 +179,19 @@ async fn handle_slots_callback( get_step_message(ListingField::StartTime, draft.listing_type()) ); transition_to_field(dialogue, ListingField::StartTime, draft).await?; - send_message( - bot, - target, - &response, - get_keyboard_for_field(ListingField::StartTime), - ) - .await?; + app.bot + .send_html_message( + target, + &response, + get_keyboard_for_field(ListingField::StartTime), + ) + .await?; Ok(()) } /// Handle start time selection callback async fn handle_start_time_callback( - bot: &Bot, + app: App, dialogue: RootDialogue, mut draft: ListingDraft, button: StartTimeKeyboardButtons, @@ -215,19 +214,19 @@ async fn handle_start_time_callback( get_step_message(ListingField::EndTime, draft.listing_type()) ); transition_to_field(dialogue, ListingField::EndTime, draft).await?; - send_message( - bot, - target, - &response, - get_keyboard_for_field(ListingField::EndTime), - ) - .await?; + app.bot + .send_html_message( + target, + &response, + get_keyboard_for_field(ListingField::EndTime), + ) + .await?; Ok(()) } /// Handle duration selection callback async fn handle_duration_callback( - bot: &Bot, + app: App, dialogue: RootDialogue, mut draft: ListingDraft, button: DurationKeyboardButtons, @@ -248,11 +247,11 @@ async fn handle_duration_callback( .map_err(|e| anyhow::anyhow!("Error updating duration: {e:?}"))?; let flash = get_success_message(ListingField::EndTime, draft.listing_type()); - enter_confirm_save_listing(bot, dialogue, target, draft, Some(flash)).await + enter_confirm_save_listing(app, dialogue, target, draft, Some(flash)).await } async fn handle_starting_bid_amount_callback( - bot: &Bot, + app: App, dialogue: RootDialogue, mut draft: ListingDraft, button: EditMinimumBidIncrementKeyboardButtons, @@ -273,11 +272,11 @@ async fn handle_starting_bid_amount_callback( .map_err(|e| anyhow::anyhow!("Error updating starting bid amount: {e:?}"))?; let flash = get_success_message(ListingField::StartingBidAmount, draft.listing_type()); - enter_confirm_save_listing(bot, dialogue, target, draft, Some(flash)).await + enter_confirm_save_listing(app, dialogue, target, draft, Some(flash)).await } async fn handle_currency_type_callback( - bot: &Bot, + app: App, dialogue: RootDialogue, mut draft: ListingDraft, button: CurrencyTypeKeyboardButtons, @@ -304,13 +303,15 @@ async fn handle_currency_type_callback( get_step_message(next_field, draft.listing_type()) ); transition_to_field(dialogue, next_field, draft).await?; - send_message(bot, target, &response, get_keyboard_for_field(next_field)).await?; + app.bot + .send_html_message(target, &response, get_keyboard_for_field(next_field)) + .await?; Ok(()) } /// Cancel the wizard and exit -pub async fn cancel_wizard(bot: Bot, dialogue: RootDialogue, target: MessageTarget) -> BotResult { +pub async fn cancel_wizard(app: App, dialogue: RootDialogue, target: MessageTarget) -> BotResult { info!("{target:?} cancelled new listing wizard"); - enter_select_new_listing_type(bot, dialogue, target).await?; + enter_select_new_listing_type(app, dialogue, target).await?; Ok(()) } diff --git a/src/commands/new_listing/handlers.rs b/src/commands/new_listing/handlers.rs index dca8efd..f6f600c 100644 --- a/src/commands/new_listing/handlers.rs +++ b/src/commands/new_listing/handlers.rs @@ -28,24 +28,24 @@ use crate::{ ListingDAO, }, message_utils::*, - BotError, BotResult, DialogueRootState, RootDialogue, + App, BotError, BotResult, DialogueRootState, RootDialogue, }; use anyhow::{anyhow, Context}; use log::info; -use teloxide::{prelude::*, types::*, Bot}; +use teloxide::{prelude::*, types::*}; /// Handle the /newlisting command - starts the dialogue pub(super) async fn handle_new_listing_command( - bot: Bot, + app: App, dialogue: RootDialogue, target: MessageTarget, ) -> BotResult { - enter_select_new_listing_type(bot, dialogue, target).await?; + enter_select_new_listing_type(app, dialogue, target).await?; Ok(()) } pub async fn enter_select_new_listing_type( - bot: Bot, + app: App, dialogue: RootDialogue, target: MessageTarget, ) -> BotResult { @@ -55,19 +55,19 @@ pub async fn enter_select_new_listing_type( .await .context("failed to update dialogue")?; - send_message( - &bot, - target, - get_listing_type_selection_message(), - Some(get_listing_type_keyboard()), - ) - .await?; + app.bot + .send_html_message( + target, + get_listing_type_selection_message(), + Some(get_listing_type_keyboard()), + ) + .await?; Ok(()) } /// Handle text input for any field during creation pub async fn handle_awaiting_draft_field_input( - bot: Bot, + app: App, dialogue: RootDialogue, (field, mut draft): (ListingField, ListingDraft), target: MessageTarget, @@ -97,17 +97,19 @@ pub async fn handle_awaiting_draft_field_input( get_step_message(next_field, draft.listing_type()) ); transition_to_field(dialogue, next_field, draft).await?; - send_message(&bot, target, response, get_keyboard_for_field(next_field)).await?; + app.bot + .send_html_message(target, response, get_keyboard_for_field(next_field)) + .await?; } else { // Final step - go to confirmation - enter_confirm_save_listing(&bot, dialogue, target, draft, None).await?; + enter_confirm_save_listing(app, dialogue, target, draft, None).await?; } Ok(()) } /// Handle text input for field editing pub async fn handle_editing_field_input( - bot: Bot, + app: App, dialogue: RootDialogue, (field, mut draft): (ListingField, ListingDraft), target: MessageTarget, @@ -130,41 +132,32 @@ pub async fn handle_editing_field_input( }; let flash = get_edit_success_message(field, draft.listing_type()); - enter_edit_listing_draft(&bot, target, draft, dialogue, Some(flash)).await?; + enter_edit_listing_draft(app, target, draft, dialogue, Some(flash)).await?; Ok(()) } /// Handle viewing draft confirmation callbacks pub async fn handle_viewing_draft_callback( - listing_dao: ListingDAO, - bot: Bot, + app: App, dialogue: RootDialogue, draft: ListingDraft, user: PersistedUser, callback_query: CallbackQuery, target: MessageTarget, ) -> BotResult { - let data = extract_callback_data(&bot, callback_query).await?; + let data = extract_callback_data(&app.bot, callback_query).await?; match ConfirmationKeyboardButtons::try_from(data.as_str())? { ConfirmationKeyboardButtons::Create | ConfirmationKeyboardButtons::Save => { info!("User {target:?} confirmed listing creation"); - let success_message = save_listing(&listing_dao, draft).await?; - enter_my_listings( - listing_dao, - bot, - dialogue, - user, - target, - Some(success_message), - ) - .await?; + let success_message = save_listing(&app.daos.listing, draft).await?; + enter_my_listings(app, dialogue, user, target, Some(success_message)).await?; } ConfirmationKeyboardButtons::Cancel => { info!("User {target:?} cancelled listing update"); let response = "🗑️ Changes Discarded\n\n\ Your changes have been discarded and not saved."; - send_message(&bot, target, &response, None).await?; + app.bot.send_html_message(target, &response, None).await?; dialogue.exit().await.context("failed to exit dialogue")?; } ConfirmationKeyboardButtons::Discard => { @@ -173,12 +166,12 @@ 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."; - send_message(&bot, target, &response, None).await?; + app.bot.send_html_message(target, &response, None).await?; dialogue.exit().await.context("failed to exit dialogue")?; } ConfirmationKeyboardButtons::Edit => { info!("User {target:?} chose to edit listing"); - enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?; + enter_edit_listing_draft(app, target, draft, dialogue, None).await?; } } @@ -187,18 +180,18 @@ pub async fn handle_viewing_draft_callback( /// Handle editing draft field selection callbacks pub async fn handle_editing_draft_callback( - bot: Bot, + app: App, draft: ListingDraft, dialogue: RootDialogue, callback_query: CallbackQuery, target: MessageTarget, ) -> BotResult { - let data = extract_callback_data(&bot, callback_query).await?; + let data = extract_callback_data(&app.bot, callback_query).await?; info!("User {target:?} in editing screen, showing field selection"); let button = FieldSelectionKeyboardButtons::try_from(data.as_str())?; if button == FieldSelectionKeyboardButtons::Done { - return enter_confirm_save_listing(&bot, dialogue, target, draft, None).await; + return enter_confirm_save_listing(app, dialogue, target, draft, None).await; } let field = match button { @@ -224,44 +217,46 @@ pub async fn handle_editing_draft_callback( .context("failed to update dialogue")?; let response = format!("Editing {field:?}\n\nPrevious value: {value}"); - send_message(&bot, target, response, Some(keyboard)).await?; + app.bot + .send_html_message(target, response, Some(keyboard)) + .await?; Ok(()) } /// Handle editing draft field callbacks (back button, etc.) pub async fn handle_editing_draft_field_callback( - bot: Bot, + app: App, dialogue: RootDialogue, (field, draft): (ListingField, ListingDraft), callback_query: CallbackQuery, target: MessageTarget, ) -> BotResult { - let data = extract_callback_data(&bot, callback_query).await?; + let data = extract_callback_data(&app.bot, callback_query).await?; info!("User {target:?} editing field: {field:?} -> {data:?}"); if data == "edit_back" { - enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?; + enter_edit_listing_draft(app, target, draft, dialogue, None).await?; return Ok(()); } // This callback handler typically receives button presses, not text input // For now, just redirect back to edit screen since callback data isn't suitable for validation - enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?; + enter_edit_listing_draft(app, target, draft, dialogue, None).await?; Ok(()) } /// Enter the edit listing draft screen pub async fn enter_edit_listing_draft( - bot: &Bot, + app: App, target: MessageTarget, draft: ListingDraft, dialogue: RootDialogue, flash_message: Option, ) -> BotResult { display_listing_summary( - bot, + app, target, &draft, Some(FieldSelectionKeyboardButtons::to_keyboard()), diff --git a/src/commands/new_listing/ui.rs b/src/commands/new_listing/ui.rs index b232a60..a8bd65d 100644 --- a/src/commands/new_listing/ui.rs +++ b/src/commands/new_listing/ui.rs @@ -7,14 +7,15 @@ use crate::commands::new_listing::keyboard::ConfirmationKeyboardButtons; use crate::commands::new_listing::messages::steps_for_listing_type; use crate::commands::new_listing::NewListingState; use crate::db::ListingType; +use crate::App; use crate::RootDialogue; use crate::{commands::new_listing::types::ListingDraft, message_utils::*, BotResult}; use anyhow::Context; -use teloxide::{types::InlineKeyboardMarkup, Bot}; +use teloxide::types::InlineKeyboardMarkup; /// Display the listing summary with optional flash message and keyboard pub async fn display_listing_summary( - bot: &Bot, + app: App, target: MessageTarget, draft: &ListingDraft, keyboard: Option, @@ -48,14 +49,16 @@ pub async fn display_listing_summary( response_lines.push("".to_string()); response_lines.push("Edit your listing:".to_string()); - send_message(bot, target, response_lines.join("\n"), keyboard).await?; + app.bot + .send_html_message(target, response_lines.join("\n"), keyboard) + .await?; Ok(()) } /// Show the final confirmation screen before creating/saving the listing pub async fn enter_confirm_save_listing( - bot: &Bot, + app: App, dialogue: RootDialogue, target: MessageTarget, draft: ListingDraft, @@ -75,7 +78,7 @@ pub async fn enter_confirm_save_listing( ]) }; - display_listing_summary(bot, target, &draft, Some(keyboard), flash).await?; + display_listing_summary(app, target, &draft, Some(keyboard), flash).await?; dialogue .update(NewListingState::ViewingDraft(draft)) .await diff --git a/src/commands/settings.rs b/src/commands/settings.rs index 328757e..fdec02b 100644 --- a/src/commands/settings.rs +++ b/src/commands/settings.rs @@ -1,11 +1,11 @@ use crate::{ - message_utils::{send_message, MessageTarget}, - BotResult, + message_utils::{MessageTarget, SendHtmlMessage}, + App, BotResult, }; use log::info; -use teloxide::{types::Message, Bot}; +use teloxide::types::Message; -pub async fn handle_settings(bot: Bot, msg: Message, target: MessageTarget) -> BotResult { +pub async fn handle_settings(app: App, msg: Message, target: MessageTarget) -> BotResult { let response = "⚙️ Settings (Coming Soon)\n\n\ Here you'll be able to configure:\n\ • Notification preferences\n\ @@ -20,6 +20,6 @@ pub async fn handle_settings(bot: Bot, msg: Message, target: MessageTarget) -> B msg.chat.id ); - send_message(&bot, target, response, None).await?; + app.bot.send_html_message(target, response, None).await?; Ok(()) } diff --git a/src/commands/start.rs b/src/commands/start.rs index 85d90f0..af48d37 100644 --- a/src/commands/start.rs +++ b/src/commands/start.rs @@ -3,15 +3,14 @@ use log::info; use teloxide::{ types::{CallbackQuery, Update}, utils::command::BotCommands, - Bot, }; use crate::{ commands::my_listings::enter_my_listings, - db::{user::PersistedUser, ListingDAO}, + db::user::PersistedUser, keyboard_buttons, - message_utils::{extract_callback_data, send_message, MessageTarget}, - BotResult, Command, DialogueRootState, RootDialogue, + message_utils::{extract_callback_data, MessageTarget, SendHtmlMessage as _}, + App, BotResult, Command, DialogueRootState, RootDialogue, }; keyboard_buttons! { @@ -39,80 +38,79 @@ fn get_main_menu_message() -> &'static str { } pub async fn handle_start( - bot: Bot, + app: App, dialogue: RootDialogue, target: MessageTarget, update: Update, ) -> BotResult { info!("got start message: {update:?}"); - enter_main_menu(bot, dialogue, target).await?; + enter_main_menu(app, dialogue, target).await?; Ok(()) } /// Show the main menu with buttons -pub async fn enter_main_menu(bot: Bot, dialogue: RootDialogue, target: MessageTarget) -> BotResult { +pub async fn enter_main_menu(app: App, dialogue: RootDialogue, target: MessageTarget) -> BotResult { dialogue .update(DialogueRootState::MainMenu) .await .context("failed to update dialogue")?; - send_message( - &bot, - target, - get_main_menu_message(), - Some(MainMenuButtons::to_keyboard()), - ) - .await?; + app.bot + .send_html_message( + target, + get_main_menu_message(), + Some(MainMenuButtons::to_keyboard()), + ) + .await?; Ok(()) } pub async fn handle_main_menu_callback( - listing_dao: ListingDAO, - bot: Bot, + app: App, dialogue: RootDialogue, user: PersistedUser, callback_query: CallbackQuery, target: MessageTarget, ) -> BotResult { - let data = extract_callback_data(&bot, callback_query).await?; + let data = extract_callback_data(&app.bot, callback_query).await?; info!("User {target:?} selected main menu option: {data:?}"); let button = MainMenuButtons::try_from(data.as_str())?; match button { MainMenuButtons::MyListings => { // Call show_listings_for_user directly - enter_my_listings(listing_dao, bot, dialogue, user, target, None).await?; + enter_my_listings(app, dialogue, user, target, None).await?; } MainMenuButtons::MyBids => { - send_message( - &bot, - target, - "💰 My Bids (Coming Soon)\n\n\ + app.bot + .send_html_message( + target, + "💰 My Bids (Coming Soon)\n\n\ Here you'll be able to view:\n\ • Your active bids\n\ • Bid history\n\ • Won/lost auctions\n\ • Outbid notifications\n\n\ Feature in development! 🛠️", - Some(MainMenuButtons::to_keyboard()), - ) - .await?; + Some(MainMenuButtons::to_keyboard()), + ) + .await?; } MainMenuButtons::Settings => { - send_message( - &bot, - target, - "⚙️ Settings (Coming Soon)\n\n\ + app.bot + .send_html_message( + target, + "⚙️ Settings (Coming Soon)\n\n\ Here you'll be able to configure:\n\ • Notification preferences\n\ • Language settings\n\ • Default bid increments\n\ • Outbid alerts\n\n\ Feature in development! 🛠️", - Some(MainMenuButtons::to_keyboard()), - ) - .await?; + Some(MainMenuButtons::to_keyboard()), + ) + .await?; } MainMenuButtons::Help => { let help_message = format!( @@ -121,13 +119,9 @@ pub async fn handle_main_menu_callback( 🔗 More info: Use individual commands to get started!", Command::descriptions() ); - send_message( - &bot, - target, - help_message, - Some(MainMenuButtons::to_keyboard()), - ) - .await?; + app.bot + .send_html_message(target, help_message, Some(MainMenuButtons::to_keyboard())) + .await?; } } diff --git a/src/db/dao/bid_dao.rs b/src/db/dao/bid_dao.rs new file mode 100644 index 0000000..bfc2eb9 --- /dev/null +++ b/src/db/dao/bid_dao.rs @@ -0,0 +1,188 @@ +use crate::db::{ + bid::{NewBid, PersistedBid, PersistedBidFields}, + bind_fields::BindFields, +}; +use anyhow::Result; +use chrono::Utc; +use itertools::Itertools as _; +use sqlx::{prelude::*, sqlite::SqliteRow, SqlitePool}; + +#[derive(Clone)] +pub struct BidDAO(SqlitePool); + +impl BidDAO { + pub fn new(pool: SqlitePool) -> Self { + Self(pool) + } +} + +#[allow(unused)] +impl BidDAO { + pub async fn insert_bid(&self, bid: NewBid) -> Result { + let now = Utc::now(); + let binds = BindFields::default() + .push("listing_id", &bid.listing_id) + .push("buyer_id", &bid.buyer_id) + .push("bid_amount", &bid.bid_amount) + .push("description", &bid.description) + .push("is_cancelled", &bid.is_cancelled) + .push("slot_number", &bid.slot_number) + .push("proxy_bid_id", &bid.proxy_bid_id) + .push("created_at", &now) + .push("updated_at", &now); + + let query_str = format!( + r#" + INSERT INTO bids ({}) VALUES ({}) + RETURNING * + "#, + binds.bind_names().join(", "), + binds.bind_placeholders().join(", ") + ); + + let row = binds + .bind_to_query(sqlx::query(&query_str)) + .fetch_one(&self.0) + .await?; + Ok(FromRow::from_row(&row)?) + } +} + +impl FromRow<'_, SqliteRow> for PersistedBid { + fn from_row(row: &'_ SqliteRow) -> std::result::Result { + Ok(PersistedBid { + persisted: PersistedBidFields::from_row(row)?, + listing_id: row.get("listing_id"), + buyer_id: row.get("buyer_id"), + bid_amount: row.get("bid_amount"), + description: row.get("description"), + is_cancelled: row.get("is_cancelled"), + slot_number: row.get("slot_number"), + proxy_bid_id: row.get("proxy_bid_id"), + }) + } +} + +impl FromRow<'_, SqliteRow> for PersistedBidFields { + fn from_row(row: &'_ SqliteRow) -> std::result::Result { + Ok(PersistedBidFields { + id: row.get("id"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::{ + listing::{BasicAuctionFields, ListingFields}, + models::{listing::NewListing, user::NewUser}, + CurrencyType, ListingDAO, MoneyAmount, UserDAO, + }; + use crate::test_utils::create_test_pool; + use chrono::Utc; + use teloxide::types::UserId; + + async fn create_test_user_and_listing() -> ( + UserDAO, + ListingDAO, + BidDAO, + crate::db::UserDbId, + crate::db::ListingDbId, + ) { + let pool = create_test_pool().await; + let user_dao = UserDAO::new(pool.clone()); + let listing_dao = ListingDAO::new(pool.clone()); + let bid_dao = BidDAO::new(pool); + + // Create a test user + let new_user = NewUser { + persisted: (), + telegram_id: UserId(12345).into(), + first_name: "Test User".to_string(), + last_name: None, + username: Some("testuser".to_string()), + is_banned: false, + }; + + let user = user_dao + .insert_user(&new_user) + .await + .expect("Failed to insert test user"); + + // Create a test listing + let new_listing = NewListing { + persisted: (), + base: crate::db::listing::ListingBase { + seller_id: user.persisted.id, + title: "Test Listing".to_string(), + description: Some("Test description".to_string()), + currency_type: CurrencyType::Usd, + starts_at: Utc::now(), + ends_at: Utc::now() + chrono::Duration::hours(24), + }, + fields: ListingFields::BasicAuction(BasicAuctionFields { + starting_bid: MoneyAmount::from_str("10.00").unwrap(), + buy_now_price: Some(MoneyAmount::from_str("100.00").unwrap()), + min_increment: MoneyAmount::from_str("1.00").unwrap(), + anti_snipe_minutes: Some(5), + }), + }; + + let listing = listing_dao + .insert_listing(new_listing) + .await + .expect("Failed to insert test listing"); + + ( + user_dao, + listing_dao, + bid_dao, + user.persisted.id, + listing.persisted.id, + ) + } + + #[tokio::test] + async fn test_insert_bid() { + let (_user_dao, _listing_dao, bid_dao, user_id, listing_id) = + create_test_user_and_listing().await; + + let new_bid = NewBid { + persisted: (), + listing_id, + buyer_id: user_id, + bid_amount: MoneyAmount::from_str("25.50").unwrap(), + description: Some("Test bid description".to_string()), + is_cancelled: false, + slot_number: Some(1), + proxy_bid_id: None, + }; + + // Insert bid + let inserted_bid = bid_dao + .insert_bid(new_bid.clone()) + .await + .expect("Failed to insert bid"); + + // Verify the inserted bid has the correct values + assert_eq!(inserted_bid.listing_id, new_bid.listing_id); + assert_eq!(inserted_bid.buyer_id, new_bid.buyer_id); + assert_eq!(inserted_bid.bid_amount, new_bid.bid_amount); + assert_eq!(inserted_bid.description, new_bid.description); + assert_eq!(inserted_bid.is_cancelled, new_bid.is_cancelled); + assert_eq!(inserted_bid.slot_number, new_bid.slot_number); + assert_eq!(inserted_bid.proxy_bid_id, new_bid.proxy_bid_id); + + // Verify persisted fields are populated + assert!(inserted_bid.persisted.id.get() > 0); + assert!(inserted_bid.persisted.created_at <= chrono::Utc::now()); + assert!(inserted_bid.persisted.updated_at <= chrono::Utc::now()); + assert_eq!( + inserted_bid.persisted.created_at, + inserted_bid.persisted.updated_at + ); + } +} diff --git a/src/db/dao/listing_dao.rs b/src/db/dao/listing_dao.rs index 7705a72..f1dc82b 100644 --- a/src/db/dao/listing_dao.rs +++ b/src/db/dao/listing_dao.rs @@ -22,24 +22,6 @@ use crate::db::{ #[derive(Clone)] pub struct ListingDAO(SqlitePool); -const LISTING_RETURN_FIELDS: &[&str] = &[ - "id", - "seller_id", - "listing_type", - "title", - "description", - "currency_type", - "starts_at", - "ends_at", - "created_at", - "updated_at", - "starting_bid", - "buy_now_price", - "min_increment", - "anti_snipe_minutes", - "slots_available", -]; - impl ListingDAO { pub fn new(pool: SqlitePool) -> Self { Self(pool) @@ -59,11 +41,10 @@ impl ListingDAO { let query_str = format!( r#" INSERT INTO listings ({}) VALUES ({}) - RETURNING {} + RETURNING * "#, binds.bind_names().join(", "), binds.bind_placeholders().join(", "), - LISTING_RETURN_FIELDS.join(", ") ); let row = binds @@ -83,13 +64,12 @@ impl ListingDAO { SET {} WHERE id = ? AND seller_id = ? - RETURNING {} + RETURNING * "#, binds .bind_names() .map(|name| format!("{name} = ?")) .join(", "), - LISTING_RETURN_FIELDS.join(", ") ); let row = binds @@ -103,26 +83,21 @@ impl ListingDAO { /// Find a listing by its ID 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(&self.0) - .await?; + let result = sqlx::query_as("SELECT * FROM listings WHERE id = ?") + .bind(listing_id) + .fetch_optional(&self.0) + .await?; Ok(result) } /// Find all listings by a seller 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(&self.0) - .await?; + let rows = + sqlx::query_as("SELECT * FROM listings WHERE seller_id = ? ORDER BY created_at DESC") + .bind(seller_id) + .fetch_all(&self.0) + .await?; Ok(rows) } diff --git a/src/db/dao/mod.rs b/src/db/dao/mod.rs index a9d6981..d809073 100644 --- a/src/db/dao/mod.rs +++ b/src/db/dao/mod.rs @@ -1,6 +1,26 @@ -pub mod listing_dao; -pub mod user_dao; +mod bid_dao; +mod listing_dao; +mod user_dao; // Re-export DAO structs for easy access +pub use bid_dao::BidDAO; pub use listing_dao::ListingDAO; +use sqlx::SqlitePool; pub use user_dao::UserDAO; + +#[derive(Clone)] +pub struct DAOs { + pub user: UserDAO, + pub listing: ListingDAO, + pub bid: BidDAO, +} + +impl DAOs { + pub fn new(pool: SqlitePool) -> Self { + Self { + user: UserDAO::new(pool.clone()), + listing: ListingDAO::new(pool.clone()), + bid: BidDAO::new(pool), + } + } +} diff --git a/src/db/dao/user_dao.rs b/src/db/dao/user_dao.rs index cea754c..006600f 100644 --- a/src/db/dao/user_dao.rs +++ b/src/db/dao/user_dao.rs @@ -17,17 +17,6 @@ use crate::db::{ #[derive(Clone)] pub struct UserDAO(SqlitePool); -const USER_RETURN_FIELDS: &[&str] = &[ - "id", - "telegram_id", - "username", - "first_name", - "last_name", - "is_banned", - "created_at", - "updated_at", -]; - #[allow(unused)] impl UserDAO { pub fn new(pool: SqlitePool) -> Self { @@ -46,11 +35,10 @@ impl UserDAO { r#" INSERT INTO users ({}) VALUES ({}) - RETURNING {} + RETURNING * "#, binds.bind_names().join(", "), binds.bind_placeholders().join(", "), - USER_RETURN_FIELDS.join(", ") ); let query = sqlx::query(&query_str); let row = binds.bind_to_query(query).fetch_one(&self.0).await?; @@ -60,16 +48,12 @@ impl UserDAO { /// Find a user by their ID 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 - FROM users - WHERE id = ? - "#, + Ok( + sqlx::query_as::<_, PersistedUser>("SELECT * FROM users WHERE id = ? ") + .bind(user_id) + .fetch_optional(&self.0) + .await?, ) - .bind(user_id) - .fetch_optional(&self.0) - .await?) } /// Find a user by their Telegram ID @@ -108,11 +92,10 @@ impl UserDAO { username = EXCLUDED.username, first_name = EXCLUDED.first_name, last_name = EXCLUDED.last_name - RETURNING {} + RETURNING * "#, binds.bind_names().join(", "), binds.bind_placeholders().join(", "), - USER_RETURN_FIELDS.join(", ") ); let row = binds diff --git a/src/db/models/bid.rs b/src/db/models/bid.rs index 80b9504..65d75e5 100644 --- a/src/db/models/bid.rs +++ b/src/db/models/bid.rs @@ -1,15 +1,26 @@ use chrono::{DateTime, Utc}; -use sqlx::FromRow; -use crate::db::MoneyAmount; +use crate::db::{BidDbId, ListingDbId, MoneyAmount, ProxyBidDbId, UserDbId}; + +pub type PersistedBid = Bid; +#[allow(unused)] +pub type NewBid = Bid<()>; + +#[derive(Debug, Clone)] +#[allow(unused)] +pub struct PersistedBidFields { + pub id: BidDbId, + pub created_at: DateTime, + pub updated_at: DateTime, +} /// Actual bids placed on listings +#[derive(Debug, Clone)] #[allow(unused)] -#[derive(Debug, Clone, FromRow)] -pub struct Bid { - pub id: i64, - pub listing_id: i64, - pub buyer_id: i64, +pub struct Bid

{ + pub persisted: P, + pub listing_id: ListingDbId, + pub buyer_id: UserDbId, pub bid_amount: MoneyAmount, // For blind listings @@ -20,20 +31,24 @@ pub struct Bid { pub slot_number: Option, // For multi-slot listings // Reference to proxy bid if auto-generated - pub proxy_bid_id: Option, - - pub created_at: DateTime, - pub updated_at: DateTime, + pub proxy_bid_id: Option, } -/// New bid data for insertion -#[allow(unused)] -#[derive(Debug, Clone)] -pub struct NewBid { - pub listing_id: i64, - pub buyer_id: i64, - pub bid_amount: MoneyAmount, - pub description: Option, - pub slot_number: Option, - pub proxy_bid_id: Option, +impl

Bid

{ + pub fn new_basic( + listing_id: ListingDbId, + buyer_id: UserDbId, + bid_amount: MoneyAmount, + ) -> NewBid { + NewBid { + persisted: (), + listing_id, + buyer_id, + bid_amount, + description: None, + is_cancelled: false, + slot_number: None, + proxy_bid_id: None, + } + } } diff --git a/src/db/models/listing.rs b/src/db/models/listing.rs index 5c40650..5088259 100644 --- a/src/db/models/listing.rs +++ b/src/db/models/listing.rs @@ -26,7 +26,6 @@ pub struct PersistedListingFields { /// Main listing/auction entity #[derive(Debug, Clone, Eq, PartialEq)] -#[allow(unused)] pub struct Listing { pub persisted: P, pub base: ListingBase, @@ -49,7 +48,6 @@ impl<'a, P: Debug + Clone> From<&'a mut Listing

> for ListingBaseFieldsMut<'a> /// Common fields shared by all listing types #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] -#[allow(unused)] pub struct ListingBase { pub seller_id: UserDbId, pub title: String, @@ -72,7 +70,6 @@ impl ListingBase { /// Fields specific to basic auction listings #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[allow(unused)] pub struct BasicAuctionFields { pub starting_bid: MoneyAmount, pub buy_now_price: Option, @@ -82,7 +79,6 @@ pub struct BasicAuctionFields { /// Fields specific to multi-slot auction listings #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[allow(unused)] pub struct MultiSlotAuctionFields { pub starting_bid: MoneyAmount, pub buy_now_price: MoneyAmount, @@ -93,7 +89,6 @@ pub struct MultiSlotAuctionFields { /// Fields specific to fixed price listings #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[allow(unused)] pub struct FixedPriceListingFields { pub buy_now_price: MoneyAmount, pub slots_available: i32, @@ -101,13 +96,11 @@ pub struct FixedPriceListingFields { /// Fields specific to blind auction listings #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[allow(unused)] pub struct BlindAuctionFields { pub starting_bid: MoneyAmount, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[allow(unused)] pub enum ListingFields { BasicAuction(BasicAuctionFields), MultiSlotAuction(MultiSlotAuctionFields), diff --git a/src/db/types/db_id.rs b/src/db/types/db_id.rs new file mode 100644 index 0000000..a627d61 --- /dev/null +++ b/src/db/types/db_id.rs @@ -0,0 +1,93 @@ +use serde::{Deserialize, Serialize}; +use sqlx::{ + encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type, +}; +use std::fmt; + +macro_rules! impl_db_id { + ($id_name:ident, $id_type:ty) => { + #[doc = "Type-safe wrapper for "] + #[doc = stringify!($id_name)] + #[doc = " IDs"] + #[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, + )] + pub struct $id_name($id_type); + + impl $id_name { + /// Create a new ListingId from an i64 + pub fn new(id: $id_type) -> Self { + Self(id) + } + + /// Get the inner i64 value + pub fn get(&self) -> $id_type { + self.0 + } + } + + impl From<$id_type> for $id_name { + fn from(id: $id_type) -> Self { + Self(id) + } + } + + impl From<$id_name> for $id_type { + fn from(value: $id_name) -> Self { + value.0 + } + } + + impl fmt::Display for $id_name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } + } + + // SQLx implementations for database compatibility + impl Type for $id_name { + fn type_info() -> SqliteTypeInfo { + <$id_type as Type>::type_info() + } + + fn compatible(ty: &SqliteTypeInfo) -> bool { + <$id_type as Type>::compatible(ty) + } + } + + impl<'q> Encode<'q, Sqlite> for $id_name { + fn encode_by_ref( + &self, + args: &mut Vec>, + ) -> Result { + <$id_type as Encode<'q, Sqlite>>::encode_by_ref(&self.0, args) + } + } + + impl<'r> Decode<'r, Sqlite> for $id_name { + fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result { + let id = <$id_type as Decode<'r, Sqlite>>::decode(value)?; + Ok(Self(id)) + } + } + }; +} + +impl_db_id!(BidDbId, i64); +impl_db_id!(ProxyBidDbId, i64); +impl_db_id!(ListingDbId, i64); +impl_db_id!(UserDbId, i64); + +impl_db_id!(TelegramUserDbId, i64); + +impl From for TelegramUserDbId { + fn from(id: teloxide::types::UserId) -> Self { + Self(id.0 as i64) + } +} + +impl From for teloxide::types::UserId { + fn from(user_id: TelegramUserDbId) -> Self { + teloxide::types::UserId(user_id.0 as u64) + } +} diff --git a/src/db/types/db_id/listing_db_id.rs b/src/db/types/db_id/listing_db_id.rs deleted file mode 100644 index d77fcf2..0000000 --- a/src/db/types/db_id/listing_db_id.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! ListingId newtype for type-safe listing identification -//! -//! This newtype prevents accidentally mixing up listing IDs with other ID types -//! while maintaining compatibility with the database layer through SQLx traits. - -use serde::{Deserialize, Serialize}; -use sqlx::{ - encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type, -}; -use std::fmt; - -/// Type-safe wrapper for listing IDs -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -pub struct ListingDbId(i64); - -impl ListingDbId { - /// Create a new ListingId from an i64 - pub fn new(id: i64) -> Self { - Self(id) - } - - /// Get the inner i64 value - pub fn get(&self) -> i64 { - self.0 - } -} - -impl From for ListingDbId { - fn from(id: i64) -> Self { - Self(id) - } -} - -impl From for i64 { - fn from(listing_id: ListingDbId) -> Self { - listing_id.0 - } -} - -impl fmt::Display for ListingDbId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -// SQLx implementations for database compatibility -impl Type for ListingDbId { - fn type_info() -> SqliteTypeInfo { - >::type_info() - } - - fn compatible(ty: &SqliteTypeInfo) -> bool { - >::compatible(ty) - } -} - -impl<'q> Encode<'q, Sqlite> for ListingDbId { - fn encode_by_ref( - &self, - args: &mut Vec>, - ) -> Result { - >::encode_by_ref(&self.0, args) - } -} - -impl<'r> Decode<'r, Sqlite> for ListingDbId { - fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result { - let id = >::decode(value)?; - Ok(Self(id)) - } -} diff --git a/src/db/types/db_id/mod.rs b/src/db/types/db_id/mod.rs deleted file mode 100644 index e99f651..0000000 --- a/src/db/types/db_id/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod listing_db_id; -mod telegram_user_db_id; -mod user_db_id; - -pub use listing_db_id::ListingDbId; -pub use telegram_user_db_id::TelegramUserDbId; -pub use user_db_id::UserDbId; diff --git a/src/db/types/db_id/telegram_user_db_id.rs b/src/db/types/db_id/telegram_user_db_id.rs deleted file mode 100644 index b90b1a5..0000000 --- a/src/db/types/db_id/telegram_user_db_id.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! TelegramUserId -//! newtype for type-safe user identification -//! -//! This newtype prevents accidentally mixing up user IDs with other ID types -//! while maintaining compatibility with the database layer through SQLx traits. - -use sqlx::{ - encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type, -}; -use std::fmt; - -/// Type-safe wrapper for user IDs -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct TelegramUserDbId(teloxide::types::UserId); - -impl TelegramUserDbId { - /// Create a new TelegramUserId - /// from an i64 - pub fn new(id: teloxide::types::UserId) -> Self { - Self(id) - } - - /// Get the inner i64 value - pub fn get(&self) -> teloxide::types::UserId { - self.0 - } -} - -impl From for TelegramUserDbId { - fn from(id: teloxide::types::UserId) -> Self { - Self(id) - } -} - -impl From for TelegramUserDbId { - fn from(id: u64) -> Self { - Self(teloxide::types::UserId(id)) - } -} - -impl From for teloxide::types::UserId { - fn from(user_id: TelegramUserDbId) -> Self { - user_id.0 - } -} - -impl fmt::Display for TelegramUserDbId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -// SQLx implementations for database compatibility -impl Type for TelegramUserDbId { - fn type_info() -> SqliteTypeInfo { - >::type_info() - } - - fn compatible(ty: &SqliteTypeInfo) -> bool { - >::compatible(ty) - } -} - -impl<'q> Encode<'q, Sqlite> for TelegramUserDbId { - fn encode_by_ref( - &self, - args: &mut Vec>, - ) -> Result { - >::encode(self.0 .0 as i64, args) - } -} - -impl<'r> Decode<'r, Sqlite> for TelegramUserDbId { - fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result { - let id = >::decode(value)?; - Ok(Self(teloxide::types::UserId(id as u64))) - } -} diff --git a/src/db/types/db_id/user_db_id.rs b/src/db/types/db_id/user_db_id.rs deleted file mode 100644 index 8b3e471..0000000 --- a/src/db/types/db_id/user_db_id.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! UserId newtype for type-safe user identification -//! -//! This newtype prevents accidentally mixing up user IDs with other ID types -//! while maintaining compatibility with the database layer through SQLx traits. - -use serde::{Deserialize, Serialize}; -use sqlx::{ - encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type, -}; -use std::fmt; - -/// Type-safe wrapper for user IDs -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct UserDbId(i64); - -impl UserDbId { - /// Create a new UserId from an i64 - pub fn new(id: i64) -> Self { - Self(id) - } - - /// Get the inner i64 value - pub fn get(&self) -> i64 { - self.0 - } -} - -impl From for UserDbId { - fn from(id: i64) -> Self { - Self(id) - } -} - -impl From for i64 { - fn from(user_id: UserDbId) -> Self { - user_id.0 - } -} - -impl fmt::Display for UserDbId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -// SQLx implementations for database compatibility -impl Type for UserDbId { - fn type_info() -> SqliteTypeInfo { - >::type_info() - } - - fn compatible(ty: &SqliteTypeInfo) -> bool { - >::compatible(ty) - } -} - -impl<'q> Encode<'q, Sqlite> for UserDbId { - fn encode_by_ref( - &self, - args: &mut Vec>, - ) -> Result { - >::encode_by_ref(&self.0, args) - } -} - -impl<'r> Decode<'r, Sqlite> for UserDbId { - fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result { - let id = >::decode(value)?; - Ok(Self(id)) - } -} diff --git a/src/dptree_utils.rs b/src/dptree_utils.rs index 25c20f1..7430548 100644 --- a/src/dptree_utils.rs +++ b/src/dptree_utils.rs @@ -1,3 +1,10 @@ +use std::{collections::BTreeSet, ops::ControlFlow, sync::Arc}; + +use dptree::{ + di::{Asyncify, Injectable}, + from_fn_with_description, Handler, HandlerDescription, HandlerSignature, Type, +}; + #[macro_export] macro_rules! case { // Basic variant matching without parameters @@ -72,6 +79,62 @@ macro_rules! case { }; } +pub trait MapTwo<'a, Output, Descr> { + #[must_use] + #[track_caller] + fn map2(self, proj: Proj) -> Handler<'a, Output, Descr> + where + Asyncify: Injectable<(NewType1, NewType2), Args> + Send + Sync + 'a, + NewType1: Send + Sync + 'static, + NewType2: Send + Sync + 'static; +} + +impl<'a, Output, Descr> MapTwo<'a, Output, Descr> for Handler<'a, Output, Descr> +where + Output: 'a, + Descr: HandlerDescription, +{ + fn map2(self, proj: Proj) -> Handler<'a, Output, Descr> + where + Asyncify: Injectable<(NewType1, NewType2), Args> + Send + Sync + 'a, + NewType1: Send + Sync + 'static, + NewType2: Send + Sync + 'static, + { + let proj = Arc::new(Asyncify(proj)); + + self.chain(from_fn_with_description( + Descr::map(), + move |container, cont| { + let proj = Arc::clone(&proj); + + async move { + let proj = proj.inject(&container); + let (res1, res2) = proj().await; + std::mem::drop(proj); + + let mut intermediate = container.clone(); + intermediate.insert(res1); + intermediate.insert(res2); + match cont(intermediate).await { + ControlFlow::Continue(_) => ControlFlow::Continue(container), + ControlFlow::Break(result) => ControlFlow::Break(result), + } + } + }, + HandlerSignature::Other { + obligations: + as Injectable<(NewType1, NewType2), Args>>::obligations(), + guaranteed_outcomes: BTreeSet::from_iter(vec![ + Type::of::(), + Type::of::(), + ]), + conditional_outcomes: BTreeSet::new(), + continues: true, + }, + )) + } +} + #[cfg(test)] mod tests { use std::ops::ControlFlow; diff --git a/src/handle_error.rs b/src/handle_error.rs index adac690..6ff3d13 100644 --- a/src/handle_error.rs +++ b/src/handle_error.rs @@ -1,18 +1,16 @@ use crate::{ - message_utils::{send_message, MessageTarget}, + message_utils::{MessageTarget, SendHtmlMessage}, wrap_endpoint, BotError, BotResult, WrappedAsyncFn, }; use futures::future::BoxFuture; -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:?}"); match error { - BotError::UserVisibleError(message) => send_message(&bot, target, message, None).await?, + BotError::UserVisibleError(message) => bot.send_html_message(target, message, None).await?, BotError::InternalError(_) => { - send_message( - &bot, + bot.send_html_message( target, "An internal error occurred. Please try again later.", None, @@ -27,7 +25,7 @@ fn boxed_handle_error( bot: Bot, target: MessageTarget, error: BotError, -) -> Pin + Send>> { +) -> BoxFuture<'static, BotResult> { Box::pin(handle_error(bot, target, error)) } diff --git a/src/main.rs b/src/main.rs index f8ab6b6..3097e42 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,7 +19,7 @@ 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::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::sqlite_storage::SqliteStorage; @@ -33,6 +33,18 @@ use teloxide::dispatching::dialogue::serializer::Json; use teloxide::{prelude::*, types::BotCommand, utils::command::BotCommands}; pub use wrap_endpoint::*; +#[derive(Clone)] +pub struct App { + pub bot: Bot, + pub daos: DAOs, +} + +impl App { + pub fn new(bot: Bot, daos: DAOs) -> Self { + Self { bot, daos } + } +} + /// Set up the bot's command menu that appears when users tap the menu button async fn setup_bot_commands(bot: &Bot) -> Result<()> { info!("Setting up bot command menu..."); @@ -94,6 +106,8 @@ async fn main() -> Result<()> { setup_bot_commands(&bot).await?; let dialog_storage = SqliteStorage::new(db_pool.clone(), Json).await?; + let daos = DAOs::new(db_pool.clone()); + let app = App::new(bot.clone(), daos.clone()); // Create dispatcher with dialogue system Dispatcher::builder( @@ -137,11 +151,7 @@ async fn main() -> Result<()> { ) .branch(Update::filter_message().endpoint(with_error_handler(unknown_message_handler))), ) - .dependencies(dptree::deps![ - dialog_storage, - ListingDAO::new(db_pool.clone()), - UserDAO::new(db_pool.clone()) - ]) + .dependencies(dptree::deps![dialog_storage, daos, app]) .enable_ctrlc_handler() .worker_queue_size(1) .build() diff --git a/src/message_utils.rs b/src/message_utils.rs index fa58f5e..3e5faae 100644 --- a/src/message_utils.rs +++ b/src/message_utils.rs @@ -121,8 +121,28 @@ 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 -pub async fn send_message( +async fn send_html_message( bot: &Bot, target: MessageTarget, text: impl AsRef, diff --git a/src/wrap_endpoint.rs b/src/wrap_endpoint.rs index 3058b50..2780c44 100644 --- a/src/wrap_endpoint.rs +++ b/src/wrap_endpoint.rs @@ -68,32 +68,36 @@ macro_rules! generate_wrapped { }; } -generate_wrapped!([], []); -generate_wrapped!([T1], []); -generate_wrapped!([T1, T2], []); -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], []); +macro_rules! generate_wrapped_all { + // Entry: two lists (base generics, error generics) + ([$($base:ident),*], [$($err:ident),*]) => { + generate_wrapped_all!(@recurse_base [$($base),*] [$($err),*]); + }; -generate_wrapped!([], [E1]); -generate_wrapped!([T1], [E1]); -generate_wrapped!([T1, T2], [E1]); -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]); + // Recurse over base prefixes (from full list down to empty) + (@recurse_base [] [$($err:ident),*]) => { + generate_wrapped_all!(@recurse_err [] [$($err),*]); + }; + (@recurse_base [$head:ident $(, $tail:ident)*] [$($err:ident),*]) => { + generate_wrapped_all!(@recurse_err [$head $(, $tail)*] [$($err),*]); + generate_wrapped_all!(@recurse_base [$( $tail ),*] [$($err),*]); + }; -generate_wrapped!([], [E1, E2]); -generate_wrapped!([T1], [E1, E2]); -generate_wrapped!([T1, T2], [E1, E2]); -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]); + // For a fixed base prefix, recurse over error prefixes (from full list down to empty) + (@recurse_err [$($base_current:ident),*] []) => { + generate_wrapped!([$($base_current),*], []); + }; + (@recurse_err [$($base_current:ident),*] [$ehead:ident $(, $etail:ident)*]) => { + generate_wrapped!([$($base_current),*], [$ehead $(, $etail)*]); + generate_wrapped_all!(@recurse_err [$($base_current),*] [$( $etail ),*]); + }; +} + +// Generate cartesian product of prefixes up to 12 base generics and 2 error generics +generate_wrapped_all!( + [T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12], + [E1, E2] +); pub fn wrap_endpoint( fn_base: FnBase,