Files
pawctioneer-bot/src/commands/my_listings/mod.rs
2025-09-09 01:40:36 +00:00

451 lines
15 KiB
Rust

mod keyboard;
use std::ops::Deref;
use crate::{
case,
commands::{
enter_main_menu,
my_listings::keyboard::{ManageListingButtons, MyListingsButtons},
new_listing::{
enter_edit_listing_draft, enter_select_new_listing_type, keyboard::NavKeyboardButtons,
messages::steps_for_listing_type, ListingDraft,
},
},
db::{
listing::{ListingFields, PersistedListing},
user::PersistedUser,
DAOs, DbListingId, ListingType,
},
handle_error::with_error_handler,
handler_utils::{find_listing_by_id, find_or_create_db_user_from_update},
message_utils::{extract_callback_data, pluralize_with_count},
start_command_data::StartCommandData,
App, BotError, BotResult, Command, DialogueRootState, RootDialogue,
};
use anyhow::Context;
use log::info;
use serde::{Deserialize, Serialize};
use teloxide::{
dispatching::{DpHandlerDescription, UpdateFilterExt},
prelude::*,
types::{
InlineKeyboardButton, InlineKeyboardMarkup, InlineQueryResult, InlineQueryResultArticle,
InputMessageContent, InputMessageContentText, ParseMode, User,
},
};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MyListingsState {
ViewingListings,
ManagingListing(DbListingId),
}
impl From<MyListingsState> for DialogueRootState {
fn from(state: MyListingsState) -> Self {
DialogueRootState::MyListings(state)
}
}
pub fn my_listings_inline_handler() -> Handler<'static, BotResult, DpHandlerDescription> {
Update::filter_inline_query()
.filter_map_async(inline_query_extract_forward_listing)
.endpoint(handle_forward_listing)
}
pub fn my_listings_handler() -> Handler<'static, BotResult, DpHandlerDescription> {
dptree::entry()
.branch(
Update::filter_message()
.filter_map(StartCommandData::get_from_update)
.filter_map(StartCommandData::get_view_listing_details_as_buyer_start_command)
.filter_map_async(find_listing_by_id)
.endpoint(with_error_handler(handle_view_listing_details)),
)
.branch(
Update::filter_message().filter_command::<Command>().branch(
dptree::case![Command::MyListings]
.filter_map_async(find_or_create_db_user_from_update)
.endpoint(with_error_handler(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
)]
.endpoint(with_error_handler(handle_viewing_listings_callback)),
)
.branch(
case![DialogueRootState::MyListings(
MyListingsState::ManagingListing(listing_id)
)]
.endpoint(with_error_handler(handle_managing_listing_callback)),
),
)
}
async fn handle_view_listing_details(app: App, listing: PersistedListing) -> BotResult {
send_listing_details_message(app, listing, None).await?;
Ok(())
}
async fn inline_query_extract_forward_listing(
app: App,
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 = DbListingId::new(listing_id_str.parse::<i64>().ok()?);
let listing = app
.daos
.listing
.find_by_id(listing_id)
.await
.unwrap_or(None)?;
Some(listing)
}
async fn handle_forward_listing(
app: App,
inline_query: InlineQuery,
listing: PersistedListing,
) -> BotResult {
info!("Handling forward listing inline query for listing {listing:?}");
// Create inline keyboard with auction interaction buttons
let keyboard = InlineKeyboardMarkup::default()
.append_row([
InlineKeyboardButton::url(
"💰 Place Bid",
app.url_for_start_command(StartCommandData::PlaceBidOnListing(
listing.persisted.id,
)),
),
InlineKeyboardButton::callback(
"👀 Watch",
format!("inline_watch:{}", listing.persisted.id),
),
])
.append_row([InlineKeyboardButton::url(
"🔗 View Full Details",
app.url_for_start_command(StartCommandData::ViewListingDetailsAsBuyer(
listing.persisted.id,
)),
)]);
// 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.base.ends_at.format("%b %d, %Y at %H:%M UTC")
);
app.bot
.answer_inline_query(
inline_query.id,
vec![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
.map_err(|e| anyhow::anyhow!("Error answering inline query: {e:?}"))?;
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(
app: App,
dialogue: RootDialogue,
user: PersistedUser,
) -> BotResult {
enter_my_listings(app, dialogue, user, None).await?;
Ok(())
}
pub async fn enter_my_listings(
app: App,
dialogue: RootDialogue,
user: PersistedUser,
flash: Option<String>,
) -> BotResult {
// Transition to ViewingListings state
dialogue
.update(MyListingsState::ViewingListings)
.await
.context("failed to update dialogue")?;
let listings = app.daos.listing.find_by_seller(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(),
NavKeyboardButtons::Back.to_button(),
]);
if listings.is_empty() {
app.bot
.send_html_message(
"📋 <b>My Listings</b>\n\n\
You don't have any listings yet."
.to_string(),
Some(keyboard),
)
.await?;
return Ok(());
}
let mut 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")
);
if let Some(flash) = flash {
response = format!("{flash}\n\n{response}");
}
app.bot.send_html_message(response, Some(keyboard)).await?;
Ok(())
}
async fn handle_viewing_listings_callback(
app: App,
dialogue: RootDialogue,
callback_query: CallbackQuery,
user: PersistedUser,
) -> BotResult {
let data = extract_callback_data(app.bot.deref(), callback_query).await?;
if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) {
return enter_main_menu(app, dialogue).await;
}
// 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(&app.daos, user, listing_id).await?;
enter_show_listing_details(app, dialogue, listing).await?;
}
MyListingsButtons::NewListing => {
enter_select_new_listing_type(app, dialogue).await?;
}
}
Ok(())
}
async fn enter_show_listing_details(
app: App,
dialogue: RootDialogue,
listing: PersistedListing,
) -> BotResult {
let listing_id = listing.persisted.id;
dialogue
.update(MyListingsState::ManagingListing(listing_id))
.await
.context("failed to update dialogue")?;
let keyboard = InlineKeyboardMarkup::default()
.append_row([
ManageListingButtons::PreviewMessage.to_button(),
InlineKeyboardButton::switch_inline_query(
ManageListingButtons::ForwardListing.title(),
format!("forward_listing:{listing_id}"),
),
])
.append_row([
ManageListingButtons::Edit.to_button(),
ManageListingButtons::Delete.to_button(),
])
.append_row([ManageListingButtons::Back.to_button()]);
send_listing_details_message(app, listing, Some(keyboard)).await?;
Ok(())
}
async fn send_listing_details_message(
app: App,
listing: PersistedListing,
keyboard: Option<InlineKeyboardMarkup>,
) -> BotResult {
let listing_type = Into::<ListingType>::into(&listing.fields);
let mut response_lines = vec![format!("🔍 <b>{listing_type} Details</b>")];
response_lines.push("".to_string());
let draft = ListingDraft::from_persisted(listing);
for step in steps_for_listing_type(listing_type) {
let field_value = match (step.get_field_value)(&draft) {
Ok(value) => value.unwrap_or_else(|| "(none)".to_string()),
Err(_) => continue,
};
response_lines.push(format!("<b>{}:</b> {}", step.field_name, field_value));
}
app.bot
.send_html_message(response_lines.join("\n"), keyboard)
.await?;
Ok(())
}
async fn handle_managing_listing_callback(
app: App,
dialogue: RootDialogue,
callback_query: CallbackQuery,
user: PersistedUser,
listing_id: DbListingId,
) -> BotResult {
let from = callback_query.from.clone();
let data = extract_callback_data(&app.bot, callback_query).await?;
match ManageListingButtons::try_from(data.as_str())? {
ManageListingButtons::PreviewMessage => {
let listing = app
.daos
.listing
.find_by_id(listing_id)
.await?
.ok_or(anyhow::anyhow!("Listing not found"))?;
send_preview_listing_message(app, listing, from).await?;
}
ManageListingButtons::ForwardListing => {
unimplemented!("Forward listing not implemented");
}
ManageListingButtons::Edit => {
let listing = get_listing_for_user(&app.daos, user, listing_id).await?;
let draft = ListingDraft::from_persisted(listing);
enter_edit_listing_draft(app, draft, dialogue, None).await?;
}
ManageListingButtons::Delete => {
app.daos.listing.delete_listing(listing_id).await?;
enter_my_listings(app, dialogue, user, Some("Listing deleted.".to_string())).await?;
}
ManageListingButtons::Back => {
enter_my_listings(app, dialogue, user, None).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(
app: App,
listing: PersistedListing,
from: User,
) -> BotResult {
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());
}
app.bot
.with_target(from.into())
.send_html_message(
response_lines.join("\n\n"),
Some(keyboard_for_listing(&listing)),
)
.await?;
Ok(())
}
async fn get_listing_for_user(
daos: &DAOs,
user: PersistedUser,
listing_id: DbListingId,
) -> BotResult<PersistedListing> {
let listing = match daos.listing.find_by_id(listing_id).await? {
Some(listing) => listing,
None => {
return Err(BotError::UserVisibleError("❌ Listing not found.".into()));
}
};
if listing.base.seller_id != user.persisted.id {
return Err(BotError::UserVisibleError(
"❌ You can only manage your own auctions.".into(),
));
}
Ok(listing)
}