basic scaffold for placing bids
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1616,6 +1616,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"dptree",
|
||||
|
||||
@@ -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
1
src/bidding/keyboards.rs
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
212
src/bidding/mod.rs
Normal file
212
src/bidding/mod.rs
Normal 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(())
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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!")
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
// }
|
||||
|
||||
47
src/main.rs
47
src/main.rs
@@ -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()
|
||||
|
||||
@@ -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
75
src/start_command_data.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user