Compare commits

..

3 Commits

Author SHA1 Message Date
Dylan Knutson
65a50b05e2 feat: Add inline keyboard support to auction listing inline queries
- Add interactive inline keyboard with bid, watch, and share buttons
- Implement separate inline query handler before dialogue system
- Fix dispatcher structure to handle both inline queries and regular messages
- Add dynamic bot username fetching for deep links
- Support rich formatted auction listing messages with pricing info
- Handle forward_listing:ID pattern for sharing specific auctions

Resolves inline query handling and adds interactive auction sharing
2025-08-30 15:37:02 +00:00
Dylan Knutson
c9a824dd88 cargo clippy 2025-08-30 05:50:44 +00:00
Dylan Knutson
5d7a5b26c1 feat: enhance UX with listing type selection and main menu improvements
- Add listing type selection to new listing wizard
  - Create SelectingListingType state for choosing between 4 listing types
  - Add ListingTypeKeyboardButtons with clear descriptions
  - Support BasicAuction, MultiSlotAuction, FixedPriceListing, BlindAuction
  - Update handlers to start with type selection before title input

- Improve main menu navigation
  - Add main menu with action buttons for /start command
  - Create MainMenuButtons with New Listing, My Listings, My Bids, Settings, Help
  - Add back button to My Listings screen for better navigation
  - Implement proper dialogue state management between screens

- Refactor callback handling for type safety
  - Convert string literal matching to enum-based callback handling
  - Use try_from() pattern for all keyboard button callbacks
  - Ensure compile-time safety and exhaustive matching
  - Apply pattern to listing type, slots, duration, and start time callbacks

- Eliminate code duplication
  - Extract reusable main menu functions (enter_main_menu, get_main_menu_message)
  - Centralize main menu logic and message content
  - Update all main menu transitions to use shared functions

- Technical improvements
  - Add proper error handling for invalid callback data
  - Maintain backward compatibility with existing flows
  - Follow established patterns for keyboard button definitions
  - Ensure all changes compile without errors
2025-08-30 05:45:49 +00:00
21 changed files with 878 additions and 418 deletions

View File

@@ -3,9 +3,9 @@ pub mod my_bids;
pub mod my_listings;
pub mod new_listing;
pub mod settings;
pub mod start;
mod start;
pub use help::handle_help;
pub use my_bids::handle_my_bids;
pub use settings::handle_settings;
pub use start::handle_start;
pub use start::{enter_main_menu, handle_main_menu_callback, handle_start};

View File

