From 71fe1e60c07a1989663218ce31e2dfbd4317cbc3 Mon Sep 17 00:00:00 2001 From: Dylan Knutson Date: Thu, 28 Aug 2025 06:58:55 +0000 Subject: [PATCH] feat: Add comprehensive edit screen to new listing wizard - Replace individual state structs with unified ListingDraft struct - Add EditingListing state with field selection interface - Implement individual field editing states (Title, Description, Price, Slots, StartTime, Duration) - Add field-specific keyboards with Back buttons and Clear functionality for description - Update all handlers to use ListingDraft instead of separate state structs - Rename Confirming to ViewingDraft for clarity - Add proper validation and error handling for all field edits - Enable seamless navigation between edit screen and confirmation - Maintain all existing functionality while adding edit capabilities --- Cargo.lock | 4 + Cargo.toml | 10 +- pawctioneer_bot_dev.db | Bin 86016 -> 94208 bytes src/commands/help.rs | 4 +- src/commands/mod.rs | 6 +- src/commands/my_bids.rs | 4 +- src/commands/my_listings.rs | 4 +- src/commands/new_listing.rs | 1594 +++++++++++++++++++++++++++++- src/commands/settings.rs | 4 +- src/commands/start.rs | 4 +- src/db/models/bid.rs | 3 +- src/db/models/listing.rs | 14 +- src/db/models/listing_media.rs | 3 +- src/db/models/listing_type.rs | 4 +- src/db/models/proxy_bid.rs | 3 +- src/db/models/user.rs | 3 +- src/db/models/user_settings.rs | 3 +- src/db/types/currency_type.rs | 4 +- src/db/types/listing_id.rs | 3 +- src/db/types/money_amount.rs | 6 + src/db/types/telegram_user_id.rs | 3 +- src/db/types/user_id.rs | 3 +- src/main.rs | 55 +- src/message_utils.rs | 40 + src/sqlite_storage.rs | 150 +++ src/wizard_utils.rs | 64 ++ 26 files changed, 1928 insertions(+), 67 deletions(-) create mode 100644 src/message_utils.rs create mode 100644 src/sqlite_storage.rs create mode 100644 src/wizard_utils.rs diff --git a/Cargo.lock b/Cargo.lock index 8b7f406..c0e1ca2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1548,12 +1548,16 @@ dependencies = [ "chrono", "dotenvy", "env_logger", + "futures", + "lazy_static", "log", "rstest", "rust_decimal", "serde", "sqlx", "teloxide", + "teloxide-core", + "thiserror", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index a493287..3bbd88c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,13 +13,17 @@ sqlx = { version = "0.8.6", features = [ "macros", "rust_decimal", ] } -rust_decimal = { version = "1.33", features = ["serde"] } -chrono = { version = "0.4", features = ["serde"] } -serde = { version = "1.0", features = ["derive"] } +rust_decimal = { version = "1.33" } +chrono = { version = "0.4" } log = "0.4" env_logger = "0.11.8" anyhow = "1.0" dotenvy = "0.15" +lazy_static = "1.4" +serde = "1.0.219" +futures = "0.3.31" +thiserror = "2.0.16" +teloxide-core = "0.13.0" [dev-dependencies] rstest = "0.26.1" diff --git a/pawctioneer_bot_dev.db b/pawctioneer_bot_dev.db index 20629ed6a97ca4ef9aeae3b9f2c156a20a897aee..15972693173aef76c8edcc9623ba5880776ac972 100644 GIT binary patch delta 495 zcmZozz}oPDb%L~@JOcxRI0FI*Ow=)Ely6Mfn!vbFUXfjnftSslfj5AA2bVwR3(ihX zO^yp3wH!L^E7{f9+}Un!78Fort8e5NW*0X!WNh>-NleN~ElJJEugFYEjZeu;%*jtL zO)bV05pxc5bqsM;2yt}saaF*SSJ2>6P*6}v&PXhY&rDHp@^ts~3sDFN^7M5Kid67+ zjno0lgKdyZFHKc&^6__4@beE*@C)_v;nH*#VG|EGoV=G`LL1ZYctcDvP#|z@S5jxZ zr!gsjgNZ+Sv!H-Ce|@6{V`HOAqY@(vYm*HFW0IR?O0`m0W@>q6Ub;(BVp@rkRkcz{ zW=T$}l9f_PYH^7IgrlRBl3JWxlvz-cnV+X*rIe@umP8iRQA#SUjL*w2k1r_7Oiop@ zGBhwSFwjvd&dDz+j!!I0%*;tNO9B~erlV9`l2}v{pOIf$RIFrWpre#hT9gPf8YX09 cQd`Rfibeqj2L5lG1r6Ttb8#}WazdO80N@jp!2kdN delta 79 zcmZp8z}m2Yb%L}YGXn#IC<6lUPt-AHWNu8@n!vbFUY3D@fty{8fj5AA2bVwR3(ihX bO^yp3wH!L^E7{dH3kq ResponseResult<()> { +pub async fn handle_help(bot: Bot, msg: Message) -> HandlerResult { let help_message = format!( "๐Ÿ“‹ Available Commands:\n\n{}\n\n\ ๐Ÿ“ง Support: Contact @admin for help\n\ diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 74440b2..ae05ddf 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -9,6 +9,10 @@ pub mod start; pub use help::handle_help; pub use my_bids::handle_my_bids; pub use my_listings::handle_my_listings; -pub use new_listing::handle_new_listing; +pub use new_listing::{ + handle_new_listing, new_listing_callback_handler, new_listing_dialogue_handler, +}; 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_bids.rs b/src/commands/my_bids.rs index a1d3146..01074ef 100644 --- a/src/commands/my_bids.rs +++ b/src/commands/my_bids.rs @@ -1,7 +1,9 @@ use log::info; use teloxide::{prelude::*, types::Message, Bot}; -pub async fn handle_my_bids(bot: Bot, msg: Message) -> ResponseResult<()> { +use crate::HandlerResult; + +pub async fn handle_my_bids(bot: Bot, msg: Message) -> HandlerResult { let response = "๐ŸŽฏ My Bids (Coming Soon)\n\n\ Here you'll be able to view:\n\ โ€ข Your active bids\n\ diff --git a/src/commands/my_listings.rs b/src/commands/my_listings.rs index 03875fc..ec4b973 100644 --- a/src/commands/my_listings.rs +++ b/src/commands/my_listings.rs @@ -1,7 +1,9 @@ use log::info; use teloxide::{prelude::*, types::Message, Bot}; -pub async fn handle_my_listings(bot: Bot, msg: Message) -> ResponseResult<()> { +use crate::HandlerResult; + +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\ diff --git a/src/commands/new_listing.rs b/src/commands/new_listing.rs index 6cf6e8d..ecd4b88 100644 --- a/src/commands/new_listing.rs +++ b/src/commands/new_listing.rs @@ -1,21 +1,1593 @@ +use chrono::{Duration, Utc}; use log::info; -use teloxide::{prelude::*, types::Message, Bot}; +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; +use teloxide::{ + dispatching::{dialogue::serializer::Json, DpHandlerDescription}, + prelude::*, + types::{ + CallbackQuery, CallbackQueryId, ChatId, InlineKeyboardButton, InlineKeyboardMarkup, + Message, ParseMode, + }, + Bot, +}; -pub async fn handle_new_listing(bot: Bot, msg: Message) -> ResponseResult<()> { - let response = "๐Ÿ—๏ธ New Listing or Auction Creation (Coming Soon)\n\n\ - This feature will allow you to create:\n\ - โ€ข Standard time-based auctions\n\ - โ€ข Multi-slot auctions\n\ - โ€ข Fixed price sales\n\ - โ€ข Blind auctions\n\n\ - Stay tuned! ๐ŸŽช"; +use crate::{ + db::{ + dao::ListingDAO, + models::new_listing::{NewListing, NewListingBase, NewListingFields}, + types::{money_amount::MoneyAmount, user_id::UserId}, + }, + message_utils::{is_cancel, is_cancel_or_no, UserHandleAndId}, + sqlite_storage::SqliteStorage, + HandlerResult, +}; +#[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, +} + +// Dialogue state for the new listing wizard +#[derive(Clone, Default, Serialize, Deserialize)] +pub enum ListingWizardState { + #[default] + Start, + AwaitingTitle(ListingDraft), + AwaitingDescription(ListingDraft), + AwaitingPrice(ListingDraft), + AwaitingSlots(ListingDraft), + AwaitingStartTime(ListingDraft), + AwaitingDuration(ListingDraft), + ViewingDraft(ListingDraft), + EditingListing(ListingDraft), + EditingTitle(ListingDraft), + EditingDescription(ListingDraft), + EditingPrice(ListingDraft), + EditingSlots(ListingDraft), + EditingStartTime(ListingDraft), + EditingDuration(ListingDraft), +} + +// Type alias for the dialogue +pub type NewListingDialogue = Dialogue>; + +// Create the dialogue handler tree for new listing wizard +pub fn new_listing_dialogue_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> { + dptree::entry() + .branch(dptree::case![ListingWizardState::Start].endpoint(start_new_listing)) + .branch( + dptree::case![ListingWizardState::AwaitingTitle(state)].endpoint(handle_title_input), + ) + .branch( + dptree::case![ListingWizardState::AwaitingDescription(state)] + .endpoint(handle_description_input), + ) + .branch( + dptree::case![ListingWizardState::AwaitingPrice(state)].endpoint(handle_price_input), + ) + .branch( + dptree::case![ListingWizardState::AwaitingSlots(state)].endpoint(handle_slots_input), + ) + .branch( + dptree::case![ListingWizardState::AwaitingStartTime(state)] + .endpoint(handle_start_time_input), + ) + .branch( + dptree::case![ListingWizardState::AwaitingDuration(state)] + .endpoint(handle_duration_input), + ) + .branch( + dptree::case![ListingWizardState::ViewingDraft(state)].endpoint(handle_viewing_draft), + ) + .branch( + dptree::case![ListingWizardState::EditingListing(state)] + .endpoint(handle_editing_screen), + ) + .branch(dptree::case![ListingWizardState::EditingTitle(state)].endpoint(handle_edit_title)) + .branch( + dptree::case![ListingWizardState::EditingDescription(state)] + .endpoint(handle_edit_description), + ) + .branch(dptree::case![ListingWizardState::EditingPrice(state)].endpoint(handle_edit_price)) + .branch(dptree::case![ListingWizardState::EditingSlots(state)].endpoint(handle_edit_slots)) + .branch( + dptree::case![ListingWizardState::EditingStartTime(state)] + .endpoint(handle_edit_start_time), + ) + .branch( + dptree::case![ListingWizardState::EditingDuration(state)] + .endpoint(handle_edit_duration), + ) +} + +// Handle the /newlisting command - starts the dialogue +pub async fn handle_new_listing( + bot: Bot, + dialogue: NewListingDialogue, + msg: Message, +) -> HandlerResult { info!( - "User {} ({}) attempted to create new listing or auctionc", + "User {} ({}) started new fixed price listing wizard", msg.chat.username().unwrap_or("unknown"), msg.chat.id ); - bot.send_message(msg.chat.id, response).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):"; + + bot.send_message(msg.chat.id, response) + .parse_mode(ParseMode::Html) + .await?; + dialogue + .update(ListingWizardState::AwaitingTitle(ListingDraft::default())) + .await?; + Ok(()) +} + +// Handle the Start state (same as handle_new_listing for now) +pub async fn start_new_listing( + bot: Bot, + dialogue: NewListingDialogue, + msg: Message, +) -> HandlerResult { + handle_new_listing(bot, dialogue, msg).await +} + +// Individual handler functions for each dialogue state + +pub async fn handle_title_input( + bot: Bot, + dialogue: NewListingDialogue, + mut draft: ListingDraft, + msg: Message, +) -> HandlerResult { + let chat_id = msg.chat.id; + let text = msg.text().unwrap_or("").trim(); + + info!( + "User {} entered title input", + UserHandleAndId::from_chat(&msg.chat) + ); + + if is_cancel(text) { + return cancel_wizard(bot, dialogue, msg).await; + } + + if text.is_empty() { + bot.send_message( + chat_id, + "โŒ Title cannot be empty. Please enter a title for your listing:", + ) + .await?; + return Ok(()); + } + + if text.len() > 100 { + bot.send_message( + chat_id, + "โŒ Title is too long (max 100 characters). Please enter a shorter title:", + ) + .await?; + return Ok(()); + } + + draft.title = text.to_string(); + dialogue + .update(ListingWizardState::AwaitingDescription(draft)) + .await?; + + let response = "โœ… Title saved!\n\n\ + Step 2 of 6: Description\n\ + Please enter a description for your listing (optional)."; + + let skip_button = InlineKeyboardMarkup::new([[InlineKeyboardButton::callback("Skip", "skip")]]); + + bot.send_message(chat_id, response) + .parse_mode(ParseMode::Html) + .reply_markup(skip_button) + .await?; + + Ok(()) +} + +pub async fn handle_description_input( + bot: Bot, + dialogue: NewListingDialogue, + mut draft: ListingDraft, + msg: Message, +) -> HandlerResult { + let chat_id = msg.chat.id; + let text = msg.text().unwrap_or("").trim(); + + info!( + "User {} entered description input", + UserHandleAndId::from_chat(&msg.chat) + ); + + if is_cancel(text) { + return cancel_wizard(bot, dialogue, msg).await; + } + + if text.len() > 1000 { + bot.send_message(chat_id, "โŒ Description is too long (max 1000 characters). Please enter a shorter description or 'skip':") + .await?; + return Ok(()); + } + draft.description = Some(text.to_string()); + + dialogue + .update(ListingWizardState::AwaitingPrice(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"; + + bot.send_message(chat_id, response) + .parse_mode(ParseMode::Html) + .await?; + + Ok(()) +} + +pub async fn handle_description_callback( + bot: Bot, + dialogue: NewListingDialogue, + draft: ListingDraft, + callback_query: CallbackQuery, +) -> HandlerResult { + let data = match callback_query.data.as_deref() { + Some(data) => data, + None => return Ok(()), + }; + + if data == "skip" { + // Answer the callback query to remove the loading state + bot.answer_callback_query(callback_query.id).await?; + + // Process as if user typed "skip" + dialogue + .update(ListingWizardState::AwaitingPrice(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 { + let chat_id = message.chat().id; + bot.edit_message_text(chat_id, message.id(), response) + .parse_mode(ParseMode::Html) + .await?; + } + } + + Ok(()) +} + +// Create callback query handler for skip button, slots buttons, and start time button +pub fn new_listing_callback_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> { + dptree::entry() + .branch( + dptree::case![ListingWizardState::AwaitingDescription(state)] + .endpoint(handle_description_callback), + ) + .branch( + dptree::case![ListingWizardState::AwaitingSlots(state)].endpoint(handle_slots_callback), + ) + .branch( + dptree::case![ListingWizardState::AwaitingStartTime(state)] + .endpoint(handle_start_time_callback), + ) + .branch( + dptree::case![ListingWizardState::ViewingDraft(state)] + .endpoint(handle_viewing_draft_callback), + ) + .branch( + dptree::case![ListingWizardState::EditingListing(state)] + .endpoint(handle_editing_callback), + ) + .branch( + dptree::case![ListingWizardState::EditingTitle(state)] + .endpoint(handle_edit_field_callback), + ) + .branch( + dptree::case![ListingWizardState::EditingDescription(state)] + .endpoint(handle_edit_field_callback), + ) + .branch( + dptree::case![ListingWizardState::EditingPrice(state)] + .endpoint(handle_edit_field_callback), + ) + .branch( + dptree::case![ListingWizardState::EditingSlots(state)] + .endpoint(handle_edit_field_callback), + ) + .branch( + dptree::case![ListingWizardState::EditingStartTime(state)] + .endpoint(handle_edit_field_callback), + ) + .branch( + dptree::case![ListingWizardState::EditingDuration(state)] + .endpoint(handle_edit_field_callback), + ) +} + +pub async fn handle_slots_callback( + bot: Bot, + dialogue: NewListingDialogue, + draft: ListingDraft, + callback_query: CallbackQuery, +) -> HandlerResult { + let data = match callback_query.data.as_deref() { + Some(data) => data, + None => return Ok(()), + }; + + if data.starts_with("slots_") { + info!( + "User {} selected slots button: {}", + UserHandleAndId::from_user(&callback_query.from), + data + ); + + // Extract the slots number from the callback data + let slots_str = data.strip_prefix("slots_").unwrap(); + if let Ok(slots) = slots_str.parse::() { + if let Some(message) = callback_query.message { + // Answer the callback query to remove the loading state + bot.answer_callback_query(callback_query.id).await?; + + let chat_id = message.chat().id; + // Process the slots selection and send response using shared logic + process_slots_and_respond(&bot, dialogue, draft, chat_id, slots).await?; + } else { + handle_callback_error(&bot, dialogue, callback_query.id).await?; + } + } + } + + Ok(()) +} + +pub async fn handle_start_time_callback( + bot: Bot, + dialogue: NewListingDialogue, + draft: ListingDraft, + callback_query: CallbackQuery, +) -> HandlerResult { + let data = match callback_query.data.as_deref() { + Some(data) => data, + None => return Ok(()), + }; + + if data == "start_time_now" { + info!( + "User {} selected 'Now' for start time", + UserHandleAndId::from_user(&callback_query.from) + ); + + if let Some(message) = callback_query.message { + // Answer the callback query to remove the loading state + bot.answer_callback_query(callback_query.id).await?; + + let chat_id = message.chat().id; + // Process start time of 0 (immediate start) + process_start_time_and_respond(&bot, dialogue, draft, chat_id, 0).await?; + } else { + handle_callback_error(&bot, dialogue, callback_query.id).await?; + } + } + + Ok(()) +} + +// Helper function to handle callback query errors (missing message) +async fn handle_callback_error( + bot: &Bot, + dialogue: NewListingDialogue, + callback_query_id: CallbackQueryId, +) -> HandlerResult { + // Reset dialogue to idle state + dialogue.exit().await?; + + // Answer callback query with error message + bot.answer_callback_query(callback_query_id) + .text("โŒ Error: Unable to process request. Please try again.") + .show_alert(true) + .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_id: ChatId, + slots: i32, +) -> HandlerResult { + // Update dialogue state + draft.slots_available = slots; + dialogue + .update(ListingWizardState::AwaitingStartTime(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)" + ); + + bot.send_message(chat_id, response) + .parse_mode(ParseMode::Html) + .reply_markup(create_start_time_keyboard()) + .await?; + + Ok(()) +} + +pub async fn handle_viewing_draft_callback( + bot: Bot, + dialogue: NewListingDialogue, + state: ListingDraft, + callback_query: CallbackQuery, +) -> HandlerResult { + let data = match callback_query.data.as_deref() { + Some(data) => data, + None => return Ok(()), + }; + + let callback_id = callback_query.id; + + // Answer the callback query to remove the loading state + bot.answer_callback_query(callback_id.clone()).await?; + let message = match callback_query.message { + Some(message) => message, + None => { + handle_callback_error(&bot, dialogue, callback_id).await?; + return Ok(()); + } + }; + + match data { + "confirm_create" => { + info!( + "User {} confirmed listing creation", + UserHandleAndId::from_user(&callback_query.from) + ); + + // Exit dialogue and create listing + dialogue.exit().await?; + + let chat_id = message.chat().id; + let response = "โœ… Listing Created Successfully!\n\n\ + Your fixed price listing has been created and is now active.\n\ + Other users can now purchase items from your listing."; + + bot.edit_message_text(chat_id, message.id(), response) + .parse_mode(ParseMode::Html) + .await?; + } + "confirm_discard" => { + info!( + "User {} discarded listing creation", + UserHandleAndId::from_user(&callback_query.from) + ); + + // Exit dialogue and send cancellation message + dialogue.exit().await?; + + let chat_id = message.chat().id; + let response = "๐Ÿ—‘๏ธ Listing Discarded\n\n\ + Your listing has been discarded and not created.\n\ + You can start a new listing anytime with /newlisting."; + + bot.edit_message_text(chat_id, message.id(), response) + .await?; + } + "confirm_edit" => { + info!( + "User {} chose to edit listing", + UserHandleAndId::from_user(&callback_query.from) + ); + + // Go to editing state to allow user to modify specific fields + dialogue + .update(ListingWizardState::EditingListing(state.clone())) + .await?; + + let chat_id = message.chat().id; + + // Delete the old message and show the edit screen + bot.delete_message(chat_id, message.id()).await?; + show_edit_screen(bot, chat_id, state).await?; + } + _ => { + // Unknown callback data, ignore + } + } + + Ok(()) +} + +// Helper function to create start time inline keyboard with "Now" button +fn create_start_time_keyboard() -> InlineKeyboardMarkup { + InlineKeyboardMarkup::new([[InlineKeyboardButton::callback("Now", "start_time_now")]]) +} + +// Helper function to create confirmation inline keyboard with Create/Discard/Edit buttons +fn create_confirmation_keyboard() -> InlineKeyboardMarkup { + InlineKeyboardMarkup::default() + .append_row([ + InlineKeyboardButton::callback("โœ… Create", "confirm_create"), + InlineKeyboardButton::callback("๐Ÿ—‘๏ธ Discard", "confirm_discard"), + ]) + .append_row([InlineKeyboardButton::callback("โœ๏ธ Edit", "confirm_edit")]) +} + +// Helper function to create field selection keyboard for editing +fn create_field_selection_keyboard() -> InlineKeyboardMarkup { + InlineKeyboardMarkup::default() + .append_row([ + InlineKeyboardButton::callback("๐Ÿ“ Title", "edit_title"), + InlineKeyboardButton::callback("๐Ÿ“„ Description", "edit_description"), + ]) + .append_row([ + InlineKeyboardButton::callback("๐Ÿ’ฐ Price", "edit_price"), + InlineKeyboardButton::callback("๐Ÿ”ข Slots", "edit_slots"), + ]) + .append_row([ + InlineKeyboardButton::callback("โฐ Start Time", "edit_start_time"), + InlineKeyboardButton::callback("โณ Duration", "edit_duration"), + ]) + .append_row([InlineKeyboardButton::callback( + "โœ… Done Editing", + "edit_done", + )]) +} + +// Helper function to create back button keyboard +fn create_back_button_keyboard() -> InlineKeyboardMarkup { + InlineKeyboardMarkup::new([[InlineKeyboardButton::callback("๐Ÿ”™ Back", "edit_back")]]) +} + +fn create_back_button_keyboard_with_clear(field: &str) -> InlineKeyboardMarkup { + InlineKeyboardMarkup::new([[ + InlineKeyboardButton::callback("๐Ÿ”™ Back", "edit_back"), + InlineKeyboardButton::callback( + format!("๐Ÿงน Clear {}", field), + format!("edit_clear_{}", field), + ), + ]]) +} + +// 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_id: ChatId, + hours: i32, +) -> HandlerResult { + // Update dialogue state + draft.start_hours = hours; + dialogue + .update(ListingWizardState::AwaitingDuration(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 + ); + + bot.send_message(chat_id, response) + .parse_mode(ParseMode::Html) + .await?; + + Ok(()) +} + +pub async fn handle_price_input( + bot: Bot, + dialogue: NewListingDialogue, + mut draft: ListingDraft, + msg: Message, +) -> HandlerResult { + let chat_id = msg.chat.id; + let text = msg.text().unwrap_or("").trim(); + + info!( + "User {} entered price input", + UserHandleAndId::from_chat(&msg.chat) + ); + + if is_cancel(text) { + return cancel_wizard(bot, dialogue, msg).await; + } + + let price = match MoneyAmount::from_str(text) { + Ok(amount) => { + if amount.cents() <= 0 { + bot.send_message( + chat_id, + "โŒ Price must be greater than $0.00. Please enter a valid price:", + ) + .await?; + return Ok(()); + } + amount + } + Err(_) => { + bot.send_message( + chat_id, + "โŒ Invalid price format. Please enter a valid price (e.g., 10.50, 25, 0.99):", + ) + .await?; + return Ok(()); + } + }; + + draft.buy_now_price = price; + dialogue + .update(ListingWizardState::AwaitingSlots(draft)) + .await?; + + 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):", + price + ); + + let slots_buttons = InlineKeyboardMarkup::new([ + [ + InlineKeyboardButton::callback("1", "slots_1"), + InlineKeyboardButton::callback("2", "slots_2"), + ], + [ + InlineKeyboardButton::callback("5", "slots_5"), + InlineKeyboardButton::callback("10", "slots_10"), + ], + ]); + + bot.send_message(chat_id, response) + .parse_mode(ParseMode::Html) + .reply_markup(slots_buttons) + .await?; + + Ok(()) +} + +pub async fn handle_slots_input( + bot: Bot, + dialogue: NewListingDialogue, + draft: ListingDraft, + msg: Message, +) -> HandlerResult { + let chat_id = msg.chat.id; + let text = msg.text().unwrap_or("").trim(); + + info!( + "User {} entered slots input", + UserHandleAndId::from_chat(&msg.chat) + ); + + if is_cancel(text) { + return cancel_wizard(bot, dialogue, msg).await; + } + + let slots = match text.parse::() { + Ok(num) => { + if num < 1 || num > 1000 { + bot.send_message( + chat_id, + "โŒ Number of slots must be between 1 and 1000. Please enter a valid number:", + ) + .await?; + return Ok(()); + } + num + } + Err(_) => { + bot.send_message( + chat_id, + "โŒ Invalid number. Please enter a number from 1 to 1000:", + ) + .await?; + return Ok(()); + } + }; + + process_slots_and_respond(&bot, dialogue, draft, chat_id, slots).await?; + + Ok(()) +} + +pub async fn handle_start_time_input( + bot: Bot, + dialogue: NewListingDialogue, + draft: ListingDraft, + msg: Message, +) -> HandlerResult { + let chat_id = msg.chat.id; + let text = msg.text().unwrap_or("").trim(); + + info!( + "User {} entered start time input", + UserHandleAndId::from_chat(&msg.chat) + ); + + if is_cancel(text) { + return cancel_wizard(bot, dialogue, msg).await; + } + + let hours = match text.parse::() { + Ok(num) => { + if num < 0 || num > 168 { + bot.send_message( + chat_id, + "โŒ Start time must be between 0 and 168 hours. Please enter a valid number:", + ) + .reply_markup(create_start_time_keyboard()) + .await?; + return Ok(()); + } + num + } + Err(_) => { + bot.send_message( + chat_id, + "โŒ Invalid number. Please enter hours (0 for immediate, up to 168 for 7 days):", + ) + .reply_markup(create_start_time_keyboard()) + .await?; + return Ok(()); + } + }; + + process_start_time_and_respond(&bot, dialogue, draft, chat_id, hours).await?; + + Ok(()) +} + +pub async fn handle_duration_input( + bot: Bot, + dialogue: NewListingDialogue, + mut draft: ListingDraft, + msg: Message, +) -> HandlerResult { + let chat_id = msg.chat.id; + let text = msg.text().unwrap_or("").trim(); + + info!( + "User {} entered duration input", + UserHandleAndId::from_chat(&msg.chat) + ); + + if is_cancel(text) { + return cancel_wizard(bot, dialogue, msg).await; + } + + let duration = match text.parse::() { + Ok(num) => { + if num < 1 || num > 720 { + bot.send_message( + chat_id, + "โŒ Duration must be between 1 and 720 hours. Please enter a valid number:", + ) + .await?; + return Ok(()); + } + num + } + Err(_) => { + bot.send_message( + chat_id, + "โŒ Invalid number. Please enter duration in hours (1-720):", + ) + .await?; + return Ok(()); + } + }; + + draft.duration_hours = duration; + dialogue + .update(ListingWizardState::ViewingDraft(draft.clone())) + .await?; + + show_confirmation(bot, chat_id, draft).await?; + + Ok(()) +} + +async fn show_confirmation(bot: Bot, chat_id: ChatId, state: ListingDraft) -> 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" } + ); + + bot.send_message(chat_id, response) + .parse_mode(ParseMode::Html) + .reply_markup(create_confirmation_keyboard()) + .await?; + + Ok(()) +} + +async fn show_edit_screen(bot: Bot, chat_id: ChatId, state: ListingDraft) -> 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!( + "โœ๏ธ Edit Listing\n\n\ + Current Values:\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" } + ); + + bot.send_message(chat_id, response) + .parse_mode(ParseMode::Html) + .reply_markup(create_field_selection_keyboard()) + .await?; + + Ok(()) +} + +pub async fn handle_viewing_draft( + bot: Bot, + dialogue: NewListingDialogue, + state: ListingDraft, + msg: Message, + db_pool: SqlitePool, +) -> HandlerResult { + let chat_id = msg.chat.id; + let text = msg.text().unwrap_or("").trim(); + + info!( + "User {} entered confirmation input", + UserHandleAndId::from_chat(&msg.chat) + ); + + if is_cancel_or_no(text) { + return cancel_wizard(bot, dialogue, msg).await; + } + + if text.eq_ignore_ascii_case("yes") { + create_listing( + bot, + dialogue, + msg, + db_pool, + state.title, + state.description, + state.buy_now_price, + state.slots_available, + state.start_hours, + state.duration_hours, + ) + .await?; + } else { + bot.send_message(chat_id, "Please confirm your choice:") + .reply_markup(create_confirmation_keyboard()) + .await?; + } + + Ok(()) +} + +pub async fn handle_editing_screen(bot: Bot, state: ListingDraft, msg: Message) -> HandlerResult { + let chat_id = msg.chat.id; + + info!( + "User {} in editing screen, showing field selection", + UserHandleAndId::from_chat(&msg.chat) + ); + + // Show the edit screen with current values and field selection + show_edit_screen(bot, chat_id, state).await?; + + Ok(()) +} + +async fn create_listing( + bot: Bot, + dialogue: NewListingDialogue, + msg: Message, + db_pool: SqlitePool, + title: String, + description: Option, + buy_now_price: MoneyAmount, + slots_available: i32, + start_hours: i32, + duration_hours: i32, +) -> HandlerResult { + let chat_id = msg.chat.id; + let user_id = msg + .from + .as_ref() + .map(|u| u.id.0 as i64) + .unwrap_or(chat_id.0); + + let now = Utc::now(); + let starts_at = now + Duration::hours(start_hours as i64); + let ends_at = starts_at + Duration::hours(duration_hours as i64); + + let new_listing_base = NewListingBase::new( + UserId::new(user_id), + title.clone(), + description, + starts_at, + ends_at, + ); + + let new_listing = NewListing { + base: new_listing_base, + fields: NewListingFields::FixedPriceListing { + buy_now_price, + 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, buy_now_price, slots_available + ); + + bot.send_message(chat_id, response) + .parse_mode(ParseMode::Html) + .await?; + dialogue.exit().await?; + + info!( + "Fixed price listing created successfully for user {}: {:?}", + user_id, listing.base.id + ); + } + Err(e) => { + log::error!("Failed to create listing for user {}: {}", user_id, e); + bot.send_message( + chat_id, + "โŒ **Error:** Failed to create listing. Please try again later.", + ) + .await?; + } + } + + Ok(()) +} + +async fn cancel_wizard(bot: Bot, dialogue: NewListingDialogue, msg: Message) -> HandlerResult { + info!( + "User {} cancelled new listing wizard", + UserHandleAndId::from_chat(&msg.chat) + ); + dialogue.exit().await?; + bot.send_message(msg.chat.id, "โŒ Listing creation cancelled.") + .await?; + + Ok(()) +} + +// Individual field editing handlers +pub async fn handle_edit_title( + bot: Bot, + dialogue: NewListingDialogue, + mut state: ListingDraft, + msg: Message, +) -> HandlerResult { + let chat_id = msg.chat.id; + let text = msg.text().unwrap_or("").trim(); + + info!( + "User {} editing title: '{}'", + UserHandleAndId::from_chat(&msg.chat), + text + ); + + if text.is_empty() { + bot.send_message( + chat_id, + "โŒ Title cannot be empty. Please enter a valid title:", + ) + .reply_markup(create_back_button_keyboard()) + .await?; + return Ok(()); + } + + if text.len() > 100 { + bot.send_message( + chat_id, + "โŒ Title too long. Please enter a title with 100 characters or less:", + ) + .reply_markup(create_back_button_keyboard()) + .await?; + return Ok(()); + } + + // Update the title + state.title = text.to_string(); + + // Go back to editing listing state + dialogue + .update(ListingWizardState::EditingListing(state.clone())) + .await?; + + bot.send_message(chat_id, "โœ… Title updated!").await?; + + show_edit_screen(bot, chat_id, state).await?; + + Ok(()) +} + +pub async fn handle_edit_description( + bot: Bot, + dialogue: NewListingDialogue, + mut state: ListingDraft, + msg: Message, +) -> HandlerResult { + let chat_id = msg.chat.id; + let text = msg.text().unwrap_or("").trim(); + + info!( + "User {} editing description: '{}'", + UserHandleAndId::from_chat(&msg.chat), + text + ); + + if text.eq_ignore_ascii_case("none") { + state.description = None; + } else if text.is_empty() { + bot.send_message( + chat_id, + "โŒ Please enter a description or type 'none' for no description:", + ) + .reply_markup(create_back_button_keyboard_with_clear("description")) + .await?; + return Ok(()); + } else if text.len() > 500 { + bot.send_message( + chat_id, + "โŒ Description too long. Please enter a description with 500 characters or less:", + ) + .reply_markup(create_back_button_keyboard_with_clear("description")) + .await?; + return Ok(()); + } else { + state.description = Some(text.to_string()); + } + + // Go back to editing listing state + dialogue + .update(ListingWizardState::EditingListing(state.clone())) + .await?; + + bot.send_message(chat_id, "โœ… Description updated!").await?; + + show_edit_screen(bot, chat_id, state).await?; + + Ok(()) +} + +pub async fn handle_edit_price( + bot: Bot, + dialogue: NewListingDialogue, + mut state: ListingDraft, + msg: Message, +) -> HandlerResult { + let chat_id = msg.chat.id; + let text = msg.text().unwrap_or("").trim(); + + info!( + "User {} editing price: '{}'", + UserHandleAndId::from_chat(&msg.chat), + text + ); + + let price = match text.parse::() { + Ok(p) if p > 0.0 && p <= 10000.0 => match MoneyAmount::from_str(&format!("{:.2}", p)) { + Ok(amount) => amount, + Err(_) => { + bot.send_message( + chat_id, + "โŒ Invalid price format. Please enter a price between $0.01 and $10,000.00 (e.g., 25.99):", + ) + .reply_markup(create_back_button_keyboard()) + .await?; + return Ok(()); + } + }, + _ => { + bot.send_message( + chat_id, + "โŒ Invalid price. Please enter a price between $0.01 and $10,000.00 (e.g., 25.99):", + ) + .reply_markup(create_back_button_keyboard()) + .await?; + return Ok(()); + } + }; + + // Update the price + state.buy_now_price = price; + + // Go back to editing listing state + dialogue + .update(ListingWizardState::EditingListing(state.clone())) + .await?; + + bot.send_message(chat_id, "โœ… Price updated!").await?; + + show_edit_screen(bot, chat_id, state).await?; + + Ok(()) +} + +pub async fn handle_edit_slots( + bot: Bot, + dialogue: NewListingDialogue, + mut state: ListingDraft, + msg: Message, +) -> HandlerResult { + let chat_id = msg.chat.id; + let text = msg.text().unwrap_or("").trim(); + + info!( + "User {} editing slots: '{}'", + UserHandleAndId::from_chat(&msg.chat), + text + ); + + let slots = match text.parse::() { + Ok(s) if (1..=1000).contains(&s) => s, + _ => { + bot.send_message( + chat_id, + "โŒ Invalid number. Please enter a number between 1 and 1000:", + ) + .reply_markup(create_back_button_keyboard()) + .await?; + return Ok(()); + } + }; + + // Update the slots + state.slots_available = slots; + + // Go back to editing listing state + dialogue + .update(ListingWizardState::EditingListing(state.clone())) + .await?; + + bot.send_message(chat_id, "โœ… Slots updated!").await?; + + show_edit_screen(bot, chat_id, state).await?; + + Ok(()) +} + +pub async fn handle_edit_start_time( + bot: Bot, + dialogue: NewListingDialogue, + mut state: ListingDraft, + msg: Message, +) -> HandlerResult { + let chat_id = msg.chat.id; + let text = msg.text().unwrap_or("").trim(); + + info!( + "User {} editing start time: '{}'", + UserHandleAndId::from_chat(&msg.chat), + text + ); + + let start_hours = match text.parse::() { + Ok(h) if (0..=168).contains(&h) => h, + _ => { + bot.send_message( + chat_id, + "โŒ Invalid number. Please enter hours from now (0-168):", + ) + .reply_markup(create_back_button_keyboard()) + .await?; + return Ok(()); + } + }; + + // Update the start time + state.start_hours = start_hours; + + // Go back to editing listing state + dialogue + .update(ListingWizardState::EditingListing(state.clone())) + .await?; + + bot.send_message(chat_id, "โœ… Start time updated!").await?; + + show_edit_screen(bot, chat_id, state).await?; + + Ok(()) +} + +pub async fn handle_edit_duration( + bot: Bot, + dialogue: NewListingDialogue, + mut state: ListingDraft, + msg: Message, +) -> HandlerResult { + let chat_id = msg.chat.id; + let text = msg.text().unwrap_or("").trim(); + + info!( + "User {} editing duration: '{}'", + UserHandleAndId::from_chat(&msg.chat), + text + ); + + let duration = match text.parse::() { + Ok(d) if (1..=720).contains(&d) => d, + _ => { + bot.send_message( + chat_id, + "โŒ Invalid number. Please enter duration in hours (1-720):", + ) + .reply_markup(create_back_button_keyboard()) + .await?; + return Ok(()); + } + }; + + // Update the duration + state.duration_hours = duration; + + // Go back to editing listing state + dialogue + .update(ListingWizardState::EditingListing(state.clone())) + .await?; + + bot.send_message(chat_id, "โœ… Duration updated!").await?; + + show_edit_screen(bot, chat_id, state).await?; + + Ok(()) +} + +pub async fn handle_editing_callback( + bot: Bot, + dialogue: NewListingDialogue, + state: ListingDraft, + callback_query: CallbackQuery, +) -> HandlerResult { + let data = match callback_query.data.as_deref() { + Some(data) => data, + None => return Ok(()), + }; + + let callback_id = callback_query.id.clone(); + + // Answer the callback query to remove the loading state + bot.answer_callback_query(callback_id.clone()).await?; + let message = match callback_query.message { + Some(message) => message, + None => { + handle_callback_error(&bot, dialogue, callback_id).await?; + return Ok(()); + } + }; + + match data { + "edit_title" => { + info!( + "User {} chose to edit title", + UserHandleAndId::from_user(&callback_query.from) + ); + + dialogue + .update(ListingWizardState::EditingTitle(state.clone())) + .await?; + + let chat_id = message.chat().id; + let response = format!( + "๐Ÿ“ Edit Title\n\nCurrent title: {}\n\nPlease enter the new title for your listing:", + state.title + ); + + bot.edit_message_text(chat_id, message.id(), response) + .reply_markup(create_back_button_keyboard()) + .await?; + } + "edit_description" => { + info!( + "User {} chose to edit description", + UserHandleAndId::from_user(&callback_query.from) + ); + + dialogue + .update(ListingWizardState::EditingDescription(state.clone())) + .await?; + + let chat_id = message.chat().id; + let current_desc = state.description.as_deref().unwrap_or("*No description*"); + let response = format!( + "๐Ÿ“„ Edit Description\n\nCurrent description: {}\n\nPlease enter the new description for your listing (or type 'none' for no description):", + current_desc + ); + + bot.edit_message_text(chat_id, message.id(), response) + .reply_markup(create_back_button_keyboard()) + .parse_mode(ParseMode::Html) + .await?; + } + "edit_clear_description" => { + info!( + "User {} chose to clear description", + UserHandleAndId::from_user(&callback_query.from) + ); + // clear the description and go back to the edit screen + let mut state = state.clone(); + state.description = None; + dialogue + .update(ListingWizardState::EditingListing(state.clone())) + .await?; + show_edit_screen(bot, message.chat().id, state).await?; + } + "edit_price" => { + info!( + "User {} chose to edit price", + UserHandleAndId::from_user(&callback_query.from) + ); + + dialogue + .update(ListingWizardState::EditingPrice(state.clone())) + .await?; + + let chat_id = message.chat().id; + let response = format!( + "๐Ÿ’ฐ Edit Price\n\nCurrent price: ${}\n\nPlease enter the new buy-now price in USD (e.g., 25.99):", + state.buy_now_price + ); + + bot.edit_message_text(chat_id, message.id(), response) + .reply_markup(create_back_button_keyboard()) + .parse_mode(ParseMode::Html) + .await?; + } + "edit_slots" => { + info!( + "User {} chose to edit slots", + UserHandleAndId::from_user(&callback_query.from) + ); + + dialogue + .update(ListingWizardState::EditingSlots(state.clone())) + .await?; + + let chat_id = message.chat().id; + let response = format!( + "๐Ÿ”ข Edit Slots\n\nCurrent slots: {}\n\nPlease enter the number of available slots (1-1000):", + state.slots_available + ); + + bot.edit_message_text(chat_id, message.id(), response) + .reply_markup(create_back_button_keyboard()) + .parse_mode(ParseMode::Html) + .await?; + } + "edit_start_time" => { + info!( + "User {} chose to edit start time", + UserHandleAndId::from_user(&callback_query.from) + ); + + dialogue + .update(ListingWizardState::EditingStartTime(state.clone())) + .await?; + + let chat_id = message.chat().id; + let current_start = 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!( + "โฐ Edit Start Time\n\nCurrent start time: {}\n\nPlease enter how many hours from now the listing should start (0 for immediate start, 1-168 for delayed start):", + current_start + ); + + bot.edit_message_text(chat_id, message.id(), response) + .reply_markup(create_back_button_keyboard()) + .parse_mode(ParseMode::Html) + .await?; + } + "edit_duration" => { + info!( + "User {} chose to edit duration", + UserHandleAndId::from_user(&callback_query.from) + ); + + dialogue + .update(ListingWizardState::EditingDuration(state.clone())) + .await?; + + let chat_id = message.chat().id; + let response = format!( + "โณ Edit Duration\n\nCurrent duration: {} hour{}\n\nPlease enter the listing duration in hours (1-720):", + state.duration_hours, + if state.duration_hours == 1 { "" } else { "s" } + ); + + bot.edit_message_text(chat_id, message.id(), response) + .reply_markup(create_back_button_keyboard()) + .parse_mode(ParseMode::Html) + .await?; + } + "edit_back" => { + info!( + "User {} chose to go back to edit screen", + UserHandleAndId::from_user(&callback_query.from) + ); + + let chat_id = message.chat().id; + + // Delete the current message and show the edit screen again + bot.delete_message(chat_id, message.id()).await?; + show_edit_screen(bot, chat_id, state).await?; + } + "edit_done" => { + info!( + "User {} finished editing, going back to confirmation", + UserHandleAndId::from_user(&callback_query.from) + ); + + // Go back to confirmation state + dialogue + .update(ListingWizardState::ViewingDraft(state.clone())) + .await?; + + let chat_id = message.chat().id; + + // Delete the edit screen and show confirmation + bot.delete_message(chat_id, message.id()).await?; + show_confirmation(bot, chat_id, state).await?; + } + _ => { + // Unknown callback data, ignore + } + } + + Ok(()) +} + +pub async fn handle_edit_field_callback( + bot: Bot, + dialogue: NewListingDialogue, + state: ListingDraft, + callback_query: CallbackQuery, +) -> HandlerResult { + let data = match callback_query.data.as_deref() { + Some(data) => data, + None => return Ok(()), + }; + + let callback_id = callback_query.id.clone(); + + // Answer the callback query to remove the loading state + bot.answer_callback_query(callback_id.clone()).await?; + let message = match callback_query.message { + Some(message) => message, + None => { + handle_callback_error(&bot, dialogue, callback_id).await?; + return Ok(()); + } + }; + + match data { + "edit_back" => { + info!( + "User {} chose to go back from field editing", + UserHandleAndId::from_user(&callback_query.from) + ); + + // Go back to editing listing state + dialogue + .update(ListingWizardState::EditingListing(state.clone())) + .await?; + + let chat_id = message.chat().id; + + // Delete the current message and show the edit screen again + bot.delete_message(chat_id, message.id()).await?; + show_edit_screen(bot, chat_id, state).await?; + } + _ => { + // Unknown callback data, ignore + } + } + Ok(()) } diff --git a/src/commands/settings.rs b/src/commands/settings.rs index 4611f5d..431661a 100644 --- a/src/commands/settings.rs +++ b/src/commands/settings.rs @@ -1,7 +1,9 @@ use log::info; use teloxide::{prelude::*, types::Message, Bot}; -pub async fn handle_settings(bot: Bot, msg: Message) -> ResponseResult<()> { +use crate::HandlerResult; + +pub async fn handle_settings(bot: Bot, msg: Message) -> HandlerResult { let response = "โš™๏ธ Settings (Coming Soon)\n\n\ Here you'll be able to configure:\n\ โ€ข Notification preferences\n\ diff --git a/src/commands/start.rs b/src/commands/start.rs index 94e5478..7a749e2 100644 --- a/src/commands/start.rs +++ b/src/commands/start.rs @@ -1,7 +1,9 @@ use log::info; use teloxide::{prelude::*, types::Message, Bot}; -pub async fn handle_start(bot: Bot, msg: Message) -> ResponseResult<()> { +use crate::HandlerResult; + +pub async fn handle_start(bot: Bot, msg: Message) -> HandlerResult { let welcome_message = "๐ŸŽฏ Welcome to Pawctioneer Bot! ๐ŸŽฏ\n\n\ This bot helps you participate in various types of auctions:\n\ โ€ข Standard auctions with anti-sniping protection\n\ diff --git a/src/db/models/bid.rs b/src/db/models/bid.rs index 2f8493c..d52d37f 100644 --- a/src/db/models/bid.rs +++ b/src/db/models/bid.rs @@ -1,11 +1,10 @@ use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; use sqlx::FromRow; use crate::db::money_amount::MoneyAmount; /// Actual bids placed on listings -#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +#[derive(Debug, Clone, FromRow)] pub struct Bid { pub id: i64, pub listing_id: i64, diff --git a/src/db/models/listing.rs b/src/db/models/listing.rs index b91748b..f38e601 100644 --- a/src/db/models/listing.rs +++ b/src/db/models/listing.rs @@ -13,6 +13,13 @@ use super::listing_type::ListingType; use crate::db::{listing_id::ListingId, money_amount::MoneyAmount, user_id::UserId}; use chrono::{DateTime, Utc}; +/// Main listing/auction entity +#[derive(Debug, Clone)] +pub struct Listing { + pub base: ListingBase, + pub fields: ListingFields, +} + /// Common fields shared by all listing types #[derive(Debug, Clone)] pub struct ListingBase { @@ -26,13 +33,6 @@ pub struct ListingBase { pub updated_at: DateTime, } -/// Main listing/auction entity enum -#[derive(Debug, Clone)] -pub struct Listing { - pub base: ListingBase, - pub fields: ListingFields, -} - #[derive(Debug, Clone)] pub enum ListingFields { BasicAuction { diff --git a/src/db/models/listing_media.rs b/src/db/models/listing_media.rs index da574e7..1d401fe 100644 --- a/src/db/models/listing_media.rs +++ b/src/db/models/listing_media.rs @@ -1,9 +1,8 @@ use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; use sqlx::FromRow; /// Media attachments for listings -#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +#[derive(Debug, Clone, FromRow)] pub struct ListingMedia { pub id: i64, pub listing_id: i64, diff --git a/src/db/models/listing_type.rs b/src/db/models/listing_type.rs index a75a0c8..504cbbf 100644 --- a/src/db/models/listing_type.rs +++ b/src/db/models/listing_type.rs @@ -1,7 +1,5 @@ -use serde::{Deserialize, Serialize}; - /// Types of listings supported by the platform -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[derive(Debug, Clone, PartialEq, Eq, sqlx::Type)] #[sqlx(type_name = "TEXT")] #[sqlx(rename_all = "snake_case")] pub enum ListingType { diff --git a/src/db/models/proxy_bid.rs b/src/db/models/proxy_bid.rs index 9bb4448..e9b87b2 100644 --- a/src/db/models/proxy_bid.rs +++ b/src/db/models/proxy_bid.rs @@ -1,11 +1,10 @@ use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; use sqlx::FromRow; use crate::db::money_amount::MoneyAmount; /// Proxy bid strategies (automatic bidding settings) -#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +#[derive(Debug, Clone, FromRow)] pub struct ProxyBid { pub id: i64, pub listing_id: i64, diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 650647c..393cdc8 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -1,11 +1,10 @@ use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; use sqlx::FromRow; use crate::db::{TelegramUserId, UserId}; /// Core user information -#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +#[derive(Debug, Clone, FromRow)] pub struct User { pub id: UserId, pub telegram_id: TelegramUserId, diff --git a/src/db/models/user_settings.rs b/src/db/models/user_settings.rs index a2abe59..8f481cf 100644 --- a/src/db/models/user_settings.rs +++ b/src/db/models/user_settings.rs @@ -1,9 +1,8 @@ use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; use sqlx::FromRow; /// User preferences and settings -#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +#[derive(Debug, Clone, FromRow)] pub struct UserSettings { pub user_id: i64, pub language_code: String, diff --git a/src/db/types/currency_type.rs b/src/db/types/currency_type.rs index 4ef5705..a2a14d6 100644 --- a/src/db/types/currency_type.rs +++ b/src/db/types/currency_type.rs @@ -1,12 +1,10 @@ -use serde::{Deserialize, Serialize}; use sqlx::{ encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type, }; /// Currency types supported by the platform -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CurrencyType { - #[serde(rename = "USD")] USD, } diff --git a/src/db/types/listing_id.rs b/src/db/types/listing_id.rs index 4df3bca..8de0d97 100644 --- a/src/db/types/listing_id.rs +++ b/src/db/types/listing_id.rs @@ -3,14 +3,13 @@ //! This newtype prevents accidentally mixing up listing IDs with other ID types //! while maintaining compatibility with the database layer through SQLx traits. -use serde::{Deserialize, Serialize}; use sqlx::{ encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type, }; use std::fmt; /// Type-safe wrapper for listing IDs -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ListingId(i64); impl ListingId { diff --git a/src/db/types/money_amount.rs b/src/db/types/money_amount.rs index 0e03a5e..05c9ae0 100644 --- a/src/db/types/money_amount.rs +++ b/src/db/types/money_amount.rs @@ -45,6 +45,12 @@ impl MoneyAmount { } } +impl Default for MoneyAmount { + fn default() -> Self { + Self::zero() + } +} + // Allow easy conversion from Decimal impl From for MoneyAmount { fn from(decimal: Decimal) -> Self { diff --git a/src/db/types/telegram_user_id.rs b/src/db/types/telegram_user_id.rs index 21d9d8e..f48d757 100644 --- a/src/db/types/telegram_user_id.rs +++ b/src/db/types/telegram_user_id.rs @@ -4,14 +4,13 @@ //! This newtype prevents accidentally mixing up user IDs with other ID types //! while maintaining compatibility with the database layer through SQLx traits. -use serde::{Deserialize, Serialize}; use sqlx::{ encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type, }; use std::fmt; /// Type-safe wrapper for user IDs -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct TelegramUserId(teloxide::types::UserId); impl TelegramUserId { diff --git a/src/db/types/user_id.rs b/src/db/types/user_id.rs index 81aee66..181a48a 100644 --- a/src/db/types/user_id.rs +++ b/src/db/types/user_id.rs @@ -3,14 +3,13 @@ //! This newtype prevents accidentally mixing up user IDs with other ID types //! while maintaining compatibility with the database layer through SQLx traits. -use serde::{Deserialize, Serialize}; use sqlx::{ encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type, }; use std::fmt; /// Type-safe wrapper for user IDs -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct UserId(i64); impl UserId { diff --git a/src/main.rs b/src/main.rs index 5ba5d67..00e3033 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,24 @@ -use anyhow::Result; -use log::info; -use teloxide::{prelude::*, utils::command::BotCommands}; - mod commands; mod config; mod db; +mod message_utils; +mod sqlite_storage; +mod wizard_utils; + +use anyhow::Result; +use log::info; +use teloxide::dispatching::dialogue::serializer::Json; +use teloxide::{prelude::*, types::CallbackQuery, utils::command::BotCommands}; #[cfg(test)] mod test_utils; - +use commands::new_listing::ListingWizardState; use commands::*; use config::Config; +use crate::sqlite_storage::SqliteStorage; + +pub type HandlerResult = anyhow::Result<()>; + #[derive(BotCommands, Clone)] #[command(rename_rule = "lowercase", description = "Auction Bot Commands")] pub enum Command { @@ -39,21 +47,34 @@ async fn main() -> Result<()> { info!("Starting Pawctioneer Bot..."); let bot = Bot::new(&config.telegram_token); - // Create dispatcher with direct command routing + let dialog_storage = SqliteStorage::new(db_pool.clone(), Json).await?; + + // Create dispatcher with dialogue system Dispatcher::builder( bot, - 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::NewListing].endpoint(handle_new_listing)) - .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() + .branch( + Update::filter_message() + .enter_dialogue::, ListingWizardState>() + .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::NewListing].endpoint(handle_new_listing)) + .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)), + ) + .branch(new_listing_dialogue_handler()), + ) + .branch( + Update::filter_callback_query() + .enter_dialogue::, ListingWizardState>() + .branch(new_listing_callback_handler()), + ), ) - .dependencies(dptree::deps![db_pool]) + .dependencies(dptree::deps![db_pool, dialog_storage]) .enable_ctrlc_handler() .build() .dispatch() diff --git a/src/message_utils.rs b/src/message_utils.rs new file mode 100644 index 0000000..2dceca8 --- /dev/null +++ b/src/message_utils.rs @@ -0,0 +1,40 @@ +use std::fmt::Display; + +use teloxide::types::{Chat, User}; + +pub struct UserHandleAndId<'s> { + pub handle: Option<&'s str>, + pub id: Option, +} +impl<'s> Display for UserHandleAndId<'s> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} ({})", + self.handle.unwrap_or("unknown"), + self.id.unwrap_or(-1) + ) + } +} +impl<'s> UserHandleAndId<'s> { + pub fn from_chat(chat: &'s Chat) -> Self { + Self { + handle: chat.username(), + id: Some(chat.id.0), + } + } + pub fn from_user(user: &'s User) -> Self { + Self { + handle: user.username.as_deref(), + id: Some(user.id.0 as i64), + } + } +} + +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") +} diff --git a/src/sqlite_storage.rs b/src/sqlite_storage.rs new file mode 100644 index 0000000..6bacac8 --- /dev/null +++ b/src/sqlite_storage.rs @@ -0,0 +1,150 @@ +use futures::future::BoxFuture; +use serde::{de::DeserializeOwned, Serialize}; +use sqlx::{sqlite::SqlitePool, Executor}; +use std::{ + convert::Infallible, + fmt::{Debug, Display}, + str, + sync::Arc, +}; +use teloxide::dispatching::dialogue::{serializer::Serializer, Storage}; +use teloxide_core::types::ChatId; +use thiserror::Error; + +/// A persistent dialogue storage based on [SQLite](https://www.sqlite.org/). +pub struct SqliteStorage { + pool: SqlitePool, + serializer: S, +} + +/// An error returned from [`SqliteStorage`]. +#[derive(Debug, Error)] +pub enum SqliteStorageError +where + SE: Debug + Display, +{ + #[error("dialogue serialization error: {0}")] + SerdeError(SE), + + #[error("sqlite error: {0}")] + SqliteError(#[from] sqlx::Error), + + /// Returned from [`SqliteStorage::remove_dialogue`]. + #[error("row not found")] + DialogueNotFound, +} + +impl SqliteStorage { + pub async fn new( + pool: SqlitePool, + serializer: S, + ) -> Result, SqliteStorageError> { + sqlx::query( + " +CREATE TABLE IF NOT EXISTS teloxide_dialogues ( + chat_id BIGINT PRIMARY KEY, + dialogue BLOB NOT NULL +); + ", + ) + .execute(&pool) + .await?; + + Ok(Arc::new(Self { pool, serializer })) + } +} + +impl Storage for SqliteStorage +where + S: Send + Sync + Serializer + 'static, + D: Send + Serialize + DeserializeOwned + 'static, + >::Error: Debug + Display, +{ + type Error = SqliteStorageError<>::Error>; + + /// Returns [`sqlx::Error::RowNotFound`] if a dialogue does not exist. + fn remove_dialogue( + self: Arc, + ChatId(chat_id): ChatId, + ) -> BoxFuture<'static, Result<(), Self::Error>> { + Box::pin(async move { + let deleted_rows_count = + sqlx::query("DELETE FROM teloxide_dialogues WHERE chat_id = ?") + .bind(chat_id) + .execute(&self.pool) + .await? + .rows_affected(); + + if deleted_rows_count == 0 { + return Err(SqliteStorageError::DialogueNotFound); + } + + Ok(()) + }) + } + + fn update_dialogue( + self: Arc, + ChatId(chat_id): ChatId, + dialogue: D, + ) -> BoxFuture<'static, Result<(), Self::Error>> { + Box::pin(async move { + let d = self + .serializer + .serialize(&dialogue) + .map_err(SqliteStorageError::SerdeError)?; + + self.pool + .acquire() + .await? + .execute( + sqlx::query( + " + INSERT INTO teloxide_dialogues VALUES (?, ?) + ON CONFLICT(chat_id) DO UPDATE SET dialogue=excluded.dialogue + ", + ) + .bind(chat_id) + .bind(d), + ) + .await?; + Ok(()) + }) + } + + fn get_dialogue( + self: Arc, + chat_id: ChatId, + ) -> BoxFuture<'static, Result, Self::Error>> { + Box::pin(async move { + get_dialogue(&self.pool, chat_id) + .await? + .map(|d| { + self.serializer + .deserialize(&d) + .map_err(SqliteStorageError::SerdeError) + }) + .transpose() + }) + } +} + +async fn get_dialogue( + pool: &SqlitePool, + ChatId(chat_id): ChatId, +) -> Result>, sqlx::Error> { + #[derive(sqlx::FromRow)] + struct DialogueDbRow { + dialogue: Vec, + } + + let bytes = sqlx::query_as::<_, DialogueDbRow>( + "SELECT dialogue FROM teloxide_dialogues WHERE chat_id = ?", + ) + .bind(chat_id) + .fetch_optional(pool) + .await? + .map(|r| r.dialogue); + + Ok(bytes) +} diff --git a/src/wizard_utils.rs b/src/wizard_utils.rs new file mode 100644 index 0000000..a94ce7c --- /dev/null +++ b/src/wizard_utils.rs @@ -0,0 +1,64 @@ +/// Define a set of states for a wizard. +/// Example: +/// ``` +/// wizard_step_states! { +/// struct AwaitingDescription { +/// title: String, +/// } +/// struct AwaitingPrice { +/// title: String, +/// description: Option, +/// }, +/// struct AwaitingSlots { +/// title: String, +/// description: Option, +/// price: Option, +/// } +/// } +/// ``` +/// Constructs structs for each step of the wizard, and +/// generators for converting one step into the next. +/// The above example would generate the following structs: +/// ``` +/// struct AwaitingDescription { +/// title: String, +/// } +/// struct AwaitingPrice { +/// title: String, +/// description: Option, +/// } +/// struct AwaitingSlots { +/// title: String, +/// description: Option, +/// price: Option, +/// } +/// ``` +/// And the following implementations: +/// ``` +/// impl AwaitingDescription { +/// fn add_description(self, description: Option) -> AwaitingPrice { +/// AwaitingPrice { +/// title: self.title, +/// description, +/// } +/// } +/// } +/// impl AwaitingPrice { +/// fn add_buy_now_price(self, buy_now_price: MoneyAmount) -> AwaitingSlots { +/// AwaitingSlots { +/// title: self.title, +/// description: self.description, +/// buy_now_price, +/// } +/// } +/// } +/// ``` +macro_rules! wizard_step_states { + ($($vis:vis struct $name:ident { $($field_vis:vis $field:ident: $ty:ty),* $(,)? }),*) => { + $( + $vis struct $name { + $($field_vis $field: $ty),* + } + )* + }; +}