basic scaffold for placing bids

This commit is contained in:
Dylan Knutson
2025-09-03 00:28:46 +00:00
parent c53dccbea2
commit 9ad562a4b2
21 changed files with 602 additions and 261 deletions

1
Cargo.lock generated
View File

@@ -1616,6 +1616,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"base64",
"chrono",
"dotenvy",
"dptree",

View File

@@ -31,6 +31,7 @@ regex = "1.11.2"
paste = "1.0"
dptree = "0.5.1"
seq-macro = "0.3.6"
base64 = "0.22.1"
[dev-dependencies]
rstest = "0.26.1"

1
src/bidding/keyboards.rs Normal file
View File

@@ -0,0 +1 @@

212
src/bidding/mod.rs Normal file
View File

@@ -0,0 +1,212 @@
mod keyboards;
use crate::{
case,
commands::new_listing::validations::{validate_price, SetFieldError},
db::{
listing::{ListingFields, PersistedListing},
user::PersistedUser,
ListingDbId, MoneyAmount, UserDAO,
},
handle_error::with_error_handler,
handler_utils::find_listing_by_id,
message_utils::{send_message, MessageTarget},
start_command_data::StartCommandData,
BotError, BotHandler, BotResult, DialogueRootState, RootDialogue,
};
use anyhow::{anyhow, Context};
use log::info;
use serde::{Deserialize, Serialize};
use teloxide::{
dispatching::UpdateFilterExt,
types::{CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, Update},
Bot,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum BiddingState {
AwaitingBidAmount(ListingDbId),
AwaitingConfirmBidAmount(ListingDbId, MoneyAmount),
}
impl From<BiddingState> for DialogueRootState {
fn from(state: BiddingState) -> Self {
DialogueRootState::Bidding(state)
}
}
pub fn bidding_handler() -> BotHandler {
dptree::entry()
.branch(
Update::filter_message()
.filter_map(StartCommandData::get_from_update)
.filter_map(StartCommandData::get_place_bid_on_listing_start_command)
.filter_map_async(find_listing_by_id)
.endpoint(with_error_handler(handle_place_bid_on_listing)),
)
.branch(
Update::filter_message()
.chain(case![DialogueRootState::Bidding(
BiddingState::AwaitingBidAmount(listing_id)
)])
.filter_map_async(find_listing_by_id)
.endpoint(with_error_handler(handle_awaiting_bid_amount_input)),
)
.branch(
Update::filter_callback_query()
.chain(case![DialogueRootState::Bidding(
BiddingState::AwaitingConfirmBidAmount(listing_id, bid_amount)
)])
.filter_map_async(
async |listing_dao, (listing_id, _): (ListingDbId, MoneyAmount)| {
find_listing_by_id(listing_dao, listing_id).await
},
)
.endpoint(with_error_handler(
handle_awaiting_confirm_bid_amount_callback,
)),
)
}
async fn handle_place_bid_on_listing(
bot: Bot,
user_dao: UserDAO,
target: MessageTarget,
user: PersistedUser,
listing: PersistedListing,
dialogue: RootDialogue,
) -> BotResult {
info!("Handling place bid on listing for listing {listing:?} for user {user:?}");
let seller = user_dao
.find_by_id(listing.base.seller_id)
.await?
.ok_or(BotError::UserVisibleError("Seller not found".to_string()))?;
let fields = match &listing.fields {
ListingFields::BasicAuction(fields) => fields,
_ => {
return Err(BotError::UserVisibleError(
"Unsupported listing type".to_string(),
))
}
};
dialogue
.update(BiddingState::AwaitingBidAmount(listing.persisted.id))
.await
.context("failed to update dialogue")?;
let mut response_lines = vec![];
response_lines.push(format!(
"Place bid on listing for listing <b>{}</b>, ran by {}",
listing.base.title,
seller
.username
.clone()
.unwrap_or_else(|| seller.telegram_id.to_string())
));
response_lines.push(format!("You are bidding on this listing as: {user:?}"));
response_lines.push(format!("Minimum bid: {}", fields.min_increment));
let keyboard = InlineKeyboardMarkup::default()
.append_row([InlineKeyboardButton::callback("Bid $1", "cancel")]);
send_message(&bot, target, response_lines.join("\n"), Some(keyboard)).await?;
Ok(())
}
async fn handle_awaiting_bid_amount_input(
bot: Bot,
listing: PersistedListing,
target: MessageTarget,
dialogue: RootDialogue,
msg: Message,
) -> BotResult {
// parse the bid amount into a MoneyAmount
let text = msg
.text()
.ok_or(BotError::user_visible("Please enter a valid bid amount"))?;
let bid_amount = match validate_price(text) {
Ok(bid_amount) => bid_amount,
Err(SetFieldError::ValidationFailed(e)) => {
return Err(BotError::user_visible(e));
}
Err(other) => {
return Err(anyhow!("Error validating bid amount: {other:?}").into());
}
};
send_message(
&bot,
target,
format!("Confirm bid amount: {bid_amount} - this cannot be undone!"),
Some(InlineKeyboardMarkup::default().append_row([
InlineKeyboardButton::callback(
format!("Confirm bid amount: {bid_amount}"),
"confirm_bid",
),
InlineKeyboardButton::callback("Cancel", "cancel_bid"),
])),
)
.await?;
dialogue
.update(BiddingState::AwaitingConfirmBidAmount(
listing.persisted.id,
bid_amount,
))
.await
.context("failed to update dialogue")?;
Ok(())
}
async fn handle_awaiting_confirm_bid_amount_callback(
bot: Bot,
listing: PersistedListing,
(_, bid_amount): (ListingDbId, MoneyAmount),
target: MessageTarget,
dialogue: RootDialogue,
callback_query: CallbackQuery,
) -> BotResult {
let callback_data = callback_query
.data
.as_deref()
.ok_or(BotError::user_visible("Missing data in callback query"))?;
let bid_amount = match callback_data {
"confirm_bid" => bid_amount,
"cancel_bid" => {
dialogue.exit().await.context("failed to exit dialogue")?;
send_message(&bot, target, "Bid cancelled", None).await?;
return Ok(());
}
_ => {
return Err(BotError::user_visible(format!(
"Invalid response {callback_data}"
)))
}
};
dialogue.exit().await.context("failed to exit dialogue")?;
send_message(
&bot,
target.only_chat_id(),
format!(
"Bid placed for {}{} on {}",
listing.base.currency_type.symbol(),
bid_amount,
listing.base.title
),
None,
)
.await?;
// TODO - keyboard with buttons to:
// - be notified if they are outbid
// - be notified when the auction ends
// - view details about the auction
Ok(())
}

View File

@@ -7,6 +7,11 @@ pub enum BotError {
#[error(transparent)]
InternalError(#[from] anyhow::Error),
}
impl BotError {
pub fn user_visible(msg: impl Into<String>) -> Self {
Self::UserVisibleError(msg.into())
}
}
pub type BotResult<T = ()> = Result<T, BotError>;
pub type BotHandler = dptree::Handler<'static, BotResult, DpHandlerDescription>;

View File

@@ -7,7 +7,7 @@ use crate::{
my_listings::keyboard::{ManageListingButtons, MyListingsButtons},
new_listing::{
enter_edit_listing_draft, enter_select_new_listing_type, keyboard::NavKeyboardButtons,
ListingDraft,
messages::steps_for_listing_type, ListingDraft,
},
},
db::{
@@ -15,17 +15,16 @@ use crate::{
user::PersistedUser,
ListingDAO, ListingDbId, ListingType,
},
handler_utils::{
callback_query_into_message_target, find_or_create_db_user_from_callback_query,
find_or_create_db_user_from_message, message_into_message_target,
},
handle_error::with_error_handler,
handler_utils::{find_listing_by_id, find_or_create_db_user_from_update},
message_utils::{extract_callback_data, pluralize_with_count, send_message, MessageTarget},
start_command_data::StartCommandData,
BotError, BotResult, Command, DialogueRootState, RootDialogue,
};
use anyhow::{anyhow, Context};
use base64::{prelude::BASE64_URL_SAFE, Engine};
use log::info;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use teloxide::{
dispatching::{DpHandlerDescription, UpdateFilterExt},
prelude::*,
@@ -57,46 +56,54 @@ pub fn my_listings_handler() -> Handler<'static, BotResult, DpHandlerDescription
dptree::entry()
.branch(
Update::filter_message()
.filter_command::<Command>()
.map(message_into_message_target)
.branch(
dptree::case![Command::MyListings]
.filter_map_async(find_or_create_db_user_from_message)
.endpoint(handle_my_listings_command_input),
),
.filter_map(StartCommandData::get_from_update)
.filter_map(StartCommandData::get_view_listing_details_start_command)
.filter_map_async(find_listing_by_id)
.endpoint(with_error_handler(handle_view_listing_details)),
)
.branch(
Update::filter_message().filter_command::<Command>().branch(
dptree::case![Command::MyListings]
.filter_map_async(find_or_create_db_user_from_update)
.endpoint(with_error_handler(handle_my_listings_command_input)),
),
)
.branch(
Update::filter_callback_query()
.filter_map(callback_query_into_message_target)
.branch(
// Callback when user taps a listing ID button to manage that listing
case![DialogueRootState::MyListings(
MyListingsState::ViewingListings
)]
.filter_map_async(find_or_create_db_user_from_callback_query)
.endpoint(handle_viewing_listings_callback),
.endpoint(with_error_handler(handle_viewing_listings_callback)),
)
.branch(
case![DialogueRootState::MyListings(
MyListingsState::ManagingListing(listing_id)
)]
.filter_map_async(find_or_create_db_user_from_callback_query)
.endpoint(handle_managing_listing_callback),
.endpoint(with_error_handler(handle_managing_listing_callback)),
),
)
}
async fn handle_view_listing_details(
bot: Bot,
listing: PersistedListing,
target: MessageTarget,
) -> BotResult {
send_listing_details_message(&bot, target, listing, None).await?;
Ok(())
}
async fn inline_query_extract_forward_listing(
db_pool: SqlitePool,
listing_dao: ListingDAO,
inline_query: InlineQuery,
) -> Option<PersistedListing> {
let query = &inline_query.query;
info!("Try to extract forward listing from query: {query}");
let listing_id_str = query.split("forward_listing:").nth(1)?;
let listing_id = ListingDbId::new(listing_id_str.parse::<i64>().ok()?);
let listing = ListingDAO::find_by_id(&db_pool, listing_id)
.await
.unwrap_or(None)?;
let listing = listing_dao.find_by_id(listing_id).await.unwrap_or(None)?;
Some(listing)
}
@@ -121,9 +128,16 @@ async fn handle_forward_listing(
// Create inline keyboard with auction interaction buttons
let keyboard = InlineKeyboardMarkup::default()
.append_row([
InlineKeyboardButton::callback(
"💰 Place Bid",
format!("inline_bid:{}", listing.persisted.id),
InlineKeyboardButton::url(
"💰 Place Bid?",
format!(
"tg://resolve?domain={}&start={}",
bot_username,
BASE64_URL_SAFE
.encode(format!("place_bid_on_listing:{}", listing.persisted.id))
)
.parse()
.unwrap(),
),
InlineKeyboardButton::callback(
"👀 Watch",
@@ -133,8 +147,9 @@ async fn handle_forward_listing(
.append_row([InlineKeyboardButton::url(
"🔗 View Full Details",
format!(
"https://t.me/{}?start=listing:{}",
bot_username, listing.persisted.id
"tg://resolve?domain={}&start={}",
bot_username,
BASE64_URL_SAFE.encode(format!("view_listing_details:{}", listing.persisted.id))
)
.parse()
.unwrap(),
@@ -204,18 +219,18 @@ fn get_listing_current_price(listing: &PersistedListing) -> String {
}
async fn handle_my_listings_command_input(
db_pool: SqlitePool,
listing_dao: ListingDAO,
bot: Bot,
dialogue: RootDialogue,
user: PersistedUser,
target: MessageTarget,
) -> BotResult {
enter_my_listings(db_pool, bot, dialogue, user, target, None).await?;
enter_my_listings(listing_dao, bot, dialogue, user, target, None).await?;
Ok(())
}
pub async fn enter_my_listings(
db_pool: SqlitePool,
listing_dao: ListingDAO,
bot: Bot,
dialogue: RootDialogue,
user: PersistedUser,
@@ -228,7 +243,7 @@ pub async fn enter_my_listings(
.await
.context("failed to update dialogue")?;
let listings = ListingDAO::find_by_seller(&db_pool, user.persisted.id).await?;
let listings = listing_dao.find_by_seller(user.persisted.id).await?;
// Create keyboard with buttons for each listing
let mut keyboard = teloxide::types::InlineKeyboardMarkup::default();
for listing in &listings {
@@ -267,7 +282,7 @@ pub async fn enter_my_listings(
}
async fn handle_viewing_listings_callback(
db_pool: SqlitePool,
listing_dao: ListingDAO,
bot: Bot,
dialogue: RootDialogue,
callback_query: CallbackQuery,
@@ -284,7 +299,7 @@ async fn handle_viewing_listings_callback(
let button = MyListingsButtons::try_from(data.as_str())?;
match button {
MyListingsButtons::SelectListing(listing_id) => {
let listing = get_listing_for_user(&db_pool, user, listing_id).await?;
let listing = get_listing_for_user(&listing_dao, user, listing_id).await?;
enter_show_listing_details(&bot, dialogue, listing, target).await?;
}
@@ -302,19 +317,7 @@ async fn enter_show_listing_details(
listing: PersistedListing,
target: MessageTarget,
) -> BotResult {
let listing_type = Into::<ListingType>::into(&listing.fields);
let listing_id = listing.persisted.id;
let response = format!(
"🔍 <b>{listing_type} Details</b>\n\n\
<b>Title:</b> {}\n\
<b>Description:</b> {}\n",
listing.base.title,
listing
.base
.description
.as_deref()
.unwrap_or("No description"),
);
dialogue
.update(MyListingsState::ManagingListing(listing_id))
.await
@@ -332,12 +335,34 @@ async fn enter_show_listing_details(
ManageListingButtons::Delete.to_button(),
])
.append_row([ManageListingButtons::Back.to_button()]);
send_message(bot, target, response, Some(keyboard)).await?;
send_listing_details_message(bot, target, listing, Some(keyboard)).await?;
Ok(())
}
async fn send_listing_details_message(
bot: &Bot,
target: MessageTarget,
listing: PersistedListing,
keyboard: Option<InlineKeyboardMarkup>,
) -> BotResult {
let listing_type = Into::<ListingType>::into(&listing.fields);
let mut response_lines = vec![format!("🔍 <b>{listing_type} Details</b>")];
response_lines.push("".to_string());
let draft = ListingDraft::from_persisted(listing);
for step in steps_for_listing_type(listing_type) {
let field_value = match (step.get_field_value)(&draft) {
Ok(value) => value.unwrap_or_else(|| "(none)".to_string()),
Err(_) => continue,
};
response_lines.push(format!("<b>{}:</b> {}", step.field_name, field_value));
}
send_message(bot, target, response_lines.join("\n"), keyboard).await?;
Ok(())
}
async fn handle_managing_listing_callback(
db_pool: SqlitePool,
listing_dao: ListingDAO,
bot: Bot,
dialogue: RootDialogue,
callback_query: CallbackQuery,
@@ -350,7 +375,8 @@ async fn handle_managing_listing_callback(
match ManageListingButtons::try_from(data.as_str())? {
ManageListingButtons::PreviewMessage => {
let listing = ListingDAO::find_by_id(&db_pool, listing_id)
let listing = listing_dao
.find_by_id(listing_id)
.await?
.ok_or(anyhow::anyhow!("Listing not found"))?;
send_preview_listing_message(&bot, listing, from).await?;
@@ -359,14 +385,14 @@ async fn handle_managing_listing_callback(
unimplemented!("Forward listing not implemented");
}
ManageListingButtons::Edit => {
let listing = get_listing_for_user(&db_pool, user, listing_id).await?;
let listing = get_listing_for_user(&listing_dao, user, listing_id).await?;
let draft = ListingDraft::from_persisted(listing);
enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?;
}
ManageListingButtons::Delete => {
ListingDAO::delete_listing(&db_pool, listing_id).await?;
listing_dao.delete_listing(listing_id).await?;
enter_my_listings(
db_pool,
listing_dao,
bot,
dialogue,
user,
@@ -376,7 +402,7 @@ async fn handle_managing_listing_callback(
.await?;
}
ManageListingButtons::Back => {
enter_my_listings(db_pool, bot, dialogue, user, target, None).await?;
enter_my_listings(listing_dao, bot, dialogue, user, target, None).await?;
}
}
@@ -435,11 +461,11 @@ async fn send_preview_listing_message(
}
async fn get_listing_for_user(
db_pool: &SqlitePool,
listing_dao: &ListingDAO,
user: PersistedUser,
listing_id: ListingDbId,
) -> BotResult<PersistedListing> {
let listing = match ListingDAO::find_by_id(db_pool, listing_id).await? {
let listing = match listing_dao.find_by_id(listing_id).await? {
Some(listing) => listing,
None => {
return Err(BotError::UserVisibleError("❌ Listing not found.".into()));

View File

@@ -15,17 +15,18 @@ use crate::{
ui::enter_confirm_save_listing,
},
},
db::{user::PersistedUser, CurrencyType, ListingDuration, ListingType, MoneyAmount},
db::{
user::PersistedUser, CurrencyType, ListingDAO, ListingDuration, ListingType, MoneyAmount,
},
message_utils::*,
BotResult, RootDialogue,
};
use log::{error, info};
use sqlx::SqlitePool;
use teloxide::{types::CallbackQuery, Bot};
/// Handle callbacks during the listing type selection phase
pub async fn handle_selecting_listing_type_callback(
db_pool: SqlitePool,
listing_dao: ListingDAO,
bot: Bot,
dialogue: RootDialogue,
user: PersistedUser,
@@ -36,7 +37,7 @@ pub async fn handle_selecting_listing_type_callback(
info!("User {target:?} selected listing type: {data:?}");
if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) {
return enter_my_listings(db_pool, bot, dialogue, user, target, None).await;
return enter_my_listings(listing_dao, bot, dialogue, user, target, None).await;
}
// Parse the listing type from callback data

View File

@@ -1,12 +1,5 @@
use super::{callbacks::*, handlers::*, types::*};
use crate::{
case,
handler_utils::{
callback_query_into_message_target, find_or_create_db_user_from_callback_query,
message_into_message_target,
},
BotHandler, Command, DialogueRootState,
};
use crate::{case, handle_error::with_error_handler, BotHandler, Command, DialogueRootState};
use teloxide::{dptree, prelude::*, types::Update};
// Create the dialogue handler tree for new listing wizard
@@ -14,59 +7,56 @@ pub fn new_listing_handler() -> BotHandler {
dptree::entry()
.branch(
Update::filter_message()
.map(message_into_message_target)
.branch(
dptree::entry()
.filter_command::<Command>()
.chain(case![Command::NewListing])
.endpoint(handle_new_listing_command),
.endpoint(with_error_handler(handle_new_listing_command)),
)
.branch(
case![DialogueRootState::NewListing(
NewListingState::AwaitingDraftField { field, draft }
)]
.endpoint(handle_awaiting_draft_field_input),
.endpoint(with_error_handler(handle_awaiting_draft_field_input)),
)
.branch(
case![DialogueRootState::NewListing(
NewListingState::EditingDraftField { field, draft }
)]
.endpoint(handle_editing_field_input),
.endpoint(with_error_handler(handle_editing_field_input)),
),
)
.branch(
Update::filter_callback_query()
.filter_map(callback_query_into_message_target)
.filter_map_async(find_or_create_db_user_from_callback_query)
.branch(
case![DialogueRootState::NewListing(
NewListingState::SelectingListingType
)]
.endpoint(handle_selecting_listing_type_callback),
.endpoint(with_error_handler(handle_selecting_listing_type_callback)),
)
.branch(
case![DialogueRootState::NewListing(
NewListingState::AwaitingDraftField { field, draft }
)]
.endpoint(handle_awaiting_draft_field_callback),
.endpoint(with_error_handler(handle_awaiting_draft_field_callback)),
)
.branch(
case![DialogueRootState::NewListing(
NewListingState::ViewingDraft(draft)
)]
.endpoint(handle_viewing_draft_callback),
.endpoint(with_error_handler(handle_viewing_draft_callback)),
)
.branch(
case![DialogueRootState::NewListing(
NewListingState::EditingDraft(draft)
)]
.endpoint(handle_editing_draft_callback),
.endpoint(with_error_handler(handle_editing_draft_callback)),
)
.branch(
case![DialogueRootState::NewListing(
NewListingState::EditingDraftField { field, draft }
)]
.endpoint(handle_editing_draft_field_callback),
.endpoint(with_error_handler(handle_editing_draft_field_callback)),
),
)
}

View File

@@ -28,11 +28,10 @@ use crate::{
ListingDAO,
},
message_utils::*,
BotResult, DialogueRootState, RootDialogue,
BotError, BotResult, DialogueRootState, RootDialogue,
};
use anyhow::{anyhow, Context};
use log::info;
use sqlx::SqlitePool;
use teloxide::{prelude::*, types::*, Bot};
/// Handle the /newlisting command - starts the dialogue
@@ -80,8 +79,7 @@ pub async fn handle_awaiting_draft_field_input(
match update_field_on_draft(field, &mut draft, msg.text()) {
Ok(()) => (),
Err(SetFieldError::ValidationFailed(e)) => {
send_message(&bot, target, e.clone(), None).await?;
return Ok(());
return Err(BotError::user_visible(e));
}
Err(SetFieldError::UnsupportedFieldForListingType) => {
return Err(anyhow!("Cannot update field {field:?} for listing type").into());
@@ -121,8 +119,7 @@ pub async fn handle_editing_field_input(
match update_field_on_draft(field, &mut draft, msg.text()) {
Ok(()) => (),
Err(SetFieldError::ValidationFailed(e)) => {
send_message(&bot, target, e.clone(), None).await?;
return Ok(());
return Err(BotError::user_visible(e));
}
Err(SetFieldError::UnsupportedFieldForListingType) => {
return Err(anyhow!("Cannot update field {field:?} for listing type").into());
@@ -139,7 +136,7 @@ pub async fn handle_editing_field_input(
/// Handle viewing draft confirmation callbacks
pub async fn handle_viewing_draft_callback(
db_pool: SqlitePool,
listing_dao: ListingDAO,
bot: Bot,
dialogue: RootDialogue,
draft: ListingDraft,
@@ -152,8 +149,16 @@ pub async fn handle_viewing_draft_callback(
match ConfirmationKeyboardButtons::try_from(data.as_str())? {
ConfirmationKeyboardButtons::Create | ConfirmationKeyboardButtons::Save => {
info!("User {target:?} confirmed listing creation");
let success_message = save_listing(&db_pool, draft).await?;
enter_my_listings(db_pool, bot, dialogue, user, target, Some(success_message)).await?;
let success_message = save_listing(&listing_dao, draft).await?;
enter_my_listings(
listing_dao,
bot,
dialogue,
user,
target,
Some(success_message),
)
.await?;
}
ConfirmationKeyboardButtons::Cancel => {
info!("User {target:?} cancelled listing update");
@@ -272,28 +277,24 @@ pub async fn enter_edit_listing_draft(
}
/// Save the listing to the database
async fn save_listing(db_pool: &SqlitePool, draft: ListingDraft) -> BotResult<String> {
async fn save_listing(listing_dao: &ListingDAO, draft: ListingDraft) -> BotResult<String> {
let (listing, success_message) = if let Some(fields) = draft.persisted {
let listing = ListingDAO::update_listing(
db_pool,
PersistedListing {
let listing = listing_dao
.update_listing(PersistedListing {
persisted: fields,
base: draft.base,
fields: draft.fields,
},
)
.await?;
})
.await?;
(listing, "Listing updated!")
} else {
let listing = ListingDAO::insert_listing(
db_pool,
NewListing {
let listing = listing_dao
.insert_listing(NewListing {
persisted: (),
base: draft.base,
fields: draft.fields,
},
)
.await?;
})
.await?;
(listing, "Listing created!")
};

View File

@@ -22,7 +22,7 @@ pub mod messages;
mod tests;
mod types;
mod ui;
mod validations;
pub mod validations;
// Re-export the main handler for external use
pub use handler_factory::new_listing_handler;

View File

@@ -1,12 +1,14 @@
use anyhow::Context;
use log::info;
use teloxide::{types::CallbackQuery, utils::command::BotCommands, Bot};
use sqlx::SqlitePool;
use teloxide::{
types::{CallbackQuery, Update},
utils::command::BotCommands,
Bot,
};
use crate::{
commands::my_listings::enter_my_listings,
db::user::PersistedUser,
db::{user::PersistedUser, ListingDAO},
keyboard_buttons,
message_utils::{extract_callback_data, send_message, MessageTarget},
BotResult, Command, DialogueRootState, RootDialogue,
@@ -26,7 +28,7 @@ keyboard_buttons! {
}
/// Get the main menu welcome message
pub fn get_main_menu_message() -> &'static str {
fn get_main_menu_message() -> &'static str {
"🎯 <b>Welcome to Pawctioneer Bot!</b> 🎯\n\n\
This bot helps you participate in various types of auctions:\n\
• Standard auctions with anti-sniping protection\n\
@@ -36,7 +38,13 @@ pub fn get_main_menu_message() -> &'static str {
Choose an option below to get started! 🚀"
}
pub async fn handle_start(bot: Bot, dialogue: RootDialogue, target: MessageTarget) -> BotResult {
pub async fn handle_start(
bot: Bot,
dialogue: RootDialogue,
target: MessageTarget,
update: Update,
) -> BotResult {
info!("got start message: {update:?}");
enter_main_menu(bot, dialogue, target).await?;
Ok(())
}
@@ -60,7 +68,7 @@ pub async fn enter_main_menu(bot: Bot, dialogue: RootDialogue, target: MessageTa
}
pub async fn handle_main_menu_callback(
db_pool: SqlitePool,
listing_dao: ListingDAO,
bot: Bot,
dialogue: RootDialogue,
user: PersistedUser,
@@ -74,7 +82,7 @@ pub async fn handle_main_menu_callback(
match button {
MainMenuButtons::MyListings => {
// Call show_listings_for_user directly
enter_my_listings(db_pool, bot, dialogue, user, target, None).await?;
enter_my_listings(listing_dao, bot, dialogue, user, target, None).await?;
}
MainMenuButtons::MyBids => {
send_message(

View File

@@ -19,7 +19,8 @@ use crate::db::{
};
/// Data Access Object for Listing operations
pub struct ListingDAO;
#[derive(Clone)]
pub struct ListingDAO(SqlitePool);
const LISTING_RETURN_FIELDS: &[&str] = &[
"id",
@@ -40,11 +41,12 @@ const LISTING_RETURN_FIELDS: &[&str] = &[
];
impl ListingDAO {
pub fn new(pool: SqlitePool) -> Self {
Self(pool)
}
/// Insert a new listing into the database
pub async fn insert_listing(
pool: &SqlitePool,
listing: NewListing,
) -> Result<PersistedListing> {
pub async fn insert_listing(&self, listing: NewListing) -> Result<PersistedListing> {
let now = Utc::now();
let binds = binds_for_listing(&listing)
@@ -66,15 +68,12 @@ impl ListingDAO {
let row = binds
.bind_to_query(sqlx::query(&query_str))
.fetch_one(pool)
.fetch_one(&self.0)
.await?;
Ok(FromRow::from_row(&row)?)
}
pub async fn update_listing(
pool: &SqlitePool,
listing: PersistedListing,
) -> Result<PersistedListing> {
pub async fn update_listing(&self, listing: PersistedListing) -> Result<PersistedListing> {
let now = Utc::now();
let binds = binds_for_listing(&listing).push("updated_at", &now);
@@ -97,47 +96,41 @@ impl ListingDAO {
.bind_to_query(sqlx::query(&query_str))
.bind(listing.persisted.id)
.bind(listing.base.seller_id)
.fetch_one(pool)
.fetch_one(&self.0)
.await?;
Ok(FromRow::from_row(&row)?)
}
/// Find a listing by its ID
pub async fn find_by_id(
pool: &SqlitePool,
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!(
"SELECT {} FROM listings WHERE id = ?",
LISTING_RETURN_FIELDS.join(", ")
))
.bind(listing_id)
.fetch_optional(pool)
.fetch_optional(&self.0)
.await?;
Ok(result)
}
/// Find all listings by a seller
pub async fn find_by_seller(
pool: &SqlitePool,
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!(
"SELECT {} FROM listings WHERE seller_id = ? ORDER BY created_at DESC",
LISTING_RETURN_FIELDS.join(", ")
))
.bind(seller_id)
.fetch_all(pool)
.fetch_all(&self.0)
.await?;
Ok(rows)
}
/// Delete a listing
pub async fn delete_listing(pool: &SqlitePool, listing_id: ListingDbId) -> Result<()> {
pub async fn delete_listing(&self, listing_id: ListingDbId) -> Result<()> {
sqlx::query("DELETE FROM listings WHERE id = ?")
.bind(listing_id)
.execute(pool)
.execute(&self.0)
.await?;
Ok(())

View File

@@ -14,7 +14,8 @@ use crate::db::{
};
/// Data Access Object for User operations
pub struct UserDAO;
#[derive(Clone)]
pub struct UserDAO(SqlitePool);
const USER_RETURN_FIELDS: &[&str] = &[
"id",
@@ -29,8 +30,12 @@ const USER_RETURN_FIELDS: &[&str] = &[
#[allow(unused)]
impl UserDAO {
pub fn new(pool: SqlitePool) -> Self {
Self(pool)
}
/// Insert a new user into the database
pub async fn insert_user(pool: &SqlitePool, new_user: &NewUser) -> Result<PersistedUser> {
pub async fn insert_user(&self, new_user: &NewUser) -> Result<PersistedUser> {
let binds = BindFields::default()
.push("telegram_id", &new_user.telegram_id)
.push("first_name", &new_user.first_name)
@@ -48,13 +53,13 @@ impl UserDAO {
USER_RETURN_FIELDS.join(", ")
);
let query = sqlx::query(&query_str);
let row = binds.bind_to_query(query).fetch_one(pool).await?;
let row = binds.bind_to_query(query).fetch_one(&self.0).await?;
Ok(FromRow::from_row(&row)?)
}
/// Find a user by their ID
pub async fn find_by_id(pool: &SqlitePool, user_id: UserDbId) -> Result<Option<PersistedUser>> {
pub async fn find_by_id(&self, user_id: UserDbId) -> Result<Option<PersistedUser>> {
Ok(sqlx::query_as::<_, PersistedUser>(
r#"
SELECT id, telegram_id, first_name, last_name, username, is_banned, created_at, updated_at
@@ -63,13 +68,13 @@ impl UserDAO {
"#,
)
.bind(user_id)
.fetch_optional(pool)
.fetch_optional(&self.0)
.await?)
}
/// Find a user by their Telegram ID
pub async fn find_by_telegram_id(
pool: &SqlitePool,
&self,
telegram_id: impl Into<TelegramUserDbId>,
) -> Result<Option<PersistedUser>> {
let telegram_id = telegram_id.into();
@@ -81,12 +86,12 @@ impl UserDAO {
"#,
)
.bind(telegram_id)
.fetch_optional(pool)
.fetch_optional(&self.0)
.await?)
}
pub async fn find_or_create_by_telegram_user(
pool: &SqlitePool,
&self,
user: teloxide::types::User,
) -> Result<PersistedUser> {
let binds = BindFields::default()
@@ -112,7 +117,7 @@ impl UserDAO {
let row = binds
.bind_to_query(sqlx::query(&query_str))
.fetch_one(pool)
.fetch_one(&self.0)
.await?;
let user = FromRow::from_row(&row)?;
@@ -121,7 +126,7 @@ impl UserDAO {
}
/// Update a user's information
pub async fn update_user(pool: &SqlitePool, user: &PersistedUser) -> Result<PersistedUser> {
pub async fn update_user(&self, user: &PersistedUser) -> Result<PersistedUser> {
let updated_user = sqlx::query_as::<_, PersistedUser>(
r#"
UPDATE users
@@ -135,32 +140,28 @@ impl UserDAO {
.bind(&user.last_name)
.bind(user.is_banned) // sqlx automatically converts bool to INTEGER for SQLite
.bind(user.persisted.id)
.fetch_one(pool)
.fetch_one(&self.0)
.await?;
Ok(updated_user)
}
/// Set a user's ban status
pub async fn set_ban_status(
pool: &SqlitePool,
user_id: UserDbId,
is_banned: bool,
) -> Result<()> {
pub async fn set_ban_status(&self, user_id: UserDbId, is_banned: bool) -> Result<()> {
sqlx::query("UPDATE users SET is_banned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.bind(is_banned) // sqlx automatically converts bool to INTEGER for SQLite
.bind(user_id)
.execute(pool)
.execute(&self.0)
.await?;
Ok(())
}
/// Delete a user (soft delete by setting is_banned = true might be better in production)
pub async fn delete_user(pool: &SqlitePool, user_id: UserDbId) -> Result<()> {
pub async fn delete_user(&self, user_id: UserDbId) -> Result<()> {
sqlx::query("DELETE FROM users WHERE id = ?")
.bind(user_id)
.execute(pool)
.execute(&self.0)
.await?;
Ok(())
@@ -194,7 +195,7 @@ mod tests {
use teloxide::types::UserId;
/// Create test database for UserDAO tests
async fn create_test_pool() -> SqlitePool {
async fn create_test_dao() -> UserDAO {
let pool = SqlitePool::connect("sqlite::memory:")
.await
.expect("Failed to create in-memory database");
@@ -205,12 +206,12 @@ mod tests {
.await
.expect("Failed to run database migrations");
pool
UserDAO::new(pool)
}
#[tokio::test]
async fn test_insert_and_find_user() {
let pool = create_test_pool().await;
let dao = create_test_dao().await;
let new_user = NewUser {
persisted: (),
@@ -222,7 +223,8 @@ mod tests {
};
// Insert user
let inserted_user = UserDAO::insert_user(&pool, &new_user)
let inserted_user = dao
.insert_user(&new_user)
.await
.expect("Failed to insert user");
@@ -232,7 +234,8 @@ mod tests {
assert!(!inserted_user.is_banned);
// Find by ID
let found_user = UserDAO::find_by_id(&pool, inserted_user.persisted.id)
let found_user = dao
.find_by_id(inserted_user.persisted.id)
.await
.expect("Failed to find user by id")
.expect("User should be found");
@@ -241,7 +244,8 @@ mod tests {
assert_eq!(found_user.telegram_id, inserted_user.telegram_id);
// Find by telegram ID
let found_by_telegram = UserDAO::find_by_telegram_id(&pool, UserId(12345))
let found_by_telegram = dao
.find_by_telegram_id(UserId(12345))
.await
.expect("Failed to find user by telegram_id")
.expect("User should be found");
@@ -252,12 +256,11 @@ mod tests {
#[tokio::test]
async fn test_get_or_create_user() {
let pool = create_test_pool().await;
let dao = create_test_dao().await;
// First call should create the user
let user1 = UserDAO::find_or_create_by_telegram_user(
&pool,
teloxide::types::User {
let user1 = dao
.find_or_create_by_telegram_user(teloxide::types::User {
id: UserId(67890),
is_bot: false,
first_name: "New User".to_string(),
@@ -266,18 +269,16 @@ mod tests {
language_code: None,
is_premium: false,
added_to_attachment_menu: false,
},
)
.await
.expect("Failed to get or create user");
})
.await
.expect("Failed to get or create user");
assert_eq!(user1.telegram_id, 67890.into());
assert_eq!(user1.username, Some("newuser".to_string()));
// Second call should return the same user
let user2 = UserDAO::find_or_create_by_telegram_user(
&pool,
teloxide::types::User {
let user2 = dao
.find_or_create_by_telegram_user(teloxide::types::User {
id: UserId(67890),
is_bot: false,
first_name: "New User".to_string(),
@@ -286,10 +287,9 @@ mod tests {
language_code: None,
is_premium: false,
added_to_attachment_menu: false,
},
)
.await
.expect("Failed to get or create user");
})
.await
.expect("Failed to get or create user");
assert_eq!(user1.persisted.id, user2.persisted.id);
assert_eq!(user2.username, Some("newuser".to_string())); // Original username preserved
@@ -297,7 +297,7 @@ mod tests {
#[tokio::test]
async fn test_update_user() {
let pool = create_test_pool().await;
let pool = create_test_dao().await;
let new_user = NewUser {
persisted: (),
@@ -328,7 +328,7 @@ mod tests {
#[tokio::test]
async fn test_delete_user() {
let pool = create_test_pool().await;
let pool = create_test_dao().await;
let new_user = NewUser {
persisted: (),
@@ -358,7 +358,7 @@ mod tests {
#[tokio::test]
async fn test_find_nonexistent_user() {
let pool = create_test_pool().await;
let pool = create_test_dao().await;
// Try to find a user that doesn't exist
let not_found = UserDAO::find_by_id(&pool, UserDbId::new(99999))
@@ -390,7 +390,7 @@ mod tests {
#[case] first_name: Option<&str>,
#[case] last_name: Option<&str>,
) {
let pool = create_test_pool().await;
let pool = create_test_dao().await;
let user_id = UserId(12345);
let initial = teloxide::types::User {
@@ -440,7 +440,7 @@ mod tests {
#[tokio::test]
async fn test_multiple_users_separate() {
let pool = create_test_pool().await;
let pool = create_test_dao().await;
let user1 = teloxide::types::User {
id: UserId(111),
@@ -477,7 +477,7 @@ mod tests {
#[tokio::test]
async fn test_upsert_preserves_id_and_timestamps() {
let pool = create_test_pool().await;
let pool = create_test_dao().await;
let user = teloxide::types::User {
id: UserId(333),

View File

@@ -36,14 +36,14 @@ pub struct Listing<P: Debug + Clone> {
pub type ListingBaseFields<'a> = (&'a ListingBase, &'a ListingFields);
pub type ListingBaseFieldsMut<'a> = (&'a mut ListingBase, &'a mut ListingFields);
impl<'a, P: Debug + Clone> Into<ListingBaseFields<'a>> for &'a Listing<P> {
fn into(self) -> ListingBaseFields<'a> {
(&self.base, &self.fields)
impl<'a, P: Debug + Clone> From<&'a Listing<P>> for ListingBaseFields<'a> {
fn from(value: &'a Listing<P>) -> Self {
(&value.base, &value.fields)
}
}
impl<'a, P: Debug + Clone> Into<ListingBaseFieldsMut<'a>> for &'a mut Listing<P> {
fn into(self) -> ListingBaseFieldsMut<'a> {
(&mut self.base, &mut self.fields)
impl<'a, P: Debug + Clone> From<&'a mut Listing<P>> for ListingBaseFieldsMut<'a> {
fn from(value: &'a mut Listing<P>) -> Self {
(&mut value.base, &mut value.fields)
}
}
@@ -135,40 +135,17 @@ impl From<&ListingFields> for ListingType {
#[cfg(test)]
mod tests {
use super::*;
use crate::db::{ListingDAO, TelegramUserDbId};
use crate::db::{TelegramUserDbId, UserDAO};
use chrono::Duration;
use rstest::rstest;
use sqlx::SqlitePool;
/// Test utilities for creating an in-memory database with migrations
async fn create_test_pool() -> SqlitePool {
// Create an in-memory SQLite database for testing
let pool = SqlitePool::connect("sqlite::memory:")
.await
.expect("Failed to create in-memory database");
// Run the migration
apply_test_migrations(&pool).await;
pool
}
/// Apply the database migrations for testing
async fn apply_test_migrations(pool: &SqlitePool) {
// Run the actual migrations from the migrations directory
sqlx::migrate!("./migrations")
.run(pool)
.await
.expect("Failed to run database migrations");
}
/// Create a test user using UserDAO and return their ID
async fn create_test_user(
pool: &SqlitePool,
user_dao: &UserDAO,
telegram_id: TelegramUserDbId,
username: Option<&str>,
) -> UserDbId {
use crate::db::{models::user::NewUser, UserDAO};
use crate::db::models::user::NewUser;
let new_user = NewUser {
persisted: (),
@@ -179,7 +156,8 @@ mod tests {
is_banned: false,
};
let user = UserDAO::insert_user(pool, &new_user)
let user = user_dao
.insert_user(&new_user)
.await
.expect("Failed to create test user");
user.persisted.id
@@ -208,8 +186,12 @@ mod tests {
#[case(ListingFields::FixedPriceListing(FixedPriceListingFields { buy_now_price: MoneyAmount::from_str("100.00").unwrap(), slots_available: 10 }))]
#[tokio::test]
async fn test_blind_auction_crud(#[case] fields: ListingFields) {
use crate::{db::ListingDAO, test_utils::create_test_pool};
let pool = create_test_pool().await;
let seller_id = create_test_user(&pool, 99999.into(), Some("testuser")).await;
let user_dao = UserDAO::new(pool.clone());
let listing_dao = ListingDAO::new(pool.clone());
let seller_id = create_test_user(&user_dao, 99999.into(), Some("testuser")).await;
let new_listing = build_base_listing(
seller_id,
"Test Auction",
@@ -219,14 +201,16 @@ mod tests {
.with_fields(fields);
// Insert using DAO
let created_listing = ListingDAO::insert_listing(&pool, new_listing.clone())
let created_listing = listing_dao
.insert_listing(new_listing.clone())
.await
.expect("Failed to insert listing");
assert_eq!(created_listing.base, new_listing.base);
assert_eq!(created_listing.fields, new_listing.fields);
let read_listing = ListingDAO::find_by_id(&pool, created_listing.persisted.id)
let read_listing = listing_dao
.find_by_id(created_listing.persisted.id)
.await
.expect("Failed to find listing")
.expect("Listing should exist");

View File

@@ -7,11 +7,10 @@ use std::{future::Future, pin::Pin};
use teloxide::Bot;
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 {
BotError::UserVisibleError(message) => send_message(&bot, target, message, None).await?,
BotError::InternalError(error) => {
log::error!("Internal error: {error}");
BotError::InternalError(_) => {
send_message(
&bot,
target,

View File

@@ -1,32 +1,24 @@
use sqlx::SqlitePool;
use teloxide::types::{CallbackQuery, Message};
use log::warn;
use teloxide::types::Update;
use crate::{
db::{user::PersistedUser, UserDAO},
db::{listing::PersistedListing, user::PersistedUser, ListingDAO, ListingDbId, UserDAO},
message_utils::MessageTarget,
};
pub async fn find_or_create_db_user_from_message(
db_pool: SqlitePool,
message: Message,
pub async fn find_or_create_db_user_from_update(
user_dao: UserDAO,
update: Update,
) -> 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
let user = update.from()?.clone();
find_or_create_db_user(user_dao, user).await
}
pub async fn find_or_create_db_user(
db_pool: SqlitePool,
user_dao: UserDAO,
user: teloxide::types::User,
) -> Option<PersistedUser> {
match UserDAO::find_or_create_by_telegram_user(&db_pool, user).await {
match user_dao.find_or_create_by_telegram_user(user).await {
Ok(user) => {
log::debug!("loaded user from db: {user:?}");
Some(user)
@@ -38,10 +30,31 @@ pub async fn find_or_create_db_user(
}
}
pub fn message_into_message_target(message: Message) -> MessageTarget {
message.chat.id.into()
pub async fn find_listing_by_id(
listing_dao: ListingDAO,
listing_id: ListingDbId,
) -> Option<PersistedListing> {
listing_dao.find_by_id(listing_id).await.unwrap_or(None)
}
pub fn callback_query_into_message_target(callback_query: CallbackQuery) -> Option<MessageTarget> {
(&callback_query).try_into().ok()
pub fn update_into_message_target(update: Update) -> Option<MessageTarget> {
match update.kind {
teloxide::types::UpdateKind::Message(message) => Some(message.chat.into()),
teloxide::types::UpdateKind::InlineQuery(inline_query) => Some(inline_query.from.into()),
teloxide::types::UpdateKind::CallbackQuery(callback_query) => {
(&callback_query).try_into().ok()
}
_ => {
warn!("Received unexpected update kind: {update:?}");
None
}
}
}
// pub fn message_into_message_target(message: Message) -> MessageTarget {
// message.chat.id.into()
// }
// pub fn callback_query_into_message_target(callback_query: CallbackQuery) -> Option<MessageTarget> {
// (&callback_query).try_into().ok()
// }

View File

@@ -1,3 +1,4 @@
mod bidding;
mod bot_result;
mod commands;
mod config;
@@ -8,20 +9,20 @@ mod handler_utils;
mod keyboard_utils;
mod message_utils;
mod sqlite_storage;
mod start_command_data;
#[cfg(test)]
mod test_utils;
mod wrap_endpoint;
use crate::handle_error::with_error_handler;
use crate::handler_utils::{callback_query_into_message_target, message_into_message_target};
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 crate::bidding::{bidding_handler, BiddingState};
use crate::commands::{
my_listings::{my_listings_handler, my_listings_inline_handler, MyListingsState},
new_listing::{new_listing_handler, NewListingState},
};
use crate::db::{ListingDAO, UserDAO};
use crate::handle_error::with_error_handler;
use crate::handler_utils::{find_or_create_db_user_from_update, update_into_message_target};
use crate::sqlite_storage::SqliteStorage;
use anyhow::Result;
pub use bot_result::*;
use commands::*;
@@ -73,6 +74,7 @@ enum DialogueRootState {
MainMenu,
NewListing(NewListingState),
MyListings(MyListingsState),
Bidding(BiddingState),
}
type RootDialogue = Dialogue<DialogueRootState, SqliteStorage<Json>>;
@@ -97,24 +99,23 @@ async fn main() -> Result<()> {
Dispatcher::builder(
bot,
dptree::entry()
.filter_map(update_into_message_target)
.filter_map_async(find_or_create_db_user_from_update)
.branch(my_listings_inline_handler())
.branch(
dptree::entry()
.enter_dialogue::<Update, SqliteStorage<Json>, DialogueRootState>()
.branch(new_listing_handler())
.branch(my_listings_handler())
.branch(bidding_handler())
.branch(
Update::filter_callback_query()
.filter_map(callback_query_into_message_target)
.branch(
dptree::case![DialogueRootState::MainMenu]
.filter_map_async(find_or_create_db_user_from_callback_query)
.endpoint(with_error_handler(handle_main_menu_callback)),
),
Update::filter_callback_query().branch(
dptree::case![DialogueRootState::MainMenu]
.endpoint(with_error_handler(handle_main_menu_callback)),
),
)
.branch(
Update::filter_message()
.map(message_into_message_target)
.filter_command::<Command>()
.branch(
dptree::case![Command::Start]
@@ -134,13 +135,13 @@ async fn main() -> Result<()> {
),
),
)
.branch(
Update::filter_message()
.map(message_into_message_target)
.endpoint(with_error_handler(unknown_message_handler)),
),
.branch(Update::filter_message().endpoint(with_error_handler(unknown_message_handler))),
)
.dependencies(dptree::deps![db_pool, dialog_storage])
.dependencies(dptree::deps![
dialog_storage,
ListingDAO::new(db_pool.clone()),
UserDAO::new(db_pool.clone())
])
.enable_ctrlc_handler()
.worker_queue_size(1)
.build()

View File

@@ -57,6 +57,15 @@ pub struct MessageTarget {
pub message_id: Option<MessageId>,
}
impl MessageTarget {
pub fn only_chat_id(self) -> MessageTarget {
MessageTarget {
chat_id: self.chat_id,
message_id: None,
}
}
}
impl From<ChatId> for MessageTarget {
fn from(val: ChatId) -> Self {
MessageTarget {
@@ -120,6 +129,7 @@ pub async fn send_message(
keyboard: Option<InlineKeyboardMarkup>,
) -> BotResult {
if let Some(message_id) = target.message_id {
log::info!("Editing message in chat: {target:?}");
let mut message = bot
.edit_message_text(target.chat_id, message_id, text.as_ref())
.parse_mode(ParseMode::Html);
@@ -128,6 +138,7 @@ pub async fn send_message(
}
message.await.context("failed to edit message")?;
} else {
log::info!("Sending message to chat: {target:?}");
let mut message = bot
.send_message(target.chat_id, text.as_ref())
.parse_mode(ParseMode::Html);

75
src/start_command_data.rs Normal file
View File

@@ -0,0 +1,75 @@
use base64::{prelude::BASE64_URL_SAFE, Engine};
use log::info;
use teloxide::types::{MediaKind, MessageKind, UpdateKind};
use crate::db::ListingDbId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StartCommandData {
PlaceBidOnListing(ListingDbId),
ViewListingDetails(ListingDbId),
}
impl From<StartCommandData> for String {
fn from(value: StartCommandData) -> Self {
match value {
StartCommandData::PlaceBidOnListing(listing_id) => {
format!("place_bid_on_listing:{listing_id}")
}
StartCommandData::ViewListingDetails(listing_id) => {
format!("view_listing_details:{listing_id}")
}
}
}
}
impl StartCommandData {
pub fn get_from_update(update: teloxide::types::Update) -> Option<StartCommandData> {
let message = match update.kind {
UpdateKind::Message(message) => Some(message),
_ => None,
}?;
let message = match message.kind {
MessageKind::Common(message) => Some(message),
_ => None,
}?;
let message = match message.media_kind {
MediaKind::Text(media_text) => Some(media_text),
_ => None,
}?;
let message = message.text.strip_prefix("/start ")?;
let decoded = BASE64_URL_SAFE.decode(message).ok()?;
let decoded = String::from_utf8(decoded).ok()?;
let parts = decoded.split(":").map(|s| s.trim()).collect::<Vec<&str>>();
info!("command parts: {parts:?}");
match parts.first()?.trim() {
"place_bid_on_listing" => Some(StartCommandData::PlaceBidOnListing(ListingDbId::new(
parts.get(1)?.parse::<i64>().ok()?,
))),
"view_listing_details" => Some(StartCommandData::ViewListingDetails(ListingDbId::new(
parts.get(1)?.parse::<i64>().ok()?,
))),
_ => None,
}
}
pub fn get_place_bid_on_listing_start_command(
command: StartCommandData,
) -> Option<ListingDbId> {
if let StartCommandData::PlaceBidOnListing(listing_id) = command {
Some(listing_id)
} else {
None
}
}
pub fn get_view_listing_details_start_command(
command: StartCommandData,
) -> Option<ListingDbId> {
if let StartCommandData::ViewListingDetails(listing_id) = command {
Some(listing_id)
} else {
None
}
}
}

View File

@@ -1,5 +1,7 @@
//! Test utilities including timestamp comparison macros
use sqlx::SqlitePool;
/// Assert that two timestamps are approximately equal within a given epsilon tolerance.
///
/// This macro is useful for testing timestamps that may have small variations due to
@@ -87,6 +89,20 @@ macro_rules! assert_timestamps_approx_eq_default {
};
}
pub async fn create_test_pool() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:")
.await
.expect("Failed to create in-memory database");
// Run migrations
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("Failed to run database migrations");
pool
}
#[cfg(test)]
mod tests {
use chrono::{Duration, Utc};

View File

@@ -75,6 +75,7 @@ generate_wrapped!([T1, T2, T3], []);
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]);
generate_wrapped!([T1], [E1]);
@@ -83,6 +84,7 @@ generate_wrapped!([T1, T2, T3], [E1]);
generate_wrapped!([T1, T2, T3, T4], [E1]);
generate_wrapped!([T1, T2, T3, T4, T5], [E1]);
generate_wrapped!([T1, T2, T3, T4, T5, T6], [E1]);
generate_wrapped!([T1, T2, T3, T4, T5, T6, T7], [E1]);
generate_wrapped!([], [E1, E2]);
generate_wrapped!([T1], [E1, E2]);
@@ -91,6 +93,7 @@ generate_wrapped!([T1, T2, T3], [E1, E2]);
generate_wrapped!([T1, T2, T3, T4], [E1, E2]);
generate_wrapped!([T1, T2, T3, T4, T5], [E1, E2]);
generate_wrapped!([T1, T2, T3, T4, T5, T6], [E1, E2]);
generate_wrapped!([T1, T2, T3, T4, T5, T6, T7], [E1, E2]);
pub fn wrap_endpoint<FnBase, FnError, ErrorType, FnBaseArgs, FnErrorArgs>(
fn_base: FnBase,