create App struct

This commit is contained in:
Dylan Knutson
2025-09-05 03:50:09 +00:00
parent 9ad562a4b2
commit da7e59fe0f
25 changed files with 769 additions and 627 deletions

View File

@@ -4,15 +4,17 @@ use crate::{
case, case,
commands::new_listing::validations::{validate_price, SetFieldError}, commands::new_listing::validations::{validate_price, SetFieldError},
db::{ db::{
bid::NewBid,
listing::{ListingFields, PersistedListing}, listing::{ListingFields, PersistedListing},
user::PersistedUser, user::PersistedUser,
ListingDbId, MoneyAmount, UserDAO, BidDAO, ListingDbId, MoneyAmount, UserDAO,
}, },
dptree_utils::MapTwo,
handle_error::with_error_handler, handle_error::with_error_handler,
handler_utils::find_listing_by_id, handler_utils::find_listing_by_id,
message_utils::{send_message, MessageTarget}, message_utils::{MessageTarget, SendHtmlMessage},
start_command_data::StartCommandData, start_command_data::StartCommandData,
BotError, BotHandler, BotResult, DialogueRootState, RootDialogue, App, BotError, BotHandler, BotResult, DialogueRootState, RootDialogue,
}; };
use anyhow::{anyhow, Context}; use anyhow::{anyhow, Context};
use log::info; use log::info;
@@ -20,7 +22,6 @@ use serde::{Deserialize, Serialize};
use teloxide::{ use teloxide::{
dispatching::UpdateFilterExt, dispatching::UpdateFilterExt,
types::{CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, Update}, types::{CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, Update},
Bot,
}; };
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -40,8 +41,11 @@ pub fn bidding_handler() -> BotHandler {
Update::filter_message() Update::filter_message()
.filter_map(StartCommandData::get_from_update) .filter_map(StartCommandData::get_from_update)
.filter_map(StartCommandData::get_place_bid_on_listing_start_command) .filter_map(StartCommandData::get_place_bid_on_listing_start_command)
.filter_map_async(find_listing_by_id) .branch(
.endpoint(with_error_handler(handle_place_bid_on_listing)), dptree::entry()
.filter_map_async(find_listing_by_id)
.endpoint(with_error_handler(handle_place_bid_on_listing)),
),
) )
.branch( .branch(
Update::filter_message() Update::filter_message()
@@ -56,11 +60,12 @@ pub fn bidding_handler() -> BotHandler {
.chain(case![DialogueRootState::Bidding( .chain(case![DialogueRootState::Bidding(
BiddingState::AwaitingConfirmBidAmount(listing_id, bid_amount) BiddingState::AwaitingConfirmBidAmount(listing_id, bid_amount)
)]) )])
.filter_map_async( .map2(|(listing_id, bid_amount): (ListingDbId, MoneyAmount)| {
async |listing_dao, (listing_id, _): (ListingDbId, MoneyAmount)| { (listing_id, bid_amount)
find_listing_by_id(listing_dao, listing_id).await })
}, .filter_map_async(async |listing_dao, listing_id| {
) find_listing_by_id(listing_dao, listing_id).await
})
.endpoint(with_error_handler( .endpoint(with_error_handler(
handle_awaiting_confirm_bid_amount_callback, handle_awaiting_confirm_bid_amount_callback,
)), )),
@@ -68,7 +73,7 @@ pub fn bidding_handler() -> BotHandler {
} }
async fn handle_place_bid_on_listing( async fn handle_place_bid_on_listing(
bot: Bot, app: App,
user_dao: UserDAO, user_dao: UserDAO,
target: MessageTarget, target: MessageTarget,
user: PersistedUser, user: PersistedUser,
@@ -110,13 +115,15 @@ async fn handle_place_bid_on_listing(
let keyboard = InlineKeyboardMarkup::default() let keyboard = InlineKeyboardMarkup::default()
.append_row([InlineKeyboardButton::callback("Bid $1", "cancel")]); .append_row([InlineKeyboardButton::callback("Bid $1", "cancel")]);
send_message(&bot, target, response_lines.join("\n"), Some(keyboard)).await?; app.bot
.send_html_message(target, response_lines.join("\n"), Some(keyboard))
.await?;
Ok(()) Ok(())
} }
async fn handle_awaiting_bid_amount_input( async fn handle_awaiting_bid_amount_input(
bot: Bot, app: App,
listing: PersistedListing, listing: PersistedListing,
target: MessageTarget, target: MessageTarget,
dialogue: RootDialogue, dialogue: RootDialogue,
@@ -136,19 +143,20 @@ async fn handle_awaiting_bid_amount_input(
} }
}; };
send_message( let bid_amount_str = format!("{}{}", listing.base.currency_type.symbol(), bid_amount);
&bot, app.bot
target, .send_html_message(
format!("Confirm bid amount: {bid_amount} - this cannot be undone!"), target,
Some(InlineKeyboardMarkup::default().append_row([ format!("Confirm bid amount: {bid_amount_str} - this cannot be undone!"),
InlineKeyboardButton::callback( Some(InlineKeyboardMarkup::default().append_row([
format!("Confirm bid amount: {bid_amount}"), InlineKeyboardButton::callback(
"confirm_bid", format!("Confirm bid amount: {bid_amount_str}"),
), "confirm_bid",
InlineKeyboardButton::callback("Cancel", "cancel_bid"), ),
])), InlineKeyboardButton::callback("Cancel", "cancel_bid"),
) ])),
.await?; )
.await?;
dialogue dialogue
.update(BiddingState::AwaitingConfirmBidAmount( .update(BiddingState::AwaitingConfirmBidAmount(
@@ -162,9 +170,11 @@ async fn handle_awaiting_bid_amount_input(
} }
async fn handle_awaiting_confirm_bid_amount_callback( async fn handle_awaiting_confirm_bid_amount_callback(
bot: Bot, app: App,
listing: PersistedListing, listing: PersistedListing,
(_, bid_amount): (ListingDbId, MoneyAmount), user: PersistedUser,
bid_dao: BidDAO,
bid_amount: MoneyAmount,
target: MessageTarget, target: MessageTarget,
dialogue: RootDialogue, dialogue: RootDialogue,
callback_query: CallbackQuery, callback_query: CallbackQuery,
@@ -178,7 +188,9 @@ async fn handle_awaiting_confirm_bid_amount_callback(
"confirm_bid" => bid_amount, "confirm_bid" => bid_amount,
"cancel_bid" => { "cancel_bid" => {
dialogue.exit().await.context("failed to exit dialogue")?; dialogue.exit().await.context("failed to exit dialogue")?;
send_message(&bot, target, "Bid cancelled", None).await?; app.bot
.send_html_message(target, "Bid cancelled", None)
.await?;
return Ok(()); return Ok(());
} }
_ => { _ => {
@@ -188,20 +200,19 @@ async fn handle_awaiting_confirm_bid_amount_callback(
} }
}; };
let bid = NewBid::new_basic(listing.persisted.id, user.persisted.id, bid_amount);
bid_dao.insert_bid(bid).await?;
dialogue.exit().await.context("failed to exit dialogue")?; dialogue.exit().await.context("failed to exit dialogue")?;
send_message( let bid_amount_str = format!("{}{}", listing.base.currency_type.symbol(), bid_amount);
&bot, app.bot
target.only_chat_id(), .send_html_message(
format!( target.only_chat_id(),
"Bid placed for {}{} on {}", format!("Bid placed for {bid_amount_str} on {}", listing.base.title),
listing.base.currency_type.symbol(), None,
bid_amount, )
listing.base.title .await?;
),
None,
)
.await?;
// TODO - keyboard with buttons to: // TODO - keyboard with buttons to:
// - be notified if they are outbid // - be notified if they are outbid

View File

@@ -1,10 +1,7 @@
use crate::{ use crate::{message_utils::{MessageTarget, SendHtmlMessage}, App, BotResult, Command};
message_utils::{send_message, MessageTarget}, use teloxide::utils::command::BotCommands;
BotResult, Command,
};
use teloxide::{utils::command::BotCommands, Bot};
pub async fn handle_help(bot: Bot, target: MessageTarget) -> BotResult { pub async fn handle_help(app: App, target: MessageTarget) -> BotResult {
let help_message = format!( let help_message = format!(
"📋 Available Commands:\n\n{}\n\n\ "📋 Available Commands:\n\n{}\n\n\
📧 Support: Contact @admin for help\n\ 📧 Support: Contact @admin for help\n\
@@ -12,6 +9,8 @@ pub async fn handle_help(bot: Bot, target: MessageTarget) -> BotResult {
Command::descriptions() Command::descriptions()
); );
send_message(&bot, target, help_message, None).await?; app.bot
.send_html_message(target, help_message, None)
.await?;
Ok(()) Ok(())
} }

View File

@@ -1,11 +1,8 @@
use crate::{ use crate::{message_utils::{MessageTarget, SendHtmlMessage}, App, BotResult};
message_utils::{send_message, MessageTarget},
BotResult,
};
use log::info; use log::info;
use teloxide::{types::Message, Bot}; use teloxide::types::Message;
pub async fn handle_my_bids(bot: Bot, msg: Message, target: MessageTarget) -> BotResult { pub async fn handle_my_bids(app: App, msg: Message, target: MessageTarget) -> BotResult {
let response = "🎯 My Bids (Coming Soon)\n\n\ let response = "🎯 My Bids (Coming Soon)\n\n\
Here you'll be able to view:\n\ Here you'll be able to view:\n\
• Your active bids\n\ • Your active bids\n\
@@ -20,6 +17,6 @@ pub async fn handle_my_bids(bot: Bot, msg: Message, target: MessageTarget) -> Bo
msg.chat.id msg.chat.id
); );
send_message(&bot, target, response, None).await?; app.bot.send_html_message(target, response, None).await?;
Ok(()) Ok(())
} }

View File

@@ -13,13 +13,13 @@ use crate::{
db::{ db::{
listing::{ListingFields, PersistedListing}, listing::{ListingFields, PersistedListing},
user::PersistedUser, user::PersistedUser,
ListingDAO, ListingDbId, ListingType, DAOs, ListingDbId, ListingType,
}, },
handle_error::with_error_handler, handle_error::with_error_handler,
handler_utils::{find_listing_by_id, find_or_create_db_user_from_update}, handler_utils::{find_listing_by_id, find_or_create_db_user_from_update},
message_utils::{extract_callback_data, pluralize_with_count, send_message, MessageTarget}, message_utils::{extract_callback_data, pluralize_with_count, MessageTarget, SendHtmlMessage},
start_command_data::StartCommandData, start_command_data::StartCommandData,
BotError, BotResult, Command, DialogueRootState, RootDialogue, App, BotError, BotResult, Command, DialogueRootState, RootDialogue,
}; };
use anyhow::{anyhow, Context}; use anyhow::{anyhow, Context};
use base64::{prelude::BASE64_URL_SAFE, Engine}; use base64::{prelude::BASE64_URL_SAFE, Engine};
@@ -87,34 +87,40 @@ pub fn my_listings_handler() -> Handler<'static, BotResult, DpHandlerDescription
} }
async fn handle_view_listing_details( async fn handle_view_listing_details(
bot: Bot, app: App,
listing: PersistedListing, listing: PersistedListing,
target: MessageTarget, target: MessageTarget,
) -> BotResult { ) -> BotResult {
send_listing_details_message(&bot, target, listing, None).await?; send_listing_details_message(app, target, listing, None).await?;
Ok(()) Ok(())
} }
async fn inline_query_extract_forward_listing( async fn inline_query_extract_forward_listing(
listing_dao: ListingDAO, app: App,
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 = listing_dao.find_by_id(listing_id).await.unwrap_or(None)?; let listing = app
.daos
.listing
.find_by_id(listing_id)
.await
.unwrap_or(None)?;
Some(listing) Some(listing)
} }
async fn handle_forward_listing( async fn handle_forward_listing(
bot: Bot, app: App,
inline_query: InlineQuery, inline_query: InlineQuery,
listing: PersistedListing, listing: PersistedListing,
) -> BotResult { ) -> BotResult {
info!("Handling forward listing inline query for listing {listing:?}"); info!("Handling forward listing inline query for listing {listing:?}");
let bot_username = match bot let bot_username = match app
.bot
.get_me() .get_me()
.await .await
.context("failed to get bot username")? .context("failed to get bot username")?
@@ -175,22 +181,23 @@ async fn handle_forward_listing(
listing.base.ends_at.format("%b %d, %Y at %H:%M UTC") listing.base.ends_at.format("%b %d, %Y at %H:%M UTC")
); );
bot.answer_inline_query( app.bot
inline_query.id, .answer_inline_query(
[InlineQueryResult::Article( inline_query.id,
InlineQueryResultArticle::new( [InlineQueryResult::Article(
listing.persisted.id.to_string(), InlineQueryResultArticle::new(
format!("💰 {} - ${}", listing.base.title, current_price), listing.persisted.id.to_string(),
InputMessageContent::Text( format!("💰 {} - ${}", listing.base.title, current_price),
InputMessageContentText::new(message_content).parse_mode(ParseMode::Html), InputMessageContent::Text(
), InputMessageContentText::new(message_content).parse_mode(ParseMode::Html),
) ),
.description(&listing.base.title) )
.reply_markup(keyboard), // Add the inline keyboard here! .description(&listing.base.title)
)], .reply_markup(keyboard), // Add the inline keyboard here!
) )],
.await )
.map_err(|e| anyhow::anyhow!("Error answering inline query: {e:?}"))?; .await
.map_err(|e| anyhow::anyhow!("Error answering inline query: {e:?}"))?;
Ok(()) Ok(())
} }
@@ -219,19 +226,17 @@ fn get_listing_current_price(listing: &PersistedListing) -> String {
} }
async fn handle_my_listings_command_input( async fn handle_my_listings_command_input(
listing_dao: ListingDAO, app: App,
bot: Bot,
dialogue: RootDialogue, dialogue: RootDialogue,
user: PersistedUser, user: PersistedUser,
target: MessageTarget, target: MessageTarget,
) -> BotResult { ) -> BotResult {
enter_my_listings(listing_dao, bot, dialogue, user, target, None).await?; enter_my_listings(app, dialogue, user, target, None).await?;
Ok(()) Ok(())
} }
pub async fn enter_my_listings( pub async fn enter_my_listings(
listing_dao: ListingDAO, app: App,
bot: Bot,
dialogue: RootDialogue, dialogue: RootDialogue,
user: PersistedUser, user: PersistedUser,
target: MessageTarget, target: MessageTarget,
@@ -243,7 +248,7 @@ pub async fn enter_my_listings(
.await .await
.context("failed to update dialogue")?; .context("failed to update dialogue")?;
let listings = listing_dao.find_by_seller(user.persisted.id).await?; let listings = app.daos.listing.find_by_seller(user.persisted.id).await?;
// Create keyboard with buttons for each listing // Create keyboard with buttons for each listing
let mut keyboard = teloxide::types::InlineKeyboardMarkup::default(); let mut keyboard = teloxide::types::InlineKeyboardMarkup::default();
for listing in &listings { for listing in &listings {
@@ -255,14 +260,14 @@ pub async fn enter_my_listings(
]); ]);
if listings.is_empty() { if listings.is_empty() {
send_message( app.bot
&bot, .send_html_message(
target, target,
"📋 <b>My Listings</b>\n\n\ "📋 <b>My Listings</b>\n\n\
You don't have any listings yet.", You don't have any listings yet.",
Some(keyboard), Some(keyboard),
) )
.await?; .await?;
return Ok(()); return Ok(());
} }
@@ -277,34 +282,34 @@ pub async fn enter_my_listings(
response = format!("{flash}\n\n{response}"); response = format!("{flash}\n\n{response}");
} }
send_message(&bot, target, response, Some(keyboard)).await?; app.bot
.send_html_message(target, response, Some(keyboard))
.await?;
Ok(()) Ok(())
} }
async fn handle_viewing_listings_callback( async fn handle_viewing_listings_callback(
listing_dao: ListingDAO, app: App,
bot: Bot,
dialogue: RootDialogue, dialogue: RootDialogue,
callback_query: CallbackQuery, callback_query: CallbackQuery,
user: PersistedUser, user: PersistedUser,
target: MessageTarget, target: MessageTarget,
) -> BotResult { ) -> BotResult {
let data = extract_callback_data(&bot, callback_query).await?; let data = extract_callback_data(&app.bot, callback_query).await?;
if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) { if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) {
return enter_main_menu(bot, dialogue, target).await; return enter_main_menu(app, dialogue, target).await;
} }
// Check if it's the back to menu button // Check if it's the back to menu button
let button = MyListingsButtons::try_from(data.as_str())?; let button = MyListingsButtons::try_from(data.as_str())?;
match button { match button {
MyListingsButtons::SelectListing(listing_id) => { MyListingsButtons::SelectListing(listing_id) => {
let listing = get_listing_for_user(&listing_dao, user, listing_id).await?; let listing = get_listing_for_user(&app.daos, user, listing_id).await?;
enter_show_listing_details(app, dialogue, listing, target).await?;
enter_show_listing_details(&bot, dialogue, listing, target).await?;
} }
MyListingsButtons::NewListing => { MyListingsButtons::NewListing => {
enter_select_new_listing_type(bot, dialogue, target).await?; enter_select_new_listing_type(app, dialogue, target).await?;
} }
} }
@@ -312,7 +317,7 @@ async fn handle_viewing_listings_callback(
} }
async fn enter_show_listing_details( async fn enter_show_listing_details(
bot: &Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
listing: PersistedListing, listing: PersistedListing,
target: MessageTarget, target: MessageTarget,
@@ -335,12 +340,12 @@ async fn enter_show_listing_details(
ManageListingButtons::Delete.to_button(), ManageListingButtons::Delete.to_button(),
]) ])
.append_row([ManageListingButtons::Back.to_button()]); .append_row([ManageListingButtons::Back.to_button()]);
send_listing_details_message(bot, target, listing, Some(keyboard)).await?; send_listing_details_message(app, target, listing, Some(keyboard)).await?;
Ok(()) Ok(())
} }
async fn send_listing_details_message( async fn send_listing_details_message(
bot: &Bot, app: App,
target: MessageTarget, target: MessageTarget,
listing: PersistedListing, listing: PersistedListing,
keyboard: Option<InlineKeyboardMarkup>, keyboard: Option<InlineKeyboardMarkup>,
@@ -357,13 +362,14 @@ async fn send_listing_details_message(
}; };
response_lines.push(format!("<b>{}:</b> {}", step.field_name, field_value)); response_lines.push(format!("<b>{}:</b> {}", step.field_name, field_value));
} }
send_message(bot, target, response_lines.join("\n"), keyboard).await?; app.bot
.send_html_message(target, response_lines.join("\n"), keyboard)
.await?;
Ok(()) Ok(())
} }
async fn handle_managing_listing_callback( async fn handle_managing_listing_callback(
listing_dao: ListingDAO, app: App,
bot: Bot,
dialogue: RootDialogue, dialogue: RootDialogue,
callback_query: CallbackQuery, callback_query: CallbackQuery,
user: PersistedUser, user: PersistedUser,
@@ -371,29 +377,30 @@ async fn handle_managing_listing_callback(
target: MessageTarget, target: MessageTarget,
) -> BotResult { ) -> BotResult {
let from = callback_query.from.clone(); let from = callback_query.from.clone();
let data = extract_callback_data(&bot, callback_query).await?; let data = extract_callback_data(&app.bot, callback_query).await?;
match ManageListingButtons::try_from(data.as_str())? { match ManageListingButtons::try_from(data.as_str())? {
ManageListingButtons::PreviewMessage => { ManageListingButtons::PreviewMessage => {
let listing = listing_dao let listing = app
.daos
.listing
.find_by_id(listing_id) .find_by_id(listing_id)
.await? .await?
.ok_or(anyhow::anyhow!("Listing not found"))?; .ok_or(anyhow::anyhow!("Listing not found"))?;
send_preview_listing_message(&bot, listing, from).await?; send_preview_listing_message(app, listing, from).await?;
} }
ManageListingButtons::ForwardListing => { ManageListingButtons::ForwardListing => {
unimplemented!("Forward listing not implemented"); unimplemented!("Forward listing not implemented");
} }
ManageListingButtons::Edit => { ManageListingButtons::Edit => {
let listing = get_listing_for_user(&listing_dao, user, listing_id).await?; let listing = get_listing_for_user(&app.daos, user, listing_id).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(app, target, draft, dialogue, None).await?;
} }
ManageListingButtons::Delete => { ManageListingButtons::Delete => {
listing_dao.delete_listing(listing_id).await?; app.daos.listing.delete_listing(listing_id).await?;
enter_my_listings( enter_my_listings(
listing_dao, app,
bot,
dialogue, dialogue,
user, user,
target, target,
@@ -402,7 +409,7 @@ async fn handle_managing_listing_callback(
.await?; .await?;
} }
ManageListingButtons::Back => { ManageListingButtons::Back => {
enter_my_listings(listing_dao, bot, dialogue, user, target, None).await?; enter_my_listings(app, dialogue, user, target, None).await?;
} }
} }
@@ -441,7 +448,7 @@ fn keyboard_for_listing(listing: &PersistedListing) -> InlineKeyboardMarkup {
} }
async fn send_preview_listing_message( async fn send_preview_listing_message(
bot: &Bot, app: App,
listing: PersistedListing, listing: PersistedListing,
from: User, from: User,
) -> BotResult { ) -> BotResult {
@@ -450,22 +457,22 @@ async fn send_preview_listing_message(
if let Some(description) = &listing.base.description { if let Some(description) = &listing.base.description {
response_lines.push(description.to_owned()); response_lines.push(description.to_owned());
} }
send_message( app.bot
bot, .send_html_message(
from.into(), from.into(),
response_lines.join("\n\n"), response_lines.join("\n\n"),
Some(keyboard_for_listing(&listing)), Some(keyboard_for_listing(&listing)),
) )
.await?; .await?;
Ok(()) Ok(())
} }
async fn get_listing_for_user( async fn get_listing_for_user(
listing_dao: &ListingDAO, daos: &DAOs,
user: PersistedUser, user: PersistedUser,
listing_id: ListingDbId, listing_id: ListingDbId,
) -> BotResult<PersistedListing> { ) -> BotResult<PersistedListing> {
let listing = match listing_dao.find_by_id(listing_id).await? { let listing = match daos.listing.find_by_id(listing_id).await? {
Some(listing) => listing, Some(listing) => listing,
None => { None => {
return Err(BotError::UserVisibleError("❌ Listing not found.".into())); return Err(BotError::UserVisibleError("❌ Listing not found.".into()));

View File

@@ -15,29 +15,26 @@ use crate::{
ui::enter_confirm_save_listing, ui::enter_confirm_save_listing,
}, },
}, },
db::{ db::{user::PersistedUser, CurrencyType, ListingDuration, ListingType, MoneyAmount},
user::PersistedUser, CurrencyType, ListingDAO, ListingDuration, ListingType, MoneyAmount,
},
message_utils::*, message_utils::*,
BotResult, RootDialogue, App, BotResult, RootDialogue,
}; };
use log::{error, info}; use log::{error, info};
use teloxide::{types::CallbackQuery, Bot}; use teloxide::types::CallbackQuery;
/// Handle callbacks during the listing type selection phase /// Handle callbacks during the listing type selection phase
pub async fn handle_selecting_listing_type_callback( pub async fn handle_selecting_listing_type_callback(
listing_dao: ListingDAO, app: App,
bot: Bot,
dialogue: RootDialogue, dialogue: RootDialogue,
user: PersistedUser, user: PersistedUser,
callback_query: CallbackQuery, callback_query: CallbackQuery,
target: MessageTarget, target: MessageTarget,
) -> BotResult { ) -> BotResult {
let data = extract_callback_data(&bot, callback_query).await?; let data = extract_callback_data(&app.bot, callback_query).await?;
info!("User {target:?} selected listing type: {data:?}"); info!("User {target:?} selected listing type: {data:?}");
if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) { if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) {
return enter_my_listings(listing_dao, bot, dialogue, user, target, None).await; return enter_my_listings(app, dialogue, user, target, None).await;
} }
// Parse the listing type from callback data // Parse the listing type from callback data
@@ -64,38 +61,38 @@ pub async fn handle_selecting_listing_type_callback(
get_step_message(ListingField::Title, listing_type) get_step_message(ListingField::Title, listing_type)
); );
send_message( app.bot
&bot, .send_html_message(
target, target,
response, response,
get_keyboard_for_field(ListingField::Title), get_keyboard_for_field(ListingField::Title),
) )
.await?; .await?;
Ok(()) Ok(())
} }
/// Handle callbacks during the field input phase /// Handle callbacks during the field input phase
pub async fn handle_awaiting_draft_field_callback( pub async fn handle_awaiting_draft_field_callback(
bot: Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
(field, draft): (ListingField, ListingDraft), (field, draft): (ListingField, ListingDraft),
callback_query: CallbackQuery, callback_query: CallbackQuery,
target: MessageTarget, target: MessageTarget,
) -> BotResult { ) -> BotResult {
let data = extract_callback_data(&bot, callback_query).await?; let data = extract_callback_data(&app.bot, callback_query).await?;
info!("User {target:?} selected callback: {data:?}"); info!("User {target:?} selected callback: {data:?}");
if let Ok(button) = NavKeyboardButtons::try_from(data.as_str()) { if let Ok(button) = NavKeyboardButtons::try_from(data.as_str()) {
match button { match button {
NavKeyboardButtons::Back => { NavKeyboardButtons::Back => {
return enter_select_new_listing_type(bot, dialogue, target).await; return enter_select_new_listing_type(app, dialogue, target).await;
} }
NavKeyboardButtons::Skip => { NavKeyboardButtons::Skip => {
return handle_skip_field(&bot, dialogue, field, draft, target).await; return handle_skip_field(app, dialogue, field, draft, target).await;
} }
NavKeyboardButtons::Cancel => { NavKeyboardButtons::Cancel => {
return cancel_wizard(bot, dialogue, target).await; return cancel_wizard(app, dialogue, target).await;
} }
} }
} }
@@ -104,23 +101,23 @@ pub async fn handle_awaiting_draft_field_callback(
match field { match field {
ListingField::Slots => { ListingField::Slots => {
let button = SlotsKeyboardButtons::try_from(data.as_str())?; let button = SlotsKeyboardButtons::try_from(data.as_str())?;
handle_slots_callback(&bot, dialogue, draft, button, target).await handle_slots_callback(app, dialogue, draft, button, target).await
} }
ListingField::StartTime => { ListingField::StartTime => {
let button = StartTimeKeyboardButtons::try_from(data.as_str())?; let button = StartTimeKeyboardButtons::try_from(data.as_str())?;
handle_start_time_callback(&bot, dialogue, draft, button, target).await handle_start_time_callback(app, dialogue, draft, button, target).await
} }
ListingField::EndTime => { ListingField::EndTime => {
let button = DurationKeyboardButtons::try_from(data.as_str())?; let button = DurationKeyboardButtons::try_from(data.as_str())?;
handle_duration_callback(&bot, dialogue, draft, button, target).await handle_duration_callback(app, dialogue, draft, button, target).await
} }
ListingField::MinBidIncrement => { ListingField::MinBidIncrement => {
let button = EditMinimumBidIncrementKeyboardButtons::try_from(data.as_str())?; let button = EditMinimumBidIncrementKeyboardButtons::try_from(data.as_str())?;
handle_starting_bid_amount_callback(&bot, dialogue, draft, button, target).await handle_starting_bid_amount_callback(app, dialogue, draft, button, target).await
} }
ListingField::CurrencyType => { ListingField::CurrencyType => {
let button = CurrencyTypeKeyboardButtons::try_from(data.as_str())?; let button = CurrencyTypeKeyboardButtons::try_from(data.as_str())?;
handle_currency_type_callback(&bot, dialogue, draft, button, target).await handle_currency_type_callback(app, dialogue, draft, button, target).await
} }
_ => { _ => {
error!("Unknown callback data for field {field:?}: {data}"); error!("Unknown callback data for field {field:?}: {data}");
@@ -130,7 +127,7 @@ pub async fn handle_awaiting_draft_field_callback(
} }
async fn handle_skip_field( async fn handle_skip_field(
bot: &Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
current_field: ListingField, current_field: ListingField,
draft: ListingDraft, draft: ListingDraft,
@@ -146,16 +143,18 @@ async fn handle_skip_field(
get_step_message(next_field, draft.listing_type()) get_step_message(next_field, draft.listing_type())
); );
transition_to_field(dialogue, next_field, draft).await?; transition_to_field(dialogue, next_field, draft).await?;
send_message(bot, target, response, get_keyboard_for_field(next_field)).await?; app.bot
.send_html_message(target, response, get_keyboard_for_field(next_field))
.await?;
} else { } else {
enter_confirm_save_listing(bot, dialogue, target, draft, Some(flash)).await?; enter_confirm_save_listing(app, dialogue, target, draft, Some(flash)).await?;
} }
Ok(()) Ok(())
} }
/// Handle slots selection callback /// Handle slots selection callback
async fn handle_slots_callback( async fn handle_slots_callback(
bot: &Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
mut draft: ListingDraft, mut draft: ListingDraft,
button: SlotsKeyboardButtons, button: SlotsKeyboardButtons,
@@ -180,19 +179,19 @@ async fn handle_slots_callback(
get_step_message(ListingField::StartTime, draft.listing_type()) get_step_message(ListingField::StartTime, draft.listing_type())
); );
transition_to_field(dialogue, ListingField::StartTime, draft).await?; transition_to_field(dialogue, ListingField::StartTime, draft).await?;
send_message( app.bot
bot, .send_html_message(
target, target,
&response, &response,
get_keyboard_for_field(ListingField::StartTime), get_keyboard_for_field(ListingField::StartTime),
) )
.await?; .await?;
Ok(()) Ok(())
} }
/// Handle start time selection callback /// Handle start time selection callback
async fn handle_start_time_callback( async fn handle_start_time_callback(
bot: &Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
mut draft: ListingDraft, mut draft: ListingDraft,
button: StartTimeKeyboardButtons, button: StartTimeKeyboardButtons,
@@ -215,19 +214,19 @@ async fn handle_start_time_callback(
get_step_message(ListingField::EndTime, draft.listing_type()) get_step_message(ListingField::EndTime, draft.listing_type())
); );
transition_to_field(dialogue, ListingField::EndTime, draft).await?; transition_to_field(dialogue, ListingField::EndTime, draft).await?;
send_message( app.bot
bot, .send_html_message(
target, target,
&response, &response,
get_keyboard_for_field(ListingField::EndTime), get_keyboard_for_field(ListingField::EndTime),
) )
.await?; .await?;
Ok(()) Ok(())
} }
/// Handle duration selection callback /// Handle duration selection callback
async fn handle_duration_callback( async fn handle_duration_callback(
bot: &Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
mut draft: ListingDraft, mut draft: ListingDraft,
button: DurationKeyboardButtons, button: DurationKeyboardButtons,
@@ -248,11 +247,11 @@ async fn handle_duration_callback(
.map_err(|e| anyhow::anyhow!("Error updating duration: {e:?}"))?; .map_err(|e| anyhow::anyhow!("Error updating duration: {e:?}"))?;
let flash = get_success_message(ListingField::EndTime, draft.listing_type()); let flash = get_success_message(ListingField::EndTime, draft.listing_type());
enter_confirm_save_listing(bot, dialogue, target, draft, Some(flash)).await enter_confirm_save_listing(app, dialogue, target, draft, Some(flash)).await
} }
async fn handle_starting_bid_amount_callback( async fn handle_starting_bid_amount_callback(
bot: &Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
mut draft: ListingDraft, mut draft: ListingDraft,
button: EditMinimumBidIncrementKeyboardButtons, button: EditMinimumBidIncrementKeyboardButtons,
@@ -273,11 +272,11 @@ async fn handle_starting_bid_amount_callback(
.map_err(|e| anyhow::anyhow!("Error updating starting bid amount: {e:?}"))?; .map_err(|e| anyhow::anyhow!("Error updating starting bid amount: {e:?}"))?;
let flash = get_success_message(ListingField::StartingBidAmount, draft.listing_type()); let flash = get_success_message(ListingField::StartingBidAmount, draft.listing_type());
enter_confirm_save_listing(bot, dialogue, target, draft, Some(flash)).await enter_confirm_save_listing(app, dialogue, target, draft, Some(flash)).await
} }
async fn handle_currency_type_callback( async fn handle_currency_type_callback(
bot: &Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
mut draft: ListingDraft, mut draft: ListingDraft,
button: CurrencyTypeKeyboardButtons, button: CurrencyTypeKeyboardButtons,
@@ -304,13 +303,15 @@ async fn handle_currency_type_callback(
get_step_message(next_field, draft.listing_type()) get_step_message(next_field, draft.listing_type())
); );
transition_to_field(dialogue, next_field, draft).await?; transition_to_field(dialogue, next_field, draft).await?;
send_message(bot, target, &response, get_keyboard_for_field(next_field)).await?; app.bot
.send_html_message(target, &response, get_keyboard_for_field(next_field))
.await?;
Ok(()) Ok(())
} }
/// Cancel the wizard and exit /// Cancel the wizard and exit
pub async fn cancel_wizard(bot: Bot, dialogue: RootDialogue, target: MessageTarget) -> BotResult { pub async fn cancel_wizard(app: App, dialogue: RootDialogue, target: MessageTarget) -> BotResult {
info!("{target:?} cancelled new listing wizard"); info!("{target:?} cancelled new listing wizard");
enter_select_new_listing_type(bot, dialogue, target).await?; enter_select_new_listing_type(app, dialogue, target).await?;
Ok(()) Ok(())
} }

View File

@@ -28,24 +28,24 @@ use crate::{
ListingDAO, ListingDAO,
}, },
message_utils::*, message_utils::*,
BotError, BotResult, DialogueRootState, RootDialogue, App, BotError, BotResult, DialogueRootState, RootDialogue,
}; };
use anyhow::{anyhow, Context}; use anyhow::{anyhow, Context};
use log::info; use log::info;
use teloxide::{prelude::*, types::*, Bot}; use teloxide::{prelude::*, types::*};
/// 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(
bot: Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
target: MessageTarget, target: MessageTarget,
) -> BotResult { ) -> BotResult {
enter_select_new_listing_type(bot, dialogue, target).await?; enter_select_new_listing_type(app, dialogue, target).await?;
Ok(()) Ok(())
} }
pub async fn enter_select_new_listing_type( pub async fn enter_select_new_listing_type(
bot: Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
target: MessageTarget, target: MessageTarget,
) -> BotResult { ) -> BotResult {
@@ -55,19 +55,19 @@ pub async fn enter_select_new_listing_type(
.await .await
.context("failed to update dialogue")?; .context("failed to update dialogue")?;
send_message( app.bot
&bot, .send_html_message(
target, target,
get_listing_type_selection_message(), get_listing_type_selection_message(),
Some(get_listing_type_keyboard()), Some(get_listing_type_keyboard()),
) )
.await?; .await?;
Ok(()) Ok(())
} }
/// Handle text input for any field during creation /// Handle text input for any field during creation
pub async fn handle_awaiting_draft_field_input( pub async fn handle_awaiting_draft_field_input(
bot: Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
(field, mut draft): (ListingField, ListingDraft), (field, mut draft): (ListingField, ListingDraft),
target: MessageTarget, target: MessageTarget,
@@ -97,17 +97,19 @@ pub async fn handle_awaiting_draft_field_input(
get_step_message(next_field, draft.listing_type()) get_step_message(next_field, draft.listing_type())
); );
transition_to_field(dialogue, next_field, draft).await?; transition_to_field(dialogue, next_field, draft).await?;
send_message(&bot, target, response, get_keyboard_for_field(next_field)).await?; app.bot
.send_html_message(target, response, get_keyboard_for_field(next_field))
.await?;
} else { } else {
// Final step - go to confirmation // Final step - go to confirmation
enter_confirm_save_listing(&bot, dialogue, target, draft, None).await?; enter_confirm_save_listing(app, dialogue, target, draft, None).await?;
} }
Ok(()) Ok(())
} }
/// Handle text input for field editing /// Handle text input for field editing
pub async fn handle_editing_field_input( pub async fn handle_editing_field_input(
bot: Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
(field, mut draft): (ListingField, ListingDraft), (field, mut draft): (ListingField, ListingDraft),
target: MessageTarget, target: MessageTarget,
@@ -130,41 +132,32 @@ pub async fn handle_editing_field_input(
}; };
let flash = get_edit_success_message(field, draft.listing_type()); let flash = get_edit_success_message(field, draft.listing_type());
enter_edit_listing_draft(&bot, target, draft, dialogue, Some(flash)).await?; enter_edit_listing_draft(app, target, draft, dialogue, Some(flash)).await?;
Ok(()) Ok(())
} }
/// Handle viewing draft confirmation callbacks /// Handle viewing draft confirmation callbacks
pub async fn handle_viewing_draft_callback( pub async fn handle_viewing_draft_callback(
listing_dao: ListingDAO, app: App,
bot: Bot,
dialogue: RootDialogue, dialogue: RootDialogue,
draft: ListingDraft, draft: ListingDraft,
user: PersistedUser, user: PersistedUser,
callback_query: CallbackQuery, callback_query: CallbackQuery,
target: MessageTarget, target: MessageTarget,
) -> BotResult { ) -> BotResult {
let data = extract_callback_data(&bot, callback_query).await?; let data = extract_callback_data(&app.bot, callback_query).await?;
match ConfirmationKeyboardButtons::try_from(data.as_str())? { match ConfirmationKeyboardButtons::try_from(data.as_str())? {
ConfirmationKeyboardButtons::Create | ConfirmationKeyboardButtons::Save => { ConfirmationKeyboardButtons::Create | ConfirmationKeyboardButtons::Save => {
info!("User {target:?} confirmed listing creation"); info!("User {target:?} confirmed listing creation");
let success_message = save_listing(&listing_dao, draft).await?; let success_message = save_listing(&app.daos.listing, draft).await?;
enter_my_listings( enter_my_listings(app, dialogue, user, target, Some(success_message)).await?;
listing_dao,
bot,
dialogue,
user,
target,
Some(success_message),
)
.await?;
} }
ConfirmationKeyboardButtons::Cancel => { ConfirmationKeyboardButtons::Cancel => {
info!("User {target:?} cancelled listing update"); info!("User {target:?} cancelled listing update");
let response = "🗑️ <b>Changes Discarded</b>\n\n\ let response = "🗑️ <b>Changes Discarded</b>\n\n\
Your changes have been discarded and not saved."; Your changes have been discarded and not saved.";
send_message(&bot, target, &response, None).await?; app.bot.send_html_message(target, &response, None).await?;
dialogue.exit().await.context("failed to exit dialogue")?; dialogue.exit().await.context("failed to exit dialogue")?;
} }
ConfirmationKeyboardButtons::Discard => { ConfirmationKeyboardButtons::Discard => {
@@ -173,12 +166,12 @@ pub async fn handle_viewing_draft_callback(
let response = "🗑️ <b>Listing Discarded</b>\n\n\ let response = "🗑️ <b>Listing Discarded</b>\n\n\
Your listing has been discarded and not created.\n\ Your listing has been discarded and not created.\n\
You can start a new listing anytime with /newlisting."; You can start a new listing anytime with /newlisting.";
send_message(&bot, target, &response, None).await?; app.bot.send_html_message(target, &response, None).await?;
dialogue.exit().await.context("failed to exit dialogue")?; dialogue.exit().await.context("failed to exit dialogue")?;
} }
ConfirmationKeyboardButtons::Edit => { ConfirmationKeyboardButtons::Edit => {
info!("User {target:?} chose to edit listing"); info!("User {target:?} chose to edit listing");
enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?; enter_edit_listing_draft(app, target, draft, dialogue, None).await?;
} }
} }
@@ -187,18 +180,18 @@ pub async fn handle_viewing_draft_callback(
/// Handle editing draft field selection callbacks /// Handle editing draft field selection callbacks
pub async fn handle_editing_draft_callback( pub async fn handle_editing_draft_callback(
bot: Bot, app: App,
draft: ListingDraft, draft: ListingDraft,
dialogue: RootDialogue, dialogue: RootDialogue,
callback_query: CallbackQuery, callback_query: CallbackQuery,
target: MessageTarget, target: MessageTarget,
) -> BotResult { ) -> BotResult {
let data = extract_callback_data(&bot, callback_query).await?; let data = extract_callback_data(&app.bot, callback_query).await?;
info!("User {target:?} in editing screen, showing field selection"); info!("User {target:?} in editing screen, showing field selection");
let button = FieldSelectionKeyboardButtons::try_from(data.as_str())?; let button = FieldSelectionKeyboardButtons::try_from(data.as_str())?;
if button == FieldSelectionKeyboardButtons::Done { if button == FieldSelectionKeyboardButtons::Done {
return enter_confirm_save_listing(&bot, dialogue, target, draft, None).await; return enter_confirm_save_listing(app, dialogue, target, draft, None).await;
} }
let field = match button { let field = match button {
@@ -224,44 +217,46 @@ pub async fn handle_editing_draft_callback(
.context("failed to update dialogue")?; .context("failed to update dialogue")?;
let response = format!("Editing {field:?}\n\nPrevious value: {value}"); let response = format!("Editing {field:?}\n\nPrevious value: {value}");
send_message(&bot, target, response, Some(keyboard)).await?; app.bot
.send_html_message(target, response, Some(keyboard))
.await?;
Ok(()) Ok(())
} }
/// Handle editing draft field callbacks (back button, etc.) /// Handle editing draft field callbacks (back button, etc.)
pub async fn handle_editing_draft_field_callback( pub async fn handle_editing_draft_field_callback(
bot: Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
(field, draft): (ListingField, ListingDraft), (field, draft): (ListingField, ListingDraft),
callback_query: CallbackQuery, callback_query: CallbackQuery,
target: MessageTarget, target: MessageTarget,
) -> BotResult { ) -> BotResult {
let data = extract_callback_data(&bot, callback_query).await?; let data = extract_callback_data(&app.bot, callback_query).await?;
info!("User {target:?} editing field: {field:?} -> {data:?}"); info!("User {target:?} editing field: {field:?} -> {data:?}");
if data == "edit_back" { if data == "edit_back" {
enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?; enter_edit_listing_draft(app, target, draft, dialogue, None).await?;
return Ok(()); return Ok(());
} }
// This callback handler typically receives button presses, not text input // This callback handler typically receives button presses, not text input
// For now, just redirect back to edit screen since callback data isn't suitable for validation // For now, just redirect back to edit screen since callback data isn't suitable for validation
enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?; enter_edit_listing_draft(app, target, draft, dialogue, None).await?;
Ok(()) Ok(())
} }
/// Enter the edit listing draft screen /// Enter the edit listing draft screen
pub async fn enter_edit_listing_draft( pub async fn enter_edit_listing_draft(
bot: &Bot, app: App,
target: MessageTarget, target: MessageTarget,
draft: ListingDraft, draft: ListingDraft,
dialogue: RootDialogue, dialogue: RootDialogue,
flash_message: Option<String>, flash_message: Option<String>,
) -> BotResult { ) -> BotResult {
display_listing_summary( display_listing_summary(
bot, app,
target, target,
&draft, &draft,
Some(FieldSelectionKeyboardButtons::to_keyboard()), Some(FieldSelectionKeyboardButtons::to_keyboard()),

View File

@@ -7,14 +7,15 @@ use crate::commands::new_listing::keyboard::ConfirmationKeyboardButtons;
use crate::commands::new_listing::messages::steps_for_listing_type; use crate::commands::new_listing::messages::steps_for_listing_type;
use crate::commands::new_listing::NewListingState; use crate::commands::new_listing::NewListingState;
use crate::db::ListingType; use crate::db::ListingType;
use crate::App;
use crate::RootDialogue; use crate::RootDialogue;
use crate::{commands::new_listing::types::ListingDraft, message_utils::*, BotResult}; use crate::{commands::new_listing::types::ListingDraft, message_utils::*, BotResult};
use anyhow::Context; use anyhow::Context;
use teloxide::{types::InlineKeyboardMarkup, Bot}; use teloxide::types::InlineKeyboardMarkup;
/// Display the listing summary with optional flash message and keyboard /// Display the listing summary with optional flash message and keyboard
pub async fn display_listing_summary( pub async fn display_listing_summary(
bot: &Bot, app: App,
target: MessageTarget, target: MessageTarget,
draft: &ListingDraft, draft: &ListingDraft,
keyboard: Option<InlineKeyboardMarkup>, keyboard: Option<InlineKeyboardMarkup>,
@@ -48,14 +49,16 @@ pub async fn display_listing_summary(
response_lines.push("".to_string()); response_lines.push("".to_string());
response_lines.push("Edit your listing:".to_string()); response_lines.push("Edit your listing:".to_string());
send_message(bot, target, response_lines.join("\n"), keyboard).await?; app.bot
.send_html_message(target, response_lines.join("\n"), keyboard)
.await?;
Ok(()) Ok(())
} }
/// Show the final confirmation screen before creating/saving the listing /// Show the final confirmation screen before creating/saving the listing
pub async fn enter_confirm_save_listing( pub async fn enter_confirm_save_listing(
bot: &Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
target: MessageTarget, target: MessageTarget,
draft: ListingDraft, draft: ListingDraft,
@@ -75,7 +78,7 @@ pub async fn enter_confirm_save_listing(
]) ])
}; };
display_listing_summary(bot, target, &draft, Some(keyboard), flash).await?; display_listing_summary(app, target, &draft, Some(keyboard), flash).await?;
dialogue dialogue
.update(NewListingState::ViewingDraft(draft)) .update(NewListingState::ViewingDraft(draft))
.await .await

View File

@@ -1,11 +1,11 @@
use crate::{ use crate::{
message_utils::{send_message, MessageTarget}, message_utils::{MessageTarget, SendHtmlMessage},
BotResult, App, BotResult,
}; };
use log::info; use log::info;
use teloxide::{types::Message, Bot}; use teloxide::types::Message;
pub async fn handle_settings(bot: Bot, msg: Message, target: MessageTarget) -> BotResult { pub async fn handle_settings(app: App, msg: Message, target: MessageTarget) -> BotResult {
let response = "⚙️ Settings (Coming Soon)\n\n\ let response = "⚙️ Settings (Coming Soon)\n\n\
Here you'll be able to configure:\n\ Here you'll be able to configure:\n\
• Notification preferences\n\ • Notification preferences\n\
@@ -20,6 +20,6 @@ pub async fn handle_settings(bot: Bot, msg: Message, target: MessageTarget) -> B
msg.chat.id msg.chat.id
); );
send_message(&bot, target, response, None).await?; app.bot.send_html_message(target, response, None).await?;
Ok(()) Ok(())
} }

View File

@@ -3,15 +3,14 @@ use log::info;
use teloxide::{ use teloxide::{
types::{CallbackQuery, Update}, types::{CallbackQuery, Update},
utils::command::BotCommands, utils::command::BotCommands,
Bot,
}; };
use crate::{ use crate::{
commands::my_listings::enter_my_listings, commands::my_listings::enter_my_listings,
db::{user::PersistedUser, ListingDAO}, db::user::PersistedUser,
keyboard_buttons, keyboard_buttons,
message_utils::{extract_callback_data, send_message, MessageTarget}, message_utils::{extract_callback_data, MessageTarget, SendHtmlMessage as _},
BotResult, Command, DialogueRootState, RootDialogue, App, BotResult, Command, DialogueRootState, RootDialogue,
}; };
keyboard_buttons! { keyboard_buttons! {
@@ -39,80 +38,79 @@ fn get_main_menu_message() -> &'static str {
} }
pub async fn handle_start( pub async fn handle_start(
bot: Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
target: MessageTarget, target: MessageTarget,
update: Update, update: Update,
) -> BotResult { ) -> BotResult {
info!("got start message: {update:?}"); info!("got start message: {update:?}");
enter_main_menu(bot, dialogue, target).await?; enter_main_menu(app, dialogue, target).await?;
Ok(()) Ok(())
} }
/// Show the main menu with buttons /// Show the main menu with buttons
pub async fn enter_main_menu(bot: Bot, dialogue: RootDialogue, target: MessageTarget) -> BotResult { pub async fn enter_main_menu(app: App, dialogue: RootDialogue, target: MessageTarget) -> BotResult {
dialogue dialogue
.update(DialogueRootState::MainMenu) .update(DialogueRootState::MainMenu)
.await .await
.context("failed to update dialogue")?; .context("failed to update dialogue")?;
send_message( app.bot
&bot, .send_html_message(
target, target,
get_main_menu_message(), get_main_menu_message(),
Some(MainMenuButtons::to_keyboard()), Some(MainMenuButtons::to_keyboard()),
) )
.await?; .await?;
Ok(()) Ok(())
} }
pub async fn handle_main_menu_callback( pub async fn handle_main_menu_callback(
listing_dao: ListingDAO, app: App,
bot: Bot,
dialogue: RootDialogue, dialogue: RootDialogue,
user: PersistedUser, user: PersistedUser,
callback_query: CallbackQuery, callback_query: CallbackQuery,
target: MessageTarget, target: MessageTarget,
) -> BotResult { ) -> BotResult {
let data = extract_callback_data(&bot, callback_query).await?; let data = extract_callback_data(&app.bot, callback_query).await?;
info!("User {target:?} selected main menu option: {data:?}"); info!("User {target:?} selected main menu option: {data:?}");
let button = MainMenuButtons::try_from(data.as_str())?; let button = MainMenuButtons::try_from(data.as_str())?;
match button { match button {
MainMenuButtons::MyListings => { MainMenuButtons::MyListings => {
// Call show_listings_for_user directly // Call show_listings_for_user directly
enter_my_listings(listing_dao, bot, dialogue, user, target, None).await?; enter_my_listings(app, dialogue, user, target, None).await?;
} }
MainMenuButtons::MyBids => { MainMenuButtons::MyBids => {
send_message( app.bot
&bot, .send_html_message(
target, target,
"💰 <b>My Bids (Coming Soon)</b>\n\n\ "💰 <b>My Bids (Coming Soon)</b>\n\n\
Here you'll be able to view:\n\ Here you'll be able to view:\n\
• Your active bids\n\ • Your active bids\n\
• Bid history\n\ • Bid history\n\
• Won/lost auctions\n\ • Won/lost auctions\n\
• Outbid notifications\n\n\ • Outbid notifications\n\n\
Feature in development! 🛠️", Feature in development! 🛠️",
Some(MainMenuButtons::to_keyboard()), Some(MainMenuButtons::to_keyboard()),
) )
.await?; .await?;
} }
MainMenuButtons::Settings => { MainMenuButtons::Settings => {
send_message( app.bot
&bot, .send_html_message(
target, target,
"⚙️ <b>Settings (Coming Soon)</b>\n\n\ "⚙️ <b>Settings (Coming Soon)</b>\n\n\
Here you'll be able to configure:\n\ Here you'll be able to configure:\n\
• Notification preferences\n\ • Notification preferences\n\
• Language settings\n\ • Language settings\n\
• Default bid increments\n\ • Default bid increments\n\
• Outbid alerts\n\n\ • Outbid alerts\n\n\
Feature in development! 🛠️", Feature in development! 🛠️",
Some(MainMenuButtons::to_keyboard()), Some(MainMenuButtons::to_keyboard()),
) )
.await?; .await?;
} }
MainMenuButtons::Help => { MainMenuButtons::Help => {
let help_message = format!( let help_message = format!(
@@ -121,13 +119,9 @@ pub async fn handle_main_menu_callback(
🔗 <b>More info:</b> Use individual commands to get started!", 🔗 <b>More info:</b> Use individual commands to get started!",
Command::descriptions() Command::descriptions()
); );
send_message( app.bot
&bot, .send_html_message(target, help_message, Some(MainMenuButtons::to_keyboard()))
target, .await?;
help_message,
Some(MainMenuButtons::to_keyboard()),
)
.await?;
} }
} }

188
src/db/dao/bid_dao.rs Normal file
View File

@@ -0,0 +1,188 @@
use crate::db::{
bid::{NewBid, PersistedBid, PersistedBidFields},
bind_fields::BindFields,
};
use anyhow::Result;
use chrono::Utc;
use itertools::Itertools as _;
use sqlx::{prelude::*, sqlite::SqliteRow, SqlitePool};
#[derive(Clone)]
pub struct BidDAO(SqlitePool);
impl BidDAO {
pub fn new(pool: SqlitePool) -> Self {
Self(pool)
}
}
#[allow(unused)]
impl BidDAO {
pub async fn insert_bid(&self, bid: NewBid) -> Result<PersistedBid> {
let now = Utc::now();
let binds = BindFields::default()
.push("listing_id", &bid.listing_id)
.push("buyer_id", &bid.buyer_id)
.push("bid_amount", &bid.bid_amount)
.push("description", &bid.description)
.push("is_cancelled", &bid.is_cancelled)
.push("slot_number", &bid.slot_number)
.push("proxy_bid_id", &bid.proxy_bid_id)
.push("created_at", &now)
.push("updated_at", &now);
let query_str = format!(
r#"
INSERT INTO bids ({}) VALUES ({})
RETURNING *
"#,
binds.bind_names().join(", "),
binds.bind_placeholders().join(", ")
);
let row = binds
.bind_to_query(sqlx::query(&query_str))
.fetch_one(&self.0)
.await?;
Ok(FromRow::from_row(&row)?)
}
}
impl FromRow<'_, SqliteRow> for PersistedBid {
fn from_row(row: &'_ SqliteRow) -> std::result::Result<Self, sqlx::Error> {
Ok(PersistedBid {
persisted: PersistedBidFields::from_row(row)?,
listing_id: row.get("listing_id"),
buyer_id: row.get("buyer_id"),
bid_amount: row.get("bid_amount"),
description: row.get("description"),
is_cancelled: row.get("is_cancelled"),
slot_number: row.get("slot_number"),
proxy_bid_id: row.get("proxy_bid_id"),
})
}
}
impl FromRow<'_, SqliteRow> for PersistedBidFields {
fn from_row(row: &'_ SqliteRow) -> std::result::Result<Self, sqlx::Error> {
Ok(PersistedBidFields {
id: row.get("id"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::{
listing::{BasicAuctionFields, ListingFields},
models::{listing::NewListing, user::NewUser},
CurrencyType, ListingDAO, MoneyAmount, UserDAO,
};
use crate::test_utils::create_test_pool;
use chrono::Utc;
use teloxide::types::UserId;
async fn create_test_user_and_listing() -> (
UserDAO,
ListingDAO,
BidDAO,
crate::db::UserDbId,
crate::db::ListingDbId,
) {
let pool = create_test_pool().await;
let user_dao = UserDAO::new(pool.clone());
let listing_dao = ListingDAO::new(pool.clone());
let bid_dao = BidDAO::new(pool);
// Create a test user
let new_user = NewUser {
persisted: (),
telegram_id: UserId(12345).into(),
first_name: "Test User".to_string(),
last_name: None,
username: Some("testuser".to_string()),
is_banned: false,
};
let user = user_dao
.insert_user(&new_user)
.await
.expect("Failed to insert test user");
// Create a test listing
let new_listing = NewListing {
persisted: (),
base: crate::db::listing::ListingBase {
seller_id: user.persisted.id,
title: "Test Listing".to_string(),
description: Some("Test description".to_string()),
currency_type: CurrencyType::Usd,
starts_at: Utc::now(),
ends_at: Utc::now() + chrono::Duration::hours(24),
},
fields: ListingFields::BasicAuction(BasicAuctionFields {
starting_bid: MoneyAmount::from_str("10.00").unwrap(),
buy_now_price: Some(MoneyAmount::from_str("100.00").unwrap()),
min_increment: MoneyAmount::from_str("1.00").unwrap(),
anti_snipe_minutes: Some(5),
}),
};
let listing = listing_dao
.insert_listing(new_listing)
.await
.expect("Failed to insert test listing");
(
user_dao,
listing_dao,
bid_dao,
user.persisted.id,
listing.persisted.id,
)
}
#[tokio::test]
async fn test_insert_bid() {
let (_user_dao, _listing_dao, bid_dao, user_id, listing_id) =
create_test_user_and_listing().await;
let new_bid = NewBid {
persisted: (),
listing_id,
buyer_id: user_id,
bid_amount: MoneyAmount::from_str("25.50").unwrap(),
description: Some("Test bid description".to_string()),
is_cancelled: false,
slot_number: Some(1),
proxy_bid_id: None,
};
// Insert bid
let inserted_bid = bid_dao
.insert_bid(new_bid.clone())
.await
.expect("Failed to insert bid");
// Verify the inserted bid has the correct values
assert_eq!(inserted_bid.listing_id, new_bid.listing_id);
assert_eq!(inserted_bid.buyer_id, new_bid.buyer_id);
assert_eq!(inserted_bid.bid_amount, new_bid.bid_amount);
assert_eq!(inserted_bid.description, new_bid.description);
assert_eq!(inserted_bid.is_cancelled, new_bid.is_cancelled);
assert_eq!(inserted_bid.slot_number, new_bid.slot_number);
assert_eq!(inserted_bid.proxy_bid_id, new_bid.proxy_bid_id);
// Verify persisted fields are populated
assert!(inserted_bid.persisted.id.get() > 0);
assert!(inserted_bid.persisted.created_at <= chrono::Utc::now());
assert!(inserted_bid.persisted.updated_at <= chrono::Utc::now());
assert_eq!(
inserted_bid.persisted.created_at,
inserted_bid.persisted.updated_at
);
}
}

View File

@@ -22,24 +22,6 @@ use crate::db::{
#[derive(Clone)] #[derive(Clone)]
pub struct ListingDAO(SqlitePool); pub struct ListingDAO(SqlitePool);
const LISTING_RETURN_FIELDS: &[&str] = &[
"id",
"seller_id",
"listing_type",
"title",
"description",
"currency_type",
"starts_at",
"ends_at",
"created_at",
"updated_at",
"starting_bid",
"buy_now_price",
"min_increment",
"anti_snipe_minutes",
"slots_available",
];
impl ListingDAO { impl ListingDAO {
pub fn new(pool: SqlitePool) -> Self { pub fn new(pool: SqlitePool) -> Self {
Self(pool) Self(pool)
@@ -59,11 +41,10 @@ impl ListingDAO {
let query_str = format!( let query_str = format!(
r#" r#"
INSERT INTO listings ({}) VALUES ({}) INSERT INTO listings ({}) VALUES ({})
RETURNING {} RETURNING *
"#, "#,
binds.bind_names().join(", "), binds.bind_names().join(", "),
binds.bind_placeholders().join(", "), binds.bind_placeholders().join(", "),
LISTING_RETURN_FIELDS.join(", ")
); );
let row = binds let row = binds
@@ -83,13 +64,12 @@ impl ListingDAO {
SET {} SET {}
WHERE id = ? WHERE id = ?
AND seller_id = ? AND seller_id = ?
RETURNING {} RETURNING *
"#, "#,
binds binds
.bind_names() .bind_names()
.map(|name| format!("{name} = ?")) .map(|name| format!("{name} = ?"))
.join(", "), .join(", "),
LISTING_RETURN_FIELDS.join(", ")
); );
let row = binds let row = binds
@@ -103,26 +83,21 @@ impl ListingDAO {
/// Find a listing by its ID /// Find a listing by its ID
pub async fn find_by_id(&self, listing_id: ListingDbId) -> Result<Option<PersistedListing>> { pub async fn find_by_id(&self, listing_id: ListingDbId) -> Result<Option<PersistedListing>> {
let result = sqlx::query_as(&format!( let result = sqlx::query_as("SELECT * FROM listings WHERE id = ?")
"SELECT {} FROM listings WHERE id = ?", .bind(listing_id)
LISTING_RETURN_FIELDS.join(", ") .fetch_optional(&self.0)
)) .await?;
.bind(listing_id)
.fetch_optional(&self.0)
.await?;
Ok(result) Ok(result)
} }
/// Find all listings by a seller /// Find all listings by a seller
pub async fn find_by_seller(&self, seller_id: UserDbId) -> Result<Vec<PersistedListing>> { pub async fn find_by_seller(&self, seller_id: UserDbId) -> Result<Vec<PersistedListing>> {
let rows = sqlx::query_as(&format!( let rows =
"SELECT {} FROM listings WHERE seller_id = ? ORDER BY created_at DESC", sqlx::query_as("SELECT * FROM listings WHERE seller_id = ? ORDER BY created_at DESC")
LISTING_RETURN_FIELDS.join(", ") .bind(seller_id)
)) .fetch_all(&self.0)
.bind(seller_id) .await?;
.fetch_all(&self.0)
.await?;
Ok(rows) Ok(rows)
} }

View File

@@ -1,6 +1,26 @@
pub mod listing_dao; mod bid_dao;
pub mod user_dao; mod listing_dao;
mod user_dao;
// Re-export DAO structs for easy access // Re-export DAO structs for easy access
pub use bid_dao::BidDAO;
pub use listing_dao::ListingDAO; pub use listing_dao::ListingDAO;
use sqlx::SqlitePool;
pub use user_dao::UserDAO; pub use user_dao::UserDAO;
#[derive(Clone)]
pub struct DAOs {
pub user: UserDAO,
pub listing: ListingDAO,
pub bid: BidDAO,
}
impl DAOs {
pub fn new(pool: SqlitePool) -> Self {
Self {
user: UserDAO::new(pool.clone()),
listing: ListingDAO::new(pool.clone()),
bid: BidDAO::new(pool),
}
}
}

View File

@@ -17,17 +17,6 @@ use crate::db::{
#[derive(Clone)] #[derive(Clone)]
pub struct UserDAO(SqlitePool); pub struct UserDAO(SqlitePool);
const USER_RETURN_FIELDS: &[&str] = &[
"id",
"telegram_id",
"username",
"first_name",
"last_name",
"is_banned",
"created_at",
"updated_at",
];
#[allow(unused)] #[allow(unused)]
impl UserDAO { impl UserDAO {
pub fn new(pool: SqlitePool) -> Self { pub fn new(pool: SqlitePool) -> Self {
@@ -46,11 +35,10 @@ impl UserDAO {
r#" r#"
INSERT INTO users ({}) INSERT INTO users ({})
VALUES ({}) VALUES ({})
RETURNING {} RETURNING *
"#, "#,
binds.bind_names().join(", "), binds.bind_names().join(", "),
binds.bind_placeholders().join(", "), binds.bind_placeholders().join(", "),
USER_RETURN_FIELDS.join(", ")
); );
let query = sqlx::query(&query_str); let query = sqlx::query(&query_str);
let row = binds.bind_to_query(query).fetch_one(&self.0).await?; let row = binds.bind_to_query(query).fetch_one(&self.0).await?;
@@ -60,16 +48,12 @@ impl UserDAO {
/// Find a user by their ID /// Find a user by their ID
pub async fn find_by_id(&self, user_id: UserDbId) -> Result<Option<PersistedUser>> { pub async fn find_by_id(&self, user_id: UserDbId) -> Result<Option<PersistedUser>> {
Ok(sqlx::query_as::<_, PersistedUser>( Ok(
r#" sqlx::query_as::<_, PersistedUser>("SELECT * FROM users WHERE id = ? ")
SELECT id, telegram_id, first_name, last_name, username, is_banned, created_at, updated_at .bind(user_id)
FROM users .fetch_optional(&self.0)
WHERE id = ? .await?,
"#,
) )
.bind(user_id)
.fetch_optional(&self.0)
.await?)
} }
/// Find a user by their Telegram ID /// Find a user by their Telegram ID
@@ -108,11 +92,10 @@ impl UserDAO {
username = EXCLUDED.username, username = EXCLUDED.username,
first_name = EXCLUDED.first_name, first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name last_name = EXCLUDED.last_name
RETURNING {} RETURNING *
"#, "#,
binds.bind_names().join(", "), binds.bind_names().join(", "),
binds.bind_placeholders().join(", "), binds.bind_placeholders().join(", "),
USER_RETURN_FIELDS.join(", ")
); );
let row = binds let row = binds

View File

@@ -1,15 +1,26 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::FromRow;
use crate::db::MoneyAmount; use crate::db::{BidDbId, ListingDbId, MoneyAmount, ProxyBidDbId, UserDbId};
pub type PersistedBid = Bid<PersistedBidFields>;
#[allow(unused)]
pub type NewBid = Bid<()>;
#[derive(Debug, Clone)]
#[allow(unused)]
pub struct PersistedBidFields {
pub id: BidDbId,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Actual bids placed on listings /// Actual bids placed on listings
#[derive(Debug, Clone)]
#[allow(unused)] #[allow(unused)]
#[derive(Debug, Clone, FromRow)] pub struct Bid<P> {
pub struct Bid { pub persisted: P,
pub id: i64, pub listing_id: ListingDbId,
pub listing_id: i64, pub buyer_id: UserDbId,
pub buyer_id: i64,
pub bid_amount: MoneyAmount, pub bid_amount: MoneyAmount,
// For blind listings // For blind listings
@@ -20,20 +31,24 @@ pub struct Bid {
pub slot_number: Option<i32>, // For multi-slot listings pub slot_number: Option<i32>, // For multi-slot listings
// Reference to proxy bid if auto-generated // Reference to proxy bid if auto-generated
pub proxy_bid_id: Option<i64>, pub proxy_bid_id: Option<ProxyBidDbId>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
} }
/// New bid data for insertion impl<P> Bid<P> {
#[allow(unused)] pub fn new_basic(
#[derive(Debug, Clone)] listing_id: ListingDbId,
pub struct NewBid { buyer_id: UserDbId,
pub listing_id: i64, bid_amount: MoneyAmount,
pub buyer_id: i64, ) -> NewBid {
pub bid_amount: MoneyAmount, NewBid {
pub description: Option<String>, persisted: (),
pub slot_number: Option<i32>, listing_id,
pub proxy_bid_id: Option<i64>, buyer_id,
bid_amount,
description: None,
is_cancelled: false,
slot_number: None,
proxy_bid_id: None,
}
}
} }

View File

@@ -26,7 +26,6 @@ pub struct PersistedListingFields {
/// Main listing/auction entity /// Main listing/auction entity
#[derive(Debug, Clone, Eq, PartialEq)] #[derive(Debug, Clone, Eq, PartialEq)]
#[allow(unused)]
pub struct Listing<P: Debug + Clone> { pub struct Listing<P: Debug + Clone> {
pub persisted: P, pub persisted: P,
pub base: ListingBase, pub base: ListingBase,
@@ -49,7 +48,6 @@ impl<'a, P: Debug + Clone> From<&'a mut Listing<P>> for ListingBaseFieldsMut<'a>
/// Common fields shared by all listing types /// Common fields shared by all listing types
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[allow(unused)]
pub struct ListingBase { pub struct ListingBase {
pub seller_id: UserDbId, pub seller_id: UserDbId,
pub title: String, pub title: String,
@@ -72,7 +70,6 @@ impl ListingBase {
/// Fields specific to basic auction listings /// Fields specific to basic auction listings
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[allow(unused)]
pub struct BasicAuctionFields { pub struct BasicAuctionFields {
pub starting_bid: MoneyAmount, pub starting_bid: MoneyAmount,
pub buy_now_price: Option<MoneyAmount>, pub buy_now_price: Option<MoneyAmount>,
@@ -82,7 +79,6 @@ pub struct BasicAuctionFields {
/// Fields specific to multi-slot auction listings /// Fields specific to multi-slot auction listings
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[allow(unused)]
pub struct MultiSlotAuctionFields { pub struct MultiSlotAuctionFields {
pub starting_bid: MoneyAmount, pub starting_bid: MoneyAmount,
pub buy_now_price: MoneyAmount, pub buy_now_price: MoneyAmount,
@@ -93,7 +89,6 @@ pub struct MultiSlotAuctionFields {
/// Fields specific to fixed price listings /// Fields specific to fixed price listings
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[allow(unused)]
pub struct FixedPriceListingFields { pub struct FixedPriceListingFields {
pub buy_now_price: MoneyAmount, pub buy_now_price: MoneyAmount,
pub slots_available: i32, pub slots_available: i32,
@@ -101,13 +96,11 @@ pub struct FixedPriceListingFields {
/// Fields specific to blind auction listings /// Fields specific to blind auction listings
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[allow(unused)]
pub struct BlindAuctionFields { pub struct BlindAuctionFields {
pub starting_bid: MoneyAmount, pub starting_bid: MoneyAmount,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[allow(unused)]
pub enum ListingFields { pub enum ListingFields {
BasicAuction(BasicAuctionFields), BasicAuction(BasicAuctionFields),
MultiSlotAuction(MultiSlotAuctionFields), MultiSlotAuction(MultiSlotAuctionFields),

93
src/db/types/db_id.rs Normal file
View File

@@ -0,0 +1,93 @@
use serde::{Deserialize, Serialize};
use sqlx::{
encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type,
};
use std::fmt;
macro_rules! impl_db_id {
($id_name:ident, $id_type:ty) => {
#[doc = "Type-safe wrapper for "]
#[doc = stringify!($id_name)]
#[doc = " IDs"]
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
pub struct $id_name($id_type);
impl $id_name {
/// Create a new ListingId from an i64
pub fn new(id: $id_type) -> Self {
Self(id)
}
/// Get the inner i64 value
pub fn get(&self) -> $id_type {
self.0
}
}
impl From<$id_type> for $id_name {
fn from(id: $id_type) -> Self {
Self(id)
}
}
impl From<$id_name> for $id_type {
fn from(value: $id_name) -> Self {
value.0
}
}
impl fmt::Display for $id_name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
// SQLx implementations for database compatibility
impl Type<Sqlite> for $id_name {
fn type_info() -> SqliteTypeInfo {
<$id_type as Type<Sqlite>>::type_info()
}
fn compatible(ty: &SqliteTypeInfo) -> bool {
<$id_type as Type<Sqlite>>::compatible(ty)
}
}
impl<'q> Encode<'q, Sqlite> for $id_name {
fn encode_by_ref(
&self,
args: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
) -> Result<IsNull, BoxDynError> {
<$id_type as Encode<'q, Sqlite>>::encode_by_ref(&self.0, args)
}
}
impl<'r> Decode<'r, Sqlite> for $id_name {
fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
let id = <$id_type as Decode<'r, Sqlite>>::decode(value)?;
Ok(Self(id))
}
}
};
}
impl_db_id!(BidDbId, i64);
impl_db_id!(ProxyBidDbId, i64);
impl_db_id!(ListingDbId, i64);
impl_db_id!(UserDbId, i64);
impl_db_id!(TelegramUserDbId, i64);
impl From<teloxide::types::UserId> for TelegramUserDbId {
fn from(id: teloxide::types::UserId) -> Self {
Self(id.0 as i64)
}
}
impl From<TelegramUserDbId> for teloxide::types::UserId {
fn from(user_id: TelegramUserDbId) -> Self {
teloxide::types::UserId(user_id.0 as u64)
}
}

View File

@@ -1,71 +0,0 @@
//! ListingId newtype for type-safe listing identification
//!
//! This newtype prevents accidentally mixing up listing IDs with other ID types
//! while maintaining compatibility with the database layer through SQLx traits.
use serde::{Deserialize, Serialize};
use sqlx::{
encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type,
};
use std::fmt;
/// Type-safe wrapper for listing IDs
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct ListingDbId(i64);
impl ListingDbId {
/// Create a new ListingId from an i64
pub fn new(id: i64) -> Self {
Self(id)
}
/// Get the inner i64 value
pub fn get(&self) -> i64 {
self.0
}
}
impl From<i64> for ListingDbId {
fn from(id: i64) -> Self {
Self(id)
}
}
impl From<ListingDbId> for i64 {
fn from(listing_id: ListingDbId) -> Self {
listing_id.0
}
}
impl fmt::Display for ListingDbId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
// SQLx implementations for database compatibility
impl Type<Sqlite> for ListingDbId {
fn type_info() -> SqliteTypeInfo {
<i64 as Type<Sqlite>>::type_info()
}
fn compatible(ty: &SqliteTypeInfo) -> bool {
<i64 as Type<Sqlite>>::compatible(ty)
}
}
impl<'q> Encode<'q, Sqlite> for ListingDbId {
fn encode_by_ref(
&self,
args: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
) -> Result<IsNull, BoxDynError> {
<i64 as Encode<'q, Sqlite>>::encode_by_ref(&self.0, args)
}
}
impl<'r> Decode<'r, Sqlite> for ListingDbId {
fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
let id = <i64 as Decode<'r, Sqlite>>::decode(value)?;
Ok(Self(id))
}
}

View File

@@ -1,7 +0,0 @@
mod listing_db_id;
mod telegram_user_db_id;
mod user_db_id;
pub use listing_db_id::ListingDbId;
pub use telegram_user_db_id::TelegramUserDbId;
pub use user_db_id::UserDbId;

View File

@@ -1,78 +0,0 @@
//! TelegramUserId
//! newtype for type-safe user identification
//!
//! This newtype prevents accidentally mixing up user IDs with other ID types
//! while maintaining compatibility with the database layer through SQLx traits.
use sqlx::{
encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type,
};
use std::fmt;
/// Type-safe wrapper for user IDs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TelegramUserDbId(teloxide::types::UserId);
impl TelegramUserDbId {
/// Create a new TelegramUserId
/// from an i64
pub fn new(id: teloxide::types::UserId) -> Self {
Self(id)
}
/// Get the inner i64 value
pub fn get(&self) -> teloxide::types::UserId {
self.0
}
}
impl From<teloxide::types::UserId> for TelegramUserDbId {
fn from(id: teloxide::types::UserId) -> Self {
Self(id)
}
}
impl From<u64> for TelegramUserDbId {
fn from(id: u64) -> Self {
Self(teloxide::types::UserId(id))
}
}
impl From<TelegramUserDbId> for teloxide::types::UserId {
fn from(user_id: TelegramUserDbId) -> Self {
user_id.0
}
}
impl fmt::Display for TelegramUserDbId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
// SQLx implementations for database compatibility
impl Type<Sqlite> for TelegramUserDbId {
fn type_info() -> SqliteTypeInfo {
<i64 as Type<Sqlite>>::type_info()
}
fn compatible(ty: &SqliteTypeInfo) -> bool {
<i64 as Type<Sqlite>>::compatible(ty)
}
}
impl<'q> Encode<'q, Sqlite> for TelegramUserDbId {
fn encode_by_ref(
&self,
args: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
) -> Result<IsNull, BoxDynError> {
<i64 as Encode<'q, Sqlite>>::encode(self.0 .0 as i64, args)
}
}
impl<'r> Decode<'r, Sqlite> for TelegramUserDbId {
fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
let id = <i64 as Decode<'r, Sqlite>>::decode(value)?;
Ok(Self(teloxide::types::UserId(id as u64)))
}
}

View File

@@ -1,71 +0,0 @@
//! UserId newtype for type-safe user identification
//!
//! This newtype prevents accidentally mixing up user IDs with other ID types
//! while maintaining compatibility with the database layer through SQLx traits.
use serde::{Deserialize, Serialize};
use sqlx::{
encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type,
};
use std::fmt;
/// Type-safe wrapper for user IDs
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct UserDbId(i64);
impl UserDbId {
/// Create a new UserId from an i64
pub fn new(id: i64) -> Self {
Self(id)
}
/// Get the inner i64 value
pub fn get(&self) -> i64 {
self.0
}
}
impl From<i64> for UserDbId {
fn from(id: i64) -> Self {
Self(id)
}
}
impl From<UserDbId> for i64 {
fn from(user_id: UserDbId) -> Self {
user_id.0
}
}
impl fmt::Display for UserDbId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
// SQLx implementations for database compatibility
impl Type<Sqlite> for UserDbId {
fn type_info() -> SqliteTypeInfo {
<i64 as Type<Sqlite>>::type_info()
}
fn compatible(ty: &SqliteTypeInfo) -> bool {
<i64 as Type<Sqlite>>::compatible(ty)
}
}
impl<'q> Encode<'q, Sqlite> for UserDbId {
fn encode_by_ref(
&self,
args: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
) -> Result<IsNull, BoxDynError> {
<i64 as Encode<'q, Sqlite>>::encode_by_ref(&self.0, args)
}
}
impl<'r> Decode<'r, Sqlite> for UserDbId {
fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
let id = <i64 as Decode<'r, Sqlite>>::decode(value)?;
Ok(Self(id))
}
}

View File

@@ -1,3 +1,10 @@
use std::{collections::BTreeSet, ops::ControlFlow, sync::Arc};
use dptree::{
di::{Asyncify, Injectable},
from_fn_with_description, Handler, HandlerDescription, HandlerSignature, Type,
};
#[macro_export] #[macro_export]
macro_rules! case { macro_rules! case {
// Basic variant matching without parameters // Basic variant matching without parameters
@@ -72,6 +79,62 @@ macro_rules! case {
}; };
} }
pub trait MapTwo<'a, Output, Descr> {
#[must_use]
#[track_caller]
fn map2<Proj, NewType1, NewType2, Args>(self, proj: Proj) -> Handler<'a, Output, Descr>
where
Asyncify<Proj>: Injectable<(NewType1, NewType2), Args> + Send + Sync + 'a,
NewType1: Send + Sync + 'static,
NewType2: Send + Sync + 'static;
}
impl<'a, Output, Descr> MapTwo<'a, Output, Descr> for Handler<'a, Output, Descr>
where
Output: 'a,
Descr: HandlerDescription,
{
fn map2<Proj, NewType1, NewType2, Args>(self, proj: Proj) -> Handler<'a, Output, Descr>
where
Asyncify<Proj>: Injectable<(NewType1, NewType2), Args> + Send + Sync + 'a,
NewType1: Send + Sync + 'static,
NewType2: Send + Sync + 'static,
{
let proj = Arc::new(Asyncify(proj));
self.chain(from_fn_with_description(
Descr::map(),
move |container, cont| {
let proj = Arc::clone(&proj);
async move {
let proj = proj.inject(&container);
let (res1, res2) = proj().await;
std::mem::drop(proj);
let mut intermediate = container.clone();
intermediate.insert(res1);
intermediate.insert(res2);
match cont(intermediate).await {
ControlFlow::Continue(_) => ControlFlow::Continue(container),
ControlFlow::Break(result) => ControlFlow::Break(result),
}
}
},
HandlerSignature::Other {
obligations:
<Asyncify<Proj> as Injectable<(NewType1, NewType2), Args>>::obligations(),
guaranteed_outcomes: BTreeSet::from_iter(vec![
Type::of::<NewType1>(),
Type::of::<NewType2>(),
]),
conditional_outcomes: BTreeSet::new(),
continues: true,
},
))
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::ops::ControlFlow; use std::ops::ControlFlow;

View File

@@ -1,18 +1,16 @@
use crate::{ use crate::{
message_utils::{send_message, MessageTarget}, message_utils::{MessageTarget, SendHtmlMessage},
wrap_endpoint, BotError, BotResult, WrappedAsyncFn, wrap_endpoint, BotError, BotResult, WrappedAsyncFn,
}; };
use futures::future::BoxFuture; use futures::future::BoxFuture;
use std::{future::Future, pin::Pin};
use teloxide::Bot; use teloxide::Bot;
pub async fn handle_error(bot: Bot, target: MessageTarget, error: BotError) -> BotResult { pub async fn handle_error(bot: Bot, target: MessageTarget, error: BotError) -> BotResult {
log::error!("Error in handler: {error:?}"); log::error!("Error in handler: {error:?}");
match error { match error {
BotError::UserVisibleError(message) => send_message(&bot, target, message, None).await?, BotError::UserVisibleError(message) => bot.send_html_message(target, message, None).await?,
BotError::InternalError(_) => { BotError::InternalError(_) => {
send_message( bot.send_html_message(
&bot,
target, target,
"An internal error occurred. Please try again later.", "An internal error occurred. Please try again later.",
None, None,
@@ -27,7 +25,7 @@ fn boxed_handle_error(
bot: Bot, bot: Bot,
target: MessageTarget, target: MessageTarget,
error: BotError, error: BotError,
) -> Pin<Box<dyn Future<Output = BotResult> + Send>> { ) -> BoxFuture<'static, BotResult> {
Box::pin(handle_error(bot, target, error)) Box::pin(handle_error(bot, target, error))
} }

View File

@@ -19,7 +19,7 @@ use crate::commands::{
my_listings::{my_listings_handler, my_listings_inline_handler, MyListingsState}, my_listings::{my_listings_handler, my_listings_inline_handler, MyListingsState},
new_listing::{new_listing_handler, NewListingState}, new_listing::{new_listing_handler, NewListingState},
}; };
use crate::db::{ListingDAO, UserDAO}; use crate::db::DAOs;
use crate::handle_error::with_error_handler; use crate::handle_error::with_error_handler;
use crate::handler_utils::{find_or_create_db_user_from_update, update_into_message_target}; use crate::handler_utils::{find_or_create_db_user_from_update, update_into_message_target};
use crate::sqlite_storage::SqliteStorage; use crate::sqlite_storage::SqliteStorage;
@@ -33,6 +33,18 @@ use teloxide::dispatching::dialogue::serializer::Json;
use teloxide::{prelude::*, types::BotCommand, utils::command::BotCommands}; use teloxide::{prelude::*, types::BotCommand, utils::command::BotCommands};
pub use wrap_endpoint::*; pub use wrap_endpoint::*;
#[derive(Clone)]
pub struct App {
pub bot: Bot,
pub daos: DAOs,
}
impl App {
pub fn new(bot: Bot, daos: DAOs) -> Self {
Self { bot, daos }
}
}
/// Set up the bot's command menu that appears when users tap the menu button /// Set up the bot's command menu that appears when users tap the menu button
async fn setup_bot_commands(bot: &Bot) -> Result<()> { async fn setup_bot_commands(bot: &Bot) -> Result<()> {
info!("Setting up bot command menu..."); info!("Setting up bot command menu...");
@@ -94,6 +106,8 @@ async fn main() -> Result<()> {
setup_bot_commands(&bot).await?; setup_bot_commands(&bot).await?;
let dialog_storage = SqliteStorage::new(db_pool.clone(), Json).await?; let dialog_storage = SqliteStorage::new(db_pool.clone(), Json).await?;
let daos = DAOs::new(db_pool.clone());
let app = App::new(bot.clone(), daos.clone());
// Create dispatcher with dialogue system // Create dispatcher with dialogue system
Dispatcher::builder( Dispatcher::builder(
@@ -137,11 +151,7 @@ async fn main() -> Result<()> {
) )
.branch(Update::filter_message().endpoint(with_error_handler(unknown_message_handler))), .branch(Update::filter_message().endpoint(with_error_handler(unknown_message_handler))),
) )
.dependencies(dptree::deps![ .dependencies(dptree::deps![dialog_storage, daos, app])
dialog_storage,
ListingDAO::new(db_pool.clone()),
UserDAO::new(db_pool.clone())
])
.enable_ctrlc_handler() .enable_ctrlc_handler()
.worker_queue_size(1) .worker_queue_size(1)
.build() .build()

View File

@@ -121,8 +121,28 @@ impl TryFrom<&CallbackQuery> for MessageTarget {
} }
} }
pub trait SendHtmlMessage {
async fn send_html_message(
&self,
target: MessageTarget,
text: impl AsRef<str>,
keyboard: Option<InlineKeyboardMarkup>,
) -> BotResult;
}
impl SendHtmlMessage for Bot {
async fn send_html_message(
&self,
target: MessageTarget,
text: impl AsRef<str>,
keyboard: Option<InlineKeyboardMarkup>,
) -> BotResult {
send_html_message(self, target, text, keyboard).await
}
}
// Unified HTML message sending utility // Unified HTML message sending utility
pub async fn send_message( async fn send_html_message(
bot: &Bot, bot: &Bot,
target: MessageTarget, target: MessageTarget,
text: impl AsRef<str>, text: impl AsRef<str>,

View File

@@ -68,32 +68,36 @@ macro_rules! generate_wrapped {
}; };
} }
generate_wrapped!([], []); macro_rules! generate_wrapped_all {
generate_wrapped!([T1], []); // Entry: two lists (base generics, error generics)
generate_wrapped!([T1, T2], []); ([$($base:ident),*], [$($err:ident),*]) => {
generate_wrapped!([T1, T2, T3], []); generate_wrapped_all!(@recurse_base [$($base),*] [$($err),*]);
generate_wrapped!([T1, T2, T3, T4], []); };
generate_wrapped!([T1, T2, T3, T4, T5], []);
generate_wrapped!([T1, T2, T3, T4, T5, T6], []);
generate_wrapped!([T1, T2, T3, T4, T5, T6, T7], []);
generate_wrapped!([], [E1]); // Recurse over base prefixes (from full list down to empty)
generate_wrapped!([T1], [E1]); (@recurse_base [] [$($err:ident),*]) => {
generate_wrapped!([T1, T2], [E1]); generate_wrapped_all!(@recurse_err [] [$($err),*]);
generate_wrapped!([T1, T2, T3], [E1]); };
generate_wrapped!([T1, T2, T3, T4], [E1]); (@recurse_base [$head:ident $(, $tail:ident)*] [$($err:ident),*]) => {
generate_wrapped!([T1, T2, T3, T4, T5], [E1]); generate_wrapped_all!(@recurse_err [$head $(, $tail)*] [$($err),*]);
generate_wrapped!([T1, T2, T3, T4, T5, T6], [E1]); generate_wrapped_all!(@recurse_base [$( $tail ),*] [$($err),*]);
generate_wrapped!([T1, T2, T3, T4, T5, T6, T7], [E1]); };
generate_wrapped!([], [E1, E2]); // For a fixed base prefix, recurse over error prefixes (from full list down to empty)
generate_wrapped!([T1], [E1, E2]); (@recurse_err [$($base_current:ident),*] []) => {
generate_wrapped!([T1, T2], [E1, E2]); generate_wrapped!([$($base_current),*], []);
generate_wrapped!([T1, T2, T3], [E1, E2]); };
generate_wrapped!([T1, T2, T3, T4], [E1, E2]); (@recurse_err [$($base_current:ident),*] [$ehead:ident $(, $etail:ident)*]) => {
generate_wrapped!([T1, T2, T3, T4, T5], [E1, E2]); generate_wrapped!([$($base_current),*], [$ehead $(, $etail)*]);
generate_wrapped!([T1, T2, T3, T4, T5, T6], [E1, E2]); generate_wrapped_all!(@recurse_err [$($base_current),*] [$( $etail ),*]);
generate_wrapped!([T1, T2, T3, T4, T5, T6, T7], [E1, E2]); };
}
// Generate cartesian product of prefixes up to 12 base generics and 2 error generics
generate_wrapped_all!(
[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12],
[E1, E2]
);
pub fn wrap_endpoint<FnBase, FnError, ErrorType, FnBaseArgs, FnErrorArgs>( pub fn wrap_endpoint<FnBase, FnError, ErrorType, FnBaseArgs, FnErrorArgs>(
fn_base: FnBase, fn_base: FnBase,