Compare commits

...

3 Commits

Author SHA1 Message Date
Dylan Knutson
9ad562a4b2 basic scaffold for placing bids 2025-09-03 00:28:46 +00:00
Dylan Knutson
c53dccbea2 error handler wrapper helper 2025-09-02 19:55:15 +00:00
Dylan Knutson
8610b4cc52 handler wrapper for catching error 2025-09-02 19:11:57 +00:00
26 changed files with 932 additions and 379 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(())
}

17
src/bot_result.rs Normal file
View File

@@ -0,0 +1,17 @@
use teloxide::dispatching::DpHandlerDescription;
#[derive(thiserror::Error, Debug)]
pub enum BotError {
#[error("User visible error: {0}")]
UserVisibleError(String),
#[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

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

View File

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

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,16 +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},
Command, DialogueRootState, HandlerResult, RootDialogue,
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::*,
@@ -46,56 +46,64 @@ impl From<MyListingsState> for DialogueRootState {
}
}
pub fn my_listings_inline_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> {
pub fn my_listings_inline_handler() -> Handler<'static, BotResult, DpHandlerDescription> {
Update::filter_inline_query()
.filter_map_async(inline_query_extract_forward_listing)
.endpoint(handle_forward_listing)
}
pub fn my_listings_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> {
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)
}
@@ -103,20 +111,33 @@ async fn handle_forward_listing(
bot: Bot,
inline_query: InlineQuery,
listing: PersistedListing,
) -> HandlerResult {
) -> BotResult {
info!("Handling forward listing inline query for listing {listing:?}");
let bot_username = match bot.get_me().await?.username.as_ref() {
let bot_username = match bot
.get_me()
.await
.context("failed to get bot username")?
.username
.as_ref()
{
Some(username) => username.to_string(),
None => anyhow::bail!("Bot username not found"),
None => return Err(anyhow!("Bot username not found").into()),
};
// 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",
@@ -126,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(),
@@ -167,7 +189,8 @@ async fn handle_forward_listing(
.reply_markup(keyboard), // Add the inline keyboard here!
)],
)
.await?;
.await
.map_err(|e| anyhow::anyhow!("Error answering inline query: {e:?}"))?;
Ok(())
}
@@ -196,28 +219,31 @@ 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,
) -> HandlerResult {
enter_my_listings(db_pool, bot, dialogue, user, target, None).await?;
) -> BotResult {
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,
target: MessageTarget,
flash: Option<String>,
) -> HandlerResult {
) -> BotResult {
// Transition to ViewingListings state
dialogue.update(MyListingsState::ViewingListings).await?;
dialogue
.update(MyListingsState::ViewingListings)
.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 {
@@ -256,13 +282,13 @@ 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,
user: PersistedUser,
target: MessageTarget,
) -> HandlerResult {
) -> BotResult {
let data = extract_callback_data(&bot, callback_query).await?;
if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) {
@@ -273,8 +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, &bot, user, listing_id, target.clone()).await?;
let listing = get_listing_for_user(&listing_dao, user, listing_id).await?;
enter_show_listing_details(&bot, dialogue, listing, target).await?;
}
@@ -291,23 +316,12 @@ async fn enter_show_listing_details(
dialogue: RootDialogue,
listing: PersistedListing,
target: MessageTarget,
) -> HandlerResult {
let listing_type = Into::<ListingType>::into(&listing.fields);
) -> BotResult {
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?;
.await
.context("failed to update dialogue")?;
let keyboard = InlineKeyboardMarkup::default()
.append_row([
ManageListingButtons::PreviewMessage.to_button(),
@@ -321,25 +335,48 @@ 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,
user: PersistedUser,
listing_id: ListingDbId,
target: MessageTarget,
) -> HandlerResult {
) -> BotResult {
let from = callback_query.from.clone();
let data = extract_callback_data(&bot, callback_query).await?;
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?;
@@ -348,15 +385,14 @@ async fn handle_managing_listing_callback(
unimplemented!("Forward listing not implemented");
}
ManageListingButtons::Edit => {
let listing =
get_listing_for_user(&db_pool, &bot, user, listing_id, target.clone()).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,
@@ -366,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?;
}
}
@@ -408,7 +444,7 @@ async fn send_preview_listing_message(
bot: &Bot,
listing: PersistedListing,
from: User,
) -> HandlerResult {
) -> BotResult {
let mut response_lines = vec![];
response_lines.push(format!("<b>{}</b>", &listing.base.title));
if let Some(description) = &listing.base.description {
@@ -425,29 +461,21 @@ async fn send_preview_listing_message(
}
async fn get_listing_for_user(
db_pool: &SqlitePool,
bot: &Bot,
listing_dao: &ListingDAO,
user: PersistedUser,
listing_id: ListingDbId,
target: MessageTarget,
) -> HandlerResult<PersistedListing> {
let listing = match ListingDAO::find_by_id(db_pool, listing_id).await? {
) -> BotResult<PersistedListing> {
let listing = match listing_dao.find_by_id(listing_id).await? {
Some(listing) => listing,
None => {
send_message(bot, target, "❌ Listing not found.", None).await?;
return Err(anyhow::anyhow!("Listing not found"));
return Err(BotError::UserVisibleError("❌ Listing not found.".into()));
}
};
if listing.base.seller_id != user.persisted.id {
send_message(
bot,
target,
"❌ You can only manage your own auctions.",
None,
)
.await?;
return Err(anyhow::anyhow!("User does not own listing"));
return Err(BotError::UserVisibleError(
"❌ You can only manage your own auctions.".into(),
));
}
Ok(listing)

View File

@@ -15,28 +15,29 @@ use crate::{
ui::enter_confirm_save_listing,
},
},
db::{user::PersistedUser, CurrencyType, ListingDuration, ListingType, MoneyAmount},
db::{
user::PersistedUser, CurrencyType, ListingDAO, ListingDuration, ListingType, MoneyAmount,
},
message_utils::*,
HandlerResult, RootDialogue,
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,
callback_query: CallbackQuery,
target: MessageTarget,
) -> HandlerResult {
) -> BotResult {
let data = extract_callback_data(&bot, callback_query).await?;
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
@@ -81,7 +82,7 @@ pub async fn handle_awaiting_draft_field_callback(
(field, draft): (ListingField, ListingDraft),
callback_query: CallbackQuery,
target: MessageTarget,
) -> HandlerResult {
) -> BotResult {
let data = extract_callback_data(&bot, callback_query).await?;
info!("User {target:?} selected callback: {data:?}");
@@ -134,7 +135,7 @@ async fn handle_skip_field(
current_field: ListingField,
draft: ListingDraft,
target: MessageTarget,
) -> HandlerResult {
) -> BotResult {
let field_name = get_field_name(current_field, draft.listing_type());
let next_field = get_next_field(current_field, draft.listing_type());
let flash = format!("{field_name} skipped!");
@@ -159,7 +160,7 @@ async fn handle_slots_callback(
mut draft: ListingDraft,
button: SlotsKeyboardButtons,
target: MessageTarget,
) -> HandlerResult {
) -> BotResult {
let num_slots = match button {
SlotsKeyboardButtons::OneSlot => 1,
SlotsKeyboardButtons::TwoSlots => 2,
@@ -196,7 +197,7 @@ async fn handle_start_time_callback(
mut draft: ListingDraft,
button: StartTimeKeyboardButtons,
target: MessageTarget,
) -> HandlerResult {
) -> BotResult {
let start_time = match button {
StartTimeKeyboardButtons::Now => ListingDuration::zero(),
};
@@ -231,7 +232,7 @@ async fn handle_duration_callback(
mut draft: ListingDraft,
button: DurationKeyboardButtons,
target: MessageTarget,
) -> HandlerResult {
) -> BotResult {
let duration = ListingDuration::days(match button {
DurationKeyboardButtons::OneDay => 1,
DurationKeyboardButtons::ThreeDays => 3,
@@ -256,12 +257,13 @@ async fn handle_starting_bid_amount_callback(
mut draft: ListingDraft,
button: EditMinimumBidIncrementKeyboardButtons,
target: MessageTarget,
) -> HandlerResult {
) -> BotResult {
let starting_bid_amount = MoneyAmount::from_str(match button {
EditMinimumBidIncrementKeyboardButtons::OneDollar => "1.00",
EditMinimumBidIncrementKeyboardButtons::FiveDollars => "5.00",
EditMinimumBidIncrementKeyboardButtons::TenDollars => "10.00",
})?;
})
.map_err(|e| anyhow::anyhow!("Error parsing starting bid amount: {e:?}"))?;
update_field_on_draft(
ListingField::StartingBidAmount,
@@ -280,7 +282,7 @@ async fn handle_currency_type_callback(
mut draft: ListingDraft,
button: CurrencyTypeKeyboardButtons,
target: MessageTarget,
) -> HandlerResult {
) -> BotResult {
let currency_type = match button {
CurrencyTypeKeyboardButtons::Usd => CurrencyType::Usd,
CurrencyTypeKeyboardButtons::Cad => CurrencyType::Cad,
@@ -307,11 +309,7 @@ async fn handle_currency_type_callback(
}
/// Cancel the wizard and exit
pub async fn cancel_wizard(
bot: Bot,
dialogue: RootDialogue,
target: MessageTarget,
) -> HandlerResult {
pub async fn cancel_wizard(bot: Bot, dialogue: RootDialogue, target: MessageTarget) -> BotResult {
info!("{target:?} cancelled new listing wizard");
enter_select_new_listing_type(bot, dialogue, target).await?;
Ok(())

View File

@@ -3,11 +3,13 @@
//! This module handles the core logic for processing and updating listing fields
//! during both initial creation and editing workflows.
use anyhow::Context;
use crate::commands::new_listing::messages::step_for_field;
use crate::commands::new_listing::{types::NewListingState, validations::*};
use crate::{
commands::new_listing::types::{ListingDraft, ListingField},
HandlerResult, RootDialogue,
BotResult, RootDialogue,
};
/// Helper function to transition to next field
@@ -15,10 +17,11 @@ pub async fn transition_to_field(
dialogue: RootDialogue,
field: ListingField,
draft: ListingDraft,
) -> HandlerResult {
) -> BotResult {
dialogue
.update(NewListingState::AwaitingDraftField { field, draft })
.await?;
.await
.context("failed to update dialogue")?;
Ok(())
}

View File

@@ -1,72 +1,62 @@
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,
},
Command, DialogueRootState, Handler,
};
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
pub fn new_listing_handler() -> Handler {
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::*,
DialogueRootState, HandlerResult, RootDialogue,
BotError, BotResult, DialogueRootState, RootDialogue,
};
use anyhow::bail;
use anyhow::{anyhow, Context};
use log::info;
use sqlx::SqlitePool;
use teloxide::{prelude::*, types::*, Bot};
/// Handle the /newlisting command - starts the dialogue
@@ -40,7 +39,7 @@ pub(super) async fn handle_new_listing_command(
bot: Bot,
dialogue: RootDialogue,
target: MessageTarget,
) -> HandlerResult {
) -> BotResult {
enter_select_new_listing_type(bot, dialogue, target).await?;
Ok(())
}
@@ -49,11 +48,12 @@ pub async fn enter_select_new_listing_type(
bot: Bot,
dialogue: RootDialogue,
target: MessageTarget,
) -> HandlerResult {
) -> BotResult {
// Initialize the dialogue to listing type selection state
dialogue
.update(NewListingState::SelectingListingType)
.await?;
.await
.context("failed to update dialogue")?;
send_message(
&bot,
@@ -72,21 +72,20 @@ pub async fn handle_awaiting_draft_field_input(
(field, mut draft): (ListingField, ListingDraft),
target: MessageTarget,
msg: Message,
) -> HandlerResult {
) -> BotResult {
info!("User {target:?} entered input step: {field:?}");
// Process the field update
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) => {
bail!("Cannot update field {field:?} for listing type");
return Err(anyhow!("Cannot update field {field:?} for listing type").into());
}
Err(SetFieldError::FieldRequired) => {
bail!("Cannot update field {field:?} on existing listing");
return Err(anyhow!("Cannot update field {field:?} on existing listing").into());
}
};
@@ -113,21 +112,20 @@ pub async fn handle_editing_field_input(
(field, mut draft): (ListingField, ListingDraft),
target: MessageTarget,
msg: Message,
) -> HandlerResult {
) -> BotResult {
info!("User {target:?} editing field {field:?}");
// Process the field update
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) => {
bail!("Cannot update field {field:?} for listing type");
return Err(anyhow!("Cannot update field {field:?} for listing type").into());
}
Err(SetFieldError::FieldRequired) => {
bail!("Cannot update field {field:?} on existing listing");
return Err(anyhow!("Cannot update field {field:?} on existing listing").into());
}
};
@@ -138,28 +136,36 @@ 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,
user: PersistedUser,
callback_query: CallbackQuery,
target: MessageTarget,
) -> HandlerResult {
) -> BotResult {
let data = extract_callback_data(&bot, callback_query).await?;
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");
let response = "🗑️ <b>Changes Discarded</b>\n\n\
Your changes have been discarded and not saved.";
send_message(&bot, target, &response, None).await?;
dialogue.exit().await?;
dialogue.exit().await.context("failed to exit dialogue")?;
}
ConfirmationKeyboardButtons::Discard => {
info!("User {target:?} discarded listing creation");
@@ -168,7 +174,7 @@ pub async fn handle_viewing_draft_callback(
Your listing has been discarded and not created.\n\
You can start a new listing anytime with /newlisting.";
send_message(&bot, target, &response, None).await?;
dialogue.exit().await?;
dialogue.exit().await.context("failed to exit dialogue")?;
}
ConfirmationKeyboardButtons::Edit => {
info!("User {target:?} chose to edit listing");
@@ -186,7 +192,7 @@ pub async fn handle_editing_draft_callback(
dialogue: RootDialogue,
callback_query: CallbackQuery,
target: MessageTarget,
) -> HandlerResult {
) -> BotResult {
let data = extract_callback_data(&bot, callback_query).await?;
info!("User {target:?} in editing screen, showing field selection");
@@ -203,7 +209,7 @@ pub async fn handle_editing_draft_callback(
FieldSelectionKeyboardButtons::StartTime => ListingField::StartTime,
FieldSelectionKeyboardButtons::Duration => ListingField::EndTime,
FieldSelectionKeyboardButtons::Done => {
return Err(anyhow::anyhow!("Done button should not be used here"))
return Err(anyhow::anyhow!("Done button should not be used here").into());
}
};
@@ -214,7 +220,8 @@ pub async fn handle_editing_draft_callback(
.update(DialogueRootState::NewListing(
NewListingState::EditingDraftField { field, draft },
))
.await?;
.await
.context("failed to update dialogue")?;
let response = format!("Editing {field:?}\n\nPrevious value: {value}");
send_message(&bot, target, response, Some(keyboard)).await?;
@@ -229,7 +236,7 @@ pub async fn handle_editing_draft_field_callback(
(field, draft): (ListingField, ListingDraft),
callback_query: CallbackQuery,
target: MessageTarget,
) -> HandlerResult {
) -> BotResult {
let data = extract_callback_data(&bot, callback_query).await?;
info!("User {target:?} editing field: {field:?} -> {data:?}");
@@ -252,7 +259,7 @@ pub async fn enter_edit_listing_draft(
draft: ListingDraft,
dialogue: RootDialogue,
flash_message: Option<String>,
) -> HandlerResult {
) -> BotResult {
display_listing_summary(
bot,
target,
@@ -263,34 +270,31 @@ pub async fn enter_edit_listing_draft(
.await?;
dialogue
.update(NewListingState::EditingDraft(draft))
.await?;
.await
.context("failed to update dialogue")?;
Ok(())
}
/// Save the listing to the database
async fn save_listing(db_pool: &SqlitePool, draft: ListingDraft) -> HandlerResult<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

@@ -8,7 +8,8 @@ use crate::commands::new_listing::messages::steps_for_listing_type;
use crate::commands::new_listing::NewListingState;
use crate::db::ListingType;
use crate::RootDialogue;
use crate::{commands::new_listing::types::ListingDraft, message_utils::*, HandlerResult};
use crate::{commands::new_listing::types::ListingDraft, message_utils::*, BotResult};
use anyhow::Context;
use teloxide::{types::InlineKeyboardMarkup, Bot};
/// Display the listing summary with optional flash message and keyboard
@@ -18,7 +19,7 @@ pub async fn display_listing_summary(
draft: &ListingDraft,
keyboard: Option<InlineKeyboardMarkup>,
flash_message: Option<String>,
) -> HandlerResult {
) -> BotResult {
let mut response_lines = vec![];
let listing_type: ListingType = (&draft.fields).into();
@@ -59,7 +60,7 @@ pub async fn enter_confirm_save_listing(
target: MessageTarget,
draft: ListingDraft,
flash: Option<String>,
) -> HandlerResult {
) -> BotResult {
let keyboard = if draft.persisted.is_some() {
InlineKeyboardMarkup::default().append_row([
ConfirmationKeyboardButtons::Save.to_button(),
@@ -77,6 +78,7 @@ pub async fn enter_confirm_save_listing(
display_listing_summary(bot, target, &draft, Some(keyboard), flash).await?;
dialogue
.update(NewListingState::ViewingDraft(draft))
.await?;
.await
.context("failed to update dialogue")?;
Ok(())
}

View File

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

View File

@@ -1,14 +1,17 @@
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},
Command, DialogueRootState, HandlerResult, RootDialogue,
BotResult, Command, DialogueRootState, RootDialogue,
};
keyboard_buttons! {
@@ -25,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\
@@ -39,18 +42,19 @@ pub async fn handle_start(
bot: Bot,
dialogue: RootDialogue,
target: MessageTarget,
) -> HandlerResult {
update: Update,
) -> BotResult {
info!("got start message: {update:?}");
enter_main_menu(bot, dialogue, target).await?;
Ok(())
}
/// Show the main menu with buttons
pub async fn enter_main_menu(
bot: Bot,
dialogue: RootDialogue,
target: MessageTarget,
) -> HandlerResult {
dialogue.update(DialogueRootState::MainMenu).await?;
pub async fn enter_main_menu(bot: Bot, dialogue: RootDialogue, target: MessageTarget) -> BotResult {
dialogue
.update(DialogueRootState::MainMenu)
.await
.context("failed to update dialogue")?;
send_message(
&bot,
@@ -64,13 +68,13 @@ pub async fn enter_main_menu(
}
pub async fn handle_main_menu_callback(
db_pool: SqlitePool,
listing_dao: ListingDAO,
bot: Bot,
dialogue: RootDialogue,
user: PersistedUser,
callback_query: CallbackQuery,
target: MessageTarget,
) -> HandlerResult {
) -> BotResult {
let data = extract_callback_data(&bot, callback_query).await?;
info!("User {target:?} selected main menu option: {data:?}");
@@ -78,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");

46
src/handle_error.rs Normal file
View File

@@ -0,0 +1,46 @@
use crate::{
message_utils::{send_message, MessageTarget},
wrap_endpoint, BotError, BotResult, WrappedAsyncFn,
};
use futures::future::BoxFuture;
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:?}");
match error {
BotError::UserVisibleError(message) => send_message(&bot, target, message, None).await?,
BotError::InternalError(_) => {
send_message(
&bot,
target,
"An internal error occurred. Please try again later.",
None,
)
.await?;
}
}
Ok(())
}
fn boxed_handle_error(
bot: Bot,
target: MessageTarget,
error: BotError,
) -> Pin<Box<dyn Future<Output = BotResult> + Send>> {
Box::pin(handle_error(bot, target, error))
}
pub type ErrorHandlerWrapped<FnBase, FnBaseArgs> = WrappedAsyncFn<
FnBase,
fn(Bot, MessageTarget, BotError) -> BoxFuture<'static, BotResult>,
BotError,
FnBaseArgs,
(Bot, MessageTarget),
>;
pub fn with_error_handler<FnBase, FnBaseArgs>(
handler: FnBase,
) -> ErrorHandlerWrapped<FnBase, FnBaseArgs> {
wrap_endpoint(handler, boxed_handle_error)
}

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,33 +1,37 @@
mod bidding;
mod bot_result;
mod commands;
mod config;
mod db;
mod dptree_utils;
mod handle_error;
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::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::*;
use config::Config;
use log::info;
use serde::{Deserialize, Serialize};
use teloxide::dispatching::{dialogue::serializer::Json, DpHandlerDescription};
use teloxide::dispatching::dialogue::serializer::Json;
use teloxide::{prelude::*, types::BotCommand, utils::command::BotCommands};
pub type HandlerResult<T = ()> = anyhow::Result<T>;
pub type Handler = dptree::Handler<'static, HandlerResult, DpHandlerDescription>;
pub use wrap_endpoint::*;
/// Set up the bot's command menu that appears when users tap the menu button
async fn setup_bot_commands(bot: &Bot) -> Result<()> {
@@ -70,6 +74,7 @@ enum DialogueRootState {
MainMenu,
NewListing(NewListingState),
MyListings(MyListingsState),
Bidding(BiddingState),
}
type RootDialogue = Dialogue<DialogueRootState, SqliteStorage<Json>>;
@@ -94,34 +99,49 @@ 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(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].endpoint(handle_start))
.branch(dptree::case![Command::Help].endpoint(handle_help))
.branch(dptree::case![Command::MyBids].endpoint(handle_my_bids))
.branch(dptree::case![Command::Settings].endpoint(handle_settings)),
.branch(
dptree::case![Command::Start]
.endpoint(with_error_handler(handle_start)),
)
.branch(
dptree::case![Command::Help]
.endpoint(with_error_handler(handle_help)),
)
.branch(
dptree::case![Command::MyBids]
.endpoint(with_error_handler(handle_my_bids)),
)
.branch(
dptree::case![Command::Settings]
.endpoint(with_error_handler(handle_settings)),
),
),
)
.branch(Update::filter_message().endpoint(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()
@@ -131,17 +151,11 @@ async fn main() -> Result<()> {
Ok(())
}
async fn unknown_message_handler(bot: Bot, msg: Message) -> HandlerResult {
bot.send_message(
msg.chat.id,
format!(
"
Unknown command: `{}`\n\n\
Try /help to see the list of commands.\
",
msg.text().unwrap_or("")
),
)
.await?;
Ok(())
async fn unknown_message_handler(msg: Message) -> BotResult {
Err(BotError::UserVisibleError(format!(
"Unknown command: `{}`\n\n\
Try /help to see the list of commands.\
",
msg.text().unwrap_or("")
)))
}

View File

@@ -1,5 +1,5 @@
use crate::HandlerResult;
use anyhow::bail;
use crate::BotResult;
use anyhow::{anyhow, Context};
use chrono::{DateTime, Utc};
use num::One;
use std::fmt::Display;
@@ -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 {
@@ -118,23 +127,25 @@ pub async fn send_message(
target: MessageTarget,
text: impl AsRef<str>,
keyboard: Option<InlineKeyboardMarkup>,
) -> HandlerResult {
) -> 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);
if let Some(kb) = keyboard {
message = message.reply_markup(kb);
}
message.await?;
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);
if let Some(kb) = keyboard {
message = message.reply_markup(kb);
}
message.await?;
message.await.context("failed to send message")?;
}
Ok(())
}
@@ -149,13 +160,10 @@ pub fn create_single_button_keyboard(text: &str, callback_data: &str) -> InlineK
}
// Extract callback data and answer callback query
pub async fn extract_callback_data(
bot: &Bot,
callback_query: CallbackQuery,
) -> HandlerResult<String> {
pub async fn extract_callback_data(bot: &Bot, callback_query: CallbackQuery) -> BotResult<String> {
let data = match callback_query.data {
Some(data) => data,
None => bail!("Missing data in callback query"),
None => return Err(anyhow!("Missing data in callback query"))?,
};
// Answer the callback query to remove loading state

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};

137
src/wrap_endpoint.rs Normal file
View File

@@ -0,0 +1,137 @@
use dptree::di::{CompiledFn, DependencyMap, Injectable};
use dptree::Type;
use std::sync::Arc;
use std::{collections::BTreeSet, future::Future, marker::PhantomData};
pub struct WrappedAsyncFn<FnBase, FnError, ErrorType, FnBaseArgs, FnErrorArgs> {
fn_base: FnBase,
fn_error: FnError,
_error_type: PhantomData<ErrorType>,
_fn_base_args: PhantomData<FnBaseArgs>,
_fn_error_args: PhantomData<FnErrorArgs>,
}
impl<FnBase, FnError, ErrorType, FnBaseArgs, FnErrorArgs>
WrappedAsyncFn<FnBase, FnError, ErrorType, FnBaseArgs, FnErrorArgs>
{
fn new(fn_base: FnBase, fn_error: FnError) -> Self {
Self {
fn_base,
fn_error,
_error_type: PhantomData,
_fn_base_args: PhantomData,
_fn_error_args: PhantomData,
}
}
}
macro_rules! generate_wrapped {
([$($base_generic:ident),*], [$($error_generic:ident),*]) => {
impl<FnBase, FnError, FnBaseFut, FnErrorFut, T, ErrorType, $($base_generic,)* $($error_generic,)*>
Injectable<Result<T, ErrorType>, ($($base_generic,)* $($error_generic,)*)>
for WrappedAsyncFn<FnBase, FnError, ErrorType, ($($base_generic,)*), ($($error_generic,)*)>
where
FnBase: Fn($($base_generic,)*) -> FnBaseFut + Send + Sync + 'static,
FnBaseFut: Future<Output = Result<T, ErrorType>> + Send + 'static,
FnError: Fn($($error_generic,)* ErrorType) -> FnErrorFut + Send + Sync + 'static,
FnErrorFut: Future<Output = Result<T, ErrorType>> + Send + 'static,
T: 'static + Send + Sync,
ErrorType: 'static + Send + Sync,
$($base_generic: Clone + Send + Sync + 'static,)*
$($error_generic: Clone + Send + Sync + 'static,)*
{
#[allow(non_snake_case)]
#[allow(unused_variables)]
fn inject<'a>(&'a self, container: &'a DependencyMap) -> CompiledFn<'a, Result<T, ErrorType>> {
Arc::new(move || {
$(let $base_generic = std::borrow::Borrow::<$base_generic>::borrow(&container.get()).clone();)*
let base_fut = (self.fn_base)($($base_generic),*);
Box::pin(async move {
match base_fut.await {
Ok(t) => Ok(t),
Err(err) => {
$(let $error_generic = std::borrow::Borrow::<$error_generic>::borrow(&container.get()).clone();)*
(self.fn_error)($($error_generic,)* err).await
}
}
})
})
}
fn input_types() -> BTreeSet<Type> {
BTreeSet::from_iter(vec![
$(Type::of::<$base_generic>(),)*
$(Type::of::<$error_generic>(),)*
])
}
}
};
}
generate_wrapped!([], []);
generate_wrapped!([T1], []);
generate_wrapped!([T1, T2], []);
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]);
generate_wrapped!([T1, T2], [E1]);
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]);
generate_wrapped!([T1, T2], [E1, E2]);
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,
error_fn: FnError,
) -> WrappedAsyncFn<FnBase, FnError, ErrorType, FnBaseArgs, FnErrorArgs> {
WrappedAsyncFn::new(fn_base, error_fn)
}
#[cfg(test)]
mod tests {
use crate::wrap_endpoint;
use dptree::{deps, Handler};
use std::ops::ControlFlow;
use teloxide::dispatching::DpHandlerDescription;
#[derive(Debug, PartialEq, Eq)]
enum MyError {
ErrorValue,
}
type MyHandler = Handler<'static, Result<&'static str, MyError>, DpHandlerDescription>;
#[tokio::test]
async fn test_handler_errors() {
let service: MyHandler = dptree::entry().endpoint(wrap_endpoint(
async |_: &'static str| Err(MyError::ErrorValue),
async |_: MyError| Ok("caught"),
));
let result = service.dispatch(deps!["test"]).await;
assert_eq!(result, ControlFlow::Break(Ok("caught")));
}
#[tokio::test]
async fn test_handler_ok() {
let service: MyHandler = dptree::entry().endpoint(wrap_endpoint(
async |arg1: &'static str| Ok(arg1),
async |error: MyError| Err(error),
));
let result = service.dispatch(deps!["test"]).await;
assert_eq!(result, ControlFlow::Break(Ok("test")));
}
}