Files
pawctioneer-bot/src/commands/new_listing.rs
Dylan Knutson 71fe1e60c0 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
2025-08-28 06:58:55 +00:00

1594 lines
47 KiB
Rust

use chrono::{Duration, Utc};
use log::info;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use teloxide::{
dispatching::{dialogue::serializer::Json, DpHandlerDescription},
prelude::*,
types::{
CallbackQuery, CallbackQueryId, ChatId, InlineKeyboardButton, InlineKeyboardMarkup,
Message, ParseMode,
},
Bot,
};
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<String>,
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<ListingWizardState, SqliteStorage<Json>>;
// 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 {} ({}) started new fixed price listing wizard",
msg.chat.username().unwrap_or("unknown"),
msg.chat.id
);
let response = "🛍️ <b>Creating New Fixed Price Listing</b>\n\n\
Let's create your fixed price listing step by step!\n\n\
<i>Step 1 of 6: Title</i>\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\
<i>Step 2 of 6: Description</i>\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\
<i>Step 3 of 6: Price</i>\n\
Please enter the fixed price for your listing (e.g., 10.50, 25, 0.99):\n\n\
💡 <i>Price should be in USD</i>";
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\
<i>Step 3 of 6: Price</i>\n\
Please enter the fixed price for your listing (e.g., 10.50, 25, 0.99):\n\n\
💡 <i>Price should be in USD</i>";
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::<i32>() {
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: <b>{slots}</b>\n\n\
<i>Step 5 of 6: Start Time</i>\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 = "✅ <b>Listing Created Successfully!</b>\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 = "🗑️ <b>Listing Discarded</b>\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: <b>{}</b>\n\n\
<i>Step 6 of 6: Duration</i>\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: <b>${}</b>\n\n\
<i>Step 4 of 6: Available Slots</i>\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::<i32>() {
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::<i32>() {
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::<i32>() {
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!(
"📋 <b>Listing Summary</b>\n\n\
<b>Title:</b> {}\n\
<b>Description:</b> {}\n\
<b>Price:</b> ${}\n\
<b>Available Slots:</b> {}\n\
<b>Start Time:</b> {}\n\
<b>Duration:</b> {} 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!(
"✏️ <b>Edit Listing</b>\n\n\
<b>Current Values:</b>\n\
📝 <b>Title:</b> {}\n\
📄 <b>Description:</b> {}\n\
💰 <b>Price:</b> ${}\n\
🔢 <b>Available Slots:</b> {}\n\
⏰ <b>Start Time:</b> {}\n\
⏳ <b>Duration:</b> {} 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<String>,
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!(
"✅ <b>Listing Created Successfully!</b>\n\n\
<b>Listing ID:</b> {}\n\
<b>Title:</b> {}\n\
<b>Price:</b> ${}\n\
<b>Slots Available:</b> {}\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::<f64>() {
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::<i32>() {
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::<i32>() {
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::<i32>() {
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!(
"📝 <b>Edit Title</b>\n\nCurrent title: <i>{}</i>\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!(
"📄 <b>Edit Description</b>\n\nCurrent description: <i>{}</i>\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!(
"💰 <b>Edit Price</b>\n\nCurrent price: <i>${}</i>\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!(
"🔢 <b>Edit Slots</b>\n\nCurrent slots: <i>{}</i>\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!(
"⏰ <b>Edit Start Time</b>\n\nCurrent start time: <i>{}</i>\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!(
"⏳ <b>Edit Duration</b>\n\nCurrent duration: <i>{} hour{}</i>\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(())
}