Files
pawctioneer-bot/src/commands/new_listing.rs
Dylan Knutson 1d4d5c05ed Complete message utility adoption - fix all remaining send_html_message usage
- Created edit_html_message utility for consistent message editing
- Converted all bot.edit_message_text() calls to use edit_html_message
- Fixed markdown to HTML conversion (**Error:** → <b>Error:</b>, *text* → <i>text</i>)
- Added MessageId import to fix compilation errors
- 100% message utility adoption achieved - all direct bot message calls eliminated
2025-08-28 14:16:42 +00:00

1759 lines
53 KiB
Rust

use crate::{
db::{
dao::ListingDAO,
models::new_listing::{NewListing, NewListingBase, NewListingFields},
types::{money_amount::MoneyAmount, user_id::UserId},
},
message_utils::{
edit_html_message, is_cancel, is_cancel_or_no, send_html_message, UserHandleAndId,
},
sqlite_storage::SqliteStorage,
Command, HandlerResult,
};
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,
},
Bot,
};
#[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
type NewListingDialogue = Dialogue<ListingWizardState, SqliteStorage<Json>>;
// ============================================================================
// 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<String, String> {
if text.is_empty() {
return Err("❌ Title cannot be empty. Please enter a title for your listing:".to_string());
}
if text.len() > 100 {
return Err(
"❌ Title is too long (max 100 characters). Please enter a shorter title:".to_string(),
);
}
Ok(text.to_string())
}
fn validate_price(text: &str) -> Result<MoneyAmount, String> {
match MoneyAmount::from_str(text) {
Ok(amount) => {
if amount.cents() <= 0 {
Err("❌ Price must be greater than $0.00. Please enter a valid price:".to_string())
} else {
Ok(amount)
}
}
Err(_) => Err(
"❌ Invalid price format. Please enter a valid price (e.g., 10.50, 25, 0.99):"
.to_string(),
),
}
}
fn validate_slots(text: &str) -> Result<i32, String> {
match text.parse::<i32>() {
Ok(slots) if slots >= 1 && slots <= 1000 => Ok(slots),
Ok(_) => Err(
"❌ Number of slots must be between 1 and 1000. Please enter a valid number:"
.to_string(),
),
Err(_) => Err("❌ Invalid number. Please enter a number from 1 to 1000:".to_string()),
}
}
fn validate_duration(text: &str) -> Result<i32, String> {
match text.parse::<i32>() {
Ok(hours) if hours >= 1 && hours <= 720 => Ok(hours), // 1 hour to 30 days
Ok(_) => Err(
"❌ Duration must be between 1 and 720 hours. Please enter a valid number:".to_string(),
),
Err(_) => Err("❌ Invalid number. Please enter number of hours (1-720):".to_string()),
}
}
fn validate_start_time(text: &str) -> Result<i32, String> {
match text.parse::<i32>() {
Ok(hours) if hours >= 0 && hours <= 168 => Ok(hours), // Max 1 week delay
Ok(_) => Err(
"❌ Start time must be between 0 and 168 hours. Please enter a valid number:"
.to_string(),
),
Err(_) => Err(
"❌ Invalid number. Please enter number of hours (0 for immediate start):".to_string(),
),
}
}
// 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),
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,
) -> HandlerResult {
if let Err(e) = bot.answer_callback_query(callback_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<InlineKeyboardButton> = 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<InlineKeyboardButton> = 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.)
fn create_numeric_options_keyboard(options: &[(i32, &str)], prefix: &str) -> InlineKeyboardMarkup {
let buttons: Vec<InlineKeyboardButton> = options
.iter()
.map(|(value, label)| {
InlineKeyboardButton::callback(*label, format!("{}_{}", prefix, value))
})
.collect();
InlineKeyboardMarkup::new([buttons])
}
// 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"),
])
}
// 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"),
],
&[("✏️ 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", "edit_price"), ("🔢 Slots", "edit_slots")],
&[
("⏰ Start Time", "edit_start_time"),
("⏱️ Duration", "edit_duration"),
],
&[("✅ Done", "edit_done")],
])
}
// Create start time keyboard
fn create_start_time_keyboard() -> InlineKeyboardMarkup {
create_single_button_keyboard("Now", "start_time_now")
}
// Create back button keyboard
fn create_back_button_keyboard() -> InlineKeyboardMarkup {
create_single_button_keyboard("🔙 Back", "edit_back")
}
// Create back button with clear option
fn create_back_button_keyboard_with_clear(field: &str) -> InlineKeyboardMarkup {
create_single_row_keyboard(&[
("🔙 Back", "edit_back"),
(
&format!("🧹 Clear {}", field),
&format!("edit_clear_{}", field),
),
])
}
// Create skip button keyboard
fn create_skip_keyboard() -> InlineKeyboardMarkup {
create_single_button_keyboard("Skip", "skip")
}
// ============================================================================
// HANDLER TREE AND MAIN FUNCTIONS
// ============================================================================
// Create the dialogue handler tree for new listing wizard
pub fn new_listing_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> {
dptree::entry()
.branch(
Update::filter_message()
.enter_dialogue::<Message, SqliteStorage<Json>, ListingWizardState>()
.branch(dptree::entry().filter_command::<Command>().branch(
dptree::case![Command::NewListing].endpoint(handle_new_listing_command),
))
.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),
),
)
.branch(
Update::filter_callback_query()
.enter_dialogue::<CallbackQuery, SqliteStorage<Json>, ListingWizardState>()
.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::AwaitingDuration(state)]
.endpoint(handle_duration_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),
),
)
}
// Handle the /newlisting command - starts the dialogue by setting it to Start state
async fn handle_new_listing_command(
bot: Bot,
dialogue: NewListingDialogue,
msg: Message,
) -> HandlerResult {
info!(
"User {} ({}) started new fixed price listing wizard",
msg.chat.username().unwrap_or("unknown"),
msg.chat.id
);
// Initialize the dialogue to Start state
dialogue.update(ListingWizardState::Start).await?;
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):";
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 = "🛍️ <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):";
send_html_message(&bot, msg.chat.id, response, None).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();
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?;
let response = "✅ Title saved!\n\n\
<i>Step 2 of 6: Description</i>\n\
Please enter a description for your listing (optional).";
send_html_message(&bot, chat_id, response, Some(create_skip_keyboard())).await
}
Err(error_msg) => send_html_message(&bot, chat_id, &error_msg, None).await,
}
}
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();
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?;
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>";
send_html_message(&bot, chat_id, response, None).await
}
pub async fn handle_description_callback(
bot: Bot,
dialogue: NewListingDialogue,
draft: 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,
};
if data == "skip" {
log_user_callback_action(&callback_query, "skipped description");
transition_to_state(&dialogue, 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;
edit_html_message(&bot, chat_id, message.id(), &response, None).await?;
}
}
Ok(())
}
pub async fn handle_slots_callback(
bot: Bot,
dialogue: NewListingDialogue,
draft: 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,
};
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::<i32>() {
// 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 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)"
);
send_html_message(&bot, chat_id, &response, Some(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.";
edit_html_message(&bot, chat_id, message.id(), &response, None).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.";
edit_html_message(&bot, chat_id, message.id(), &response, None).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 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
);
send_html_message(&bot, chat_id, &response, Some(create_duration_keyboard())).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();
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: <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):",
draft.buy_now_price
);
send_html_message(&bot, chat_id, &response, Some(create_slots_keyboard())).await?
}
Err(error_msg) => send_html_message(&bot, chat_id, &error_msg, None).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();
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?;
}
Err(error_msg) => {
send_html_message(&bot, chat_id, &error_msg, None).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();
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?;
}
Err(error_msg) => {
send_html_message(
&bot,
chat_id,
&error_msg,
Some(create_start_time_keyboard()),
)
.await?;
}
}
Ok(())
}
pub async fn handle_duration_input(
bot: Bot,
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?;
}
Err(error_msg) => {
send_html_message(&bot, chat_id, &error_msg, None).await?;
}
}
Ok(())
}
pub async fn handle_duration_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(()),
};
let chat_id = match callback_query.message {
Some(message) => message.chat().id,
_ => return Ok(()),
};
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
}
async fn process_duration_and_respond(
bot: Bot,
dialogue: NewListingDialogue,
mut draft: ListingDraft,
chat_id: ChatId,
duration: i32,
) -> HandlerResult {
draft.duration_hours = duration;
dialogue
.update(ListingWizardState::ViewingDraft(draft.clone()))
.await?;
show_confirmation(bot, chat_id, draft).await
}
async fn show_confirmation(bot: Bot, chat_id: ChatId, state: ListingDraft) -> HandlerResult {
let description_text = state
.description
.as_deref()
.unwrap_or("<i>No description</i>");
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" }
);
send_html_message(
&bot,
chat_id,
&response,
Some(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("<i>No description</i>");
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" }
);
send_html_message(
&bot,
chat_id,
&response,
Some(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 {
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(
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
);
send_html_message(&bot, chat_id, &response, None).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);
send_html_message(
&bot,
chat_id,
"❌ <b>Error:</b> Failed to create listing. Please try again later.",
None,
)
.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?;
send_html_message(&bot, msg.chat.id, "❌ Listing creation cancelled.", None).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() {
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();
// Go back to editing listing state
dialogue
.update(ListingWizardState::EditingListing(state.clone()))
.await?;
send_html_message(&bot, chat_id, "✅ Title updated!", None).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() {
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());
}
// Go back to editing listing state
dialogue
.update(ListingWizardState::EditingListing(state.clone()))
.await?;
send_html_message(&bot, chat_id, "✅ Description updated!", None).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(_) => {
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?;
return Ok(());
}
};
// Update the price
state.buy_now_price = price;
// Go back to editing listing state
dialogue
.update(ListingWizardState::EditingListing(state.clone()))
.await?;
send_html_message(&bot, chat_id, "✅ Price updated!", None).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,
_ => {
send_html_message(
&bot,
chat_id,
"❌ Invalid number. Please enter a number between 1 and 1000:",
Some(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?;
send_html_message(&bot, chat_id, "✅ Slots updated!", None).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,
_ => {
send_html_message(
&bot,
chat_id,
"❌ Invalid number. Please enter hours from now (0-168):",
Some(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?;
send_html_message(&bot, chat_id, "✅ Start time updated!", None).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,
_ => {
send_html_message(
&bot,
chat_id,
"❌ Invalid number. Please enter duration in hours (1-720):",
Some(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?;
send_html_message(&bot, chat_id, "✅ Duration updated!", None).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
);
edit_html_message(
&bot,
chat_id,
message.id(),
&response,
Some(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("<i>No description</i>");
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
);
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!(
"💰 <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
);
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!(
"🔢 <b>Edit Slots</b>\n\nCurrent slots: <i>{}</i>\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!(
"⏰ <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
);
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!(
"⏳ <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" }
);
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
}
}
Ok(())
}