From cf02bfd6d719e3a0e1e481b89e249e24e4a93449 Mon Sep 17 00:00:00 2001 From: Dylan Knutson Date: Fri, 29 Aug 2025 06:31:19 +0000 Subject: [PATCH] Major refactor: restructure new listing command and update data models - Refactor new_listing from single file to modular structure - Add handler factory pattern for state management - Improve keyboard utilities and validations - Update database models for bid, listing, and user systems - Add new types: listing_duration, user_row_id - Remove deprecated user_id type - Update Docker configuration - Enhance test utilities and message handling --- .devcontainer/Dockerfile.devcontainer | 6 + .devcontainer/devcontainer.json | 11 +- Cargo.lock | 45 + Cargo.toml | 1 + src/commands/mod.rs | 4 - src/commands/my_listings.rs | 281 +++- src/commands/new_listing.rs | 1348 ------------------- src/commands/new_listing/handler_factory.rs | 56 + src/commands/new_listing/keyboard.rs | 53 + src/commands/new_listing/mod.rs | 1070 +++++++++++++++ src/commands/new_listing/types.rs | 45 + src/commands/new_listing/validations.rs | 74 + src/db/dao/listing_dao.rs | 57 +- src/db/dao/user_dao.rs | 26 +- src/db/models/bid.rs | 4 +- src/db/models/listing.rs | 8 +- src/db/models/listing_media.rs | 1 + src/db/models/new_listing.rs | 6 +- src/db/models/proxy_bid.rs | 2 +- src/db/models/user.rs | 4 +- src/db/models/user_settings.rs | 1 + src/db/types/listing_duration.rs | 81 ++ src/db/types/listing_id.rs | 3 +- src/db/types/mod.rs | 14 +- src/db/types/{user_id.rs => user_row_id.rs} | 18 +- src/keyboard_utils.rs | 4 +- src/main.rs | 93 +- src/message_utils.rs | 163 ++- src/sqlite_storage.rs | 1 - src/test_utils.rs | 2 - 30 files changed, 1936 insertions(+), 1546 deletions(-) create mode 100644 .devcontainer/Dockerfile.devcontainer delete mode 100644 src/commands/new_listing.rs create mode 100644 src/commands/new_listing/handler_factory.rs create mode 100644 src/commands/new_listing/keyboard.rs create mode 100644 src/commands/new_listing/mod.rs create mode 100644 src/commands/new_listing/types.rs create mode 100644 src/commands/new_listing/validations.rs create mode 100644 src/db/types/listing_duration.rs rename src/db/types/{user_id.rs => user_row_id.rs} (82%) diff --git a/.devcontainer/Dockerfile.devcontainer b/.devcontainer/Dockerfile.devcontainer new file mode 100644 index 0000000..3f114ac --- /dev/null +++ b/.devcontainer/Dockerfile.devcontainer @@ -0,0 +1,6 @@ +FROM mcr.microsoft.com/devcontainers/rust:1-1-bookworm + +RUN sudo apt update && \ + sudo apt install -qqy --no-install-recommends --no-install-suggests sqlite3 && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 9dec148..c8266bc 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,7 +3,12 @@ { "name": "Rust", // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/rust:1-1-bookworm", + "image": { + "build": { + "dockerfile": "Dockerfile.devcontainer", + "context": "." + } + }, "features": { "ghcr.io/braun-daniel/devcontainer-features/fzf:1": {}, "ghcr.io/stuartleeks/dev-container-features/shell-history:0": {}, @@ -19,7 +24,7 @@ "eamodio.gitlens" ] } - } + }, // Use 'mounts' to make the cargo cache persistent in a Docker Volume. // "mounts": [ // { @@ -33,7 +38,7 @@ // Use 'forwardPorts' to make a list of ports inside the container available locally. // "forwardPorts": [], // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "rustc --version", + "postCreateCommand": "sudo apt update && sudo apt install -y sqlite3" // Configure tool-specific properties. // "customizations": {}, // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. diff --git a/Cargo.lock b/Cargo.lock index c0e1ca2..64a7419 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1393,6 +1393,30 @@ dependencies = [ "tempfile", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -1410,6 +1434,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -1436,6 +1469,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1551,6 +1595,7 @@ dependencies = [ "futures", "lazy_static", "log", + "num", "rstest", "rust_decimal", "serde", diff --git a/Cargo.toml b/Cargo.toml index 3bbd88c..a42f448 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ serde = "1.0.219" futures = "0.3.31" thiserror = "2.0.16" teloxide-core = "0.13.0" +num = "0.4.3" [dev-dependencies] rstest = "0.26.1" diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 5460e8f..7d67043 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,11 +5,7 @@ pub mod new_listing; pub mod settings; pub mod start; -// Re-export all command handlers for easy access pub use help::handle_help; pub use my_bids::handle_my_bids; -pub use my_listings::handle_my_listings; pub use settings::handle_settings; pub use start::handle_start; - -// Note: Text message handling is now handled by the dialogue system diff --git a/src/commands/my_listings.rs b/src/commands/my_listings.rs index ec4b973..b621e4f 100644 --- a/src/commands/my_listings.rs +++ b/src/commands/my_listings.rs @@ -1,23 +1,270 @@ -use log::info; -use teloxide::{prelude::*, types::Message, Bot}; +use crate::{ + case, + db::{Listing, ListingDAO, ListingId, User, UserDAO}, + keyboard_buttons, + message_utils::{ + extract_callback_data, pluralize_with_count, send_message, HandleAndId, MessageTarget, + }, + Command, DialogueRootState, HandlerResult, RootDialogue, +}; +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; +use teloxide::{ + dispatching::{DpHandlerDescription, UpdateFilterExt}, + prelude::*, + types::{InlineKeyboardButton, Message, MessageId, ParseMode}, + Bot, +}; -use crate::HandlerResult; +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum MyListingsState { + ViewingListings, + ManagingListing(ListingId), + EditingListing(ListingId), +} +impl From for DialogueRootState { + fn from(state: MyListingsState) -> Self { + DialogueRootState::MyListings(state) + } +} -pub async fn handle_my_listings(bot: Bot, msg: Message) -> HandlerResult { - let response = "๐Ÿ“Š My Listings and Auctions (Coming Soon)\n\n\ - Here you'll be able to view and manage:\n\ - โ€ข Your active listings and auctions\n\ - โ€ข Listing performance\n\ - โ€ข Bid history\n\ - โ€ข Winner selection (for blind auctions)\n\n\ - Feature in development! ๐Ÿ”ง"; +keyboard_buttons! { + enum ManageListingButtons { + [ + Edit("โœ๏ธ Edit", "manage_listing_edit"), + Delete("๐Ÿ—‘๏ธ Delete", "manage_listing_delete"), + ], + [ + Back("โฌ…๏ธ Back", "manage_listing_back"), + ] + } +} - info!( - "User {} ({}) checked their listings", - msg.chat.username().unwrap_or("unknown"), - msg.chat.id - ); +pub fn my_listings_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> { + dptree::entry() + .branch( + Update::filter_message().filter_command::().branch( + dptree::case![Command::MyListings].endpoint(handle_my_listings_command_input), + ), + ) + .branch( + Update::filter_callback_query() + .branch( + // Callback when user taps a listing ID button to manage that listing + case![DialogueRootState::MyListings( + MyListingsState::ViewingListings + )] + .endpoint(handle_viewing_listings_callback), + ) + .branch( + case![DialogueRootState::MyListings( + MyListingsState::ManagingListing(listing_id) + )] + .endpoint(handle_managing_listing_callback), + ), + ) +} - bot.send_message(msg.chat.id, response).await?; +async fn handle_my_listings_command_input( + db_pool: SqlitePool, + bot: Bot, + dialogue: RootDialogue, + msg: Message, +) -> HandlerResult { + let from = msg.from.unwrap(); + show_listings_for_user(db_pool, dialogue, bot, from.id, msg.chat).await?; Ok(()) } + +async fn show_listings_for_user( + db_pool: SqlitePool, + dialogue: RootDialogue, + bot: Bot, + user: teloxide::types::UserId, + target: impl Into, +) -> HandlerResult { + // If we reach here, show the listings menu + let user = match UserDAO::find_by_telegram_id(&db_pool, user).await? { + Some(user) => user, + None => { + send_message( + &bot, + target, + "You don't have an account. Try creating an auction first.", + None, + ) + .await?; + return Err(anyhow::anyhow!("User not found")); + } + }; + + // Transition to ViewingListings state + dialogue.update(MyListingsState::ViewingListings).await?; + + let listings = ListingDAO::find_by_seller(&db_pool, user.id).await?; + if listings.is_empty() { + send_message( + &bot, + target, + "๐Ÿ“‹ My Listings\n\n\ + You don't have any listings yet.\n\ + Use /newlisting to create your first listing!", + None, + ) + .await?; + return Ok(()); + } + + // Create keyboard with buttons for each listing + let mut keyboard = teloxide::types::InlineKeyboardMarkup::default(); + for listing in &listings { + keyboard = keyboard.append_row(vec![InlineKeyboardButton::callback( + listing.base.title.to_string(), + listing.base.id.to_string(), + )]); + } + + let mut response = format!( + "๐Ÿ“‹ My Listings\n\n\ + You have {}.\n\n", + pluralize_with_count(listings.len(), "listing", "listings") + ); + + // Add each listing with its ID and title + for listing in &listings { + response.push_str(&format!( + "โ€ข ID {}: {}\n", + listing.base.id, listing.base.title + )); + } + + response.push_str("\nTap a listing ID below to view details:"); + send_message(&bot, target, response, Some(keyboard)).await?; + Ok(()) +} + +async fn handle_viewing_listings_callback( + db_pool: SqlitePool, + bot: Bot, + dialogue: RootDialogue, + callback_query: CallbackQuery, +) -> HandlerResult { + let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?; + let target = (from.clone(), message_id); + + let listing_id = ListingId::new(data.parse::()?); + let (_, listing) = + get_user_and_listing(&db_pool, &bot, from.id, listing_id, target.clone()).await?; + dialogue + .update(MyListingsState::ManagingListing(listing_id)) + .await?; + show_listing_details(&bot, listing, target).await?; + + Ok(()) +} + +async fn show_listing_details( + bot: &Bot, + listing: Listing, + target: impl Into, +) -> HandlerResult { + let response = format!( + "๐Ÿ” Viewing Listing Details\n\n\ + Title: {}\n\ + Description: {}\n\ + ID: {}", + listing.base.title, + listing + .base + .description + .as_deref() + .unwrap_or("No description"), + listing.base.id + ); + + send_message( + &bot, + target, + response, + Some(ManageListingButtons::to_keyboard()), + ) + .await?; + Ok(()) +} + +async fn handle_managing_listing_callback( + db_pool: SqlitePool, + bot: Bot, + dialogue: RootDialogue, + callback_query: CallbackQuery, + listing_id: ListingId, +) -> HandlerResult { + let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?; + let target = (from.clone(), message_id); + + let button = ManageListingButtons::try_from(data.as_str()) + .map_err(|_| anyhow::anyhow!("Invalid ManageListingButtons callback data: {}", data))?; + + match button { + ManageListingButtons::Edit => { + let (_, listing) = + get_user_and_listing(&db_pool, &bot, from.id, listing_id, target.clone()).await?; + dialogue + .update(MyListingsState::EditingListing(listing.base.id)) + .await?; + } + ManageListingButtons::Delete => { + ListingDAO::delete_listing(&db_pool, listing_id).await?; + send_message(&bot, target, "Listing deleted.", None).await?; + } + ManageListingButtons::Back => { + dialogue.update(MyListingsState::ViewingListings).await?; + show_listings_for_user(db_pool, dialogue, bot, from.id, target).await?; + } + } + + Ok(()) +} + +async fn get_user_and_listing( + db_pool: &SqlitePool, + bot: &Bot, + user_id: teloxide::types::UserId, + listing_id: ListingId, + target: impl Into, +) -> HandlerResult<(User, Listing)> { + let user = match UserDAO::find_by_telegram_id(&db_pool, user_id).await? { + Some(user) => user, + None => { + send_message( + bot, + target, + "โŒ You don't have an account. Try creating an auction first.", + None, + ) + .await?; + return Err(anyhow::anyhow!("User not found")); + } + }; + + let listing = match ListingDAO::find_by_id(&db_pool, listing_id).await? { + Some(listing) => listing, + None => { + send_message(bot, target, "โŒ Listing not found.", None).await?; + return Err(anyhow::anyhow!("Listing not found")); + } + }; + + if listing.base.seller_id != user.id { + send_message( + bot, + target, + "โŒ You can only manage your own auctions.", + None, + ) + .await?; + return Err(anyhow::anyhow!("User does not own listing")); + } + + Ok((user, listing)) +} diff --git a/src/commands/new_listing.rs b/src/commands/new_listing.rs deleted file mode 100644 index 8ba4f11..0000000 --- a/src/commands/new_listing.rs +++ /dev/null @@ -1,1348 +0,0 @@ -use crate::{ - db::{ - dao::ListingDAO, - models::new_listing::{NewListing, NewListingBase, NewListingFields}, - types::{money_amount::MoneyAmount, user_id::UserId}, - NewUser, TelegramUserId, UserDAO, - }, - keyboard_buttons, - message_utils::{ - create_multi_row_keyboard, create_single_button_keyboard, create_single_row_keyboard, - edit_html_message, extract_callback_data, is_cancel, send_html_message, HandleAndId, - }, - sqlite_storage::SqliteStorage, - Command, HandlerResult, -}; -use anyhow::bail; -use chrono::{Duration, Utc}; -use log::{error, info}; -use serde::{Deserialize, Serialize}; -use sqlx::SqlitePool; -use teloxide::{ - dispatching::{dialogue::serializer::Json, DpHandlerDescription}, - prelude::*, - types::{ - CallbackQuery, Chat, InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageId, User, - }, - Bot, -}; - -#[derive(Clone, Serialize, Deserialize, Default)] -pub struct ListingDraft { - pub title: String, - pub description: Option, - pub buy_now_price: MoneyAmount, - pub slots_available: i32, - pub start_hours: i32, - pub duration_hours: i32, -} - -#[derive(Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Debug)] -pub enum ListingField { - Title, - Description, - Price, - Slots, - StartTime, - Duration, -} - -// Dialogue state for the new listing wizard -#[derive(Clone, Default, Serialize, Deserialize)] -pub enum ListingWizardState { - #[default] - Start, - AwaitingDraftField { - field: ListingField, - draft: ListingDraft, - }, - ViewingDraft(ListingDraft), - EditingDraft(ListingDraft), - EditingDraftField { - field: ListingField, - draft: ListingDraft, - }, -} - -// Type alias for the dialogue -type NewListingDialogue = Dialogue>; - -// Common input validation functions -fn validate_title(text: &str) -> Result { - if text.is_empty() { - return Err("โŒ Title cannot be empty. Please enter a title for your listing:".to_string()); - } - if text.len() > 100 { - return Err( - "โŒ Title is too long (max 100 characters). Please enter a shorter title:".to_string(), - ); - } - Ok(text.to_string()) -} - -fn validate_description(text: &str) -> Result { - if text.len() > 1000 { - return Err( - "โŒ Description is too long (max 1000 characters). Please enter a shorter description:" - .to_string(), - ); - } - Ok(text.to_string()) -} - -fn validate_price(text: &str) -> Result { - match MoneyAmount::from_str(text) { - Ok(amount) => { - if amount.cents() <= 0 { - Err("โŒ Price must be greater than $0.00. Please enter a valid price:".to_string()) - } else { - Ok(amount) - } - } - Err(_) => Err( - "โŒ Invalid price format. Please enter a valid price (e.g., 10.50, 25, 0.99):" - .to_string(), - ), - } -} - -fn validate_slots(text: &str) -> Result { - match text.parse::() { - Ok(slots) if slots >= 1 && slots <= 1000 => Ok(slots), - Ok(_) => Err( - "โŒ Number of slots must be between 1 and 1000. Please enter a valid number:" - .to_string(), - ), - Err(_) => Err("โŒ Invalid number. Please enter a number from 1 to 1000:".to_string()), - } -} - -fn validate_duration(text: &str) -> Result { - match text.parse::() { - Ok(hours) if hours >= 1 && hours <= 720 => Ok(hours), // 1 hour to 30 days - Ok(_) => Err( - "โŒ Duration must be between 1 and 720 hours. Please enter a valid number:".to_string(), - ), - Err(_) => Err("โŒ Invalid number. Please enter number of hours (1-720):".to_string()), - } -} - -fn validate_start_time(text: &str) -> Result { - match text.parse::() { - Ok(hours) if hours >= 0 && hours <= 168 => Ok(hours), // Max 1 week delay - Ok(_) => Err( - "โŒ Start time must be between 0 and 168 hours. Please enter a valid number:" - .to_string(), - ), - Err(_) => Err( - "โŒ Invalid number. Please enter number of hours (0 for immediate start):".to_string(), - ), - } -} - -fn log_user_callback_action(callback_query: &CallbackQuery, action: &str) { - info!( - "User {} {}", - HandleAndId::from_user(&callback_query.from), - action - ); -} - -// Handle callback query errors -async fn handle_callback_error( - bot: &Bot, - dialogue: NewListingDialogue, - callback_query: CallbackQuery, -) -> HandlerResult { - if let Err(e) = bot.answer_callback_query(callback_query.id).await { - log::warn!("Failed to answer callback query: {}", e); - } - dialogue.exit().await?; - Ok(()) -} - -keyboard_buttons! { - enum DurationKeyboardButtons { - OneDay("1 day", "duration_1_day"), - ThreeDays("3 days", "duration_3_days"), - SevenDays("7 days", "duration_7_days"), - FourteenDays("14 days", "duration_14_days"), - } -} - -keyboard_buttons! { - enum SlotsKeyboardButtons { - OneSlot("1 slot", "slots_1"), - TwoSlots("2 slots", "slots_2"), - FiveSlots("5 slots", "slots_5"), - TenSlots("10 slots", "slots_10"), - } -} - -keyboard_buttons! { - enum ConfirmationKeyboardButtons { - Create("โœ… Create", "confirm_create"), - Edit("โœ๏ธ Edit", "confirm_edit"), - Discard("๐Ÿ—‘๏ธ Discard", "confirm_discard"), - } -} - -keyboard_buttons! { - enum FieldSelectionKeyboardButtons { - [ - Title("๐Ÿ“ Title", "edit_title"), - Description("๐Ÿ“„ Description", "edit_description"), - ], - [ - Price("๐Ÿ’ฐ Price", "edit_price"), - Slots("๐Ÿ”ข Slots", "edit_slots"), - ], - [ - StartTime("โฐ Start Time", "edit_start_time"), - Duration("โฑ๏ธ Duration", "edit_duration"), - ], - [ - Done("โœ… Done", "edit_done"), - ] - } -} - -keyboard_buttons! { - enum StartTimeKeyboardButtons { - Now("Now", "start_time_now"), - } -} - -fn create_back_button_keyboard_with(other_buttons: InlineKeyboardMarkup) -> InlineKeyboardMarkup { - other_buttons.append_row([InlineKeyboardButton::callback("๐Ÿ”™ Back", "edit_back")]) -} - -fn create_back_button_keyboard() -> InlineKeyboardMarkup { - create_single_button_keyboard("๐Ÿ”™ Back", "edit_back") -} - -// Create back button with clear option -fn create_back_button_keyboard_with_clear(field: &str) -> InlineKeyboardMarkup { - create_single_row_keyboard(&[ - ("๐Ÿ”™ Back", "edit_back"), - ( - &format!("๐Ÿงน Clear {}", field), - &format!("edit_clear_{}", field), - ), - ]) -} - -fn create_skip_keyboard() -> InlineKeyboardMarkup { - create_single_button_keyboard("Skip", "skip") -} - -fn create_cancel_keyboard() -> InlineKeyboardMarkup { - create_single_button_keyboard("Cancel", "cancel") -} - -fn create_skip_cancel_keyboard() -> InlineKeyboardMarkup { - create_multi_row_keyboard(&[&[("Skip", "skip"), ("Cancel", "cancel")]]) -} - -// ============================================================================ -// HANDLER TREE AND MAIN FUNCTIONS -// ============================================================================ - -// Create the dialogue handler tree for new listing wizard -pub fn new_listing_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> { - dptree::entry() - .branch( - Update::filter_message() - .enter_dialogue::, ListingWizardState>() - .branch( - dptree::entry() - .filter_command::() - .chain(dptree::case![Command::NewListing]) - .chain(dptree::case![ListingWizardState::Start]) - .endpoint(handle_new_listing_command), - ) - .branch( - dptree::case![ListingWizardState::AwaitingDraftField { field, draft }] - .endpoint(handle_awaiting_draft_field_input), - ) - .branch( - dptree::case![ListingWizardState::EditingDraftField { field, draft }] - .endpoint(handle_editing_field_input), - ), - ) - .branch( - Update::filter_callback_query() - .enter_dialogue::, ListingWizardState>() - .branch( - dptree::case![ListingWizardState::AwaitingDraftField { field, draft }] - .endpoint(handle_awaiting_draft_field_callback), - ) - .branch( - dptree::case![ListingWizardState::ViewingDraft(draft)] - .endpoint(handle_viewing_draft_callback), - ) - .branch( - dptree::case![ListingWizardState::EditingDraft(draft)] - .endpoint(handle_editing_draft_callback), - ) - .branch( - dptree::case![ListingWizardState::EditingDraftField { field, draft }] - .endpoint(handle_editing_draft_field_callback), - ), - ) -} - -// Handle the /newlisting command - starts the dialogue by setting it to Start state -async fn handle_new_listing_command( - bot: Bot, - dialogue: NewListingDialogue, - msg: Message, -) -> HandlerResult { - info!( - "User {} started new fixed price listing wizard", - HandleAndId::from_chat(&msg.chat), - ); - - // Initialize the dialogue to Start state - dialogue - .update(ListingWizardState::AwaitingDraftField { - field: ListingField::Title, - draft: ListingDraft::default(), - }) - .await?; - - let response = "๐Ÿ›๏ธ Creating New Fixed Price Listing\n\n\ - Let's create your fixed price listing step by step!\n\n\ - Step 1 of 6: Title\n\ - Please enter a title for your listing (max 100 characters):"; - - send_html_message(&bot, &msg.chat, response, Some(create_cancel_keyboard())).await?; - Ok(()) -} - -pub async fn handle_awaiting_draft_field_input( - bot: Bot, - dialogue: NewListingDialogue, - (field, draft): (ListingField, ListingDraft), - msg: Message, -) -> HandlerResult { - let chat = msg.chat.clone(); - let text = msg.text().unwrap_or("").trim(); - info!( - "User {} entered input step: {:?}", - HandleAndId::from_chat(&chat), - field - ); - - if is_cancel(text) { - return cancel_wizard(bot, dialogue, &chat).await; - } - - match field { - ListingField::Title => handle_title_input(bot, chat, text, dialogue, draft).await, - ListingField::Description => { - handle_description_input(bot, chat, text, dialogue, draft).await - } - ListingField::Price => handle_price_input(bot, chat, text, dialogue, draft).await, - ListingField::Slots => handle_slots_input(bot, chat, text, dialogue, draft).await, - ListingField::StartTime => handle_start_time_input(bot, chat, text, dialogue, draft).await, - ListingField::Duration => handle_duration_input(bot, chat, text, dialogue, draft).await, - } -} - -pub async fn handle_title_input( - bot: Bot, - chat: Chat, - text: &str, - dialogue: NewListingDialogue, - mut draft: ListingDraft, -) -> HandlerResult { - match validate_title(text) { - Ok(title) => { - draft.title = title; - dialogue - .update(ListingWizardState::AwaitingDraftField { - field: ListingField::Description, - draft, - }) - .await?; - - let response = "โœ… Title saved!\n\n\ - Step 2 of 6: Description\n\ - Please enter a description for your listing (optional)."; - - send_html_message(&bot, &chat, response, Some(create_skip_cancel_keyboard())).await - } - Err(error_msg) => send_html_message(&bot, &chat, &error_msg, None).await, - } -} - -pub async fn handle_description_input( - bot: Bot, - chat: Chat, - text: &str, - dialogue: NewListingDialogue, - mut draft: ListingDraft, -) -> HandlerResult { - draft.description = match validate_description(text) { - Ok(description) => Some(description), - Err(error_msg) => { - send_html_message(&bot, &chat, &error_msg, None).await?; - return Ok(()); - } - }; - - dialogue - .update(ListingWizardState::AwaitingDraftField { - field: ListingField::Price, - draft, - }) - .await?; - - let response = "โœ… Description saved!\n\n\ - Step 3 of 6: Price\n\ - Please enter the fixed price for your listing (e.g., 10.50, 25, 0.99):\n\n\ - ๐Ÿ’ก Price should be in USD"; - - send_html_message(&bot, &chat, response, None).await -} - -pub async fn handle_description_callback( - bot: Bot, - callback_query: CallbackQuery, - dialogue: NewListingDialogue, - draft: ListingDraft, - data: &str, - chat: HandleAndId<'_>, -) -> HandlerResult { - match data { - "skip" => { - log_user_callback_action(&callback_query, "skipped description"); - - dialogue - .update(ListingWizardState::AwaitingDraftField { - field: ListingField::Price, - draft, - }) - .await?; - - let response = "โœ… Description skipped!\n\n\ - Step 3 of 6: Price\n\ - Please enter the fixed price for your listing (e.g., 10.50, 25, 0.99):\n\n\ - ๐Ÿ’ก Price should be in USD"; - - if let Some(message) = callback_query.message { - edit_html_message(&bot, chat, message.id(), &response, None).await?; - } - } - "cancel" => { - log_user_callback_action(&callback_query, "cancelled description"); - return cancel_wizard(bot, dialogue, chat).await; - } - _ => { - handle_callback_error(&bot, dialogue, callback_query).await?; - } - } - - Ok(()) -} - -pub async fn handle_awaiting_draft_field_callback( - bot: Bot, - dialogue: NewListingDialogue, - (field, draft): (ListingField, ListingDraft), - callback_query: CallbackQuery, -) -> HandlerResult { - let (data, from) = extract_callback_data(&bot, &callback_query).await?; - let chat = HandleAndId::from_user(&from); - info!("User {} selected callback: {:?}", chat, data); - - if data == "cancel" { - return cancel_wizard(bot, dialogue, chat).await; - } - - match field { - ListingField::Title => handle_callback_error(&bot, dialogue, callback_query).await, - ListingField::Description => { - handle_description_callback(bot, callback_query, dialogue, draft, data.as_str(), chat) - .await - } - ListingField::Price => handle_callback_error(&bot, dialogue, callback_query).await, - ListingField::Slots => { - handle_slots_callback(bot, callback_query, dialogue, draft, data.as_str(), chat).await - } - ListingField::StartTime => { - handle_start_time_callback(bot, callback_query, dialogue, draft, data.as_str(), chat) - .await - } - ListingField::Duration => { - handle_duration_callback(bot, callback_query, dialogue, draft, data.as_str(), chat) - .await - } - } -} - -pub async fn handle_slots_callback( - bot: Bot, - _: CallbackQuery, - dialogue: NewListingDialogue, - draft: ListingDraft, - data: &str, - chat: HandleAndId<'_>, -) -> HandlerResult { - let button = SlotsKeyboardButtons::try_from(data).unwrap(); - let num_slots = match button { - SlotsKeyboardButtons::OneSlot => 1, - SlotsKeyboardButtons::TwoSlots => 2, - SlotsKeyboardButtons::FiveSlots => 5, - SlotsKeyboardButtons::TenSlots => 10, - }; - process_slots_and_respond(&bot, dialogue, draft, chat, num_slots).await?; - Ok(()) -} - -pub async fn handle_start_time_callback( - bot: Bot, - callback_query: CallbackQuery, - dialogue: NewListingDialogue, - draft: ListingDraft, - data: &str, - chat: impl Into>, -) -> HandlerResult { - let chat = chat.into(); - let button = StartTimeKeyboardButtons::try_from(data).unwrap(); - match button { - StartTimeKeyboardButtons::Now => { - info!("User {} selected 'Now' for start time", chat); - // Answer the callback query to remove the loading state - bot.answer_callback_query(callback_query.id).await?; - process_start_time_and_respond(&bot, dialogue, draft, chat, 0).await?; - } - } - - Ok(()) -} - -// Helper function to process slots input, update dialogue state, and send response -async fn process_slots_and_respond( - bot: &Bot, - dialogue: NewListingDialogue, - mut draft: ListingDraft, - chat: impl Into>, - slots: i32, -) -> HandlerResult { - let chat = chat.into(); - // Update dialogue state - draft.slots_available = slots; - dialogue - .update(ListingWizardState::AwaitingDraftField { - field: ListingField::StartTime, - draft, - }) - .await - .unwrap(); - - // Send response message with inline button - let response = format!( - "โœ… Available slots: {slots}\n\n\ - Step 5 of 6: Start Time\n\ - When should your listing start?\n\ - โ€ข Click 'Now' to start immediately\n\ - โ€ข Enter number of hours to delay (e.g., '2' for 2 hours from now)\n\ - โ€ข Maximum delay: 168 hours (7 days)" - ); - - send_html_message( - &bot, - chat, - &response, - Some(StartTimeKeyboardButtons::to_keyboard()), - ) - .await?; - - Ok(()) -} - -pub async fn handle_viewing_draft_callback( - db_pool: SqlitePool, - bot: Bot, - dialogue: NewListingDialogue, - draft: ListingDraft, - callback_query: CallbackQuery, -) -> HandlerResult { - let (data, from) = extract_callback_data(&bot, &callback_query).await?; - let chat = HandleAndId::from_user(&from); - let message = callback_query.message.unwrap(); - - let button = ConfirmationKeyboardButtons::try_from(data.as_str()).unwrap(); - match button { - ConfirmationKeyboardButtons::Create => { - info!( - "User {} confirmed listing creation", - HandleAndId::from_user(&callback_query.from) - ); - - // Exit dialogue and create listing - dialogue.exit().await?; - - create_listing( - db_pool, - bot, - dialogue, - callback_query.from, - message.id(), - draft.clone(), - ) - .await?; - } - ConfirmationKeyboardButtons::Discard => { - info!( - "User {} discarded listing creation", - HandleAndId::from_user(&callback_query.from) - ); - - // Exit dialogue and send cancellation message - dialogue.exit().await?; - - let response = "๐Ÿ—‘๏ธ Listing Discarded\n\n\ - Your listing has been discarded and not created.\n\ - You can start a new listing anytime with /newlisting."; - - edit_html_message(&bot, chat, message.id(), &response, None).await?; - } - ConfirmationKeyboardButtons::Edit => { - info!( - "User {} chose to edit listing", - HandleAndId::from_user(&callback_query.from) - ); - - // Go to editing state to allow user to modify specific fields - dialogue - .update(ListingWizardState::EditingDraft(draft.clone())) - .await?; - - // Delete the old message and show the edit screen - show_edit_screen(bot, chat, draft, None, message.regular_message()).await?; - } - } - - Ok(()) -} - -// Helper function to process start time input, update dialogue state, and send response -async fn process_start_time_and_respond( - bot: &Bot, - dialogue: NewListingDialogue, - mut draft: ListingDraft, - chat: impl Into>, - hours: i32, -) -> HandlerResult { - let chat = chat.into(); - // Update dialogue state - draft.start_hours = hours; - dialogue - .update(ListingWizardState::AwaitingDraftField { - field: ListingField::Duration, - draft, - }) - .await?; - - // Generate response message - let start_msg = if hours == 0 { - "immediately".to_string() - } else { - format!("in {} hour{}", hours, if hours == 1 { "" } else { "s" }) - }; - - let response = format!( - "โœ… Listing will start: {}\n\n\ - Step 6 of 6: Duration\n\ - How long should your listing run?\n\ - Enter duration in hours (minimum 1 hour, maximum 720 hours / 30 days):", - start_msg - ); - - send_html_message( - &bot, - chat, - &response, - Some(DurationKeyboardButtons::to_keyboard()), - ) - .await?; - - Ok(()) -} - -pub async fn handle_price_input( - bot: Bot, - chat: Chat, - text: &str, - dialogue: NewListingDialogue, - mut draft: ListingDraft, -) -> HandlerResult { - match validate_price(text) { - Ok(price) => { - draft.buy_now_price = price; - - let response = format!( - "โœ… Price saved: ${}\n\n\ - Step 4 of 6: Available Slots\n\ - How many items are available for sale?\n\n\ - Choose a common value below or enter a custom number (1-1000):", - draft.buy_now_price - ); - - dialogue - .update(ListingWizardState::AwaitingDraftField { - field: ListingField::Slots, - draft, - }) - .await?; - - send_html_message( - &bot, - &chat, - &response, - Some(SlotsKeyboardButtons::to_keyboard()), - ) - .await? - } - Err(error_msg) => send_html_message(&bot, &chat, &error_msg, None).await?, - } - - Ok(()) -} - -pub async fn handle_slots_input( - bot: Bot, - chat: Chat, - text: &str, - dialogue: NewListingDialogue, - draft: ListingDraft, -) -> HandlerResult { - match validate_slots(text) { - Ok(slots) => { - process_slots_and_respond(&bot, dialogue, draft, &chat, slots).await?; - } - Err(error_msg) => { - send_html_message(&bot, &chat, &error_msg, None).await?; - } - } - - Ok(()) -} - -pub async fn handle_start_time_input( - bot: Bot, - chat: Chat, - text: &str, - dialogue: NewListingDialogue, - draft: ListingDraft, -) -> HandlerResult { - match validate_start_time(text) { - Ok(hours) => { - process_start_time_and_respond(&bot, dialogue, draft, &chat, hours).await?; - } - Err(error_msg) => { - send_html_message( - &bot, - &chat, - &error_msg, - Some(StartTimeKeyboardButtons::to_keyboard()), - ) - .await?; - } - } - - Ok(()) -} - -pub async fn handle_duration_input( - bot: Bot, - chat: Chat, - text: &str, - dialogue: NewListingDialogue, - draft: ListingDraft, -) -> HandlerResult { - match validate_duration(text) { - Ok(duration) => { - process_duration_and_respond(bot, dialogue, draft, &chat, duration).await?; - } - Err(error_msg) => { - send_html_message(&bot, &chat, &error_msg, None).await?; - } - } - Ok(()) -} - -pub async fn handle_duration_callback( - bot: Bot, - _: CallbackQuery, - dialogue: NewListingDialogue, - draft: ListingDraft, - data: &str, - chat: HandleAndId<'_>, -) -> HandlerResult { - let button = DurationKeyboardButtons::try_from(data).unwrap(); - let days = match button { - DurationKeyboardButtons::OneDay => 1, - DurationKeyboardButtons::ThreeDays => 3, - DurationKeyboardButtons::SevenDays => 7, - DurationKeyboardButtons::FourteenDays => 14, - }; - - process_duration_and_respond(bot, dialogue, draft, chat, days).await -} - -async fn process_duration_and_respond( - bot: Bot, - dialogue: NewListingDialogue, - mut draft: ListingDraft, - chat: impl Into>, - duration_days: i32, -) -> HandlerResult { - let chat = chat.into(); - draft.duration_hours = duration_days * 24; - dialogue - .update(ListingWizardState::ViewingDraft(draft.clone())) - .await?; - - show_confirmation(bot, chat, draft, None).await -} - -async fn show_confirmation( - bot: Bot, - chat: HandleAndId<'_>, - state: ListingDraft, - edit_message: Option<&Message>, -) -> HandlerResult { - let description_text = state - .description - .as_deref() - .unwrap_or("No description"); - - let start_time_str = if state.start_hours == 0 { - "Immediately".to_string() - } else { - format!( - "In {} hour{}", - state.start_hours, - if state.start_hours == 1 { "" } else { "s" } - ) - }; - - let response = format!( - "๐Ÿ“‹ Listing Summary\n\n\ - Title: {}\n\ - Description: {}\n\ - Price: ${}\n\ - Available Slots: {}\n\ - Start Time: {}\n\ - Duration: {} hour{}\n\n\ - Please review your listing and choose an action:", - state.title, - description_text, - state.buy_now_price, - state.slots_available, - start_time_str, - state.duration_hours, - if state.duration_hours == 1 { "" } else { "s" } - ); - - if let Some(message) = edit_message { - edit_html_message( - &bot, - chat, - message.id, - &response, - Some(ConfirmationKeyboardButtons::to_keyboard()), - ) - .await?; - } else { - send_html_message( - &bot, - chat, - &response, - Some(ConfirmationKeyboardButtons::to_keyboard()), - ) - .await?; - } - - Ok(()) -} - -async fn show_edit_screen( - bot: Bot, - chat: HandleAndId<'_>, - state: ListingDraft, - flash_message: Option<&str>, - edit_message: Option<&Message>, -) -> HandlerResult { - let description_text = state - .description - .as_deref() - .unwrap_or("No description"); - - let start_time_str = if state.start_hours == 0 { - "Immediately".to_string() - } else { - format!( - "In {} hour{}", - state.start_hours, - if state.start_hours == 1 { "" } else { "s" } - ) - }; - - let mut response = format!( - "โœ๏ธ Editing Listing:\n\n\ - ๐Ÿ“ Title: {}\n\ - ๐Ÿ“„ Description: {}\n\ - ๐Ÿ’ฐ Price: ${}\n\ - ๐Ÿ”ข Available Slots: {}\n\ - โฐ Start Time: {}\n\ - โณ Duration: {} hour{}\n\n\ - Select a field to edit:", - state.title, - description_text, - state.buy_now_price, - state.slots_available, - start_time_str, - state.duration_hours, - if state.duration_hours == 1 { "" } else { "s" } - ); - - if let Some(flash_message) = flash_message { - response = format!("{}\n\n{}", flash_message, response); - } - - if let Some(message) = edit_message { - edit_html_message( - &bot, - chat, - message.id, - &response, - Some(FieldSelectionKeyboardButtons::to_keyboard()), - ) - .await?; - } else { - send_html_message( - &bot, - chat, - &response, - Some(FieldSelectionKeyboardButtons::to_keyboard()), - ) - .await?; - } - - Ok(()) -} - -pub async fn handle_editing_field_input( - bot: Bot, - dialogue: NewListingDialogue, - (field, draft): (ListingField, ListingDraft), - msg: Message, -) -> HandlerResult { - let chat = HandleAndId::from_chat(&msg.chat); - let text = msg.text().unwrap_or("").trim(); - - info!("User {} editing field {:?}", chat, field); - - match field { - ListingField::Title => { - handle_edit_title(bot, dialogue, draft, text, chat).await?; - } - ListingField::Description => { - handle_edit_description(bot, dialogue, draft, text, chat).await?; - } - ListingField::Price => { - handle_edit_price(bot, dialogue, draft, text, chat).await?; - } - ListingField::Slots => { - handle_edit_slots(bot, dialogue, draft, text, chat).await?; - } - ListingField::StartTime => { - handle_edit_start_time(bot, dialogue, draft, text, chat).await?; - } - ListingField::Duration => { - handle_edit_duration(bot, dialogue, draft, text, chat).await?; - } - } - - Ok(()) -} - -pub async fn handle_editing_draft_callback( - bot: Bot, - draft: ListingDraft, - dialogue: NewListingDialogue, - callback_query: CallbackQuery, -) -> HandlerResult { - let (data, from_user) = extract_callback_data(&bot, &callback_query).await?; - let chat = HandleAndId::from_user(&from_user); - info!("User {} in editing screen, showing field selection", chat); - - let (field, keyboard) = match data.as_str() { - "edit_title" => (ListingField::Title, create_back_button_keyboard()), - "edit_description" => ( - ListingField::Description, - create_back_button_keyboard_with_clear("description"), - ), - "edit_price" => (ListingField::Price, create_back_button_keyboard()), - "edit_slots" => ( - ListingField::Slots, - create_back_button_keyboard_with(SlotsKeyboardButtons::to_keyboard()), - ), - "edit_start_time" => ( - ListingField::StartTime, - create_back_button_keyboard_with(StartTimeKeyboardButtons::to_keyboard()), - ), - "edit_duration" => ( - ListingField::Duration, - create_back_button_keyboard_with(DurationKeyboardButtons::to_keyboard()), - ), - "edit_done" => { - dialogue - .update(ListingWizardState::ViewingDraft(draft.clone())) - .await?; - show_confirmation(bot, chat, draft, callback_query.regular_message()).await?; - return Ok(()); - } - _ => { - send_html_message(&bot, chat, "โŒ Invalid field. Please try again.", None).await?; - return Ok(()); - } - }; - - dialogue - .update(ListingWizardState::EditingDraftField { - field, - draft: draft.clone(), - }) - .await?; - - // update the message to show the edit screen - edit_html_message( - &bot, - chat, - callback_query.regular_message().unwrap().id, - format!("Editing {:?}", field).as_str(), - Some(keyboard), - ) - .await?; - - Ok(()) -} - -async fn create_listing( - db_pool: SqlitePool, - bot: Bot, - dialogue: NewListingDialogue, - from: User, - message_id: MessageId, - draft: ListingDraft, -) -> HandlerResult { - let now = Utc::now(); - let starts_at = now + Duration::hours(draft.start_hours as i64); - let ends_at = starts_at + Duration::hours(draft.duration_hours as i64); - let chat = HandleAndId::from_user(&from); - - let user = match UserDAO::find_by_telegram_id(&db_pool, from.id.into()).await? { - Some(user) => user, - None => { - UserDAO::insert_user( - &db_pool, - &NewUser { - telegram_id: from.id.into(), - username: from.username.clone(), - display_name: Some(from.first_name.clone()), - }, - ) - .await? - } - }; - - let new_listing_base = NewListingBase::new( - user.id, - draft.title.clone(), - draft.description.clone(), - starts_at, - ends_at, - ); - - let new_listing = NewListing { - base: new_listing_base, - fields: NewListingFields::FixedPriceListing { - buy_now_price: draft.buy_now_price, - slots_available: draft.slots_available, - }, - }; - - match ListingDAO::insert_listing(&db_pool, &new_listing).await { - Ok(listing) => { - let response = format!( - "โœ… Listing Created Successfully!\n\n\ - Listing ID: {}\n\ - Title: {}\n\ - Price: ${}\n\ - Slots Available: {}\n\n\ - Your fixed price listing is now live! ๐ŸŽ‰", - listing.base.id, listing.base.title, draft.buy_now_price, draft.slots_available - ); - - edit_html_message(&bot, chat, message_id, &response, None).await?; - dialogue.exit().await?; - - info!( - "Fixed price listing created successfully for user {}: {:?}", - chat, listing.base.id - ); - } - Err(e) => { - log::error!("Failed to create listing for user {}: {}", chat, e); - edit_html_message( - &bot, - chat, - message_id, - "โŒ Error: Failed to create listing. Please try again later.", - None, - ) - .await?; - } - } - - Ok(()) -} - -async fn cancel_wizard( - bot: Bot, - dialogue: NewListingDialogue, - chat: impl Into>, -) -> HandlerResult { - let chat: HandleAndId<'_> = chat.into(); - info!("User {} cancelled new listing wizard", chat); - dialogue.exit().await?; - send_html_message(&bot, chat, "โŒ Listing creation cancelled.", None).await?; - Ok(()) -} - -// Individual field editing handlers -pub async fn handle_edit_title( - bot: Bot, - dialogue: NewListingDialogue, - mut draft: ListingDraft, - text: &str, - chat: HandleAndId<'_>, -) -> HandlerResult { - info!("User {} editing title: '{}'", chat, text); - - draft.title = match validate_title(text) { - Ok(title) => title, - Err(error_msg) => { - send_html_message( - &bot, - chat, - &error_msg, - Some(create_back_button_keyboard_with_clear("title")), - ) - .await?; - return Ok(()); - } - }; - - // Go back to editing listing state - dialogue - .update(ListingWizardState::EditingDraft(draft.clone())) - .await?; - - show_edit_screen(bot, chat, draft, Some("โœ… Title updated!"), None).await?; - Ok(()) -} - -pub async fn handle_edit_description( - bot: Bot, - dialogue: NewListingDialogue, - mut state: ListingDraft, - text: &str, - chat: HandleAndId<'_>, -) -> HandlerResult { - info!("User {} editing description: '{}'", chat, text); - - state.description = match validate_description(text) { - Ok(description) => Some(description), - Err(error_msg) => { - send_html_message(&bot, chat, &error_msg, None).await?; - return Ok(()); - } - }; - - // Go back to editing listing state - dialogue - .update(ListingWizardState::EditingDraft(state.clone())) - .await?; - - show_edit_screen(bot, chat, state, Some("โœ… Description updated!"), None).await?; - - Ok(()) -} - -pub async fn handle_edit_price( - bot: Bot, - dialogue: NewListingDialogue, - mut state: ListingDraft, - text: &str, - chat: HandleAndId<'_>, -) -> HandlerResult { - info!("User {} editing price: '{}'", chat, text); - - state.buy_now_price = match validate_price(text) { - Ok(price) => price, - Err(error_msg) => { - send_html_message(&bot, chat, &error_msg, None).await?; - return Ok(()); - } - }; - - // Go back to editing listing state - dialogue - .update(ListingWizardState::EditingDraft(state.clone())) - .await?; - - show_edit_screen(bot, chat, state, Some("โœ… Price updated!"), None).await?; - Ok(()) -} - -pub async fn handle_edit_slots( - bot: Bot, - dialogue: NewListingDialogue, - mut state: ListingDraft, - text: &str, - chat: HandleAndId<'_>, -) -> HandlerResult { - info!("User {} editing slots: '{}'", chat, text); - - state.slots_available = match validate_slots(text) { - Ok(s) => s, - Err(error_msg) => { - send_html_message(&bot, chat, &error_msg, None).await?; - return Ok(()); - } - }; - - // Go back to editing listing state - dialogue - .update(ListingWizardState::EditingDraft(state.clone())) - .await?; - - show_edit_screen(bot, chat, state, Some("โœ… Slots updated!"), None).await?; - - Ok(()) -} - -pub async fn handle_edit_start_time( - bot: Bot, - dialogue: NewListingDialogue, - mut state: ListingDraft, - text: &str, - chat: HandleAndId<'_>, -) -> HandlerResult { - info!("User {} editing start time: '{}'", chat, text); - - state.start_hours = match validate_start_time(text) { - Ok(h) => h, - _ => { - send_html_message( - &bot, - chat, - "โŒ Invalid number. Please enter hours from now (0-168):", - Some(create_back_button_keyboard()), - ) - .await?; - return Ok(()); - } - }; - - // Go back to editing listing state - dialogue - .update(ListingWizardState::EditingDraft(state.clone())) - .await?; - - show_edit_screen(bot, chat, state, Some("โœ… Start time updated!"), None).await?; - - Ok(()) -} - -pub async fn handle_edit_duration( - bot: Bot, - dialogue: NewListingDialogue, - mut state: ListingDraft, - text: &str, - chat: HandleAndId<'_>, -) -> HandlerResult { - info!("User {} editing duration: '{}'", chat, text); - - state.duration_hours = match validate_duration(text) { - Ok(d) => d, - _ => { - send_html_message( - &bot, - chat, - "โŒ Invalid number. Please enter duration in hours (1-720):", - Some(create_back_button_keyboard()), - ) - .await?; - return Ok(()); - } - }; - - // Go back to editing listing state - dialogue - .update(ListingWizardState::EditingDraft(state.clone())) - .await?; - - show_edit_screen(bot, chat, state, Some("โœ… Duration updated!"), None).await?; - - Ok(()) -} - -pub async fn handle_editing_draft_field_callback( - bot: Bot, - dialogue: NewListingDialogue, - (field, draft): (ListingField, ListingDraft), - callback_query: CallbackQuery, -) -> HandlerResult { - let (data, from_user) = extract_callback_data(&bot, &callback_query).await?; - let message = callback_query.regular_message().unwrap(); - let chat = HandleAndId::from_user(&from_user); - info!("User {} editing field: {:?} -> {}", chat, field, &data); - if data == "edit_back" { - dialogue - .update(ListingWizardState::EditingDraft(draft.clone())) - .await?; - show_edit_screen(bot, chat, draft, None, Some(&message)).await?; - return Ok(()); - } - - match field { - ListingField::Title => { - handle_edit_title(bot, dialogue, draft, data.as_str(), chat).await?; - } - ListingField::Description => { - handle_edit_description(bot, dialogue, draft, data.as_str(), chat).await?; - } - ListingField::Price => { - handle_edit_price(bot, dialogue, draft, data.as_str(), chat).await?; - } - ListingField::Slots => { - handle_edit_slots(bot, dialogue, draft, data.as_str(), chat).await?; - } - ListingField::StartTime => { - handle_edit_start_time(bot, dialogue, draft, data.as_str(), chat).await?; - } - ListingField::Duration => { - handle_edit_duration(bot, dialogue, draft, data.as_str(), chat).await?; - } - }; - - Ok(()) -} diff --git a/src/commands/new_listing/handler_factory.rs b/src/commands/new_listing/handler_factory.rs new file mode 100644 index 0000000..91e4464 --- /dev/null +++ b/src/commands/new_listing/handler_factory.rs @@ -0,0 +1,56 @@ +use super::*; +use crate::{case, Command, DialogueRootState, Handler}; +use teloxide::{dptree, prelude::*, types::Update}; + +// Create the dialogue handler tree for new listing wizard +pub fn new_listing_handler() -> Handler { + dptree::entry() + .branch( + Update::filter_message() + .branch( + dptree::entry() + .filter_command::() + .chain(case![Command::NewListing]) + .endpoint(handle_new_listing_command), + ) + .branch( + case![DialogueRootState::NewListing( + NewListingState::AwaitingDraftField { field, draft } + )] + .endpoint(handle_awaiting_draft_field_input), + ) + .branch( + case![DialogueRootState::NewListing( + NewListingState::EditingDraftField { field, draft } + )] + .endpoint(handle_editing_field_input), + ), + ) + .branch( + Update::filter_callback_query() + .branch( + case![DialogueRootState::NewListing( + NewListingState::AwaitingDraftField { field, draft } + )] + .endpoint(handle_awaiting_draft_field_callback), + ) + .branch( + case![DialogueRootState::NewListing( + NewListingState::ViewingDraft(draft) + )] + .endpoint(handle_viewing_draft_callback), + ) + .branch( + case![DialogueRootState::NewListing( + NewListingState::EditingDraft(draft) + )] + .endpoint(handle_editing_draft_callback), + ) + .branch( + case![DialogueRootState::NewListing( + NewListingState::EditingDraftField { field, draft } + )] + .endpoint(handle_editing_draft_field_callback), + ), + ) +} diff --git a/src/commands/new_listing/keyboard.rs b/src/commands/new_listing/keyboard.rs new file mode 100644 index 0000000..cdcf556 --- /dev/null +++ b/src/commands/new_listing/keyboard.rs @@ -0,0 +1,53 @@ +use crate::keyboard_buttons; + +keyboard_buttons! { + pub enum DurationKeyboardButtons { + OneDay("1 day", "duration_1_day"), + ThreeDays("3 days", "duration_3_days"), + SevenDays("7 days", "duration_7_days"), + FourteenDays("14 days", "duration_14_days"), + } +} + +keyboard_buttons! { + pub enum SlotsKeyboardButtons { + OneSlot("1 slot", "slots_1"), + TwoSlots("2 slots", "slots_2"), + FiveSlots("5 slots", "slots_5"), + TenSlots("10 slots", "slots_10"), + } +} + +keyboard_buttons! { + pub enum ConfirmationKeyboardButtons { + Create("โœ… Create", "confirm_create"), + Edit("โœ๏ธ Edit", "confirm_edit"), + Discard("๐Ÿ—‘๏ธ Discard", "confirm_discard"), + } +} + +keyboard_buttons! { + pub enum FieldSelectionKeyboardButtons { + [ + Title("๐Ÿ“ Title", "edit_title"), + Description("๐Ÿ“„ Description", "edit_description"), + ], + [ + Price("๐Ÿ’ฐ Price", "edit_price"), + Slots("๐Ÿ”ข Slots", "edit_slots"), + ], + [ + StartTime("โฐ Start Time", "edit_start_time"), + Duration("โฑ๏ธ Duration", "edit_duration"), + ], + [ + Done("โœ… Done", "edit_done"), + ] + } +} + +keyboard_buttons! { + pub enum StartTimeKeyboardButtons { + Now("Now", "start_time_now"), + } +} diff --git a/src/commands/new_listing/mod.rs b/src/commands/new_listing/mod.rs new file mode 100644 index 0000000..2041d4e --- /dev/null +++ b/src/commands/new_listing/mod.rs @@ -0,0 +1,1070 @@ +mod handler_factory; +mod keyboard; +mod types; +mod validations; + +use crate::{ + db::{ + dao::ListingDAO, + models::new_listing::{NewListing, NewListingBase, NewListingFields}, + ListingDuration, NewUser, UserDAO, + }, + keyboard_buttons, + message_utils::*, + DialogueRootState, HandlerResult, RootDialogue, +}; +use chrono::{Duration, Utc}; +pub use handler_factory::new_listing_handler; +use keyboard::*; +use log::{error, info}; +use sqlx::SqlitePool; +use teloxide::{prelude::*, types::*, Bot}; +pub use types::*; +use validations::*; + +fn create_back_button_keyboard_with(other_buttons: InlineKeyboardMarkup) -> InlineKeyboardMarkup { + other_buttons.append_row([InlineKeyboardButton::callback("๐Ÿ”™ Back", "edit_back")]) +} + +fn create_back_button_keyboard() -> InlineKeyboardMarkup { + create_single_button_keyboard("๐Ÿ”™ Back", "edit_back") +} + +// Create back button with clear option +fn create_back_button_keyboard_with_clear(field: &str) -> InlineKeyboardMarkup { + create_single_row_keyboard(&[ + ("๐Ÿ”™ Back", "edit_back"), + ( + &format!("๐Ÿงน Clear {}", field), + &format!("edit_clear_{}", field), + ), + ]) +} + +fn create_cancel_keyboard() -> InlineKeyboardMarkup { + create_single_button_keyboard("Cancel", "cancel") +} + +fn create_skip_cancel_keyboard() -> InlineKeyboardMarkup { + create_multi_row_keyboard(&[&[("Skip", "skip"), ("Cancel", "cancel")]]) +} + +// Handle the /newlisting command - starts the dialogue by setting it to Start state +async fn handle_new_listing_command( + bot: Bot, + dialogue: RootDialogue, + msg: Message, +) -> HandlerResult { + info!( + "User {} started new fixed price listing wizard", + HandleAndId::from_chat(&msg.chat), + ); + + // Initialize the dialogue to Start state + dialogue + .update(NewListingState::AwaitingDraftField { + field: ListingField::Title, + draft: ListingDraft::default(), + }) + .await?; + + let response = "๐Ÿ›๏ธ Creating New Fixed Price Listing\n\n\ + Let's create your fixed price listing step by step!\n\n\ + Step 1 of 6: Title\n\ + Please enter a title for your listing (max 100 characters):"; + + send_message(&bot, msg.chat, response, Some(create_cancel_keyboard())).await?; + Ok(()) +} + +async fn handle_awaiting_draft_field_input( + bot: Bot, + dialogue: RootDialogue, + (field, draft): (ListingField, ListingDraft), + msg: Message, +) -> HandlerResult { + let chat = msg.chat.clone(); + let text = msg.text().unwrap_or(""); + + info!( + "User {} entered input step: {:?}", + HandleAndId::from_chat(&chat), + field + ); + + if is_cancel(text) { + return cancel_wizard(bot, dialogue, chat).await; + } + + match field { + ListingField::Title => handle_title_input(bot, chat, text, dialogue, draft).await, + ListingField::Description => { + handle_description_input(bot, chat, text, dialogue, draft).await + } + ListingField::Price => handle_price_input(bot, chat, text, dialogue, draft).await, + ListingField::Slots => handle_slots_input(bot, chat, text, dialogue, draft).await, + ListingField::StartTime => handle_start_time_input(bot, chat, text, dialogue, draft).await, + ListingField::Duration => handle_duration_input(bot, chat, text, dialogue, draft).await, + } +} + +async fn handle_title_input( + bot: Bot, + chat: Chat, + text: &str, + dialogue: RootDialogue, + mut draft: ListingDraft, +) -> HandlerResult { + match validate_title(text) { + Ok(title) => { + draft.title = title; + dialogue + .update(NewListingState::AwaitingDraftField { + field: ListingField::Description, + draft, + }) + .await?; + + let response = "โœ… Title saved!\n\n\ + Step 2 of 6: Description\n\ + Please enter a description for your listing (optional)."; + + send_message(&bot, chat, response, Some(create_skip_cancel_keyboard())).await + } + Err(error_msg) => send_message(&bot, chat, error_msg, None).await, + } +} + +async fn handle_description_input( + bot: Bot, + chat: Chat, + text: &str, + dialogue: RootDialogue, + mut draft: ListingDraft, +) -> HandlerResult { + draft.description = match validate_description(text) { + Ok(description) => Some(description), + Err(error_msg) => { + send_message(&bot, chat, error_msg, None).await?; + return Ok(()); + } + }; + + dialogue + .update(NewListingState::AwaitingDraftField { + field: ListingField::Price, + draft, + }) + .await?; + + let response = "โœ… Description saved!\n\n\ + Step 3 of 6: Price\n\ + Please enter the fixed price for your listing (e.g., 10.50, 25, 0.99):\n\n\ + ๐Ÿ’ก Price should be in USD"; + + send_message(&bot, chat, response, None).await +} + +async fn handle_description_callback( + bot: Bot, + dialogue: RootDialogue, + draft: ListingDraft, + data: &str, + target: impl Into, +) -> HandlerResult { + let target = target.into(); + match data { + "skip" => { + dialogue + .update(NewListingState::AwaitingDraftField { + field: ListingField::Price, + draft, + }) + .await?; + + let response = "โœ… Description skipped!\n\n\ + Step 3 of 6: Price\n\ + Please enter the fixed price for your listing (e.g., 10.50, 25, 0.99):\n\n\ + ๐Ÿ’ก Price should be in USD"; + + send_message(&bot, target, response, None).await?; + } + _ => { + error!("Unknown callback data: {}", data); + dialogue.exit().await?; + } + } + + Ok(()) +} + +async fn handle_awaiting_draft_field_callback( + bot: Bot, + dialogue: RootDialogue, + (field, draft): (ListingField, ListingDraft), + callback_query: CallbackQuery, +) -> HandlerResult { + let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?; + info!("User {:?} selected callback: {:?}", from, data); + let target = (from, message_id); + + if data == "cancel" { + return cancel_wizard(bot, dialogue, target).await; + } + + match field { + ListingField::Title => { + error!("Unknown callback data: {}", data); + dialogue.exit().await?; + return Ok(()); + } + ListingField::Description => { + handle_description_callback(bot, dialogue, draft, data.as_str(), target).await + } + ListingField::Price => { + error!("Unknown callback data: {}", data); + dialogue.exit().await?; + return Ok(()); + } + ListingField::Slots => { + handle_slots_callback(bot, dialogue, draft, data.as_str(), target).await + } + ListingField::StartTime => { + handle_start_time_callback(bot, dialogue, draft, data.as_str(), target).await + } + ListingField::Duration => { + handle_duration_callback(bot, dialogue, draft, data.as_str(), target).await + } + } +} + +async fn handle_slots_callback( + bot: Bot, + dialogue: RootDialogue, + draft: ListingDraft, + data: &str, + target: impl Into, +) -> HandlerResult { + let button = SlotsKeyboardButtons::try_from(data) + .map_err(|_| anyhow::anyhow!("Unknown SlotsKeyboardButtons data: {}", data))?; + let num_slots = match button { + SlotsKeyboardButtons::OneSlot => 1, + SlotsKeyboardButtons::TwoSlots => 2, + SlotsKeyboardButtons::FiveSlots => 5, + SlotsKeyboardButtons::TenSlots => 10, + }; + process_slots_and_respond(&bot, dialogue, draft, target, num_slots).await?; + Ok(()) +} + +async fn handle_start_time_callback( + bot: Bot, + dialogue: RootDialogue, + draft: ListingDraft, + data: &str, + target: impl Into, +) -> HandlerResult { + let target = target.into(); + let button = StartTimeKeyboardButtons::try_from(data) + .map_err(|_| anyhow::anyhow!("Unknown StartTimeKeyboardButtons data: {}", data))?; + let start_time = match button { + StartTimeKeyboardButtons::Now => ListingDuration::zero(), + }; + process_start_time_and_respond(&bot, dialogue, draft, target, start_time).await?; + Ok(()) +} + +// Helper function to process slots input, update dialogue state, and send response +async fn process_slots_and_respond( + bot: &Bot, + dialogue: RootDialogue, + mut draft: ListingDraft, + target: impl Into, + slots: i32, +) -> HandlerResult { + let target = target.into(); + // Update dialogue state + draft.slots_available = slots; + dialogue + .update(NewListingState::AwaitingDraftField { + field: ListingField::StartTime, + draft, + }) + .await?; + + // Send response message with inline button + let response = format!( + "โœ… Available slots: {slots}\n\n\ + Step 5 of 6: Start Time\n\ + When should your listing start?\n\ + โ€ข Click 'Now' to start immediately\n\ + โ€ข Enter duration to delay (e.g., '2 days' for 2 days from now, '1 hour' for 1 hour from now)\n\ + โ€ข Maximum delay: 168 hours (7 days)" + ); + + send_message( + &bot, + target, + &response, + Some(StartTimeKeyboardButtons::to_keyboard()), + ) + .await?; + + Ok(()) +} + +async fn handle_viewing_draft_callback( + db_pool: SqlitePool, + bot: Bot, + dialogue: RootDialogue, + draft: ListingDraft, + callback_query: CallbackQuery, +) -> HandlerResult { + let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?; + let target = (from.clone(), message_id); + + let button = ConfirmationKeyboardButtons::try_from(data.as_str()) + .map_err(|_| anyhow::anyhow!("Unknown ConfirmationKeyboardButtons data: {}", data))?; + + match button { + ConfirmationKeyboardButtons::Create => { + info!("User {:?} confirmed listing creation", target); + dialogue.exit().await?; + create_listing(db_pool, bot, dialogue, from, message_id, draft.clone()).await?; + } + ConfirmationKeyboardButtons::Discard => { + info!("User {:?} discarded listing creation", from); + + // Exit dialogue and send cancellation message + dialogue.exit().await?; + + 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?; + } + ConfirmationKeyboardButtons::Edit => { + info!("User {:?} chose to edit listing", from); + + // Go to editing state to allow user to modify specific fields + dialogue + .update(NewListingState::EditingDraft(draft.clone())) + .await?; + + // Delete the old message and show the edit screen + show_edit_screen(bot, target, draft, None).await?; + } + } + + Ok(()) +} + +// Helper function to process start time input, update dialogue state, and send response +async fn process_start_time_and_respond( + bot: &Bot, + dialogue: RootDialogue, + mut draft: ListingDraft, + target: impl Into, + duration: ListingDuration, +) -> HandlerResult { + let target = target.into(); + // Update dialogue state + draft.start_delay = duration; + dialogue + .update(NewListingState::AwaitingDraftField { + field: ListingField::Duration, + draft, + }) + .await?; + + // Generate response message + let start_msg = format!("in {}", duration); + + let response = format!( + "โœ… Listing will start: {}\n\n\ + Step 6 of 6: Duration\n\ + How long should your listing run?\n\ + Enter duration in hours (minimum 1 hour, maximum 720 hours / 30 days):", + start_msg + ); + + send_message( + &bot, + target, + &response, + Some(DurationKeyboardButtons::to_keyboard()), + ) + .await?; + + Ok(()) +} + +async fn handle_price_input( + bot: Bot, + chat: Chat, + text: &str, + dialogue: RootDialogue, + mut draft: ListingDraft, +) -> HandlerResult { + match validate_price(text) { + Ok(price) => { + draft.buy_now_price = price; + + let response = format!( + "โœ… Price saved: ${}\n\n\ + Step 4 of 6: Available Slots\n\ + How many items are available for sale?\n\n\ + Choose a common value below or enter a custom number (1-1000):", + draft.buy_now_price + ); + + dialogue + .update(NewListingState::AwaitingDraftField { + field: ListingField::Slots, + draft, + }) + .await?; + + send_message( + &bot, + chat, + response, + Some(SlotsKeyboardButtons::to_keyboard()), + ) + .await? + } + Err(error_msg) => send_message(&bot, chat, error_msg, None).await?, + } + + Ok(()) +} + +async fn handle_slots_input( + bot: Bot, + chat: Chat, + text: &str, + dialogue: RootDialogue, + draft: ListingDraft, +) -> HandlerResult { + match validate_slots(text) { + Ok(slots) => { + process_slots_and_respond(&bot, dialogue, draft, chat, slots).await?; + } + Err(error_msg) => { + send_message(&bot, chat, error_msg, None).await?; + } + } + + Ok(()) +} + +async fn handle_start_time_input( + bot: Bot, + chat: Chat, + text: &str, + dialogue: RootDialogue, + draft: ListingDraft, +) -> HandlerResult { + match validate_start_time(text) { + Ok(duration) => { + process_start_time_and_respond(&bot, dialogue, draft, chat, duration).await?; + } + Err(error_msg) => { + send_message( + &bot, + chat, + error_msg, + Some(StartTimeKeyboardButtons::to_keyboard()), + ) + .await?; + } + } + + Ok(()) +} + +async fn handle_duration_input( + bot: Bot, + chat: Chat, + text: &str, + dialogue: RootDialogue, + draft: ListingDraft, +) -> HandlerResult { + match validate_duration(text) { + Ok(duration) => { + process_duration_and_respond(bot, dialogue, draft, chat, duration).await?; + } + Err(error_msg) => { + send_message(&bot, chat, error_msg, None).await?; + } + } + Ok(()) +} + +async fn handle_duration_callback( + bot: Bot, + dialogue: RootDialogue, + draft: ListingDraft, + data: &str, + target: impl Into, +) -> HandlerResult { + let button = DurationKeyboardButtons::try_from(data).unwrap(); + let duration = ListingDuration::days(match button { + DurationKeyboardButtons::OneDay => 1, + DurationKeyboardButtons::ThreeDays => 3, + DurationKeyboardButtons::SevenDays => 7, + DurationKeyboardButtons::FourteenDays => 14, + }); + process_duration_and_respond(bot, dialogue, draft, target, duration).await +} + +async fn process_duration_and_respond( + bot: Bot, + dialogue: RootDialogue, + mut draft: ListingDraft, + target: impl Into, + duration: ListingDuration, +) -> HandlerResult { + let target = target.into(); + draft.duration = duration; + dialogue + .update(NewListingState::ViewingDraft(draft.clone())) + .await?; + show_confirmation(bot, target, draft).await +} + +async fn show_confirmation( + bot: Bot, + target: impl Into, + state: ListingDraft, +) -> HandlerResult { + let description_text = state + .description + .as_deref() + .unwrap_or("No description"); + + let start_time_str = format!("In {}", state.start_delay); + + let response = format!( + "๐Ÿ“‹ Listing Summary\n\n\ + Title: {}\n\ + Description: {}\n\ + Price: ${}\n\ + Available Slots: {}\n\ + Start Time: {}\n\ + Duration: {}\n\n\ + Please review your listing and choose an action:", + state.title, + description_text, + state.buy_now_price, + state.slots_available, + start_time_str, + state.duration + ); + + send_message( + &bot, + target, + &response, + Some(ConfirmationKeyboardButtons::to_keyboard()), + ) + .await?; + + Ok(()) +} + +async fn show_edit_screen( + bot: Bot, + target: impl Into, + state: ListingDraft, + flash_message: Option<&str>, +) -> HandlerResult { + let target = target.into(); + let description_text = state + .description + .as_deref() + .unwrap_or("No description"); + + let start_time_str = format!("In {}", state.start_delay); + + let mut response = format!( + "โœ๏ธ Editing Listing:\n\n\ + ๐Ÿ“ Title: {}\n\ + ๐Ÿ“„ Description: {}\n\ + ๐Ÿ’ฐ Price: ${}\n\ + ๐Ÿ”ข Available Slots: {}\n\ + โฐ Start Time: {}\n\ + โณ Duration: {}\n\n\ + Select a field to edit:", + state.title, + description_text, + state.buy_now_price, + state.slots_available, + start_time_str, + state.duration + ); + + if let Some(flash_message) = flash_message { + response = format!("{}\n\n{}", flash_message, response); + } + + send_message( + &bot, + target, + &response, + Some(FieldSelectionKeyboardButtons::to_keyboard()), + ) + .await?; + + Ok(()) +} + +async fn handle_editing_field_input( + bot: Bot, + dialogue: RootDialogue, + (field, draft): (ListingField, ListingDraft), + msg: Message, +) -> HandlerResult { + let chat = msg.chat.clone(); + let text = msg.text().unwrap_or("").trim(); + + info!("User {:?} editing field {:?}", chat, field); + + match field { + ListingField::Title => { + handle_edit_title(bot, dialogue, draft, text, chat).await?; + } + ListingField::Description => { + handle_edit_description(bot, dialogue, draft, text, chat).await?; + } + ListingField::Price => { + handle_edit_price(bot, dialogue, draft, text, chat).await?; + } + ListingField::Slots => { + handle_edit_slots(bot, dialogue, draft, text, chat).await?; + } + ListingField::StartTime => { + handle_edit_start_time(bot, dialogue, draft, text, chat).await?; + } + ListingField::Duration => { + handle_edit_duration(bot, dialogue, draft, text, chat).await?; + } + } + + Ok(()) +} + +async fn handle_editing_draft_callback( + bot: Bot, + draft: ListingDraft, + dialogue: RootDialogue, + callback_query: CallbackQuery, +) -> HandlerResult { + let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?; + let target = (from.clone(), message_id); + let button = FieldSelectionKeyboardButtons::try_from(data.as_str()) + .map_err(|e| anyhow::anyhow!("Invalid field selection button: {}", e))?; + + info!( + "User {} in editing screen, showing field selection", + HandleAndId::from_user(&from) + ); + + let (field, value, keyboard) = match button { + FieldSelectionKeyboardButtons::Title => ( + ListingField::Title, + draft.title.clone(), + create_back_button_keyboard(), + ), + FieldSelectionKeyboardButtons::Description => ( + ListingField::Description, + draft + .description + .as_deref() + .unwrap_or("(no description)") + .to_string(), + create_back_button_keyboard_with_clear("description"), + ), + FieldSelectionKeyboardButtons::Price => ( + ListingField::Price, + format!("${}", draft.buy_now_price), + create_back_button_keyboard(), + ), + FieldSelectionKeyboardButtons::Slots => ( + ListingField::Slots, + format!("{} slots", draft.slots_available), + create_back_button_keyboard_with(SlotsKeyboardButtons::to_keyboard()), + ), + FieldSelectionKeyboardButtons::StartTime => ( + ListingField::StartTime, + draft.start_delay.to_string(), + create_back_button_keyboard_with(StartTimeKeyboardButtons::to_keyboard()), + ), + FieldSelectionKeyboardButtons::Duration => ( + ListingField::Duration, + draft.duration.to_string(), + create_back_button_keyboard_with(DurationKeyboardButtons::to_keyboard()), + ), + FieldSelectionKeyboardButtons::Done => { + dialogue + .update(DialogueRootState::NewListing( + NewListingState::ViewingDraft(draft.clone()), + )) + .await?; + show_confirmation(bot, target, draft).await?; + return Ok(()); + } + }; + + dialogue + .update(DialogueRootState::NewListing( + NewListingState::EditingDraftField { + field, + draft: draft.clone(), + }, + )) + .await?; + + // update the message to show the edit screen + let response = format!( + "Editing {:?}\n\n\ + Previous value: {}\ + ", + field, value + ); + + send_message(&bot, target, response, Some(keyboard)).await?; + + Ok(()) +} + +async fn create_listing( + db_pool: SqlitePool, + bot: Bot, + dialogue: RootDialogue, + from: User, + message_id: MessageId, + draft: ListingDraft, +) -> HandlerResult { + let now = Utc::now(); + let starts_at = now + Into::::into(draft.start_delay); + let ends_at = starts_at + Into::::into(draft.duration); + + let user = match UserDAO::find_by_telegram_id(&db_pool, from.id.clone()).await? { + Some(user) => user, + None => { + UserDAO::insert_user( + &db_pool, + &NewUser { + telegram_id: from.id.into(), + username: from.username.clone(), + display_name: Some(from.first_name.clone()), + }, + ) + .await? + } + }; + + let new_listing_base = NewListingBase::new( + user.id, + draft.title.clone(), + draft.description.clone(), + starts_at, + ends_at, + ); + + let new_listing = NewListing { + base: new_listing_base, + fields: NewListingFields::FixedPriceListing { + buy_now_price: draft.buy_now_price, + slots_available: draft.slots_available, + }, + }; + + match ListingDAO::insert_listing(&db_pool, &new_listing).await { + Ok(listing) => { + let response = format!( + "โœ… Listing Created Successfully!\n\n\ + Listing ID: {}\n\ + Title: {}\n\ + Price: ${}\n\ + Slots Available: {}\n\n\ + Your fixed price listing is now live! ๐ŸŽ‰", + listing.base.id, listing.base.title, draft.buy_now_price, draft.slots_available + ); + + send_message(&bot, (from.clone(), message_id), response, None).await?; + dialogue.exit().await?; + + info!( + "Fixed price listing created successfully for user {:?}: {:?}", + from.id, listing.base.id + ); + } + Err(e) => { + log::error!("Failed to create listing for user {:?}: {}", from, e); + send_message( + &bot, + (from, message_id), + "โŒ Error: Failed to create listing. Please try again later.", + None, + ) + .await?; + } + } + + Ok(()) +} + +async fn cancel_wizard( + bot: Bot, + dialogue: RootDialogue, + target: impl Into, +) -> HandlerResult { + let target = target.into(); + info!("{:?} cancelled new listing wizard", target); + dialogue.exit().await?; + send_message(&bot, target, "โŒ Listing creation cancelled.", None).await?; + Ok(()) +} + +// Individual field editing handlers +async fn handle_edit_title( + bot: Bot, + dialogue: RootDialogue, + mut draft: ListingDraft, + text: &str, + target: impl Into, +) -> HandlerResult { + let target = target.into(); + info!("User {:?} editing title: '{}'", target, text); + + draft.title = match validate_title(text) { + Ok(title) => title, + Err(error_msg) => { + send_message( + &bot, + target, + error_msg, + Some(create_back_button_keyboard_with_clear("title")), + ) + .await?; + return Ok(()); + } + }; + + // Go back to editing listing state + dialogue + .update(DialogueRootState::NewListing( + NewListingState::EditingDraft(draft.clone()), + )) + .await?; + + show_edit_screen(bot, target, draft, Some("โœ… Title updated!")).await?; + Ok(()) +} + +async fn handle_edit_description( + bot: Bot, + dialogue: RootDialogue, + mut state: ListingDraft, + text: &str, + target: impl Into, +) -> HandlerResult { + let target = target.into(); + info!("User {:?} editing description: '{}'", target, text); + + state.description = match validate_description(text) { + Ok(description) => Some(description), + Err(error_msg) => { + send_message(&bot, target, error_msg, None).await?; + return Ok(()); + } + }; + + // Go back to editing listing state + dialogue + .update(DialogueRootState::NewListing( + NewListingState::EditingDraft(state.clone()), + )) + .await?; + + show_edit_screen(bot, target, state, Some("โœ… Description updated!")).await?; + + Ok(()) +} + +async fn handle_edit_price( + bot: Bot, + dialogue: RootDialogue, + mut state: ListingDraft, + text: &str, + target: impl Into, +) -> HandlerResult { + let target = target.into(); + info!("User {:?} editing price: '{}'", target, text); + + state.buy_now_price = match validate_price(text) { + Ok(price) => price, + Err(error_msg) => { + send_message(&bot, target, error_msg, None).await?; + return Ok(()); + } + }; + + // Go back to editing listing state + dialogue + .update(DialogueRootState::NewListing( + NewListingState::EditingDraft(state.clone()), + )) + .await?; + + show_edit_screen(bot, target, state, Some("โœ… Price updated!")).await?; + Ok(()) +} + +async fn handle_edit_slots( + bot: Bot, + dialogue: RootDialogue, + mut state: ListingDraft, + text: &str, + target: impl Into, +) -> HandlerResult { + let target = target.into(); + info!("User {:?} editing slots: '{}'", target, text); + + state.slots_available = match validate_slots(text) { + Ok(s) => s, + Err(error_msg) => { + send_message(&bot, target, error_msg, None).await?; + return Ok(()); + } + }; + + // Go back to editing listing state + dialogue + .update(DialogueRootState::NewListing( + NewListingState::EditingDraft(state.clone()), + )) + .await?; + + show_edit_screen(bot, target, state, Some("โœ… Slots updated!")).await?; + + Ok(()) +} + +async fn handle_edit_start_time( + bot: Bot, + dialogue: RootDialogue, + mut state: ListingDraft, + text: &str, + target: impl Into, +) -> HandlerResult { + let target = target.into(); + info!("User {:?} editing start time: '{}'", target, text); + + state.start_delay = match validate_start_time(text) { + Ok(h) => h, + _ => { + send_message( + &bot, + target, + "โŒ Invalid number. Please enter hours from now (0-168):", + Some(create_back_button_keyboard()), + ) + .await?; + return Ok(()); + } + }; + + // Go back to editing listing state + dialogue + .update(DialogueRootState::NewListing( + NewListingState::EditingDraft(state.clone()), + )) + .await?; + + show_edit_screen(bot, target, state, Some("โœ… Start time updated!")).await?; + + Ok(()) +} + +async fn handle_edit_duration( + bot: Bot, + dialogue: RootDialogue, + mut state: ListingDraft, + text: &str, + target: impl Into, +) -> HandlerResult { + let target = target.into(); + info!("User {:?} editing duration: '{}'", target, text); + + state.duration = match validate_duration(text) { + Ok(d) => d, + _ => { + send_message( + &bot, + target, + "โŒ Invalid number. Please enter duration in hours (1-720):", + Some(create_back_button_keyboard()), + ) + .await?; + return Ok(()); + } + }; + + // Go back to editing listing state + dialogue + .update(DialogueRootState::NewListing( + NewListingState::EditingDraft(state.clone()), + )) + .await?; + + show_edit_screen(bot, target, state, Some("โœ… Duration updated!")).await?; + + Ok(()) +} + +async fn handle_editing_draft_field_callback( + bot: Bot, + dialogue: RootDialogue, + (field, draft): (ListingField, ListingDraft), + callback_query: CallbackQuery, +) -> HandlerResult { + let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?; + let target = (from.clone(), message_id); + info!("User {:?} editing field: {:?} -> {}", target, field, &data); + if data == "edit_back" { + dialogue + .update(DialogueRootState::NewListing( + NewListingState::EditingDraft(draft.clone()), + )) + .await?; + show_edit_screen(bot, target, draft, None).await?; + return Ok(()); + } + + match field { + ListingField::Title => { + handle_edit_title(bot, dialogue, draft, data.as_str(), target).await?; + } + ListingField::Description => { + handle_edit_description(bot, dialogue, draft, data.as_str(), target).await?; + } + ListingField::Price => { + handle_edit_price(bot, dialogue, draft, data.as_str(), target).await?; + } + ListingField::Slots => { + handle_edit_slots(bot, dialogue, draft, data.as_str(), target).await?; + } + ListingField::StartTime => { + handle_edit_start_time(bot, dialogue, draft, data.as_str(), target).await?; + } + ListingField::Duration => { + handle_edit_duration(bot, dialogue, draft, data.as_str(), target).await?; + } + }; + + Ok(()) +} diff --git a/src/commands/new_listing/types.rs b/src/commands/new_listing/types.rs new file mode 100644 index 0000000..4616a33 --- /dev/null +++ b/src/commands/new_listing/types.rs @@ -0,0 +1,45 @@ +use crate::{ + db::{ListingDuration, MoneyAmount}, + DialogueRootState, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)] +pub struct ListingDraft { + pub title: String, + pub description: Option, + pub buy_now_price: MoneyAmount, + pub slots_available: i32, + pub start_delay: ListingDuration, + pub duration: ListingDuration, +} + +#[derive(Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Debug)] +pub enum ListingField { + Title, + Description, + Price, + Slots, + StartTime, + Duration, +} + +// Dialogue state for the new listing wizard +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub enum NewListingState { + AwaitingDraftField { + field: ListingField, + draft: ListingDraft, + }, + ViewingDraft(ListingDraft), + EditingDraft(ListingDraft), + EditingDraftField { + field: ListingField, + draft: ListingDraft, + }, +} +impl From for DialogueRootState { + fn from(state: NewListingState) -> Self { + DialogueRootState::NewListing(state) + } +} diff --git a/src/commands/new_listing/validations.rs b/src/commands/new_listing/validations.rs new file mode 100644 index 0000000..c50e162 --- /dev/null +++ b/src/commands/new_listing/validations.rs @@ -0,0 +1,74 @@ +use crate::db::{ListingDuration, MoneyAmount}; + +// Common input validation functions +pub fn validate_title(text: &str) -> Result { + if text.is_empty() { + return Err("โŒ Title cannot be empty. Please enter a title for your listing:".to_string()); + } + if text.len() > 100 { + return Err( + "โŒ Title is too long (max 100 characters). Please enter a shorter title:".to_string(), + ); + } + Ok(text.to_string()) +} + +pub fn validate_description(text: &str) -> Result { + if text.len() > 1000 { + return Err( + "โŒ Description is too long (max 1000 characters). Please enter a shorter description:" + .to_string(), + ); + } + Ok(text.to_string()) +} + +pub fn validate_price(text: &str) -> Result { + match MoneyAmount::from_str(text) { + Ok(amount) => { + if amount.cents() <= 0 { + Err("โŒ Price must be greater than $0.00. Please enter a valid price:".to_string()) + } else { + Ok(amount) + } + } + Err(_) => Err( + "โŒ Invalid price format. Please enter a valid price (e.g., 10.50, 25, 0.99):" + .to_string(), + ), + } +} + +pub fn validate_slots(text: &str) -> Result { + match text.parse::() { + Ok(slots) if slots >= 1 && slots <= 1000 => Ok(slots), + Ok(_) => Err( + "โŒ Number of slots must be between 1 and 1000. Please enter a valid number:" + .to_string(), + ), + Err(_) => Err("โŒ Invalid number. Please enter a number from 1 to 1000:".to_string()), + } +} + +pub fn validate_duration(text: &str) -> Result { + match text.parse::() { + Ok(hours) if hours >= 1 && hours <= 720 => Ok(ListingDuration::hours(hours)), // 1 hour to 30 days + Ok(_) => Err( + "โŒ Duration must be between 1 and 720 hours. Please enter a valid number:".to_string(), + ), + Err(_) => Err("โŒ Invalid number. Please enter number of hours (1-720):".to_string()), + } +} + +pub fn validate_start_time(text: &str) -> Result { + match text.parse::() { + Ok(hours) if hours >= 0 && hours <= 168 => Ok(ListingDuration::hours(hours)), // Max 1 week delay + Ok(_) => Err( + "โŒ Start time must be between 0 and 168 hours. Please enter a valid number:" + .to_string(), + ), + Err(_) => Err( + "โŒ Invalid number. Please enter number of hours (0 for immediate start):".to_string(), + ), + } +} diff --git a/src/db/dao/listing_dao.rs b/src/db/dao/listing_dao.rs index 79a9c5d..bc7e073 100644 --- a/src/db/dao/listing_dao.rs +++ b/src/db/dao/listing_dao.rs @@ -10,10 +10,7 @@ use crate::db::{ ListingBase, ListingFields, }; -use super::super::{ - listing_id::ListingId, models::listing::Listing, models::listing_type::ListingType, - user_id::UserId, -}; +use super::super::{Listing, ListingId, ListingType, UserRowId}; /// Data Access Object for Listing operations pub struct ListingDAO; @@ -121,7 +118,7 @@ impl ListingDAO { } /// Find all listings by a seller - pub async fn find_by_seller(pool: &SqlitePool, seller_id: UserId) -> Result> { + pub async fn find_by_seller(pool: &SqlitePool, seller_id: UserRowId) -> Result> { let rows = sqlx::query("SELECT * FROM listings WHERE seller_id = ? ORDER BY created_at DESC") .bind(seller_id) @@ -134,37 +131,6 @@ impl ListingDAO { .collect::>>()?) } - /// Find all listings of a specific type - pub async fn find_by_type( - pool: &SqlitePool, - listing_type: ListingType, - ) -> Result> { - let rows = - sqlx::query("SELECT * FROM listings WHERE listing_type = ? ORDER BY created_at DESC") - .bind(listing_type) - .fetch_all(pool) - .await?; - - Ok(rows - .into_iter() - .map(Self::row_to_listing) - .collect::>>()?) - } - - /// Find active listings (not ended yet) - pub async fn find_active_listings(pool: &SqlitePool) -> Result> { - let rows = sqlx::query( - "SELECT * FROM listings WHERE ends_at > CURRENT_TIMESTAMP ORDER BY ends_at ASC", - ) - .fetch_all(pool) - .await?; - - Ok(rows - .into_iter() - .map(Self::row_to_listing) - .collect::>>()?) - } - /// Delete a listing pub async fn delete_listing(pool: &SqlitePool, listing_id: ListingId) -> Result<()> { sqlx::query("DELETE FROM listings WHERE id = ?") @@ -175,25 +141,6 @@ impl ListingDAO { Ok(()) } - /// Count total listings - pub async fn count_listings(pool: &SqlitePool) -> Result { - let row = sqlx::query("SELECT COUNT(*) as count FROM listings") - .fetch_one(pool) - .await?; - - Ok(row.get("count")) - } - - /// Count listings by seller - pub async fn count_by_seller(pool: &SqlitePool, seller_id: UserId) -> Result { - let row = sqlx::query("SELECT COUNT(*) as count FROM listings WHERE seller_id = ?") - .bind(seller_id) - .fetch_one(pool) - .await?; - - Ok(row.get("count")) - } - fn row_to_listing(row: SqliteRow) -> Result { let listing_type = row.get("listing_type"); let base = ListingBase { diff --git a/src/db/dao/user_dao.rs b/src/db/dao/user_dao.rs index 795ab29..30185ed 100644 --- a/src/db/dao/user_dao.rs +++ b/src/db/dao/user_dao.rs @@ -5,11 +5,9 @@ use anyhow::Result; use sqlx::SqlitePool; -use crate::db::TelegramUserId; - -use super::super::{ +use crate::db::{ models::user::{NewUser, User}, - user_id::UserId, + TelegramUserId, UserRowId, }; /// Data Access Object for User operations @@ -35,7 +33,7 @@ impl UserDAO { } /// Find a user by their ID - pub async fn find_by_id(pool: &SqlitePool, user_id: UserId) -> Result> { + pub async fn find_by_id(pool: &SqlitePool, user_id: UserRowId) -> Result> { let user = sqlx::query_as::<_, User>( "SELECT id, telegram_id, username, display_name, is_banned, created_at, updated_at FROM users WHERE id = ?" ) @@ -49,8 +47,9 @@ impl UserDAO { /// Find a user by their Telegram ID pub async fn find_by_telegram_id( pool: &SqlitePool, - telegram_id: TelegramUserId, + telegram_id: impl Into, ) -> Result> { + let telegram_id = telegram_id.into(); let user = sqlx::query_as::<_, User>( "SELECT id, telegram_id, username, display_name, is_banned, created_at, updated_at FROM users WHERE telegram_id = ?" ) @@ -82,7 +81,11 @@ impl UserDAO { } /// Set a user's ban status - pub async fn set_ban_status(pool: &SqlitePool, user_id: UserId, is_banned: bool) -> Result<()> { + pub async fn set_ban_status( + pool: &SqlitePool, + user_id: UserRowId, + 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) @@ -93,7 +96,7 @@ impl UserDAO { } /// Delete a user (soft delete by setting is_banned = true might be better in production) - pub async fn delete_user(pool: &SqlitePool, user_id: UserId) -> Result<()> { + pub async fn delete_user(pool: &SqlitePool, user_id: UserRowId) -> Result<()> { sqlx::query("DELETE FROM users WHERE id = ?") .bind(user_id) .execute(pool) @@ -131,6 +134,7 @@ mod tests { use crate::db::models::user::{NewUser, User}; use rstest::rstest; use sqlx::SqlitePool; + use teloxide::types::UserId; /// Create test database for UserDAO tests async fn create_test_pool() -> SqlitePool { @@ -177,7 +181,7 @@ 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, 12345.into()) + let found_by_telegram = UserDAO::find_by_telegram_id(&pool, UserId(12345)) .await .expect("Failed to find user by telegram_id") .expect("User should be found"); @@ -308,13 +312,13 @@ mod tests { let pool = create_test_pool().await; // Try to find a user that doesn't exist - let not_found = UserDAO::find_by_id(&pool, UserId::new(99999)) + let not_found = UserDAO::find_by_id(&pool, UserRowId::new(99999)) .await .expect("Database operation should succeed"); assert!(not_found.is_none()); - let not_found_by_telegram = UserDAO::find_by_telegram_id(&pool, 88888.into()) + let not_found_by_telegram = UserDAO::find_by_telegram_id(&pool, UserId(88888)) .await .expect("Database operation should succeed"); diff --git a/src/db/models/bid.rs b/src/db/models/bid.rs index d52d37f..80b9504 100644 --- a/src/db/models/bid.rs +++ b/src/db/models/bid.rs @@ -1,9 +1,10 @@ use chrono::{DateTime, Utc}; use sqlx::FromRow; -use crate::db::money_amount::MoneyAmount; +use crate::db::MoneyAmount; /// Actual bids placed on listings +#[allow(unused)] #[derive(Debug, Clone, FromRow)] pub struct Bid { pub id: i64, @@ -26,6 +27,7 @@ pub struct Bid { } /// New bid data for insertion +#[allow(unused)] #[derive(Debug, Clone)] pub struct NewBid { pub listing_id: i64, diff --git a/src/db/models/listing.rs b/src/db/models/listing.rs index f38e601..d4cb2e1 100644 --- a/src/db/models/listing.rs +++ b/src/db/models/listing.rs @@ -10,7 +10,7 @@ //! Database mapping is handled through `ListingRow` with conversion traits. use super::listing_type::ListingType; -use crate::db::{listing_id::ListingId, money_amount::MoneyAmount, user_id::UserId}; +use crate::db::{ListingId, MoneyAmount, UserRowId}; use chrono::{DateTime, Utc}; /// Main listing/auction entity @@ -24,7 +24,7 @@ pub struct Listing { #[derive(Debug, Clone)] pub struct ListingBase { pub id: ListingId, - pub seller_id: UserId, + pub seller_id: UserRowId, pub title: String, pub description: Option, pub starts_at: DateTime, @@ -106,7 +106,7 @@ mod tests { pool: &SqlitePool, telegram_id: TelegramUserId, username: Option<&str>, - ) -> UserId { + ) -> UserRowId { use crate::db::{models::user::NewUser, UserDAO}; let new_user = NewUser { @@ -194,7 +194,7 @@ mod tests { } fn build_base_listing( - seller_id: UserId, + seller_id: UserRowId, title: &str, description: Option<&str>, ) -> NewListingBase { diff --git a/src/db/models/listing_media.rs b/src/db/models/listing_media.rs index 1d401fe..dc6cb61 100644 --- a/src/db/models/listing_media.rs +++ b/src/db/models/listing_media.rs @@ -3,6 +3,7 @@ use sqlx::FromRow; /// Media attachments for listings #[derive(Debug, Clone, FromRow)] +#[allow(unused)] pub struct ListingMedia { pub id: i64, pub listing_id: i64, diff --git a/src/db/models/new_listing.rs b/src/db/models/new_listing.rs index 81bfe74..48b31f5 100644 --- a/src/db/models/new_listing.rs +++ b/src/db/models/new_listing.rs @@ -1,4 +1,4 @@ -use crate::db::{ListingType, MoneyAmount, UserId}; +use crate::db::{ListingType, MoneyAmount, UserRowId}; use chrono::{DateTime, Utc}; /// New listing data for insertion @@ -21,7 +21,7 @@ impl NewListing { #[derive(Debug, Clone)] pub struct NewListingBase { - pub seller_id: UserId, + pub seller_id: UserRowId, pub title: String, pub description: Option, pub starts_at: DateTime, @@ -54,7 +54,7 @@ pub enum NewListingFields { impl NewListingBase { pub fn new( - seller_id: UserId, + seller_id: UserRowId, title: String, description: Option, starts_at: DateTime, diff --git a/src/db/models/proxy_bid.rs b/src/db/models/proxy_bid.rs index e9b87b2..830e9b9 100644 --- a/src/db/models/proxy_bid.rs +++ b/src/db/models/proxy_bid.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use sqlx::FromRow; -use crate::db::money_amount::MoneyAmount; +use crate::db::MoneyAmount; /// Proxy bid strategies (automatic bidding settings) #[derive(Debug, Clone, FromRow)] diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 393cdc8..e12dd57 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -1,12 +1,12 @@ use chrono::{DateTime, Utc}; use sqlx::FromRow; -use crate::db::{TelegramUserId, UserId}; +use crate::db::{TelegramUserId, UserRowId}; /// Core user information #[derive(Debug, Clone, FromRow)] pub struct User { - pub id: UserId, + pub id: UserRowId, pub telegram_id: TelegramUserId, pub username: Option, pub display_name: Option, diff --git a/src/db/models/user_settings.rs b/src/db/models/user_settings.rs index 8f481cf..2b06067 100644 --- a/src/db/models/user_settings.rs +++ b/src/db/models/user_settings.rs @@ -3,6 +3,7 @@ use sqlx::FromRow; /// User preferences and settings #[derive(Debug, Clone, FromRow)] +#[allow(unused)] pub struct UserSettings { pub user_id: i64, pub language_code: String, diff --git a/src/db/types/listing_duration.rs b/src/db/types/listing_duration.rs new file mode 100644 index 0000000..aacafb0 --- /dev/null +++ b/src/db/types/listing_duration.rs @@ -0,0 +1,81 @@ +use chrono::Duration; +use serde::{Deserialize, Serialize}; +use sqlx::{ + encode::IsNull, + error::BoxDynError, + sqlite::{SqliteArgumentValue, SqliteValueRef}, + Decode, Encode, Sqlite, +}; +use std::fmt::{self, Display}; + +use crate::message_utils::pluralize_with_count; + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub struct ListingDuration(i32); +impl ListingDuration { + pub fn hours(hours: i32) -> Self { + Self(hours) + } + pub fn days(days: i32) -> Self { + Self(days * 24) + } + pub fn zero() -> Self { + Default::default() + } +} +impl Default for ListingDuration { + fn default() -> Self { + Self(0) + } +} +impl From for Duration { + fn from(duration: ListingDuration) -> Self { + Duration::hours(duration.0 as i64) + } +} +impl Display for ListingDuration { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let days = self.0 / 24; + let hours = self.0 % 24; + if days > 0 { + write!(f, "{}", pluralize_with_count(days, "day", "days"))?; + } + if hours > 0 { + if days > 0 { + write!(f, " ")?; + } + write!(f, "{}", pluralize_with_count(hours, "hour", "hours"))?; + } + if days == 0 && hours == 0 { + write!(f, "0 hours")?; + } + Ok(()) + } +} +impl Encode<'_, Sqlite> for ListingDuration { + fn encode_by_ref(&self, args: &mut Vec) -> Result { + args.push(SqliteArgumentValue::Int(self.0)); + Ok(IsNull::No) + } +} +impl<'r> Decode<'r, Sqlite> for ListingDuration { + fn decode(value: SqliteValueRef<'r>) -> Result { + let value = >::decode(value)?; + Ok(Self(value)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[rstest::rstest] + #[case(ListingDuration::default(), "0 hours")] + #[case(ListingDuration::hours(1), "1 hour")] + #[case(ListingDuration::hours(2), "2 hours")] + #[case(ListingDuration::days(1), "1 day")] + #[case(ListingDuration::days(2), "2 days")] + fn test_display(#[case] duration: ListingDuration, #[case] expected: &str) { + assert_eq!(duration.to_string(), expected); + } +} diff --git a/src/db/types/listing_id.rs b/src/db/types/listing_id.rs index 8de0d97..4df3bca 100644 --- a/src/db/types/listing_id.rs +++ b/src/db/types/listing_id.rs @@ -3,13 +3,14 @@ //! 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)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct ListingId(i64); impl ListingId { diff --git a/src/db/types/mod.rs b/src/db/types/mod.rs index b91af12..f61031e 100644 --- a/src/db/types/mod.rs +++ b/src/db/types/mod.rs @@ -1,12 +1,14 @@ -pub mod currency_type; -pub mod listing_id; -pub mod money_amount; -pub mod telegram_user_id; -pub mod user_id; +mod currency_type; +mod listing_duration; +mod listing_id; +mod money_amount; +mod telegram_user_id; +mod user_row_id; // Re-export all types for easy access pub use currency_type::*; +pub use listing_duration::*; pub use listing_id::*; pub use money_amount::*; pub use telegram_user_id::*; -pub use user_id::*; +pub use user_row_id::*; diff --git a/src/db/types/user_id.rs b/src/db/types/user_row_id.rs similarity index 82% rename from src/db/types/user_id.rs rename to src/db/types/user_row_id.rs index 8de3c5c..4d4c055 100644 --- a/src/db/types/user_id.rs +++ b/src/db/types/user_row_id.rs @@ -11,9 +11,9 @@ use teloxide::types::ChatId; /// Type-safe wrapper for user IDs #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct UserId(i64); +pub struct UserRowId(i64); -impl UserId { +impl UserRowId { /// Create a new UserId from an i64 pub fn new(id: i64) -> Self { Self(id) @@ -25,26 +25,26 @@ impl UserId { } } -impl From for UserId { +impl From for UserRowId { fn from(id: i64) -> Self { Self(id) } } -impl From for i64 { - fn from(user_id: UserId) -> Self { +impl From for i64 { + fn from(user_id: UserRowId) -> Self { user_id.0 } } -impl fmt::Display for UserId { +impl fmt::Display for UserRowId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } // SQLx implementations for database compatibility -impl Type for UserId { +impl Type for UserRowId { fn type_info() -> SqliteTypeInfo { >::type_info() } @@ -54,7 +54,7 @@ impl Type for UserId { } } -impl<'q> Encode<'q, Sqlite> for UserId { +impl<'q> Encode<'q, Sqlite> for UserRowId { fn encode_by_ref( &self, args: &mut Vec>, @@ -63,7 +63,7 @@ impl<'q> Encode<'q, Sqlite> for UserId { } } -impl<'r> Decode<'r, Sqlite> for UserId { +impl<'r> Decode<'r, Sqlite> for UserRowId { fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result { let id = >::decode(value)?; Ok(Self(id)) diff --git a/src/keyboard_utils.rs b/src/keyboard_utils.rs index b656f99..00c0cb1 100644 --- a/src/keyboard_utils.rs +++ b/src/keyboard_utils.rs @@ -15,7 +15,7 @@ macro_rules! keyboard_buttons { $($variant:ident($text:literal, $callback_data:literal),)* ]),* }) => { - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + #[derive(Debug, Clone, Copy, PartialEq, Eq)] $vis enum $name { $( $($variant,)* @@ -60,8 +60,6 @@ macro_rules! keyboard_buttons { mod tests { use teloxide::types::{InlineKeyboardButton, InlineKeyboardButtonKind}; - use super::*; - keyboard_buttons! { pub enum DurationKeyboardButtons { OneDay("1 day", "duration_1_day"), diff --git a/src/main.rs b/src/main.rs index 5531646..553f5cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,23 +1,45 @@ mod commands; mod config; mod db; +mod dptree_utils; mod keyboard_utils; mod message_utils; mod sqlite_storage; - -use anyhow::Result; -use log::info; -use teloxide::dispatching::dialogue::serializer::Json; -use teloxide::{prelude::*, utils::command::BotCommands}; #[cfg(test)] mod test_utils; + +use crate::commands::{ + my_listings::{my_listings_handler, MyListingsState}, + new_listing::{new_listing_handler, NewListingState}, +}; +use crate::sqlite_storage::SqliteStorage; +use anyhow::Result; use commands::*; use config::Config; - -use crate::commands::new_listing::new_listing_handler; -use crate::sqlite_storage::SqliteStorage; +use log::info; +use serde::{Deserialize, Serialize}; +use teloxide::dispatching::{dialogue::serializer::Json, DpHandlerDescription}; +use teloxide::{prelude::*, types::BotCommand, utils::command::BotCommands}; pub type HandlerResult = anyhow::Result; +pub type Handler = dptree::Handler<'static, HandlerResult, DpHandlerDescription>; + +/// 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..."); + + // Convert our Command enum to Telegram BotCommand structs + let commands: Vec = Command::bot_commands() + .into_iter() + .map(|cmd| BotCommand::new(cmd.command, cmd.description)) + .collect(); + + // Set the commands for the bot's menu + bot.set_my_commands(commands).await?; + info!("Bot command menu configured successfully"); + + Ok(()) +} #[derive(BotCommands, Clone)] #[command(rename_rule = "lowercase", description = "Auction Bot Commands")] @@ -36,6 +58,16 @@ pub enum Command { Settings, } +#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +enum DialogueRootState { + #[default] + Start, + NewListing(NewListingState), + MyListings(MyListingsState), +} + +type RootDialogue = Dialogue>; + #[tokio::main] async fn main() -> Result<()> { // Load and validate configuration from environment/.env file @@ -47,28 +79,51 @@ async fn main() -> Result<()> { info!("Starting Pawctioneer Bot..."); let bot = Bot::new(&config.telegram_token); + // Set up the bot's command menu + setup_bot_commands(&bot).await?; + let dialog_storage = SqliteStorage::new(db_pool.clone(), Json).await?; // Create dispatcher with dialogue system Dispatcher::builder( bot, - dptree::entry().branch(new_listing_handler()).branch( - Update::filter_message().branch( - dptree::entry() - .filter_command::() - .branch(dptree::case![Command::Start].endpoint(handle_start)) - .branch(dptree::case![Command::Help].endpoint(handle_help)) - .branch(dptree::case![Command::MyListings].endpoint(handle_my_listings)) - .branch(dptree::case![Command::MyBids].endpoint(handle_my_bids)) - .branch(dptree::case![Command::Settings].endpoint(handle_settings)), - ), - ), + dptree::entry() + .enter_dialogue::, DialogueRootState>() + .branch(new_listing_handler()) + .branch(my_listings_handler()) + .branch( + Update::filter_message().branch( + dptree::entry() + .filter_command::() + .branch(dptree::case![Command::Start].endpoint(handle_start)) + .branch(dptree::case![Command::Help].endpoint(handle_help)) + .branch(dptree::case![Command::MyBids].endpoint(handle_my_bids)) + .branch(dptree::case![Command::Settings].endpoint(handle_settings)), + ), + ) + .branch(Update::filter_message().endpoint(unknown_message_handler)), ) .dependencies(dptree::deps![db_pool, dialog_storage]) .enable_ctrlc_handler() + .worker_queue_size(1) .build() .dispatch() .await; Ok(()) } + +async fn unknown_message_handler(bot: Bot, msg: Message) -> HandlerResult { + bot.send_message( + msg.chat.id, + format!( + " + Unknown command: `{}`\n\n\ + Try /help to see the list of commands.\ + ", + msg.text().unwrap_or("") + ), + ) + .await?; + Ok(()) +} diff --git a/src/message_utils.rs b/src/message_utils.rs index 9be4c50..e795fd5 100644 --- a/src/message_utils.rs +++ b/src/message_utils.rs @@ -1,19 +1,17 @@ -use std::fmt::Display; - +use crate::HandlerResult; use anyhow::bail; +use num::One; +use std::fmt::Display; use teloxide::{ - dispatching::dialogue::GetChatId, payloads::{EditMessageTextSetters as _, SendMessageSetters as _}, prelude::Requester as _, types::{ - CallbackQuery, Chat, ChatId, InlineKeyboardButton, InlineKeyboardMarkup, MessageId, - ParseMode, User, + CallbackQuery, Chat, ChatId, InlineKeyboardButton, InlineKeyboardMarkup, Message, + MessageId, ParseMode, User, }, Bot, }; -use crate::HandlerResult; - #[derive(Debug, Clone, Copy)] pub struct HandleAndId<'s> { pub handle: Option<&'s str>, @@ -52,45 +50,86 @@ impl<'s> Into> for &'s Chat { } } -pub fn is_cancel_or_no(text: &str) -> bool { - is_cancel(text) || text.eq_ignore_ascii_case("no") -} - pub fn is_cancel(text: &str) -> bool { text.eq_ignore_ascii_case("/cancel") } -// Unified HTML message sending utility -pub async fn send_html_message( - bot: &Bot, - chat: impl Into>, - text: &str, - keyboard: Option, -) -> HandlerResult { - let chat = chat.into(); - let mut message = bot.send_message(chat.id, text).parse_mode(ParseMode::Html); - if let Some(kb) = keyboard { - message = message.reply_markup(kb); - } - message.await?; - Ok(()) +#[derive(Debug, Clone)] +pub struct MessageTarget { + pub chat_id: ChatId, + pub message_id: Option, } -pub async fn edit_html_message( +impl Into for ChatId { + fn into(self) -> MessageTarget { + MessageTarget { + chat_id: self, + message_id: None, + } + } +} + +impl Into for Chat { + fn into(self) -> MessageTarget { + MessageTarget { + chat_id: self.id, + message_id: None, + } + } +} + +impl Into for User { + fn into(self) -> MessageTarget { + MessageTarget { + chat_id: self.id.into(), + message_id: None, + } + } +} + +impl Into for (User, MessageId) { + fn into(self) -> MessageTarget { + MessageTarget { + chat_id: self.0.id.into(), + message_id: Some(self.1), + } + } +} + +impl Into for (Chat, MessageId) { + fn into(self) -> MessageTarget { + MessageTarget { + chat_id: self.0.id.into(), + message_id: Some(self.1), + } + } +} + +// Unified HTML message sending utility +pub async fn send_message( bot: &Bot, - chat: impl Into>, - message_id: MessageId, - text: &str, + message_target: impl Into, + text: impl AsRef, keyboard: Option, ) -> HandlerResult { - let chat = chat.into(); - let mut edit_request = bot - .edit_message_text(chat.id, message_id, text) - .parse_mode(ParseMode::Html); - if let Some(kb) = keyboard { - edit_request = edit_request.reply_markup(kb); + let message_target = message_target.into(); + if let Some(message_id) = message_target.message_id { + let mut message = bot + .edit_message_text(message_target.chat_id, message_id, text.as_ref()) + .parse_mode(ParseMode::Html); + if let Some(kb) = keyboard { + message = message.reply_markup(kb); + } + message.await?; + } else { + let mut message = bot + .send_message(message_target.chat_id, text.as_ref()) + .parse_mode(ParseMode::Html); + if let Some(kb) = keyboard { + message = message.reply_markup(kb); + } + message.await?; } - edit_request.await?; Ok(()) } @@ -125,36 +164,48 @@ pub fn create_multi_row_keyboard(rows: &[&[(&str, &str)]]) -> InlineKeyboardMark keyboard } -// Create numeric option keyboard (common pattern for slots, duration, etc.) -pub fn create_numeric_options_keyboard( - options: &[(i32, &str)], - prefix: &str, -) -> InlineKeyboardMarkup { - let buttons: Vec = options - .iter() - .map(|(value, label)| { - InlineKeyboardButton::callback(*label, format!("{}_{}", prefix, value)) - }) - .collect(); - InlineKeyboardMarkup::new([buttons]) -} - // Extract callback data and answer callback query -pub async fn extract_callback_data( +pub async fn extract_callback_data<'c>( bot: &Bot, - callback_query: &CallbackQuery, -) -> HandlerResult<(String, User)> { - let data = match callback_query.data.as_deref() { - Some(data) => data.to_string(), + callback_query: CallbackQuery, +) -> HandlerResult<(String, User, MessageId)> { + let data = match callback_query.data { + Some(data) => data, None => bail!("Missing data in callback query"), }; let from = callback_query.from.clone(); + let message_id = if let Some(m) = callback_query.message { + m.id() + } else { + bail!("Missing message in callback query") + }; + // Answer the callback query to remove loading state if let Err(e) = bot.answer_callback_query(callback_query.id.clone()).await { log::warn!("Failed to answer callback query: {}", e); } - Ok((data, from)) + Ok((data, from, message_id)) +} + +pub fn pluralize<'a, N: One + PartialEq>( + count: N, + singular: &'a str, + plural: &'a str, +) -> &'a str { + if count == One::one() { + singular + } else { + plural + } +} + +pub fn pluralize_with_count<'a, N: One + PartialEq + Display + Copy>( + count: N, + singular: &str, + plural: &str, +) -> String { + format!("{} {}", count, pluralize(count, singular, plural)) } diff --git a/src/sqlite_storage.rs b/src/sqlite_storage.rs index 6bacac8..afaf26b 100644 --- a/src/sqlite_storage.rs +++ b/src/sqlite_storage.rs @@ -4,7 +4,6 @@ use sqlx::{sqlite::SqlitePool, Executor}; use std::{ convert::Infallible, fmt::{Debug, Display}, - str, sync::Arc, }; use teloxide::dispatching::dialogue::{serializer::Serializer, Storage}; diff --git a/src/test_utils.rs b/src/test_utils.rs index 0345bcc..22e7bb6 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -1,7 +1,5 @@ //! Test utilities including timestamp comparison macros -use chrono::{DateTime, Duration, Utc}; - /// 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