diff --git a/pawctioneer_bot_dev.db b/pawctioneer_bot_dev.db deleted file mode 100644 index 2386132..0000000 Binary files a/pawctioneer_bot_dev.db and /dev/null differ diff --git a/src/commands/new_listing.rs b/src/commands/new_listing.rs index 63a53a0..8ba4f11 100644 --- a/src/commands/new_listing.rs +++ b/src/commands/new_listing.rs @@ -3,22 +3,26 @@ use crate::{ dao::ListingDAO, models::new_listing::{NewListing, NewListingBase, NewListingFields}, types::{money_amount::MoneyAmount, user_id::UserId}, + NewUser, TelegramUserId, UserDAO, }, + keyboard_buttons, message_utils::{ - edit_html_message, is_cancel, is_cancel_or_no, send_html_message, UserHandleAndId, + 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::info; +use log::{error, info}; use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; use teloxide::{ dispatching::{dialogue::serializer::Json, DpHandlerDescription}, prelude::*, types::{ - CallbackQuery, CallbackQueryId, ChatId, InlineKeyboardButton, InlineKeyboardMarkup, Message, + CallbackQuery, Chat, InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageId, User, }, Bot, }; @@ -33,57 +37,36 @@ pub struct ListingDraft { 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, - AwaitingTitle(ListingDraft), - AwaitingDescription(ListingDraft), - AwaitingPrice(ListingDraft), - AwaitingSlots(ListingDraft), - AwaitingStartTime(ListingDraft), - AwaitingDuration(ListingDraft), + AwaitingDraftField { + field: ListingField, + draft: ListingDraft, + }, ViewingDraft(ListingDraft), - EditingListing(ListingDraft), - EditingTitle(ListingDraft), - EditingDescription(ListingDraft), - EditingPrice(ListingDraft), - EditingSlots(ListingDraft), - EditingStartTime(ListingDraft), - EditingDuration(ListingDraft), + EditingDraft(ListingDraft), + EditingDraftField { + field: ListingField, + draft: ListingDraft, + }, } // Type alias for the dialogue type NewListingDialogue = Dialogue>; -// ============================================================================ -// UTILITY FUNCTIONS FOR CODE DEDUPLICATION -// ============================================================================ - -// Extract callback data and answer callback query -async fn extract_callback_data( - bot: &Bot, - callback_query: &CallbackQuery, -) -> Result<(String, ChatId), HandlerResult> { - let data = match callback_query.data.as_deref() { - Some(data) => data.to_string(), - None => return Err(Ok(())), // Early return for missing data - }; - - let chat_id = match &callback_query.message { - Some(message) => message.chat().id, - None => return Err(Ok(())), // Early return for missing message - }; - - // 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, chat_id)) -} - // Common input validation functions fn validate_title(text: &str) -> Result { if text.is_empty() { @@ -97,6 +80,16 @@ fn validate_title(text: &str) -> Result { 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) => { @@ -147,132 +140,83 @@ fn validate_start_time(text: &str) -> Result { } } -// Logging utility for user actions -fn log_user_action(msg: &Message, action: &str) { - info!("User {} {}", UserHandleAndId::from_chat(&msg.chat), action); -} - fn log_user_callback_action(callback_query: &CallbackQuery, action: &str) { info!( "User {} {}", - UserHandleAndId::from_user(&callback_query.from), + HandleAndId::from_user(&callback_query.from), action ); } -// State transition helper -async fn transition_to_state( - dialogue: &NewListingDialogue, - state: ListingWizardState, -) -> HandlerResult { - dialogue.update(state).await?; - Ok(()) -} - // Handle callback query errors async fn handle_callback_error( bot: &Bot, dialogue: NewListingDialogue, - callback_id: CallbackQueryId, + callback_query: CallbackQuery, ) -> HandlerResult { - if let Err(e) = bot.answer_callback_query(callback_id).await { + 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 CREATION UTILITIES -// ============================================================================ - -// Create a simple single-button keyboard -fn create_single_button_keyboard(text: &str, callback_data: &str) -> InlineKeyboardMarkup { - InlineKeyboardMarkup::new([[InlineKeyboardButton::callback(text, callback_data)]]) -} - -// Create a keyboard with multiple buttons in a single row -fn create_single_row_keyboard(buttons: &[(&str, &str)]) -> InlineKeyboardMarkup { - let keyboard_buttons: Vec = buttons - .iter() - .map(|(text, callback_data)| InlineKeyboardButton::callback(*text, *callback_data)) - .collect(); - InlineKeyboardMarkup::new([keyboard_buttons]) -} - -// Create a keyboard with multiple rows -fn create_multi_row_keyboard(rows: &[&[(&str, &str)]]) -> InlineKeyboardMarkup { - let mut keyboard = InlineKeyboardMarkup::default(); - for row in rows { - let buttons: Vec = row - .iter() - .map(|(text, callback_data)| InlineKeyboardButton::callback(*text, *callback_data)) - .collect(); - keyboard = keyboard.append_row(buttons); +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 } -// Create numeric option keyboard (common pattern for slots, duration, etc.) -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]) +keyboard_buttons! { + enum SlotsKeyboardButtons { + OneSlot("1 slot", "slots_1"), + TwoSlots("2 slots", "slots_2"), + FiveSlots("5 slots", "slots_5"), + TenSlots("10 slots", "slots_10"), + } } -// Create duration-specific keyboard -fn create_duration_keyboard() -> InlineKeyboardMarkup { - create_single_row_keyboard(&[ - ("1 day", "duration_1_day"), - ("3 days", "duration_3_days"), - ("7 days", "duration_7_days"), - ("14 days", "duration_14_days"), - ]) +keyboard_buttons! { + enum ConfirmationKeyboardButtons { + Create("✅ Create", "confirm_create"), + Edit("✏️ Edit", "confirm_edit"), + Discard("🗑️ Discard", "confirm_discard"), + } } -// Create slots keyboard -fn create_slots_keyboard() -> InlineKeyboardMarkup { - let slots_options = [(1, "1"), (2, "2"), (5, "5"), (10, "10")]; - create_numeric_options_keyboard(&slots_options, "slots") -} - -// Create confirmation keyboard (Create/Discard/Edit) -fn create_confirmation_keyboard() -> InlineKeyboardMarkup { - create_multi_row_keyboard(&[ - &[ - ("✅ Create", "confirm_create"), - ("🗑️ Discard", "confirm_discard"), +keyboard_buttons! { + enum FieldSelectionKeyboardButtons { + [ + Title("📝 Title", "edit_title"), + Description("📄 Description", "edit_description"), ], - &[("✏️ Edit", "confirm_edit")], - ]) -} - -// Create field selection keyboard for editing -fn create_field_selection_keyboard() -> InlineKeyboardMarkup { - create_multi_row_keyboard(&[ - &[ - ("📝 Title", "edit_title"), - ("📄 Description", "edit_description"), + [ + Price("💰 Price", "edit_price"), + Slots("🔢 Slots", "edit_slots"), ], - &[("💰 Price", "edit_price"), ("🔢 Slots", "edit_slots")], - &[ - ("⏰ Start Time", "edit_start_time"), - ("⏱️ Duration", "edit_duration"), + [ + StartTime("⏰ Start Time", "edit_start_time"), + Duration("⏱️ Duration", "edit_duration"), ], - &[("✅ Done", "edit_done")], - ]) + [ + Done("✅ Done", "edit_done"), + ] + } } -// Create start time keyboard -fn create_start_time_keyboard() -> InlineKeyboardMarkup { - create_single_button_keyboard("Now", "start_time_now") +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")]) } -// Create back button keyboard fn create_back_button_keyboard() -> InlineKeyboardMarkup { create_single_button_keyboard("🔙 Back", "edit_back") } @@ -288,11 +232,18 @@ fn create_back_button_keyboard_with_clear(field: &str) -> InlineKeyboardMarkup { ]) } -// Create skip button keyboard 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 // ============================================================================ @@ -302,118 +253,41 @@ pub fn new_listing_handler() -> Handler<'static, HandlerResult, DpHandlerDescrip dptree::entry() .branch( Update::filter_message() - .enter_dialogue::, ListingWizardState>() - .branch(dptree::entry().filter_command::().branch( - dptree::case![Command::NewListing].endpoint(handle_new_listing_command), - )) - .branch(dptree::case![ListingWizardState::Start].endpoint(start_new_listing)) + .enter_dialogue::, ListingWizardState>() .branch( - dptree::case![ListingWizardState::AwaitingTitle(state)] - .endpoint(handle_title_input), + dptree::entry() + .filter_command::() + .chain(dptree::case![Command::NewListing]) + .chain(dptree::case![ListingWizardState::Start]) + .endpoint(handle_new_listing_command), ) .branch( - dptree::case![ListingWizardState::AwaitingDescription(state)] - .endpoint(handle_description_input), + dptree::case![ListingWizardState::AwaitingDraftField { field, draft }] + .endpoint(handle_awaiting_draft_field_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), + dptree::case![ListingWizardState::EditingDraftField { field, draft }] + .endpoint(handle_editing_field_input), ), ) .branch( Update::filter_callback_query() .enter_dialogue::, ListingWizardState>() .branch( - dptree::case![ListingWizardState::AwaitingDescription(state)] - .endpoint(handle_description_callback), + dptree::case![ListingWizardState::AwaitingDraftField { field, draft }] + .endpoint(handle_awaiting_draft_field_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::AwaitingDuration(state)] - .endpoint(handle_duration_callback), - ) - .branch( - dptree::case![ListingWizardState::ViewingDraft(state)] + dptree::case![ListingWizardState::ViewingDraft(draft)] .endpoint(handle_viewing_draft_callback), ) .branch( - dptree::case![ListingWizardState::EditingListing(state)] - .endpoint(handle_editing_callback), + dptree::case![ListingWizardState::EditingDraft(draft)] + .endpoint(handle_editing_draft_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), + dptree::case![ListingWizardState::EditingDraftField { field, draft }] + .endpoint(handle_editing_draft_field_callback), ), ) } @@ -425,198 +299,224 @@ async fn handle_new_listing_command( msg: Message, ) -> HandlerResult { info!( - "User {} ({}) started new fixed price listing wizard", - msg.chat.username().unwrap_or("unknown"), - msg.chat.id + "User {} started new fixed price listing wizard", + HandleAndId::from_chat(&msg.chat), ); // Initialize the dialogue to Start state - dialogue.update(ListingWizardState::Start).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.id, response, None).await?; - Ok(()) -} - -// Handle the /newlisting command - starts the dialogue (called from within dialogue context) -async fn handle_new_listing(bot: Bot, dialogue: NewListingDialogue, msg: Message) -> HandlerResult { - info!( - "User {} ({}) started new fixed price listing wizard", - msg.chat.username().unwrap_or("unknown"), - msg.chat.id - ); - - 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.id, response, None).await?; dialogue - .update(ListingWizardState::AwaitingTitle(ListingDraft::default())) + .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(()) } -// Handle the Start state (same as handle_new_listing for now) -pub async fn start_new_listing( +pub async fn handle_awaiting_draft_field_input( bot: Bot, dialogue: NewListingDialogue, + (field, draft): (ListingField, ListingDraft), msg: Message, ) -> HandlerResult { - handle_new_listing(bot, dialogue, msg).await -} + let chat = msg.chat.clone(); + let text = msg.text().unwrap_or("").trim(); + info!( + "User {} entered input step: {:?}", + HandleAndId::from_chat(&chat), + field + ); -// Individual handler functions for each dialogue state + 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, - msg: Message, ) -> HandlerResult { - let chat_id = msg.chat.id; - let text = msg.text().unwrap_or("").trim(); - - log_user_action(&msg, "entered title input"); - - if is_cancel(text) { - return cancel_wizard(bot, dialogue, msg).await; - } - match validate_title(text) { Ok(title) => { draft.title = title; - transition_to_state(&dialogue, ListingWizardState::AwaitingDescription(draft)).await?; + 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_id, response, Some(create_skip_keyboard())).await + send_html_message(&bot, &chat, response, Some(create_skip_cancel_keyboard())).await } - Err(error_msg) => send_html_message(&bot, chat_id, &error_msg, None).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, - msg: Message, ) -> HandlerResult { - let chat_id = msg.chat.id; - let text = msg.text().unwrap_or("").trim(); + draft.description = match validate_description(text) { + Ok(description) => Some(description), + Err(error_msg) => { + send_html_message(&bot, &chat, &error_msg, None).await?; + return Ok(()); + } + }; - log_user_action(&msg, "entered description input"); - - if is_cancel(text) { - return cancel_wizard(bot, dialogue, msg).await; - } - - if text.len() > 1000 { - let error_msg = "❌ Description is too long (max 1000 characters). Please enter a shorter description or 'skip':"; - return send_html_message(&bot, chat_id, error_msg, None).await; - } - - draft.description = Some(text.to_string()); - transition_to_state(&dialogue, ListingWizardState::AwaitingPrice(draft)).await?; + 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_id, response, None).await + send_html_message(&bot, &chat, response, None).await } pub async fn handle_description_callback( bot: Bot, + callback_query: CallbackQuery, dialogue: NewListingDialogue, draft: ListingDraft, - callback_query: CallbackQuery, + data: &str, + chat: HandleAndId<'_>, ) -> HandlerResult { - let (data, _chat_id) = match extract_callback_data(&bot, &callback_query).await { - Ok(result) => result, - Err(early_return) => return early_return, - }; + match data { + "skip" => { + log_user_callback_action(&callback_query, "skipped description"); - if data == "skip" { - log_user_callback_action(&callback_query, "skipped description"); + dialogue + .update(ListingWizardState::AwaitingDraftField { + field: ListingField::Price, + draft, + }) + .await?; - transition_to_state(&dialogue, ListingWizardState::AwaitingPrice(draft)).await?; - - let response = "✅ Description skipped!\n\n\ + 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; - edit_html_message(&bot, chat_id, message.id(), &response, None).await?; + 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_slots_callback( +pub async fn handle_awaiting_draft_field_callback( bot: Bot, dialogue: NewListingDialogue, - draft: ListingDraft, + (field, draft): (ListingField, ListingDraft), callback_query: CallbackQuery, ) -> HandlerResult { - let (data, chat_id) = match extract_callback_data(&bot, &callback_query).await { - Ok(result) => result, - Err(early_return) => return early_return, - }; + let (data, from) = extract_callback_data(&bot, &callback_query).await?; + let chat = HandleAndId::from_user(&from); + info!("User {} selected callback: {:?}", chat, data); - if data.starts_with("slots_") { - log_user_callback_action(&callback_query, &format!("selected slots button: {}", data)); - - // Extract the slots number from the callback data - let slots_str = data.strip_prefix("slots_").unwrap(); - if let Ok(slots) = slots_str.parse::() { - // 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?; - } + 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, - callback_query: CallbackQuery, + data: &str, + chat: impl Into>, ) -> 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 { + 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?; - - 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?; + process_start_time_and_respond(&bot, dialogue, draft, chat, 0).await?; } } @@ -628,13 +528,17 @@ async fn process_slots_and_respond( bot: &Bot, dialogue: NewListingDialogue, mut draft: ListingDraft, - chat_id: ChatId, + chat: impl Into>, slots: i32, ) -> HandlerResult { + let chat = chat.into(); // Update dialogue state draft.slots_available = slots; dialogue - .update(ListingWizardState::AwaitingStartTime(draft)) + .update(ListingWizardState::AwaitingDraftField { + field: ListingField::StartTime, + draft, + }) .await .unwrap(); @@ -648,86 +552,77 @@ async fn process_slots_and_respond( • Maximum delay: 168 hours (7 days)" ); - send_html_message(&bot, chat_id, &response, Some(create_start_time_keyboard())).await?; + 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, - state: ListingDraft, + draft: ListingDraft, callback_query: CallbackQuery, ) -> HandlerResult { - let data = match callback_query.data.as_deref() { - Some(data) => data, - None => return Ok(()), - }; + let (data, from) = extract_callback_data(&bot, &callback_query).await?; + let chat = HandleAndId::from_user(&from); + let message = callback_query.message.unwrap(); - 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" => { + let button = ConfirmationKeyboardButtons::try_from(data.as_str()).unwrap(); + match button { + ConfirmationKeyboardButtons::Create => { info!( "User {} confirmed listing creation", - UserHandleAndId::from_user(&callback_query.from) + HandleAndId::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."; - - edit_html_message(&bot, chat_id, message.id(), &response, None).await?; + create_listing( + db_pool, + bot, + dialogue, + callback_query.from, + message.id(), + draft.clone(), + ) + .await?; } - "confirm_discard" => { + ConfirmationKeyboardButtons::Discard => { info!( "User {} discarded listing creation", - UserHandleAndId::from_user(&callback_query.from) + HandleAndId::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."; - edit_html_message(&bot, chat_id, message.id(), &response, None).await?; + edit_html_message(&bot, chat, message.id(), &response, None).await?; } - "confirm_edit" => { + ConfirmationKeyboardButtons::Edit => { info!( "User {} chose to edit listing", - UserHandleAndId::from_user(&callback_query.from) + HandleAndId::from_user(&callback_query.from) ); // Go to editing state to allow user to modify specific fields dialogue - .update(ListingWizardState::EditingListing(state.clone())) + .update(ListingWizardState::EditingDraft(draft.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 + show_edit_screen(bot, chat, draft, None, message.regular_message()).await?; } } @@ -739,13 +634,17 @@ async fn process_start_time_and_respond( bot: &Bot, dialogue: NewListingDialogue, mut draft: ListingDraft, - chat_id: ChatId, + chat: impl Into>, hours: i32, ) -> HandlerResult { + let chat = chat.into(); // Update dialogue state draft.start_hours = hours; dialogue - .update(ListingWizardState::AwaitingDuration(draft)) + .update(ListingWizardState::AwaitingDraftField { + field: ListingField::Duration, + draft, + }) .await?; // Generate response message @@ -763,31 +662,27 @@ async fn process_start_time_and_respond( start_msg ); - send_html_message(&bot, chat_id, &response, Some(create_duration_keyboard())).await?; + 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, - msg: Message, ) -> HandlerResult { - let chat_id = msg.chat.id; - let text = msg.text().unwrap_or("").trim(); - - log_user_action(&msg, "entered price input"); - - if is_cancel(text) { - return cancel_wizard(bot, dialogue, msg).await; - } - match validate_price(text) { Ok(price) => { draft.buy_now_price = price; - transition_to_state(&dialogue, ListingWizardState::AwaitingSlots(draft.clone())) - .await?; let response = format!( "✅ Price saved: ${}\n\n\ @@ -797,9 +692,22 @@ pub async fn handle_price_input( draft.buy_now_price ); - send_html_message(&bot, chat_id, &response, Some(create_slots_keyboard())).await? + 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_id, &error_msg, None).await?, + Err(error_msg) => send_html_message(&bot, &chat, &error_msg, None).await?, } Ok(()) @@ -807,25 +715,17 @@ pub async fn handle_price_input( pub async fn handle_slots_input( bot: Bot, + chat: Chat, + text: &str, dialogue: NewListingDialogue, draft: ListingDraft, - msg: Message, ) -> HandlerResult { - let chat_id = msg.chat.id; - let text = msg.text().unwrap_or("").trim(); - - log_user_action(&msg, "entered slots input"); - - if is_cancel(text) { - return cancel_wizard(bot, dialogue, msg).await; - } - match validate_slots(text) { Ok(slots) => { - process_slots_and_respond(&bot, dialogue, draft, chat_id, slots).await?; + process_slots_and_respond(&bot, dialogue, draft, &chat, slots).await?; } Err(error_msg) => { - send_html_message(&bot, chat_id, &error_msg, None).await?; + send_html_message(&bot, &chat, &error_msg, None).await?; } } @@ -834,29 +734,21 @@ pub async fn handle_slots_input( pub async fn handle_start_time_input( bot: Bot, + chat: Chat, + text: &str, dialogue: NewListingDialogue, draft: ListingDraft, - msg: Message, ) -> HandlerResult { - let chat_id = msg.chat.id; - let text = msg.text().unwrap_or("").trim(); - - log_user_action(&msg, "entered start time input"); - - if is_cancel(text) { - return cancel_wizard(bot, dialogue, msg).await; - } - match validate_start_time(text) { Ok(hours) => { - process_start_time_and_respond(&bot, dialogue, draft, chat_id, hours).await?; + process_start_time_and_respond(&bot, dialogue, draft, &chat, hours).await?; } Err(error_msg) => { send_html_message( &bot, - chat_id, + &chat, &error_msg, - Some(create_start_time_keyboard()), + Some(StartTimeKeyboardButtons::to_keyboard()), ) .await?; } @@ -867,25 +759,17 @@ pub async fn handle_start_time_input( pub async fn handle_duration_input( bot: Bot, + chat: Chat, + text: &str, dialogue: NewListingDialogue, draft: ListingDraft, - msg: Message, ) -> HandlerResult { - let chat_id = msg.chat.id; - let text = msg.text().unwrap_or("").trim(); - - log_user_action(&msg, "entered duration input"); - - if is_cancel(text) { - return cancel_wizard(bot, dialogue, msg).await; - } - match validate_duration(text) { Ok(duration) => { - process_duration_and_respond(bot, dialogue, draft, chat_id, duration).await?; + process_duration_and_respond(bot, dialogue, draft, &chat, duration).await?; } Err(error_msg) => { - send_html_message(&bot, chat_id, &error_msg, None).await?; + send_html_message(&bot, &chat, &error_msg, None).await?; } } Ok(()) @@ -893,55 +777,45 @@ pub async fn handle_duration_input( pub async fn handle_duration_callback( bot: Bot, + _: CallbackQuery, dialogue: NewListingDialogue, draft: ListingDraft, - callback_query: CallbackQuery, + data: &str, + chat: HandleAndId<'_>, ) -> HandlerResult { - let data = match callback_query.data.as_deref() { - Some(data) => data, - None => return Ok(()), - }; - let chat_id = match callback_query.message { - Some(message) => message.chat().id, - _ => return Ok(()), + let button = DurationKeyboardButtons::try_from(data).unwrap(); + let days = match button { + DurationKeyboardButtons::OneDay => 1, + DurationKeyboardButtons::ThreeDays => 3, + DurationKeyboardButtons::SevenDays => 7, + DurationKeyboardButtons::FourteenDays => 14, }; - let days = match data { - "duration_1_day" => 1, - "duration_3_days" => 3, - "duration_7_days" => 7, - "duration_14_days" => 14, - _ => { - send_html_message( - &bot, - chat_id, - "❌ Invalid duration. Please enter number of days (1-14):", - None, - ) - .await?; - return Ok(()); - } - }; - - process_duration_and_respond(bot, dialogue, draft, chat_id, days).await + process_duration_and_respond(bot, dialogue, draft, chat, days).await } async fn process_duration_and_respond( bot: Bot, dialogue: NewListingDialogue, mut draft: ListingDraft, - chat_id: ChatId, - duration: i32, + chat: impl Into>, + duration_days: i32, ) -> HandlerResult { - draft.duration_hours = duration; + let chat = chat.into(); + draft.duration_hours = duration_days * 24; dialogue .update(ListingWizardState::ViewingDraft(draft.clone())) .await?; - show_confirmation(bot, chat_id, draft).await + show_confirmation(bot, chat, draft, None).await } -async fn show_confirmation(bot: Bot, chat_id: ChatId, state: ListingDraft) -> HandlerResult { +async fn show_confirmation( + bot: Bot, + chat: HandleAndId<'_>, + state: ListingDraft, + edit_message: Option<&Message>, +) -> HandlerResult { let description_text = state .description .as_deref() @@ -975,18 +849,35 @@ async fn show_confirmation(bot: Bot, chat_id: ChatId, state: ListingDraft) -> Ha if state.duration_hours == 1 { "" } else { "s" } ); - send_html_message( - &bot, - chat_id, - &response, - Some(create_confirmation_keyboard()), - ) - .await?; + 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_id: ChatId, state: ListingDraft) -> HandlerResult { +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() @@ -1002,9 +893,8 @@ async fn show_edit_screen(bot: Bot, chat_id: ChatId, state: ListingDraft) -> Han ) }; - let response = format!( - "✏️ Edit Listing\n\n\ - Current Values:\n\ + let mut response = format!( + "✏️ Editing Listing:\n\n\ 📝 Title: {}\n\ 📄 Description: {}\n\ 💰 Price: ${}\n\ @@ -1021,104 +911,161 @@ async fn show_edit_screen(bot: Bot, chat_id: ChatId, state: ListingDraft) -> Han if state.duration_hours == 1 { "" } else { "s" } ); - send_html_message( + 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_id, - &response, - Some(create_field_selection_keyboard()), + chat, + callback_query.regular_message().unwrap().id, + format!("Editing {:?}", field).as_str(), + Some(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 { - send_html_message( - &bot, - chat_id, - "Please confirm your choice:", - Some(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( + db_pool: SqlitePool, 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, + from: User, + message_id: MessageId, + draft: ListingDraft, ) -> 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 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( - UserId::new(user_id), - title.clone(), - description, + user.id, + draft.title.clone(), + draft.description.clone(), starts_at, ends_at, ); @@ -1126,8 +1073,8 @@ async fn create_listing( let new_listing = NewListing { base: new_listing_base, fields: NewListingFields::FixedPriceListing { - buy_now_price, - slots_available, + buy_now_price: draft.buy_now_price, + slots_available: draft.slots_available, }, }; @@ -1140,22 +1087,23 @@ async fn create_listing( Price: ${}\n\ Slots Available: {}\n\n\ Your fixed price listing is now live! 🎉", - listing.base.id, listing.base.title, buy_now_price, slots_available + listing.base.id, listing.base.title, draft.buy_now_price, draft.slots_available ); - send_html_message(&bot, chat_id, &response, None).await?; + edit_html_message(&bot, chat, message_id, &response, None).await?; dialogue.exit().await?; info!( "Fixed price listing created successfully for user {}: {:?}", - user_id, listing.base.id + chat, listing.base.id ); } Err(e) => { - log::error!("Failed to create listing for user {}: {}", user_id, e); - send_html_message( + log::error!("Failed to create listing for user {}: {}", chat, e); + edit_html_message( &bot, - chat_id, + chat, + message_id, "❌ Error: Failed to create listing. Please try again later.", None, ) @@ -1166,14 +1114,15 @@ async fn create_listing( Ok(()) } -async fn cancel_wizard(bot: Bot, dialogue: NewListingDialogue, msg: Message) -> HandlerResult { - info!( - "User {} cancelled new listing wizard", - UserHandleAndId::from_chat(&msg.chat) - ); +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, msg.chat.id, "❌ Listing creation cancelled.", None).await?; - + send_html_message(&bot, chat, "❌ Listing creation cancelled.", None).await?; Ok(()) } @@ -1181,52 +1130,32 @@ async fn cancel_wizard(bot: Bot, dialogue: NewListingDialogue, msg: Message) -> pub async fn handle_edit_title( bot: Bot, dialogue: NewListingDialogue, - mut state: ListingDraft, - msg: Message, + mut draft: ListingDraft, + text: &str, + chat: HandleAndId<'_>, ) -> HandlerResult { - let chat_id = msg.chat.id; - let text = msg.text().unwrap_or("").trim(); + info!("User {} editing title: '{}'", chat, text); - info!( - "User {} editing title: '{}'", - UserHandleAndId::from_chat(&msg.chat), - text - ); - - if text.is_empty() { - send_html_message( - &bot, - chat_id, - "❌ Title cannot be empty. Please enter a valid title:", - Some(create_back_button_keyboard()), - ) - .await?; - return Ok(()); - } - - if text.len() > 100 { - send_html_message( - &bot, - chat_id, - "❌ Title too long. Please enter a title with 100 characters or less:", - Some(create_back_button_keyboard()), - ) - .await?; - return Ok(()); - } - - // Update the title - state.title = text.to_string(); + 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::EditingListing(state.clone())) + .update(ListingWizardState::EditingDraft(draft.clone())) .await?; - send_html_message(&bot, chat_id, "✅ Title updated!", None).await?; - - show_edit_screen(bot, chat_id, state).await?; - + show_edit_screen(bot, chat, draft, Some("✅ Title updated!"), None).await?; Ok(()) } @@ -1234,49 +1163,25 @@ pub async fn handle_edit_description( bot: Bot, dialogue: NewListingDialogue, mut state: ListingDraft, - msg: Message, + text: &str, + chat: HandleAndId<'_>, ) -> HandlerResult { - let chat_id = msg.chat.id; - let text = msg.text().unwrap_or("").trim(); + info!("User {} editing description: '{}'", chat, text); - 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() { - send_html_message( - &bot, - chat_id, - "❌ Please enter a description or type 'none' for no description:", - Some(create_back_button_keyboard_with_clear("description")), - ) - .await?; - return Ok(()); - } else if text.len() > 500 { - send_html_message( - &bot, - chat_id, - "❌ Description too long. Please enter a description with 500 characters or less:", - Some(create_back_button_keyboard_with_clear("description")), - ) - .await?; - return Ok(()); - } else { - state.description = Some(text.to_string()); - } + 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::EditingListing(state.clone())) + .update(ListingWizardState::EditingDraft(state.clone())) .await?; - send_html_message(&bot, chat_id, "✅ Description updated!", None).await?; - - show_edit_screen(bot, chat_id, state).await?; + show_edit_screen(bot, chat, state, Some("✅ Description updated!"), None).await?; Ok(()) } @@ -1285,55 +1190,25 @@ pub async fn handle_edit_price( bot: Bot, dialogue: NewListingDialogue, mut state: ListingDraft, - msg: Message, + text: &str, + chat: HandleAndId<'_>, ) -> HandlerResult { - let chat_id = msg.chat.id; - let text = msg.text().unwrap_or("").trim(); + info!("User {} editing price: '{}'", chat, text); - 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(_) => { - send_html_message( - &bot, - chat_id, - "❌ Invalid price format. Please enter a price between $0.01 and $10,000.00 (e.g., 25.99):", - Some(create_back_button_keyboard()), - ) - .await?; - return Ok(()); - } - }, - _ => { - send_html_message( - &bot, - chat_id, - "❌ Invalid price. Please enter a price between $0.01 and $10,000.00 (e.g., 25.99):", - Some(create_back_button_keyboard()), - ) - .await?; + 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(()); } }; - // Update the price - state.buy_now_price = price; - // Go back to editing listing state dialogue - .update(ListingWizardState::EditingListing(state.clone())) + .update(ListingWizardState::EditingDraft(state.clone())) .await?; - send_html_message(&bot, chat_id, "✅ Price updated!", None).await?; - - show_edit_screen(bot, chat_id, state).await?; - + show_edit_screen(bot, chat, state, Some("✅ Price updated!"), None).await?; Ok(()) } @@ -1341,42 +1216,25 @@ pub async fn handle_edit_slots( bot: Bot, dialogue: NewListingDialogue, mut state: ListingDraft, - msg: Message, + text: &str, + chat: HandleAndId<'_>, ) -> HandlerResult { - let chat_id = msg.chat.id; - let text = msg.text().unwrap_or("").trim(); + info!("User {} editing slots: '{}'", chat, text); - info!( - "User {} editing slots: '{}'", - UserHandleAndId::from_chat(&msg.chat), - text - ); - - let slots = match text.parse::() { - Ok(s) if (1..=1000).contains(&s) => s, - _ => { - send_html_message( - &bot, - chat_id, - "❌ Invalid number. Please enter a number between 1 and 1000:", - Some(create_back_button_keyboard()), - ) - .await?; + state.slots_available = match validate_slots(text) { + Ok(s) => s, + Err(error_msg) => { + send_html_message(&bot, chat, &error_msg, None).await?; return Ok(()); } }; - // Update the slots - state.slots_available = slots; - // Go back to editing listing state dialogue - .update(ListingWizardState::EditingListing(state.clone())) + .update(ListingWizardState::EditingDraft(state.clone())) .await?; - send_html_message(&bot, chat_id, "✅ Slots updated!", None).await?; - - show_edit_screen(bot, chat_id, state).await?; + show_edit_screen(bot, chat, state, Some("✅ Slots updated!"), None).await?; Ok(()) } @@ -1385,23 +1243,17 @@ pub async fn handle_edit_start_time( bot: Bot, dialogue: NewListingDialogue, mut state: ListingDraft, - msg: Message, + text: &str, + chat: HandleAndId<'_>, ) -> HandlerResult { - let chat_id = msg.chat.id; - let text = msg.text().unwrap_or("").trim(); + info!("User {} editing start time: '{}'", chat, text); - 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, + state.start_hours = match validate_start_time(text) { + Ok(h) => h, _ => { send_html_message( &bot, - chat_id, + chat, "❌ Invalid number. Please enter hours from now (0-168):", Some(create_back_button_keyboard()), ) @@ -1410,17 +1262,12 @@ pub async fn handle_edit_start_time( } }; - // Update the start time - state.start_hours = start_hours; - // Go back to editing listing state dialogue - .update(ListingWizardState::EditingListing(state.clone())) + .update(ListingWizardState::EditingDraft(state.clone())) .await?; - send_html_message(&bot, chat_id, "✅ Start time updated!", None).await?; - - show_edit_screen(bot, chat_id, state).await?; + show_edit_screen(bot, chat, state, Some("✅ Start time updated!"), None).await?; Ok(()) } @@ -1429,23 +1276,17 @@ pub async fn handle_edit_duration( bot: Bot, dialogue: NewListingDialogue, mut state: ListingDraft, - msg: Message, + text: &str, + chat: HandleAndId<'_>, ) -> HandlerResult { - let chat_id = msg.chat.id; - let text = msg.text().unwrap_or("").trim(); + info!("User {} editing duration: '{}'", chat, text); - info!( - "User {} editing duration: '{}'", - UserHandleAndId::from_chat(&msg.chat), - text - ); - - let duration = match text.parse::() { - Ok(d) if (1..=720).contains(&d) => d, + state.duration_hours = match validate_duration(text) { + Ok(d) => d, _ => { send_html_message( &bot, - chat_id, + chat, "❌ Invalid number. Please enter duration in hours (1-720):", Some(create_back_button_keyboard()), ) @@ -1454,305 +1295,54 @@ pub async fn handle_edit_duration( } }; - // Update the duration - state.duration_hours = duration; - // Go back to editing listing state dialogue - .update(ListingWizardState::EditingListing(state.clone())) + .update(ListingWizardState::EditingDraft(state.clone())) .await?; - send_html_message(&bot, chat_id, "✅ Duration updated!", None).await?; - - show_edit_screen(bot, chat_id, state).await?; + show_edit_screen(bot, chat, state, Some("✅ Duration updated!"), None).await?; Ok(()) } -pub async fn handle_editing_callback( +pub async fn handle_editing_draft_field_callback( bot: Bot, dialogue: NewListingDialogue, - state: ListingDraft, + (field, draft): (ListingField, 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 - ); - - edit_html_message( - &bot, - chat_id, - message.id(), - &response, - Some(create_back_button_keyboard()), - ) + 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?; - } - "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 - ); - - edit_html_message( - &bot, - chat_id, - message.id(), - &response, - Some(create_back_button_keyboard()), - ) - .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 - ); - - edit_html_message( - &bot, - chat_id, - message.id(), - &response, - Some(create_back_button_keyboard()), - ) - .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 - ); - - edit_html_message( - &bot, - chat_id, - message.id(), - &response, - Some(create_back_button_keyboard()), - ) - .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 - ); - - edit_html_message( - &bot, - chat_id, - message.id(), - &response, - Some(create_back_button_keyboard()), - ) - .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" } - ); - - edit_html_message( - &bot, - chat_id, - message.id(), - &response, - Some(create_back_button_keyboard()), - ) - .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 - } + 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/db/types/user_id.rs b/src/db/types/user_id.rs index 181a48a..8de3c5c 100644 --- a/src/db/types/user_id.rs +++ b/src/db/types/user_id.rs @@ -7,6 +7,7 @@ use sqlx::{ encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type, }; use std::fmt; +use teloxide::types::ChatId; /// Type-safe wrapper for user IDs #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] diff --git a/src/keyboard_utils.rs b/src/keyboard_utils.rs new file mode 100644 index 0000000..b656f99 --- /dev/null +++ b/src/keyboard_utils.rs @@ -0,0 +1,83 @@ +#[macro_export] +macro_rules! keyboard_buttons { + ($vis:vis enum $name:ident { + $($variant:ident($text:literal, $callback_data:literal),)* + }) => { + keyboard_buttons! { + $vis enum $name { + [$($variant($text, $callback_data),)*] + } + } + }; + + ($vis:vis enum $name:ident { + $([ + $($variant:ident($text:literal, $callback_data:literal),)* + ]),* + }) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + $vis enum $name { + $( + $($variant,)* + )* + } + impl $name { + #[allow(unused)] + pub fn to_keyboard() -> teloxide::types::InlineKeyboardMarkup { + let markup = teloxide::types::InlineKeyboardMarkup::default(); + $( + let markup = markup.append_row([ + $( + teloxide::types::InlineKeyboardButton::callback($text, $callback_data), + )* + ]); + )* + markup + } + } + impl Into for $name { + fn into(self) -> teloxide::types::InlineKeyboardButton { + match self { + $($(Self::$variant => teloxide::types::InlineKeyboardButton::callback($text, $callback_data)),*),* + } + } + } + impl<'a> TryFrom<&'a str> for $name { + type Error = &'a str; + fn try_from(value: &'a str) -> Result { + match value { + $($( + $callback_data => Ok(Self::$variant), + )*)* + _ => Err(value), + } + } + } + }; +} + +#[cfg(test)] +mod tests { + use teloxide::types::{InlineKeyboardButton, InlineKeyboardButtonKind}; + + use super::*; + + 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"), + } + } + + #[test] + fn test_duration_keyboard_buttons() { + let button: InlineKeyboardButton = DurationKeyboardButtons::OneDay.into(); + assert_eq!(button.text, "1 day"); + assert_eq!( + button.kind, + InlineKeyboardButtonKind::CallbackData("duration_1_day".to_string()) + ); + } +} diff --git a/src/main.rs b/src/main.rs index a401e78..5531646 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod commands; mod config; mod db; +mod keyboard_utils; mod message_utils; mod sqlite_storage; @@ -16,7 +17,7 @@ use config::Config; use crate::commands::new_listing::new_listing_handler; use crate::sqlite_storage::SqliteStorage; -pub type HandlerResult = anyhow::Result<()>; +pub type HandlerResult = anyhow::Result; #[derive(BotCommands, Clone)] #[command(rename_rule = "lowercase", description = "Auction Bot Commands")] diff --git a/src/message_utils.rs b/src/message_utils.rs index e077bc7..9be4c50 100644 --- a/src/message_utils.rs +++ b/src/message_utils.rs @@ -1,43 +1,57 @@ use std::fmt::Display; +use anyhow::bail; use teloxide::{ + dispatching::dialogue::GetChatId, payloads::{EditMessageTextSetters as _, SendMessageSetters as _}, prelude::Requester as _, - types::{Chat, ChatId, InlineKeyboardMarkup, MessageId, ParseMode, User}, + types::{ + CallbackQuery, Chat, ChatId, InlineKeyboardButton, InlineKeyboardMarkup, MessageId, + ParseMode, User, + }, Bot, }; use crate::HandlerResult; -pub struct UserHandleAndId<'s> { +#[derive(Debug, Clone, Copy)] +pub struct HandleAndId<'s> { pub handle: Option<&'s str>, - pub id: Option, + pub id: ChatId, } -impl<'s> Display for UserHandleAndId<'s> { +impl<'s> Display for HandleAndId<'s> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{} ({})", - self.handle.unwrap_or("unknown"), - self.id.unwrap_or(-1) - ) + write!(f, "{}", self.handle.unwrap_or("unknown"))?; + write!(f, " ({})", self.id.0)?; + Ok(()) } } -impl<'s> UserHandleAndId<'s> { +impl<'s> HandleAndId<'s> { pub fn from_chat(chat: &'s Chat) -> Self { Self { handle: chat.username(), - id: Some(chat.id.0), + id: chat.id, } } pub fn from_user(user: &'s User) -> Self { Self { handle: user.username.as_deref(), - id: Some(user.id.0 as i64), + id: user.id.into(), } } } +impl<'s> Into> for &'s User { + fn into(self) -> HandleAndId<'s> { + HandleAndId::from_user(self) + } +} +impl<'s> Into> for &'s Chat { + fn into(self) -> HandleAndId<'s> { + HandleAndId::from_chat(self) + } +} + pub fn is_cancel_or_no(text: &str) -> bool { is_cancel(text) || text.eq_ignore_ascii_case("no") } @@ -49,11 +63,12 @@ pub fn is_cancel(text: &str) -> bool { // Unified HTML message sending utility pub async fn send_html_message( bot: &Bot, - chat_id: ChatId, + chat: impl Into>, text: &str, keyboard: Option, ) -> HandlerResult { - let mut message = bot.send_message(chat_id, text).parse_mode(ParseMode::Html); + 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); } @@ -63,13 +78,14 @@ pub async fn send_html_message( pub async fn edit_html_message( bot: &Bot, - chat_id: ChatId, + chat: impl Into>, message_id: MessageId, text: &str, keyboard: Option, ) -> HandlerResult { + let chat = chat.into(); let mut edit_request = bot - .edit_message_text(chat_id, message_id, text) + .edit_message_text(chat.id, message_id, text) .parse_mode(ParseMode::Html); if let Some(kb) = keyboard { edit_request = edit_request.reply_markup(kb); @@ -77,3 +93,68 @@ pub async fn edit_html_message( edit_request.await?; Ok(()) } + +// ============================================================================ +// KEYBOARD CREATION UTILITIES +// ============================================================================ + +// Create a simple single-button keyboard +pub fn create_single_button_keyboard(text: &str, callback_data: &str) -> InlineKeyboardMarkup { + InlineKeyboardMarkup::new([[InlineKeyboardButton::callback(text, callback_data)]]) +} + +// Create a keyboard with multiple buttons in a single row +pub fn create_single_row_keyboard(buttons: &[(&str, &str)]) -> InlineKeyboardMarkup { + let keyboard_buttons: Vec = buttons + .iter() + .map(|(text, callback_data)| InlineKeyboardButton::callback(*text, *callback_data)) + .collect(); + InlineKeyboardMarkup::new([keyboard_buttons]) +} + +// Create a keyboard with multiple rows +pub fn create_multi_row_keyboard(rows: &[&[(&str, &str)]]) -> InlineKeyboardMarkup { + let mut keyboard = InlineKeyboardMarkup::default(); + for row in rows { + let buttons: Vec = row + .iter() + .map(|(text, callback_data)| InlineKeyboardButton::callback(*text, *callback_data)) + .collect(); + keyboard = keyboard.append_row(buttons); + } + 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( + bot: &Bot, + callback_query: &CallbackQuery, +) -> HandlerResult<(String, User)> { + let data = match callback_query.data.as_deref() { + Some(data) => data.to_string(), + None => bail!("Missing data in callback query"), + }; + + let from = callback_query.from.clone(); + + // 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)) +}