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
This commit is contained in:
Dylan Knutson
2025-08-30 11:04:40 -07:00
parent 65a50b05e2
commit a39dd01452
15 changed files with 220 additions and 146 deletions

1
Cargo.lock generated
View File

@@ -1618,6 +1618,7 @@ dependencies = [
"lazy_static", "lazy_static",
"log", "log",
"num", "num",
"regex",
"rstest", "rstest",
"rust_decimal", "rust_decimal",
"serde", "serde",

View File

@@ -27,6 +27,7 @@ teloxide-core = "0.13.0"
num = "0.4.3" num = "0.4.3"
itertools = "0.14.0" itertools = "0.14.0"
async-trait = "0.1" async-trait = "0.1"
regex = "1.11.2"
[dev-dependencies] [dev-dependencies]
rstest = "0.26.1" rstest = "0.26.1"

View File

@@ -1,8 +1,62 @@
use crate::keyboard_buttons; use crate::{
db::{listing::PersistedListing, ListingDbId},
keyboard_buttons,
};
use regex::Regex;
use teloxide::types::InlineKeyboardButton;
keyboard_buttons! { // keyboard_buttons! {
pub enum MyListingsButtons { // pub enum MyListingsButtons {
BackToMenu("⬅️ Back to Menu", "my_listings_back_to_menu"), // // SelectListing("Select Listing", "my_listings:", ListingDbId ),
// SelectListing("Select Listing", "my_listings:"),
// BackToMenu("⬅️ Back to Menu", "my_listings_back_to_menu"),
// }
// }
pub enum MyListingsButtons {
SelectListing(ListingDbId),
NewListing,
BackToMenu,
}
impl MyListingsButtons {
pub fn listing_into_button(listing: &PersistedListing) -> InlineKeyboardButton {
InlineKeyboardButton::callback(
&listing.base.title,
Self::encode_listing_id(listing.persisted.id),
)
}
pub fn back_to_menu_into_button() -> InlineKeyboardButton {
InlineKeyboardButton::callback("Back to Menu", "my_listings_back_to_menu")
}
pub fn new_listing_into_button() -> InlineKeyboardButton {
InlineKeyboardButton::callback("🛍️ New Listing", "my_listings_new_listing")
}
fn encode_listing_id(listing_id: ListingDbId) -> String {
format!("my_listings:{listing_id}")
}
fn decode_listing_id(value: &str) -> Option<ListingDbId> {
let re = Regex::new(r"my_listings:(\d+)").ok()?;
let caps = re.captures(value)?;
let listing_id = caps.get(1)?.as_str().parse::<i64>().ok()?;
Some(ListingDbId::new(listing_id))
}
}
impl TryFrom<&str> for MyListingsButtons {
type Error = anyhow::Error;
fn try_from(value: &str) -> Result<Self, Self::Error> {
if let Some(listing_id) = Self::decode_listing_id(value) {
return Ok(MyListingsButtons::SelectListing(listing_id));
}
match value {
"my_listings_new_listing" => Ok(MyListingsButtons::NewListing),
"my_listings_back_to_menu" => Ok(MyListingsButtons::BackToMenu),
_ => anyhow::bail!("Unknown MyListingsButtons: {value}"),
}
} }
} }

View File

@@ -5,12 +5,15 @@ use crate::{
commands::{ commands::{
enter_main_menu, enter_main_menu,
my_listings::keyboard::{ManageListingButtons, MyListingsButtons}, my_listings::keyboard::{ManageListingButtons, MyListingsButtons},
new_listing::{enter_edit_listing_draft, ListingDraft}, new_listing::{enter_edit_listing_draft, enter_select_new_listing_type, ListingDraft},
}, },
db::{ db::{
listing::{ListingFields, PersistedListing}, listing::{ListingFields, PersistedListing},
user::PersistedUser, user::PersistedUser,
ListingDAO, ListingDbId, UserDAO, 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}, message_utils::{extract_callback_data, pluralize_with_count, send_message, MessageTarget},
Command, DialogueRootState, HandlerResult, RootDialogue, Command, DialogueRootState, HandlerResult, RootDialogue,
@@ -41,7 +44,6 @@ impl From<MyListingsState> for DialogueRootState {
pub fn my_listings_inline_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> { pub fn my_listings_inline_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> {
Update::filter_inline_query() Update::filter_inline_query()
.inspect(|query: InlineQuery| info!("Received inline query: {:?}", query))
.filter_map_async(inline_query_extract_forward_listing) .filter_map_async(inline_query_extract_forward_listing)
.endpoint(handle_forward_listing) .endpoint(handle_forward_listing)
} }
@@ -50,7 +52,9 @@ pub fn my_listings_handler() -> Handler<'static, HandlerResult, DpHandlerDescrip
dptree::entry() dptree::entry()
.branch( .branch(
Update::filter_message().filter_command::<Command>().branch( Update::filter_message().filter_command::<Command>().branch(
dptree::case![Command::MyListings].endpoint(handle_my_listings_command_input), dptree::case![Command::MyListings]
.filter_map_async(find_or_create_db_user_from_message)
.endpoint(handle_my_listings_command_input),
), ),
) )
.branch( .branch(
@@ -60,12 +64,14 @@ pub fn my_listings_handler() -> Handler<'static, HandlerResult, DpHandlerDescrip
case![DialogueRootState::MyListings( case![DialogueRootState::MyListings(
MyListingsState::ViewingListings MyListingsState::ViewingListings
)] )]
.filter_map_async(find_or_create_db_user_from_callback_query)
.endpoint(handle_viewing_listings_callback), .endpoint(handle_viewing_listings_callback),
) )
.branch( .branch(
case![DialogueRootState::MyListings( case![DialogueRootState::MyListings(
MyListingsState::ManagingListing(listing_id) MyListingsState::ManagingListing(listing_id)
)] )]
.filter_map_async(find_or_create_db_user_from_callback_query)
.endpoint(handle_managing_listing_callback), .endpoint(handle_managing_listing_callback),
), ),
) )
@@ -76,7 +82,7 @@ async fn inline_query_extract_forward_listing(
inline_query: InlineQuery, inline_query: InlineQuery,
) -> Option<PersistedListing> { ) -> Option<PersistedListing> {
let query = &inline_query.query; let query = &inline_query.query;
info!("Try to extract forward listing from query: {}", query); info!("Try to extract forward listing from query: {query}");
let listing_id_str = query.split("forward_listing:").nth(1)?; let listing_id_str = query.split("forward_listing:").nth(1)?;
let listing_id = ListingDbId::new(listing_id_str.parse::<i64>().ok()?); let listing_id = ListingDbId::new(listing_id_str.parse::<i64>().ok()?);
let listing = ListingDAO::find_by_id(&db_pool, listing_id) let listing = ListingDAO::find_by_id(&db_pool, listing_id)
@@ -90,10 +96,7 @@ async fn handle_forward_listing(
inline_query: InlineQuery, inline_query: InlineQuery,
listing: PersistedListing, listing: PersistedListing,
) -> HandlerResult { ) -> HandlerResult {
info!( info!("Handling forward listing inline query for listing {listing:?}");
"Handling forward listing inline query for listing {:?}",
listing
);
let bot_username = match bot.get_me().await?.username.as_ref() { let bot_username = match bot.get_me().await?.username.as_ref() {
Some(username) => username.to_string(), Some(username) => username.to_string(),
@@ -188,68 +191,46 @@ async fn handle_my_listings_command_input(
db_pool: SqlitePool, db_pool: SqlitePool,
bot: Bot, bot: Bot,
dialogue: RootDialogue, dialogue: RootDialogue,
user: PersistedUser,
msg: Message, msg: Message,
) -> HandlerResult { ) -> HandlerResult {
let from = msg.from.unwrap(); enter_my_listings(db_pool, bot, dialogue, user, msg.chat).await?;
show_listings_for_user(db_pool, dialogue, bot, from.id, msg.chat).await?;
Ok(()) Ok(())
} }
pub async fn show_listings_for_user( pub async fn enter_my_listings(
db_pool: SqlitePool, db_pool: SqlitePool,
dialogue: RootDialogue,
bot: Bot, bot: Bot,
user: teloxide::types::UserId, dialogue: RootDialogue,
user: PersistedUser,
target: impl Into<MessageTarget>, target: impl Into<MessageTarget>,
) -> HandlerResult { ) -> 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 // Transition to ViewingListings state
dialogue.update(MyListingsState::ViewingListings).await?; dialogue.update(MyListingsState::ViewingListings).await?;
let listings = ListingDAO::find_by_seller(&db_pool, user.persisted.id).await?; let listings = ListingDAO::find_by_seller(&db_pool, user.persisted.id).await?;
if listings.is_empty() { // Create keyboard with buttons for each listing
// Create keyboard with just the back button let mut keyboard = teloxide::types::InlineKeyboardMarkup::default();
let keyboard = for listing in &listings {
teloxide::types::InlineKeyboardMarkup::new([[MyListingsButtons::BackToMenu.into()]]); 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( send_message(
&bot, &bot,
target, target,
"📋 <b>My Listings</b>\n\n\ "📋 <b>My Listings</b>\n\n\
You don't have any listings yet.\n\ You don't have any listings yet.",
Use /newlisting to create your first listing!",
Some(keyboard), Some(keyboard),
) )
.await?; .await?;
return Ok(()); 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!( let response = format!(
"📋 <b>My Listings</b>\n\n\ "📋 <b>My Listings</b>\n\n\
You have {}.\n\n\ You have {}.\n\n\
@@ -266,30 +247,32 @@ async fn handle_viewing_listings_callback(
bot: Bot, bot: Bot,
dialogue: RootDialogue, dialogue: RootDialogue,
callback_query: CallbackQuery, callback_query: CallbackQuery,
user: PersistedUser,
) -> HandlerResult { ) -> HandlerResult {
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?; let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
let target = (from.clone(), message_id); let target = (from.clone(), message_id);
// Check if it's the back to menu button // Check if it's the back to menu button
if let Ok(button) = MyListingsButtons::try_from(data.as_str()) { let button = MyListingsButtons::try_from(data.as_str())?;
match button {
MyListingsButtons::BackToMenu => { match button {
// Transition back to main menu using the reusable function MyListingsButtons::SelectListing(listing_id) => {
enter_main_menu(bot, dialogue, target).await?; let listing =
return Ok(()); 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?
} }
} }
// 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(()) Ok(())
} }
@@ -339,6 +322,7 @@ async fn handle_managing_listing_callback(
bot: Bot, bot: Bot,
dialogue: RootDialogue, dialogue: RootDialogue,
callback_query: CallbackQuery, callback_query: CallbackQuery,
user: PersistedUser,
listing_id: ListingDbId, listing_id: ListingDbId,
) -> HandlerResult { ) -> HandlerResult {
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?; let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
@@ -346,16 +330,17 @@ async fn handle_managing_listing_callback(
match ManageListingButtons::try_from(data.as_str())? { match ManageListingButtons::try_from(data.as_str())? {
ManageListingButtons::PreviewMessage => { ManageListingButtons::PreviewMessage => {
let (_, listing) = let listing = ListingDAO::find_by_id(&db_pool, listing_id)
get_user_and_listing(&db_pool, &bot, from.id, listing_id, target.clone()).await?; .await?
.ok_or(anyhow::anyhow!("Listing not found"))?;
send_preview_listing_message(&bot, listing, from).await?; send_preview_listing_message(&bot, listing, from).await?;
} }
ManageListingButtons::ForwardListing => { ManageListingButtons::ForwardListing => {
unimplemented!("Forward listing not implemented"); unimplemented!("Forward listing not implemented");
} }
ManageListingButtons::Edit => { ManageListingButtons::Edit => {
let (_, listing) = let listing =
get_user_and_listing(&db_pool, &bot, from.id, listing_id, target.clone()).await?; get_listing_for_user(&db_pool, &bot, user, listing_id, target.clone()).await?;
let draft = ListingDraft::from_persisted(listing); let draft = ListingDraft::from_persisted(listing);
enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?; enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?;
} }
@@ -365,7 +350,7 @@ async fn handle_managing_listing_callback(
} }
ManageListingButtons::Back => { ManageListingButtons::Back => {
dialogue.update(MyListingsState::ViewingListings).await?; dialogue.update(MyListingsState::ViewingListings).await?;
show_listings_for_user(db_pool, dialogue, bot, from.id, target).await?; enter_my_listings(db_pool, bot, dialogue, user, target).await?;
} }
} }
@@ -411,7 +396,7 @@ async fn send_preview_listing_message(
let mut response_lines = vec![]; let mut response_lines = vec![];
response_lines.push(format!("<b>{}</b>", &listing.base.title)); response_lines.push(format!("<b>{}</b>", &listing.base.title));
if let Some(description) = &listing.base.description { if let Some(description) = &listing.base.description {
response_lines.push(format!("{}", description)); response_lines.push(description.to_owned());
} }
send_message( send_message(
bot, bot,
@@ -423,27 +408,13 @@ async fn send_preview_listing_message(
Ok(()) Ok(())
} }
async fn get_user_and_listing( async fn get_listing_for_user(
db_pool: &SqlitePool, db_pool: &SqlitePool,
bot: &Bot, bot: &Bot,
user_id: teloxide::types::UserId, user: PersistedUser,
listing_id: ListingDbId, listing_id: ListingDbId,
target: impl Into<MessageTarget>, target: impl Into<MessageTarget>,
) -> HandlerResult<(PersistedUser, PersistedListing)> { ) -> HandlerResult<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? { let listing = match ListingDAO::find_by_id(db_pool, listing_id).await? {
Some(listing) => listing, Some(listing) => listing,
None => { None => {
@@ -463,5 +434,5 @@ async fn get_user_and_listing(
return Err(anyhow::anyhow!("User does not own listing")); return Err(anyhow::anyhow!("User does not own listing"));
} }
Ok((user, listing)) Ok(listing)
} }

View File

@@ -6,6 +6,7 @@
use crate::{ use crate::{
commands::{ commands::{
new_listing::{ new_listing::{
enter_select_new_listing_type,
field_processing::transition_to_field, field_processing::transition_to_field,
keyboard::{ keyboard::{
DurationKeyboardButtons, ListingTypeKeyboardButtons, SlotsKeyboardButtons, DurationKeyboardButtons, ListingTypeKeyboardButtons, SlotsKeyboardButtons,
@@ -17,7 +18,7 @@ use crate::{
}, },
start::enter_main_menu, start::enter_main_menu,
}, },
db::{listing::ListingFields, ListingDuration, ListingType, UserDbId}, db::{listing::ListingFields, user::PersistedUser, ListingDuration, ListingType},
message_utils::*, message_utils::*,
HandlerResult, RootDialogue, HandlerResult, RootDialogue,
}; };
@@ -28,7 +29,7 @@ use teloxide::{types::CallbackQuery, Bot};
pub async fn handle_selecting_listing_type_callback( pub async fn handle_selecting_listing_type_callback(
bot: Bot, bot: Bot,
dialogue: RootDialogue, dialogue: RootDialogue,
seller_id: UserDbId, user: PersistedUser,
callback_query: CallbackQuery, callback_query: CallbackQuery,
) -> HandlerResult { ) -> HandlerResult {
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?; let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
@@ -52,7 +53,7 @@ pub async fn handle_selecting_listing_type_callback(
}; };
// Create draft with selected listing type // Create draft with selected listing type
let draft = ListingDraft::new_for_seller_with_type(seller_id, listing_type); let draft = ListingDraft::new_for_seller_with_type(user.persisted.id, listing_type);
// Transition to first field (Title) // Transition to first field (Title)
transition_to_field(dialogue, ListingField::Title, draft).await?; transition_to_field(dialogue, ListingField::Title, draft).await?;
@@ -86,8 +87,12 @@ pub async fn handle_awaiting_draft_field_callback(
info!("User {from:?} selected callback: {data:?}"); info!("User {from:?} selected callback: {data:?}");
let target = (from, message_id); let target = (from, message_id);
if let Ok(ListingTypeKeyboardButtons::Back) = data.as_str().try_into() {
return enter_select_new_listing_type(bot, dialogue, target).await;
}
if data == "cancel" { if data == "cancel" {
return cancel_wizard(&bot, dialogue, target).await; return cancel_wizard(bot, dialogue, target).await;
} }
// Unified callback dispatch // Unified callback dispatch
@@ -248,13 +253,12 @@ async fn handle_duration_callback(
/// Cancel the wizard and exit /// Cancel the wizard and exit
pub async fn cancel_wizard( pub async fn cancel_wizard(
bot: &Bot, bot: Bot,
dialogue: RootDialogue, dialogue: RootDialogue,
target: impl Into<MessageTarget>, target: impl Into<MessageTarget>,
) -> HandlerResult { ) -> HandlerResult {
let target = target.into(); let target = target.into();
info!("{target:?} cancelled new listing wizard"); info!("{target:?} cancelled new listing wizard");
dialogue.exit().await?; enter_select_new_listing_type(bot, dialogue, target).await?;
send_message(bot, target, "❌ Listing creation cancelled.", None).await?;
Ok(()) Ok(())
} }

View File

@@ -1,5 +1,8 @@
use super::{callbacks::*, handlers::*, types::*}; use super::{callbacks::*, handlers::*, types::*};
use crate::{case, Command, DialogueRootState, Handler}; use crate::{
case, handler_utils::find_or_create_db_user_from_callback_query, Command, DialogueRootState,
Handler,
};
use teloxide::{dptree, prelude::*, types::Update}; use teloxide::{dptree, prelude::*, types::Update};
// Create the dialogue handler tree for new listing wizard // Create the dialogue handler tree for new listing wizard
@@ -30,8 +33,9 @@ pub fn new_listing_handler() -> Handler {
Update::filter_callback_query() Update::filter_callback_query()
.branch( .branch(
case![DialogueRootState::NewListing( case![DialogueRootState::NewListing(
NewListingState::SelectingListingType { seller_id } NewListingState::SelectingListingType
)] )]
.filter_map_async(find_or_create_db_user_from_callback_query)
.endpoint(handle_selecting_listing_type_callback), .endpoint(handle_selecting_listing_type_callback),
) )
.branch( .branch(

View File

@@ -20,41 +20,33 @@ use crate::{
}, },
db::{ db::{
listing::{ListingFields, NewListing, PersistedListing}, listing::{ListingFields, NewListing, PersistedListing},
ListingDAO, UserDAO, ListingDAO,
}, },
message_utils::*, message_utils::*,
DialogueRootState, HandlerResult, RootDialogue, DialogueRootState, HandlerResult, RootDialogue,
}; };
use log::{error, info}; use log::info;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use teloxide::{prelude::*, types::*, Bot}; use teloxide::{prelude::*, types::*, Bot};
/// Handle the /newlisting command - starts the dialogue /// Handle the /newlisting command - starts the dialogue
pub(super) async fn handle_new_listing_command( pub(super) async fn handle_new_listing_command(
db_pool: SqlitePool,
bot: Bot, bot: Bot,
dialogue: RootDialogue, dialogue: RootDialogue,
msg: Message, msg: Message,
) -> HandlerResult { ) -> HandlerResult {
let user = msg.from.ok_or_else(|| anyhow::anyhow!("User not found"))?; enter_select_new_listing_type(bot, dialogue, msg.chat).await?;
enter_handle_new_listing(db_pool, bot, dialogue, user, msg.chat).await?;
Ok(()) Ok(())
} }
pub async fn enter_handle_new_listing( pub async fn enter_select_new_listing_type(
db_pool: SqlitePool,
bot: Bot, bot: Bot,
dialogue: RootDialogue, dialogue: RootDialogue,
user: User,
target: impl Into<MessageTarget>, target: impl Into<MessageTarget>,
) -> HandlerResult { ) -> HandlerResult {
let user = UserDAO::find_or_create_by_telegram_user(&db_pool, user).await?;
// Initialize the dialogue to listing type selection state // Initialize the dialogue to listing type selection state
dialogue dialogue
.update(NewListingState::SelectingListingType { .update(NewListingState::SelectingListingType)
seller_id: user.persisted.id,
})
.await?; .await?;
send_message( send_message(
@@ -84,7 +76,7 @@ pub async fn handle_awaiting_draft_field_input(
); );
if is_cancel(text) { if is_cancel(text) {
return cancel_wizard(&bot, dialogue, chat).await; return cancel_wizard(bot, dialogue, chat).await;
} }
// Process the field update // Process the field update
@@ -145,14 +137,6 @@ pub async fn handle_viewing_draft_callback(
callback_query: CallbackQuery, callback_query: CallbackQuery,
) -> HandlerResult { ) -> HandlerResult {
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?; let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
// Ensure the user exists before saving the listing
UserDAO::find_or_create_by_telegram_user(&db_pool, from.clone())
.await
.inspect_err(|e| {
error!("Error finding or creating user: {e}");
})?;
let target = (from.clone(), message_id); let target = (from.clone(), message_id);
match ConfirmationKeyboardButtons::try_from(data.as_str())? { match ConfirmationKeyboardButtons::try_from(data.as_str())? {

View File

@@ -54,7 +54,10 @@ pub fn get_edit_success_message(field: ListingField) -> &'static str {
/// Get the appropriate keyboard for a field /// Get the appropriate keyboard for a field
pub fn get_keyboard_for_field(field: ListingField) -> Option<InlineKeyboardMarkup> { pub fn get_keyboard_for_field(field: ListingField) -> Option<InlineKeyboardMarkup> {
match field { match field {
ListingField::Title => Some(create_cancel_keyboard()), ListingField::Title => Some(InlineKeyboardMarkup::new([[
// Back to listing type selection
ListingTypeKeyboardButtons::Back.to_button(),
]])),
ListingField::Description => Some(create_skip_cancel_keyboard()), ListingField::Description => Some(create_skip_cancel_keyboard()),
ListingField::Price => None, ListingField::Price => None,
ListingField::Slots => Some(SlotsKeyboardButtons::to_keyboard()), ListingField::Slots => Some(SlotsKeyboardButtons::to_keyboard()),
@@ -63,11 +66,6 @@ pub fn get_keyboard_for_field(field: ListingField) -> Option<InlineKeyboardMarku
} }
} }
// Keyboard creation helpers
fn create_cancel_keyboard() -> InlineKeyboardMarkup {
create_single_button_keyboard("Cancel", "cancel")
}
fn create_skip_cancel_keyboard() -> InlineKeyboardMarkup { fn create_skip_cancel_keyboard() -> InlineKeyboardMarkup {
create_multi_row_keyboard(&[&[("Skip", "skip"), ("Cancel", "cancel")]]) create_multi_row_keyboard(&[&[("Skip", "skip"), ("Cancel", "cancel")]])
} }

View File

@@ -20,12 +20,11 @@ mod keyboard;
pub mod messages; pub mod messages;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
mod types; mod types;
mod ui; mod ui;
mod validations; mod validations;
// Re-export the main handler for external use // Re-export the main handler for external use
pub use handler_factory::new_listing_handler; pub use handler_factory::new_listing_handler;
pub use handlers::{enter_edit_listing_draft, enter_handle_new_listing}; pub use handlers::{enter_edit_listing_draft, enter_select_new_listing_type};
pub use types::*; pub use types::*;

View File

@@ -89,9 +89,7 @@ pub enum ListingField {
// Dialogue state for the new listing wizard // Dialogue state for the new listing wizard
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub enum NewListingState { pub enum NewListingState {
SelectingListingType { SelectingListingType,
seller_id: UserDbId,
},
AwaitingDraftField { AwaitingDraftField {
field: ListingField, field: ListingField,
draft: ListingDraft, draft: ListingDraft,

View File

@@ -8,7 +8,8 @@ use teloxide::{
use sqlx::SqlitePool; use sqlx::SqlitePool;
use crate::{ use crate::{
commands::{my_listings::show_listings_for_user, new_listing::enter_handle_new_listing}, commands::my_listings::enter_my_listings,
db::user::PersistedUser,
keyboard_buttons, keyboard_buttons,
message_utils::{extract_callback_data, send_message, MessageTarget}, message_utils::{extract_callback_data, send_message, MessageTarget},
Command, DialogueRootState, HandlerResult, RootDialogue, Command, DialogueRootState, HandlerResult, RootDialogue,
@@ -16,9 +17,6 @@ use crate::{
keyboard_buttons! { keyboard_buttons! {
pub enum MainMenuButtons { pub enum MainMenuButtons {
[
NewListing("🛍️ New Listing", "menu_new_listing"),
],
[ [
MyListings("📋 My Listings", "menu_my_listings"), MyListings("📋 My Listings", "menu_my_listings"),
MyBids("💰 My Bids", "menu_my_bids"), MyBids("💰 My Bids", "menu_my_bids"),
@@ -69,6 +67,7 @@ pub async fn handle_main_menu_callback(
db_pool: SqlitePool, db_pool: SqlitePool,
bot: Bot, bot: Bot,
dialogue: RootDialogue, dialogue: RootDialogue,
user: PersistedUser,
callback_query: CallbackQuery, callback_query: CallbackQuery,
) -> HandlerResult { ) -> HandlerResult {
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?; let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
@@ -82,12 +81,9 @@ pub async fn handle_main_menu_callback(
let button = MainMenuButtons::try_from(data.as_str())?; let button = MainMenuButtons::try_from(data.as_str())?;
match button { match button {
MainMenuButtons::NewListing => {
enter_handle_new_listing(db_pool, bot, dialogue, from.clone(), target).await?;
}
MainMenuButtons::MyListings => { MainMenuButtons::MyListings => {
// Call show_listings_for_user directly // Call show_listings_for_user directly
show_listings_for_user(db_pool, dialogue, bot, from.id, target).await?; enter_my_listings(db_pool, bot, dialogue, user, target).await?;
} }
MainMenuButtons::MyBids => { MainMenuButtons::MyBids => {
send_message( send_message(

View File

@@ -8,7 +8,7 @@ pub type PersistedUser = User<PersistedUserFields>;
pub type NewUser = User<()>; pub type NewUser = User<()>;
/// Core user information /// Core user information
#[derive(Debug, Clone, FromRow)] #[derive(Clone, FromRow)]
#[allow(unused)] #[allow(unused)]
pub struct User<P: Debug + Clone> { pub struct User<P: Debug + Clone> {
pub persisted: P, pub persisted: P,
@@ -19,6 +19,22 @@ pub struct User<P: Debug + Clone> {
pub is_banned: bool, pub is_banned: bool,
} }
impl Debug for User<PersistedUserFields> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = if let Some(last_name) = self.last_name.as_deref() {
format!("{} {}", self.first_name, last_name)
} else {
self.first_name.clone()
};
let username = self.username.as_deref().unwrap_or("");
write!(
f,
"User(id: {} / {}, '{}' @{})",
self.persisted.id, self.telegram_id, name, username
)
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(unused)] #[allow(unused)]
pub struct PersistedUserFields { pub struct PersistedUserFields {

36
src/handler_utils.rs Normal file
View File

@@ -0,0 +1,36 @@
use sqlx::SqlitePool;
use teloxide::types::{CallbackQuery, Message};
use crate::db::{user::PersistedUser, UserDAO};
pub async fn find_or_create_db_user_from_message(
db_pool: SqlitePool,
message: Message,
) -> Option<PersistedUser> {
let user = message.from?;
find_or_create_db_user(db_pool, user).await
}
pub async fn find_or_create_db_user_from_callback_query(
db_pool: SqlitePool,
callback_query: CallbackQuery,
) -> Option<PersistedUser> {
let user = callback_query.from;
find_or_create_db_user(db_pool, user).await
}
pub async fn find_or_create_db_user(
db_pool: SqlitePool,
user: teloxide::types::User,
) -> Option<PersistedUser> {
match UserDAO::find_or_create_by_telegram_user(&db_pool, user).await {
Ok(user) => {
log::debug!("loaded user from db: {user:?}");
Some(user)
}
Err(e) => {
log::error!("Error finding or creating user: {e}");
None
}
}
}

View File

@@ -55,6 +55,13 @@ macro_rules! keyboard_buttons {
$($($name::$variant => $text),*),* $($($name::$variant => $text),*),*
} }
} }
#[allow(unused)]
pub fn callback_data(self) -> &'static str {
match self {
$($($name::$variant => $callback_data),*),*
}
}
} }
impl From<$name> for teloxide::types::InlineKeyboardButton { impl From<$name> for teloxide::types::InlineKeyboardButton {
fn from(value: $name) -> Self { fn from(value: $name) -> Self {

View File

@@ -2,17 +2,21 @@ mod commands;
mod config; mod config;
mod db; mod db;
mod dptree_utils; mod dptree_utils;
mod handler_utils;
mod keyboard_utils; mod keyboard_utils;
mod message_utils; mod message_utils;
mod sqlite_storage; mod sqlite_storage;
#[cfg(test)] #[cfg(test)]
mod test_utils; mod test_utils;
use crate::commands::{
my_listings::{my_listings_handler, my_listings_inline_handler, MyListingsState},
new_listing::{new_listing_handler, NewListingState},
};
use crate::sqlite_storage::SqliteStorage; use crate::sqlite_storage::SqliteStorage;
use crate::{
commands::{
my_listings::{my_listings_handler, my_listings_inline_handler, MyListingsState},
new_listing::{new_listing_handler, NewListingState},
},
handler_utils::find_or_create_db_user_from_callback_query,
};
use anyhow::Result; use anyhow::Result;
use commands::*; use commands::*;
use config::Config; use config::Config;
@@ -98,6 +102,7 @@ async fn main() -> Result<()> {
.branch( .branch(
Update::filter_callback_query().branch( Update::filter_callback_query().branch(
dptree::case![DialogueRootState::MainMenu] dptree::case![DialogueRootState::MainMenu]
.filter_map_async(find_or_create_db_user_from_callback_query)
.endpoint(handle_main_menu_callback), .endpoint(handle_main_menu_callback),
), ),
) )