Files
pawctioneer-bot/src/commands/my_listings/mod.rs
Dylan Knutson a39dd01452 Refactor bot commands and database models
- Update my_listings command structure and keyboard handling
- Enhance new_listing workflow with improved callbacks and handlers
- Refactor database user model and keyboard utilities
- Add new handler utilities module
- Update main bot configuration and start command
2025-08-30 11:04:40 -07:00

439 lines
14 KiB
Rust

mod keyboard;
use crate::{
case,
commands::{
enter_main_menu,
my_listings::keyboard::{ManageListingButtons, MyListingsButtons},
new_listing::{enter_edit_listing_draft, enter_select_new_listing_type, ListingDraft},
},
db::{
listing::{ListingFields, PersistedListing},
user::PersistedUser,
ListingDAO, ListingDbId,
},
handler_utils::{
find_or_create_db_user_from_callback_query, find_or_create_db_user_from_message,
},
message_utils::{extract_callback_data, pluralize_with_count, send_message, MessageTarget},
Command, DialogueRootState, HandlerResult, RootDialogue,
};
use log::info;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use teloxide::{
dispatching::{DpHandlerDescription, UpdateFilterExt},
prelude::*,
types::{
InlineKeyboardButton, InlineKeyboardMarkup, InlineQueryResult, InlineQueryResultArticle,
InputMessageContent, InputMessageContentText, Message, ParseMode, User,
},
Bot,
};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MyListingsState {
ViewingListings,
ManagingListing(ListingDbId),
}
impl From<MyListingsState> for DialogueRootState {
fn from(state: MyListingsState) -> Self {
DialogueRootState::MyListings(state)
}
}
pub fn my_listings_inline_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> {
Update::filter_inline_query()
.filter_map_async(inline_query_extract_forward_listing)
.endpoint(handle_forward_listing)
}
pub fn my_listings_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> {
dptree::entry()
.branch(
Update::filter_message().filter_command::<Command>().branch(
dptree::case![Command::MyListings]
.filter_map_async(find_or_create_db_user_from_message)
.endpoint(handle_my_listings_command_input),
),
)
.branch(
Update::filter_callback_query()
.branch(
// Callback when user taps a listing ID button to manage that listing
case![DialogueRootState::MyListings(
MyListingsState::ViewingListings
)]
.filter_map_async(find_or_create_db_user_from_callback_query)
.endpoint(handle_viewing_listings_callback),
)
.branch(
case![DialogueRootState::MyListings(
MyListingsState::ManagingListing(listing_id)
)]
.filter_map_async(find_or_create_db_user_from_callback_query)
.endpoint(handle_managing_listing_callback),
),
)
}
async fn inline_query_extract_forward_listing(
db_pool: SqlitePool,
inline_query: InlineQuery,
) -> Option<PersistedListing> {
let query = &inline_query.query;
info!("Try to extract forward listing from query: {query}");
let listing_id_str = query.split("forward_listing:").nth(1)?;
let listing_id = ListingDbId::new(listing_id_str.parse::<i64>().ok()?);
let listing = ListingDAO::find_by_id(&db_pool, listing_id)
.await
.unwrap_or(None)?;
Some(listing)
}
async fn handle_forward_listing(
bot: Bot,
inline_query: InlineQuery,
listing: PersistedListing,
) -> HandlerResult {
info!("Handling forward listing inline query for listing {listing:?}");
let bot_username = match bot.get_me().await?.username.as_ref() {
Some(username) => username.to_string(),
None => anyhow::bail!("Bot username not found"),
};
// Create inline keyboard with auction interaction buttons
let keyboard = InlineKeyboardMarkup::default()
.append_row([
InlineKeyboardButton::callback(
"💰 Place Bid",
format!("inline_bid:{}", listing.persisted.id),
),
InlineKeyboardButton::callback(
"👀 Watch",
format!("inline_watch:{}", listing.persisted.id),
),
])
.append_row([InlineKeyboardButton::url(
"🔗 View Full Details",
format!(
"https://t.me/{}?start=listing:{}",
bot_username, listing.persisted.id
)
.parse()
.unwrap(),
)]);
// Get the current price based on listing type
let current_price = get_listing_current_price(&listing);
// Create a more detailed message content for the shared listing
let message_content = format!(
"🎯 <b>{}</b>\n\n\
📝 {}\n\n\
💰 <b>Current Price:</b> ${}\n\
⏰ <b>Ends:</b> {}\n\n\
<i>Use the buttons below to interact! ⬇️</i>",
listing.base.title,
listing
.base
.description
.as_deref()
.unwrap_or("No description"),
current_price,
listing.persisted.end_at.format("%b %d, %Y at %H:%M UTC")
);
bot.answer_inline_query(
inline_query.id,
[InlineQueryResult::Article(
InlineQueryResultArticle::new(
listing.persisted.id.to_string(),
format!("💰 {} - ${}", listing.base.title, current_price),
InputMessageContent::Text(
InputMessageContentText::new(message_content).parse_mode(ParseMode::Html),
),
)
.description(&listing.base.title)
.reply_markup(keyboard), // Add the inline keyboard here!
)],
)
.await?;
Ok(())
}
/// Helper function to get the current price of a listing based on its type
fn get_listing_current_price(listing: &PersistedListing) -> String {
use crate::db::listing::ListingFields;
match &listing.fields {
ListingFields::BasicAuction(fields) => {
// For basic auctions, show starting bid (in a real app, you'd show current highest bid)
format!("{}", fields.starting_bid)
}
ListingFields::MultiSlotAuction(fields) => {
// For multi-slot auctions, show starting bid
format!("{}", fields.starting_bid)
}
ListingFields::FixedPriceListing(fields) => {
// For fixed price listings, show the fixed price
format!("{}", fields.buy_now_price)
}
ListingFields::BlindAuction(fields) => {
// For blind auctions, show starting bid
format!("{}", fields.starting_bid)
}
}
}
async fn handle_my_listings_command_input(
db_pool: SqlitePool,
bot: Bot,
dialogue: RootDialogue,
user: PersistedUser,
msg: Message,
) -> HandlerResult {
enter_my_listings(db_pool, bot, dialogue, user, msg.chat).await?;
Ok(())
}
pub async fn enter_my_listings(
db_pool: SqlitePool,
bot: Bot,
dialogue: RootDialogue,
user: PersistedUser,
target: impl Into<MessageTarget>,
) -> HandlerResult {
// Transition to ViewingListings state
dialogue.update(MyListingsState::ViewingListings).await?;
let listings = ListingDAO::find_by_seller(&db_pool, user.persisted.id).await?;
// Create keyboard with buttons for each listing
let mut keyboard = teloxide::types::InlineKeyboardMarkup::default();
for listing in &listings {
keyboard = keyboard.append_row(vec![MyListingsButtons::listing_into_button(listing)]);
}
keyboard = keyboard.append_row(vec![
MyListingsButtons::new_listing_into_button(),
MyListingsButtons::back_to_menu_into_button(),
]);
if listings.is_empty() {
send_message(
&bot,
target,
"📋 <b>My Listings</b>\n\n\
You don't have any listings yet.",
Some(keyboard),
)
.await?;
return Ok(());
}
let response = format!(
"📋 <b>My Listings</b>\n\n\
You have {}.\n\n\
Select a listing to view details",
pluralize_with_count(listings.len(), "listing", "listings")
);
send_message(&bot, target, response, Some(keyboard)).await?;
Ok(())
}
async fn handle_viewing_listings_callback(
db_pool: SqlitePool,
bot: Bot,
dialogue: RootDialogue,
callback_query: CallbackQuery,
user: PersistedUser,
) -> HandlerResult {
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
let target = (from.clone(), message_id);
// Check if it's the back to menu button
let button = MyListingsButtons::try_from(data.as_str())?;
match button {
MyListingsButtons::SelectListing(listing_id) => {
let listing =
get_listing_for_user(&db_pool, &bot, user, listing_id, target.clone()).await?;
dialogue
.update(MyListingsState::ManagingListing(listing_id))
.await?;
show_listing_details(&bot, listing, target).await?;
}
MyListingsButtons::NewListing => {
enter_select_new_listing_type(bot, dialogue, target).await?;
}
MyListingsButtons::BackToMenu => {
// Transition back to main menu using the reusable function
enter_main_menu(bot, dialogue, target).await?
}
}
Ok(())
}
async fn show_listing_details(
bot: &Bot,
listing: PersistedListing,
target: impl Into<MessageTarget>,
) -> HandlerResult {
let response = format!(
"🔍 <b>Listing Details</b>\n\n\
<b>Title:</b> {}\n\
<b>Description:</b> {}\n",
listing.base.title,
listing
.base
.description
.as_deref()
.unwrap_or("No description"),
);
send_message(
bot,
target,
response,
Some(
InlineKeyboardMarkup::default()
.append_row([
ManageListingButtons::PreviewMessage.to_button(),
InlineKeyboardButton::switch_inline_query(
ManageListingButtons::ForwardListing.title(),
format!("forward_listing:{}", listing.persisted.id),
),
])
.append_row([
ManageListingButtons::Edit.to_button(),
ManageListingButtons::Delete.to_button(),
])
.append_row([ManageListingButtons::Back.to_button()]),
),
)
.await?;
Ok(())
}
async fn handle_managing_listing_callback(
db_pool: SqlitePool,
bot: Bot,
dialogue: RootDialogue,
callback_query: CallbackQuery,
user: PersistedUser,
listing_id: ListingDbId,
) -> HandlerResult {
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
let target = (from.clone(), message_id);
match ManageListingButtons::try_from(data.as_str())? {
ManageListingButtons::PreviewMessage => {
let listing = ListingDAO::find_by_id(&db_pool, listing_id)
.await?
.ok_or(anyhow::anyhow!("Listing not found"))?;
send_preview_listing_message(&bot, listing, from).await?;
}
ManageListingButtons::ForwardListing => {
unimplemented!("Forward listing not implemented");
}
ManageListingButtons::Edit => {
let listing =
get_listing_for_user(&db_pool, &bot, user, listing_id, target.clone()).await?;
let draft = ListingDraft::from_persisted(listing);
enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?;
}
ManageListingButtons::Delete => {
ListingDAO::delete_listing(&db_pool, listing_id).await?;
send_message(&bot, target, "Listing deleted.", None).await?;
}
ManageListingButtons::Back => {
dialogue.update(MyListingsState::ViewingListings).await?;
enter_my_listings(db_pool, bot, dialogue, user, target).await?;
}
}
Ok(())
}
fn keyboard_for_listing(listing: &PersistedListing) -> InlineKeyboardMarkup {
let mut keyboard = InlineKeyboardMarkup::default();
match listing.fields {
ListingFields::FixedPriceListing(_) => {
keyboard = keyboard.append_row([InlineKeyboardButton::callback(
"Buy",
"fixed_price_listing_buy",
)]);
}
ListingFields::BasicAuction(_) => {
keyboard = keyboard.append_row([
InlineKeyboardButton::callback("Submit Bid", "basic_auction_bid"),
InlineKeyboardButton::callback("Buy It Now", "basic_auction_buy_it_now"),
]);
}
ListingFields::MultiSlotAuction(_) => {
keyboard = keyboard.append_row([InlineKeyboardButton::callback(
"Submit Bid",
"multi_slot_auction_submit_bid",
)]);
}
ListingFields::BlindAuction(_) => {
keyboard = keyboard.append_row([InlineKeyboardButton::callback(
"Submit Bid",
"blind_auction_submit_bid",
)]);
}
};
keyboard
}
async fn send_preview_listing_message(
bot: &Bot,
listing: PersistedListing,
from: User,
) -> HandlerResult {
let mut response_lines = vec![];
response_lines.push(format!("<b>{}</b>", &listing.base.title));
if let Some(description) = &listing.base.description {
response_lines.push(description.to_owned());
}
send_message(
bot,
from,
response_lines.join("\n\n"),
Some(keyboard_for_listing(&listing)),
)
.await?;
Ok(())
}
async fn get_listing_for_user(
db_pool: &SqlitePool,
bot: &Bot,
user: PersistedUser,
listing_id: ListingDbId,
target: impl Into<MessageTarget>,
) -> HandlerResult<PersistedListing> {
let listing = match ListingDAO::find_by_id(db_pool, listing_id).await? {
Some(listing) => listing,
None => {
send_message(bot, target, "❌ Listing not found.", None).await?;
return Err(anyhow::anyhow!("Listing not found"));
}
};
if listing.base.seller_id != user.persisted.id {
send_message(
bot,
target,
"❌ You can only manage your own auctions.",
None,
)
.await?;
return Err(anyhow::anyhow!("User does not own listing"));
}
Ok(listing)
}