@@ -1,257 +0,0 @@
use crate::{
case,
commands::new_listing::{enter_edit_listing_draft, ListingDraft},
db::{listing::PersistedListing, user::PersistedUser, ListingDAO, ListingDbId, UserDAO},
keyboard_buttons,
message_utils::{extract_callback_data, pluralize_with_count, send_message, MessageTarget},
Command, DialogueRootState, HandlerResult, RootDialogue,
};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use teloxide::{
dispatching::{DpHandlerDescription, UpdateFilterExt},
prelude::*,
types::{InlineKeyboardButton, Message},
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)
}
}
keyboard_buttons! {
enum ManageListingButtons {
[
Edit("✏️ Edit", "manage_listing_edit"),
Delete("🗑️ Delete", "manage_listing_delete"),
],
[
Back("⬅️ Back", "manage_listing_back"),
]
}
}
pub fn my_listings_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> {
dptree::entry()
.branch(
Update::filter_message().filter_command::<Command>().branch(
dptree::case![Command::MyListings].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
)]
.endpoint(handle_viewing_listings_callback),
)
.branch(
case![DialogueRootState::MyListings(
MyListingsState::ManagingListing(listing_id)
)]
.endpoint(handle_managing_listing_callback),
),
)
}
async fn handle_my_listings_command_input(
db_pool: SqlitePool,
bot: Bot,
dialogue: RootDialogue,
msg: Message,
) -> HandlerResult {
let from = msg.from.unwrap();
show_listings_for_user(db_pool, dialogue, bot, from.id, msg.chat).await?;
Ok(())
}
async fn show_listings_for_user(
db_pool: SqlitePool,
dialogue: RootDialogue,
bot: Bot,
user: teloxide::types::UserId,
target: impl Into<MessageTarget>,
) -> HandlerResult {
// If we reach here, show the listings menu
let user = match UserDAO::find_by_telegram_id(&db_pool, user).await? {
Some(user) => user,
None => {
send_message(
&bot,
target,
"You don't have an account. Try creating an auction first.",
None,
)
.await?;
return Err(anyhow::anyhow!("User not found"));
}
};
// Transition to ViewingListings state
dialogue.update(MyListingsState::ViewingListings).await?;
let listings = ListingDAO::find_by_seller(&db_pool, user.persisted.id).await?;
if listings.is_empty() {
send_message(
&bot,
target,
"📋 <b>My Listings</b>\n\n\
You don't have any listings yet.\n\
Use /newlisting to create your first listing!",
None,
)
.await?;
return Ok(());
}
// Create keyboard with buttons for each listing
let mut keyboard = teloxide::types::InlineKeyboardMarkup::default();
for listing in &listings {
keyboard = keyboard.append_row(vec![InlineKeyboardButton::callback(
listing.base.title.to_string(),
listing.persisted.id.to_string(),
)]);
}
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,
) -> HandlerResult {
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
let target = (from.clone(), message_id);
let listing_id = ListingDbId::new(data.parse::<i64>()?);
let (_, listing) =
get_user_and_listing(&db_pool, &bot, from.id, listing_id, target.clone()).await?;
dialogue
.update(MyListingsState::ManagingListing(listing_id))
.await?;
show_listing_details(&bot, listing, target).await?;
Ok(())
}
async fn show_listing_details(
bot: &Bot,
listing: PersistedListing,
target: impl Into<MessageTarget>,
) -> HandlerResult {
let response = format!(
"🔍 <b>Viewing 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(ManageListingButtons::to_keyboard()),
)
.await?;
Ok(())
}
async fn handle_managing_listing_callback(
db_pool: SqlitePool,
bot: Bot,
dialogue: RootDialogue,
callback_query: CallbackQuery,
listing_id: ListingDbId,
) -> HandlerResult {
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
let target = (from.clone(), message_id);
let button = ManageListingButtons::try_from(data.as_str())
.map_err(|_| anyhow::anyhow!("Invalid ManageListingButtons callback data: {}", data))?;
match button {
ManageListingButtons::Edit => {
let (_, listing) =
get_user_and_listing(&db_pool, &bot, from.id, 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?;
show_listings_for_user(db_pool, dialogue, bot, from.id, target).await?;
}
}
Ok(())
}
async fn get_user_and_listing(
db_pool: &SqlitePool,
bot: &Bot,
user_id: teloxide::types::UserId,
listing_id: ListingDbId,
target: impl Into<MessageTarget>,
) -> HandlerResult<(PersistedUser, PersistedListing)> {
let user = match UserDAO::find_by_telegram_id(db_pool, user_id).await? {
Some(user) => user,
None => {
send_message(
bot,
target,
"❌ You don't have an account. Try creating an auction first.",
None,
)
.await?;
return Err(anyhow::anyhow!("User not found"));
}
};
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((user, listing))
}

View File

@@ -0,0 +1,19 @@
use crate::keyboard_buttons;
keyboard_buttons! {
pub enum MyListingsButtons {
BackToMenu("⬅️ Back to Menu", "my_listings_back_to_menu"),
}
}
keyboard_buttons! {
pub enum ManageListingButtons {
[
PreviewMessage("👀 Preview", "manage_listing_preview"),
ForwardListing("↪️ Forward", "manage_listing_forward"),
],
[Edit("✏️ Edit", "manage_listing_edit"),],
[Delete("🗑️ Delete", "manage_listing_delete"),],
[Back("⬅️ Back", "manage_listing_back"),]
}
}

View File

@@ -0,0 +1,467 @@
mod keyboard;
use crate::{
case,
commands::{
enter_main_menu,
my_listings::keyboard::{ManageListingButtons, MyListingsButtons},
new_listing::{enter_edit_listing_draft, ListingDraft},
},
db::{
listing::{ListingFields, PersistedListing},
user::PersistedUser,
ListingDAO, ListingDbId, UserDAO,
},
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()
.inspect(|query: InlineQuery| info!("Received inline query: {:?}", 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].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
)]
.endpoint(handle_viewing_listings_callback),
)
.branch(
case![DialogueRootState::MyListings(
MyListingsState::ManagingListing(listing_id)
)]
.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,
msg: Message,
) -> HandlerResult {
let from = msg.from.unwrap();
show_listings_for_user(db_pool, dialogue, bot, from.id, msg.chat).await?;
Ok(())
}
pub async fn show_listings_for_user(
db_pool: SqlitePool,
dialogue: RootDialogue,
bot: Bot,
user: teloxide::types::UserId,
target: impl Into<MessageTarget>,
) -> HandlerResult {
// If we reach here, show the listings menu
let user = match UserDAO::find_by_telegram_id(&db_pool, user).await? {
Some(user) => user,
None => {
send_message(
&bot,
target,
"You don't have an account. Try creating an auction first.",
None,
)
.await?;
return Err(anyhow::anyhow!("User not found"));
}
};
// Transition to ViewingListings state
dialogue.update(MyListingsState::ViewingListings).await?;
let listings = ListingDAO::find_by_seller(&db_pool, user.persisted.id).await?;
if listings.is_empty() {
// Create keyboard with just the back button
let keyboard =
teloxide::types::InlineKeyboardMarkup::new([[MyListingsButtons::BackToMenu.into()]]);
send_message(
&bot,
target,
"📋 <b>My Listings</b>\n\n\
You don't have any listings yet.\n\
Use /newlisting to create your first listing!",
Some(keyboard),
)
.await?;
return Ok(());
}
// Create keyboard with buttons for each listing
let mut keyboard = teloxide::types::InlineKeyboardMarkup::default();
for listing in &listings {
keyboard = keyboard.append_row(vec![InlineKeyboardButton::callback(
listing.base.title.to_string(),
listing.persisted.id.to_string(),
)]);
}
// Add back to menu button
keyboard = keyboard.append_row(vec![MyListingsButtons::BackToMenu.into()]);
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,
) -> 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
if let Ok(button) = MyListingsButtons::try_from(data.as_str()) {
match button {
MyListingsButtons::BackToMenu => {
// Transition back to main menu using the reusable function
enter_main_menu(bot, dialogue, target).await?;
return Ok(());
}
}
}
// Otherwise, treat it as a listing ID
let listing_id = ListingDbId::new(data.parse::<i64>()?);
let (_, listing) =
get_user_and_listing(&db_pool, &bot, from.id, listing_id, target.clone()).await?;
dialogue
.update(MyListingsState::ManagingListing(listing_id))
.await?;
show_listing_details(&bot, listing, 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,
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) =
get_user_and_listing(&db_pool, &bot, from.id, listing_id, target.clone()).await?;
send_preview_listing_message(&bot, listing, from).await?;
}
ManageListingButtons::ForwardListing => {
unimplemented!("Forward listing not implemented");
}
ManageListingButtons::Edit => {
let (_, listing) =
get_user_and_listing(&db_pool, &bot, from.id, 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?;
show_listings_for_user(db_pool, dialogue, bot, from.id, 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(format!("{}", description));
}
send_message(
bot,
from,
response_lines.join("\n\n"),
Some(keyboard_for_listing(&listing)),
)
.await?;
Ok(())
}
async fn get_user_and_listing(
db_pool: &SqlitePool,
bot: &Bot,
user_id: teloxide::types::UserId,
listing_id: ListingDbId,
target: impl Into<MessageTarget>,
) -> HandlerResult<(PersistedUser, PersistedListing)> {
let user = match UserDAO::find_by_telegram_id(db_pool, user_id).await? {
Some(user) => user,
None => {
send_message(
bot,
target,
"❌ You don't have an account. Try creating an auction first.",
None,
)
.await?;
return Err(anyhow::anyhow!("User not found"));
}
};
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((user, listing))
}

View File

@@ -4,20 +4,77 @@
//! in the new listing creation and editing workflows.
use crate::{
commands::new_listing::{
field_processing::transition_to_field,
keyboard::*,
messages::{get_keyboard_for_field, get_step_message},
types::{ListingDraft, ListingDraftPersisted, ListingField, NewListingState},
ui::show_confirmation_screen,
commands::{
new_listing::{
field_processing::transition_to_field,
keyboard::{
DurationKeyboardButtons, ListingTypeKeyboardButtons, SlotsKeyboardButtons,
StartTimeKeyboardButtons,
},
messages::{get_keyboard_for_field, get_step_message},
types::{ListingDraft, ListingDraftPersisted, ListingField, NewListingState},
ui::show_confirmation_screen,
},
start::enter_main_menu,
},
db::{listing::ListingFields, ListingDuration},
db::{listing::ListingFields, ListingDuration, ListingType, UserDbId},
message_utils::*,
HandlerResult, RootDialogue,
};
use log::{error, info};
use teloxide::{types::CallbackQuery, Bot};
/// Handle callbacks during the listing type selection phase
pub async fn handle_selecting_listing_type_callback(
bot: Bot,
dialogue: RootDialogue,
seller_id: UserDbId,
callback_query: CallbackQuery,
) -> HandlerResult {
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
info!("User {from:?} selected listing type: {data:?}");
let target = (from, message_id);
// Parse the listing type from callback data
let (listing_type, type_name) = match ListingTypeKeyboardButtons::try_from(data.as_str())? {
ListingTypeKeyboardButtons::FixedPrice => {
(ListingType::FixedPriceListing, "Fixed Price Listing")
}
ListingTypeKeyboardButtons::BasicAuction => (ListingType::BasicAuction, "Basic Auction"),
ListingTypeKeyboardButtons::BlindAuction => (ListingType::BlindAuction, "Blind Auction"),
ListingTypeKeyboardButtons::MultiSlot => {
(ListingType::MultiSlotAuction, "Multi-Slot Auction")
}
ListingTypeKeyboardButtons::Back => {
enter_main_menu(bot, dialogue, target).await?;
return Ok(());
}
};
// Create draft with selected listing type
let draft = ListingDraft::new_for_seller_with_type(seller_id, listing_type);
// Transition to first field (Title)
transition_to_field(dialogue, ListingField::Title, draft).await?;
let response = format!(
"✅ <b>{} selected!</b>\n\n\
Let's create your listing step by step!\n\n{}",
type_name,
get_step_message(ListingField::Title)
);
send_message(
&bot,
target,
response,
get_keyboard_for_field(ListingField::Title),
)
.await?;
Ok(())
}
/// Handle callbacks during the field input phase
pub async fn handle_awaiting_draft_field_callback(
bot: Bot,
@@ -38,14 +95,17 @@ pub async fn handle_awaiting_draft_field_callback(
ListingField::Description if data == "skip" => {
handle_description_skip_callback(&bot, dialogue, draft, target).await
}
ListingField::Slots if data.starts_with("slots_") => {
handle_slots_callback(&bot, dialogue, draft, &data, target).await
ListingField::Slots => {
let button = SlotsKeyboardButtons::try_from(data.as_str())?;
handle_slots_callback(&bot, dialogue, draft, button, target).await
}
ListingField::StartTime if data.starts_with("start_time_") => {
handle_start_time_callback(&bot, dialogue, draft, &data, target).await
ListingField::StartTime => {
let button = StartTimeKeyboardButtons::try_from(data.as_str())?;
handle_start_time_callback(&bot, dialogue, draft, button, target).await
}
ListingField::Duration if data.starts_with("duration_") => {
handle_duration_callback(&bot, dialogue, draft, &data, target).await
ListingField::Duration => {
let button = DurationKeyboardButtons::try_from(data.as_str())?;
handle_duration_callback(&bot, dialogue, draft, button, target).await
}
_ => {
error!("Unknown callback data for field {field:?}: {data}");
@@ -84,12 +144,10 @@ async fn handle_slots_callback(
bot: &Bot,
dialogue: RootDialogue,
mut draft: ListingDraft,
data: &str,
button: SlotsKeyboardButtons,
target: impl Into<MessageTarget>,
) -> HandlerResult {
let target = target.into();
let button = SlotsKeyboardButtons::try_from(data)
.map_err(|_| anyhow::anyhow!("Unknown SlotsKeyboardButtons data: {}", data))?;
let num_slots = match button {
SlotsKeyboardButtons::OneSlot => 1,
SlotsKeyboardButtons::TwoSlots => 2,
@@ -124,12 +182,10 @@ async fn handle_start_time_callback(
bot: &Bot,
dialogue: RootDialogue,
mut draft: ListingDraft,
data: &str,
button: StartTimeKeyboardButtons,
target: impl Into<MessageTarget>,
) -> HandlerResult {
let target = target.into();
let button = StartTimeKeyboardButtons::try_from(data)
.map_err(|_| anyhow::anyhow!("Unknown StartTimeKeyboardButtons data: {}", data))?;
let start_time = match button {
StartTimeKeyboardButtons::Now => ListingDuration::zero(),
};
@@ -163,11 +219,10 @@ async fn handle_duration_callback(
bot: &Bot,
dialogue: RootDialogue,
mut draft: ListingDraft,
data: &str,
button: DurationKeyboardButtons,
target: impl Into<MessageTarget>,
) -> HandlerResult {
let target = target.into();
let button = DurationKeyboardButtons::try_from(data).unwrap();
let duration = ListingDuration::days(match button {
DurationKeyboardButtons::OneDay => 1,
DurationKeyboardButtons::ThreeDays => 3,

View File

@@ -28,6 +28,12 @@ pub fn new_listing_handler() -> Handler {
)
.branch(
Update::filter_callback_query()
.branch(
case![DialogueRootState::NewListing(
NewListingState::SelectingListingType { seller_id }
)]
.endpoint(handle_selecting_listing_type_callback),
)
.branch(
case![DialogueRootState::NewListing(
NewListingState::AwaitingDraftField { field, draft }

View File

@@ -12,7 +12,8 @@ use crate::{
SlotsKeyboardButtons, StartTimeKeyboardButtons,
},
messages::{
get_edit_success_message, get_keyboard_for_field, get_step_message, get_success_message,
get_edit_success_message, get_keyboard_for_field, get_listing_type_keyboard,
get_listing_type_selection_message, get_step_message, get_success_message,
},
types::{ListingDraft, ListingDraftPersisted, ListingField, NewListingState},
ui::{display_listing_summary, show_confirmation_screen},
@@ -29,38 +30,38 @@ use sqlx::SqlitePool;
use teloxide::{prelude::*, types::*, Bot};
/// Handle the /newlisting command - starts the dialogue
pub async fn handle_new_listing_command(
pub(super) async fn handle_new_listing_command(
db_pool: SqlitePool,
bot: Bot,
dialogue: RootDialogue,
msg: Message,
) -> HandlerResult {
info!(
"User {} started new fixed price listing wizard",
HandleAndId::from_chat(&msg.chat),
);
let user = msg.from.ok_or_else(|| anyhow::anyhow!("User not found"))?;
enter_handle_new_listing(db_pool, bot, dialogue, user, msg.chat).await?;
Ok(())
}
pub async fn enter_handle_new_listing(
db_pool: SqlitePool,
bot: Bot,
dialogue: RootDialogue,
user: User,
target: impl Into<MessageTarget>,
) -> HandlerResult {
let user = UserDAO::find_or_create_by_telegram_user(&db_pool, user).await?;
// Initialize the dialogue to Start state
// Initialize the dialogue to listing type selection state
dialogue
.update(NewListingState::AwaitingDraftField {
field: ListingField::Title,
draft: ListingDraft::new_for_seller(user.persisted.id),
.update(NewListingState::SelectingListingType {
seller_id: user.persisted.id,
})
.await?;
let response = format!(
"🛍️ <b>Creating New Fixed Price Listing</b>\n\n\
Let's create your fixed price listing step by step!\n\n{}",
get_step_message(ListingField::Title)
);
send_message(
&bot,
msg.chat,
response,
get_keyboard_for_field(ListingField::Title),
target,
get_listing_type_selection_message(),
Some(get_listing_type_keyboard()),
)
.await?;
Ok(())
@@ -154,10 +155,7 @@ pub async fn handle_viewing_draft_callback(
let target = (from.clone(), message_id);
let button = ConfirmationKeyboardButtons::try_from(data.as_str())
.map_err(|_| anyhow::anyhow!("Unknown ConfirmationKeyboardButtons data: {}", data))?;
match button {
match ConfirmationKeyboardButtons::try_from(data.as_str())? {
ConfirmationKeyboardButtons::Create | ConfirmationKeyboardButtons::Save => {
info!("User {target:?} confirmed listing creation");
save_listing(db_pool, bot, target, draft).await?;
@@ -197,11 +195,9 @@ pub async fn handle_editing_draft_callback(
) -> HandlerResult {
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
let target = (from, message_id);
let button = FieldSelectionKeyboardButtons::try_from(data.as_str())
.map_err(|e| anyhow::anyhow!("Invalid field selection button: {}", e))?;
info!("User {target:?} in editing screen, showing field selection");
let button = FieldSelectionKeyboardButtons::try_from(data.as_str())?;
if button == FieldSelectionKeyboardButtons::Done {
show_confirmation_screen(&bot, target, &draft).await?;
dialogue
@@ -219,7 +215,9 @@ pub async fn handle_editing_draft_callback(
FieldSelectionKeyboardButtons::Slots => ListingField::Slots,
FieldSelectionKeyboardButtons::StartTime => ListingField::StartTime,
FieldSelectionKeyboardButtons::Duration => ListingField::Duration,
FieldSelectionKeyboardButtons::Done => unreachable!(),
FieldSelectionKeyboardButtons::Done => {
return Err(anyhow::anyhow!("Done button should not be used here"))
}
};
let value = get_current_field_value(&draft, field)?;

View File

@@ -53,3 +53,17 @@ keyboard_buttons! {
Now("Now", "start_time_now"),
}
}
keyboard_buttons! {
pub enum ListingTypeKeyboardButtons {
[
FixedPrice("🛍️ Fixed Price", "listing_type_fixed_price"),
BasicAuction("⏰ Basic Auction", "listing_type_basic_auction"),
],
[
BlindAuction("🎭 Blind Auction", "listing_type_blind_auction"),
MultiSlot("🎯 Multi-Slot Auction", "listing_type_multi_slot"),
],
[Back("🔙 Back", "listing_type_back"),]
}
}

View File

@@ -71,3 +71,18 @@ fn create_cancel_keyboard() -> InlineKeyboardMarkup {
fn create_skip_cancel_keyboard() -> InlineKeyboardMarkup {
create_multi_row_keyboard(&[&[("Skip", "skip"), ("Cancel", "cancel")]])
}
/// Get the listing type selection message
pub fn get_listing_type_selection_message() -> &'static str {
"🛍️ <b>What type of listing would you like to create?</b>\n\n\
<b>🛍️ Fixed Price:</b> Set a fixed price for immediate purchase\n\
<b>⏰ Basic Auction:</b> Traditional time-based auction with bidding\n\
<b>🎭 Blind Auction:</b> Buyers submit sealed bids, you choose the winner\n\
<b>🎯 Multi-Slot Auction:</b> Multiple items/winners in one auction\n\n\
Choose your listing type:"
}
/// Get the keyboard for listing type selection
pub fn get_listing_type_keyboard() -> InlineKeyboardMarkup {
ListingTypeKeyboardButtons::to_keyboard()
}

View File

@@ -17,7 +17,7 @@ mod field_processing;
mod handler_factory;
mod handlers;
mod keyboard;
mod messages;
pub mod messages;
#[cfg(test)]
mod tests;
@@ -27,5 +27,5 @@ mod validations;
// Re-export the main handler for external use
pub use handler_factory::new_listing_handler;
pub use handlers::enter_edit_listing_draft;
pub use handlers::{enter_edit_listing_draft, enter_handle_new_listing};
pub use types::*;

View File

@@ -46,7 +46,7 @@ fn test_complete_field_processing_workflow() {
for (field, input) in workflow {
let result = process_field_update(field, &mut draft, input);
assert!(result.is_ok(), "Processing {:?} should succeed", field);
assert!(result.is_ok(), "Processing {field:?} should succeed");
}
// Verify realistic final state
@@ -130,8 +130,7 @@ fn test_natural_language_duration_conversion() {
assert_eq!(
fields.end_delay,
ListingDuration::hours(expected_hours),
"Business duration '{}' should convert correctly",
input
"Business duration '{input}' should convert correctly"
);
}
}

View File

@@ -1,10 +1,11 @@
use crate::{
db::{
listing::{
FixedPriceListingFields, ListingBase, ListingFields, NewListingFields,
PersistedListing, PersistedListingFields,
BasicAuctionFields, BlindAuctionFields, FixedPriceListingFields, ListingBase,
ListingFields, MultiSlotAuctionFields, NewListingFields, PersistedListing,
PersistedListingFields,
},
MoneyAmount, UserDbId,
ListingType, MoneyAmount, UserDbId,
},
DialogueRootState,
};
@@ -19,7 +20,34 @@ pub struct ListingDraft {
}
impl ListingDraft {
pub fn new_for_seller(seller_id: UserDbId) -> Self {
pub fn new_for_seller_with_type(seller_id: UserDbId, listing_type: ListingType) -> Self {
let fields = match listing_type {
ListingType::BasicAuction => ListingFields::BasicAuction(BasicAuctionFields {
starting_bid: MoneyAmount::default(),
buy_now_price: None,
min_increment: MoneyAmount::from_cents(100), // Default $1.00 increment
anti_snipe_minutes: Some(5),
}),
ListingType::MultiSlotAuction => {
ListingFields::MultiSlotAuction(MultiSlotAuctionFields {
starting_bid: MoneyAmount::default(),
buy_now_price: MoneyAmount::default(),
min_increment: Some(MoneyAmount::from_cents(100)), // Default $1.00 increment
slots_available: 1,
anti_snipe_minutes: 5,
})
}
ListingType::FixedPriceListing => {
ListingFields::FixedPriceListing(FixedPriceListingFields {
buy_now_price: MoneyAmount::default(),
slots_available: 1,
})
}
ListingType::BlindAuction => ListingFields::BlindAuction(BlindAuctionFields {
starting_bid: MoneyAmount::default(),
}),
};
Self {
has_changes: false,
persisted: ListingDraftPersisted::New(NewListingFields::default()),
@@ -28,10 +56,7 @@ impl ListingDraft {
title: "".to_string(),
description: None,
},
fields: ListingFields::FixedPriceListing(FixedPriceListingFields {
buy_now_price: MoneyAmount::default(),
slots_available: 0,
}),
fields,
}
}
@@ -64,6 +89,9 @@ pub enum ListingField {
// Dialogue state for the new listing wizard
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub enum NewListingState {
SelectingListingType {
seller_id: UserDbId,
},
AwaitingDraftField {
field: ListingField,
draft: ListingDraft,

View File

@@ -43,14 +43,11 @@ pub async fn display_listing_summary(
.unwrap_or("<i>No description</i>")
));
match &draft.fields {
ListingFields::FixedPriceListing(fields) => {
response_lines.push(format!(
"💰 <b>Buy it Now Price:</b> ${}",
fields.buy_now_price
));
}
_ => {}
if let ListingFields::FixedPriceListing(fields) = &draft.fields {
response_lines.push(format!(
"💰 <b>Buy it Now Price:</b> ${}",
fields.buy_now_price
));
}
match &draft.persisted {
@@ -73,7 +70,7 @@ pub async fn display_listing_summary(
response_lines.push("".to_string());
response_lines.push("Please review your listing and choose an action:".to_string());
send_message(&bot, target, response_lines.join("\n"), keyboard).await?;
send_message(bot, target, response_lines.join("\n"), keyboard).await?;
Ok(())
}

View File

@@ -1,24 +1,140 @@
use log::info;
use teloxide::{prelude::*, types::Message, Bot};
use teloxide::{
types::{CallbackQuery, Message},
utils::command::BotCommands,
Bot,
};
use crate::HandlerResult;
use sqlx::SqlitePool;
pub async fn handle_start(bot: Bot, msg: Message) -> HandlerResult {
let welcome_message = "🎯 Welcome to Pawctioneer Bot! 🎯\n\n\
This bot helps you participate in various types of auctions:\n\
• Standard auctions with anti-sniping protection\n\
• Multi-slot auctions (multiple winners)\n\
• Fixed price sales\n\
• Blind auctions\n\n\
Use /help to see all available commands.\n\n\
Ready to start your auction experience? 🚀";
use crate::{
commands::{my_listings::show_listings_for_user, new_listing::enter_handle_new_listing},
keyboard_buttons,
message_utils::{extract_callback_data, send_message, MessageTarget},
Command, DialogueRootState, HandlerResult, RootDialogue,
};
info!(
"User {} ({}) started the bot",
msg.chat.username().unwrap_or("unknown"),
msg.chat.id
);
keyboard_buttons! {
pub enum MainMenuButtons {
[
NewListing("🛍️ New Listing", "menu_new_listing"),
],
[
MyListings("📋 My Listings", "menu_my_listings"),
MyBids("💰 My Bids", "menu_my_bids"),
],
[
Settings("⚙️ Settings", "menu_settings"),
Help("❓ Help", "menu_help"),
]
}
}
/// Get the main menu welcome message
pub fn get_main_menu_message() -> &'static str {
"🎯 <b>Welcome to Pawctioneer Bot!</b> 🎯\n\n\
This bot helps you participate in various types of auctions:\n\
• Standard auctions with anti-sniping protection\n\
• Multi-slot auctions (multiple winners)\n\
• Fixed price sales\n\
• Blind auctions\n\n\
Choose an option below to get started! 🚀"
}
pub async fn handle_start(bot: Bot, dialogue: RootDialogue, msg: Message) -> HandlerResult {
enter_main_menu(bot, dialogue, msg.chat).await?;
Ok(())
}
/// Show the main menu with buttons
pub async fn enter_main_menu(
bot: Bot,
dialogue: RootDialogue,
target: impl Into<MessageTarget>,
) -> HandlerResult {
dialogue.update(DialogueRootState::MainMenu).await?;
send_message(
&bot,
target,
get_main_menu_message(),
Some(MainMenuButtons::to_keyboard()),
)
.await?;
Ok(())
}
pub async fn handle_main_menu_callback(
db_pool: SqlitePool,
bot: Bot,
dialogue: RootDialogue,
callback_query: CallbackQuery,
) -> HandlerResult {
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
let target = MessageTarget::from((from.clone(), message_id));
info!(
"User {} selected main menu option: {}",
from.username.as_deref().unwrap_or("unknown"),
data
);
let button = MainMenuButtons::try_from(data.as_str())?;
match button {
MainMenuButtons::NewListing => {
enter_handle_new_listing(db_pool, bot, dialogue, from.clone(), target).await?;
}
MainMenuButtons::MyListings => {
// Call show_listings_for_user directly
show_listings_for_user(db_pool, dialogue, bot, from.id, target).await?;
}
MainMenuButtons::MyBids => {
send_message(
&bot,
target,
"💰 <b>My Bids (Coming Soon)</b>\n\n\
Here you'll be able to view:\n\
• Your active bids\n\
• Bid history\n\
• Won/lost auctions\n\
• Outbid notifications\n\n\
Feature in development! 🛠️",
Some(MainMenuButtons::to_keyboard()),
)
.await?;
}
MainMenuButtons::Settings => {
send_message(
&bot,
target,
"⚙️ <b>Settings (Coming Soon)</b>\n\n\
Here you'll be able to configure:\n\
• Notification preferences\n\
• Language settings\n\
• Default bid increments\n\
• Outbid alerts\n\n\
Feature in development! 🛠️",
Some(MainMenuButtons::to_keyboard()),
)
.await?;
}
MainMenuButtons::Help => {
let help_message = format!(
"📋 <b>Available Commands:</b>\n\n{}\n\n\
📧 <b>Support:</b> Contact @admin for help\n\
🔗 <b>More info:</b> Use individual commands to get started!",
Command::descriptions()
);
send_message(
&bot,
target,
help_message,
Some(MainMenuButtons::to_keyboard()),
)
.await?;
}
}
bot.send_message(msg.chat.id, welcome_message).await?;
Ok(())
}

View File

@@ -1,4 +1,3 @@
use std::iter::repeat;
use sqlx::{prelude::*, query::Query, sqlite::SqliteArguments, Encode, Sqlite};
@@ -16,15 +15,11 @@ where
Box::new(move |query| query.bind(value))
}
#[derive(Default)]
pub struct BindFields {
binds: Vec<(&'static str, BindFn)>,
}
impl Default for BindFields {
fn default() -> Self {
Self { binds: vec![] }
}
}
impl BindFields {
#[must_use]
@@ -59,6 +54,6 @@ impl BindFields {
}
pub fn bind_placeholders(&self) -> impl Iterator<Item = &'static str> + '_ {
repeat("?").take(self.binds.len())
std::iter::repeat_n("?", self.binds.len())
}
}

View File

@@ -116,7 +116,7 @@ impl UserDAO {
.await?;
let user = FromRow::from_row(&row)?;
log::info!("load user from db: {:?}", user);
log::info!("load user from db: {user:?}");
Ok(user)
}
@@ -229,7 +229,7 @@ mod tests {
assert_eq!(inserted_user.telegram_id, 12345.into());
assert_eq!(inserted_user.username, Some("testuser".to_string()));
assert_eq!(inserted_user.first_name, "Test User".to_string());
assert_eq!(inserted_user.is_banned, false);
assert!(!inserted_user.is_banned);
// Find by ID
let found_user = UserDAO::find_by_id(&pool, inserted_user.persisted.id)
@@ -323,7 +323,7 @@ mod tests {
assert_eq!(updated_user.username, Some("newname".to_string()));
assert_eq!(updated_user.first_name, "New Name".to_string());
assert_eq!(updated_user.is_banned, true);
assert!(updated_user.is_banned);
}
#[tokio::test]

View File

@@ -203,7 +203,7 @@ mod tests {
// Insert test data
sqlx::query("INSERT INTO test_money (amount, currency) VALUES (?, ?)")
.bind(&amount)
.bind(amount)
.bind(CurrencyType::Usd)
.execute(&pool)
.await
@@ -245,7 +245,7 @@ mod tests {
sqlx::query("INSERT INTO test_money (amount, currency, optional_amount) VALUES (?, ?, ?)")
.bind(MoneyAmount::from_str("100.00").unwrap())
.bind(CurrencyType::Usd)
.bind(&optional_amount)
.bind(optional_amount)
.execute(&pool)
.await
.expect("Failed to insert some optional amount");
@@ -290,7 +290,7 @@ mod tests {
// Insert into database
sqlx::query("INSERT INTO test_money (amount, currency) VALUES (?, ?)")
.bind(&amount)
.bind(amount)
.bind(CurrencyType::Usd)
.execute(&pool)
.await
@@ -308,10 +308,7 @@ mod tests {
assert_eq!(
retrieved_amount.to_string(),
expected_str,
"Cent-level precision not correct for input: {} (expected: {}, got: {})",
input_str,
expected_str,
retrieved_amount.to_string()
"Cent-level precision not correct for input: {input_str} (expected: {expected_str}, got: {retrieved_amount})"
);
}
@@ -479,8 +476,7 @@ mod tests {
let threshold_amount = MoneyAmount::from_str(threshold).unwrap();
let query = format!(
"SELECT COUNT(*) as count FROM test_bids WHERE bid_amount {} ?",
operator
"SELECT COUNT(*) as count FROM test_bids WHERE bid_amount {operator} ?"
);
let count_row = sqlx::query(&query)
@@ -492,8 +488,7 @@ mod tests {
let count: i64 = count_row.get("count");
assert_eq!(
count as usize, expected_count,
"Comparison {} {} failed",
operator, threshold
"Comparison {operator} {threshold} failed"
);
}
@@ -524,8 +519,7 @@ mod tests {
let count: i64 = count_row.get("count");
assert_eq!(
count as usize, expected_count,
"BETWEEN {} AND {} failed",
min_amount, max_amount
"BETWEEN {min_amount} AND {max_amount} failed"
);
}

View File

@@ -94,11 +94,11 @@ mod tests {
#[derive(Debug, Clone, PartialEq)]
enum InnerEnum {
InnerSimple,
InnerWithParam(&'static str),
InnerWithMultiple(&'static str, i32),
InnerWithStruct { field: &'static str },
InnerWithMultiStruct { field: &'static str, number: i32 },
Simple,
WithParam(&'static str),
WithMultiple(&'static str, i32),
WithStruct { field: &'static str },
WithMultiStruct { field: &'static str, number: i32 },
}
// Helper function for testing handlers with expected results
@@ -144,29 +144,29 @@ mod tests {
)]
// Single parameter extraction from nested enum
#[case::nested_single_match(
case![TestEnum::NestedVariant(InnerEnum::InnerWithParam(p))].endpoint(|p: &'static str| async move { p }),
TestEnum::NestedVariant(InnerEnum::InnerWithParam("nested")),
case![TestEnum::NestedVariant(InnerEnum::WithParam(p))].endpoint(|p: &'static str| async move { p }),
TestEnum::NestedVariant(InnerEnum::WithParam("nested")),
Some("nested")
)]
#[case::nested_single_wrong_inner_variant(
case![TestEnum::NestedVariant(InnerEnum::InnerWithParam(p))].endpoint(|p: &'static str| async move { p }),
TestEnum::NestedVariant(InnerEnum::InnerSimple),
case![TestEnum::NestedVariant(InnerEnum::WithParam(p))].endpoint(|p: &'static str| async move { p }),
TestEnum::NestedVariant(InnerEnum::Simple),
None
)]
#[case::nested_single_wrong_outer_variant(
case![TestEnum::NestedVariant(InnerEnum::InnerWithParam(p))].endpoint(|p: &'static str| async move { p }),
case![TestEnum::NestedVariant(InnerEnum::WithParam(p))].endpoint(|p: &'static str| async move { p }),
TestEnum::DefaultVariant,
None
)]
// Single field extraction from nested struct
#[case::struct_field_match(
case![TestEnum::NestedVariant(InnerEnum::InnerWithStruct { field })].endpoint(|field: &'static str| async move { field }),
TestEnum::NestedVariant(InnerEnum::InnerWithStruct { field: "struct_value" }),
case![TestEnum::NestedVariant(InnerEnum::WithStruct { field })].endpoint(|field: &'static str| async move { field }),
TestEnum::NestedVariant(InnerEnum::WithStruct { field: "struct_value" }),
Some("struct_value")
)]
#[case::struct_field_no_match(
case![TestEnum::NestedVariant(InnerEnum::InnerWithStruct { field })].endpoint(|field: &'static str| async move { field }),
TestEnum::NestedVariant(InnerEnum::InnerSimple),
case![TestEnum::NestedVariant(InnerEnum::WithStruct { field })].endpoint(|field: &'static str| async move { field }),
TestEnum::NestedVariant(InnerEnum::Simple),
None
)]
#[tokio::test]
@@ -202,8 +202,8 @@ mod tests {
// Test cases for nested multiple parameter extraction
#[rstest::rstest]
#[case(TestEnum::NestedVariant(InnerEnum::InnerWithMultiple("nested", 123)), Some(("nested", 123)))]
#[case(TestEnum::NestedVariant(InnerEnum::InnerSimple), None)]
#[case(TestEnum::NestedVariant(InnerEnum::WithMultiple("nested", 123)), Some(("nested", 123)))]
#[case(TestEnum::NestedVariant(InnerEnum::Simple), None)]
#[case(TestEnum::DefaultVariant, None)]
#[tokio::test]
async fn test_nested_multiple_parameter_extraction(
@@ -211,7 +211,7 @@ mod tests {
#[case] expected_params: Option<(&'static str, i32)>,
) {
let handler: Handler<'static, (&str, i32), DpHandlerDescription> =
case![TestEnum::NestedVariant(InnerEnum::InnerWithMultiple(s, n))]
case![TestEnum::NestedVariant(InnerEnum::WithMultiple(s, n))]
.endpoint(|params: (&'static str, i32)| async move { params });
let input = deps![input_variant];
@@ -225,8 +225,8 @@ mod tests {
// Test cases for struct pattern extraction
#[rstest::rstest]
#[case(TestEnum::NestedVariant(InnerEnum::InnerWithStruct { field: "struct_field" }), Some("struct_field"))]
#[case(TestEnum::NestedVariant(InnerEnum::InnerSimple), None)]
#[case(TestEnum::NestedVariant(InnerEnum::WithStruct { field: "struct_field" }), Some("struct_field"))]
#[case(TestEnum::NestedVariant(InnerEnum::Simple), None)]
#[case(TestEnum::DefaultVariant, None)]
#[tokio::test]
async fn test_struct_pattern_extraction(
@@ -234,10 +234,8 @@ mod tests {
#[case] expected_field: Option<&'static str>,
) {
let handler: Handler<'static, &str, DpHandlerDescription> =
case![TestEnum::NestedVariant(InnerEnum::InnerWithStruct {
field
})]
.endpoint(|field: &'static str| async move { field });
case![TestEnum::NestedVariant(InnerEnum::WithStruct { field })]
.endpoint(|field: &'static str| async move { field });
let input = deps![input_variant];
let result = handler.dispatch(input).await;
@@ -250,8 +248,8 @@ mod tests {
// Test cases for multi-field struct pattern extraction
#[rstest::rstest]
#[case(TestEnum::NestedVariant(InnerEnum::InnerWithMultiStruct { field: "multi_field", number: 42 }), Some(("multi_field", 42)))]
#[case(TestEnum::NestedVariant(InnerEnum::InnerSimple), None)]
#[case(TestEnum::NestedVariant(InnerEnum::WithMultiStruct { field: "multi_field", number: 42 }), Some(("multi_field", 42)))]
#[case(TestEnum::NestedVariant(InnerEnum::Simple), None)]
#[case(TestEnum::DefaultVariant, None)]
#[tokio::test]
async fn test_multi_struct_pattern_extraction(
@@ -259,7 +257,7 @@ mod tests {
#[case] expected_fields: Option<(&'static str, i32)>,
) {
let handler: Handler<'static, (&str, i32), DpHandlerDescription> =
case![TestEnum::NestedVariant(InnerEnum::InnerWithMultiStruct {
case![TestEnum::NestedVariant(InnerEnum::WithMultiStruct {
field,
number
})]

View File

@@ -41,6 +41,20 @@ macro_rules! keyboard_buttons {
$($($name::$variant => teloxide::types::InlineKeyboardButton::callback($text, $callback_data)),*),*
}
}
#[allow(unused)]
pub fn to_switch_inline_query(self) -> teloxide::types::InlineKeyboardButton {
match self {
$($($name::$variant => teloxide::types::InlineKeyboardButton::switch_inline_query($text, $callback_data)),*),*
}
}
#[allow(unused)]
pub fn title(self) -> &'static str {
match self {
$($($name::$variant => $text),*),*
}
}
}
impl From<$name> for teloxide::types::InlineKeyboardButton {
fn from(value: $name) -> Self {
@@ -50,13 +64,13 @@ macro_rules! keyboard_buttons {
}
}
impl<'a> TryFrom<&'a str> for $name {
type Error = &'a str;
type Error = anyhow::Error;
fn try_from(value: &'a str) -> Result<Self, Self::Error> {
match value {
$($(
$callback_data => Ok(Self::$variant),
)*)*
_ => Err(value),
_ => anyhow::bail!("Unknown {name} button: {value}", name = stringify!($name)),
}
}
}

View File

@@ -9,7 +9,7 @@ mod sqlite_storage;
mod test_utils;
use crate::commands::{
my_listings::{my_listings_handler, MyListingsState},
my_listings::{my_listings_handler, my_listings_inline_handler, MyListingsState},
new_listing::{new_listing_handler, NewListingState},
};
use crate::sqlite_storage::SqliteStorage;
@@ -62,6 +62,7 @@ pub enum Command {
enum DialogueRootState {
#[default]
Start,
MainMenu,
NewListing(NewListingState),
MyListings(MyListingsState),
}
@@ -88,18 +89,28 @@ async fn main() -> Result<()> {
Dispatcher::builder(
bot,
dptree::entry()
.enter_dialogue::<Update, SqliteStorage<Json>, DialogueRootState>()
.branch(new_listing_handler())
.branch(my_listings_handler())
.branch(my_listings_inline_handler())
.branch(
Update::filter_message().branch(
dptree::entry()
.filter_command::<Command>()
.branch(dptree::case![Command::Start].endpoint(handle_start))
.branch(dptree::case![Command::Help].endpoint(handle_help))
.branch(dptree::case![Command::MyBids].endpoint(handle_my_bids))
.branch(dptree::case![Command::Settings].endpoint(handle_settings)),
),
dptree::entry()
.enter_dialogue::<Update, SqliteStorage<Json>, DialogueRootState>()
.branch(new_listing_handler())
.branch(my_listings_handler())
.branch(
Update::filter_callback_query().branch(
dptree::case![DialogueRootState::MainMenu]
.endpoint(handle_main_menu_callback),
),
)
.branch(
Update::filter_message().branch(
dptree::entry()
.filter_command::<Command>()
.branch(dptree::case![Command::Start].endpoint(handle_start))
.branch(dptree::case![Command::Help].endpoint(handle_help))
.branch(dptree::case![Command::MyBids].endpoint(handle_my_bids))
.branch(dptree::case![Command::Settings].endpoint(handle_settings)),
),
),
)
.branch(Update::filter_message().endpoint(unknown_message_handler)),
)

View File

@@ -143,15 +143,6 @@ pub fn create_single_button_keyboard(text: &str, callback_data: &str) -> InlineK
InlineKeyboardMarkup::new([[InlineKeyboardButton::callback(text, callback_data)]])
}
// Create a keyboard with multiple buttons in a single row
pub fn create_single_row_keyboard(buttons: &[(&str, &str)]) -> InlineKeyboardMarkup {
let keyboard_buttons: Vec<InlineKeyboardButton> = buttons
.iter()
.map(|(text, callback_data)| InlineKeyboardButton::callback(*text, *callback_data))
.collect();
InlineKeyboardMarkup::new([keyboard_buttons])
}
// Create a keyboard with multiple rows
pub fn create_multi_row_keyboard(rows: &[&[(&str, &str)]]) -> InlineKeyboardMarkup {
let mut keyboard = InlineKeyboardMarkup::default();