Compare commits

...

7 Commits

Author SHA1 Message Date
Dylan Knutson
7eada9588c tests for bidding when auction is expired 2025-09-09 02:45:56 +00:00
Dylan Knutson
0d18016993 integration tests for confirm bid amount 2025-09-09 01:40:36 +00:00
Dylan Knutson
df137403a7 id renames 2025-09-05 22:48:17 +00:00
Dylan Knutson
1c7131d801 first integration test 2025-09-05 22:33:03 +00:00
Dylan Knutson
a40ac5f345 move target into state 2025-09-05 22:17:30 +00:00
Dylan Knutson
af5b8883af mocakble message sender trait 2025-09-05 21:48:52 +00:00
Dylan Knutson
da7e59fe0f create App struct 2025-09-05 03:50:09 +00:00
45 changed files with 2391 additions and 1132 deletions

165
Cargo.lock generated
View File

@@ -529,6 +529,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1"
[[package]] [[package]]
name = "dptree" name = "dptree"
version = "0.5.1" version = "0.5.1"
@@ -554,6 +560,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "env_filter" name = "env_filter"
version = "0.1.3" version = "0.1.3"
@@ -678,6 +693,12 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fragile"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619"
[[package]] [[package]]
name = "funty" name = "funty"
version = "2.0.0" version = "2.0.0"
@@ -835,6 +856,25 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "h2"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.11.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@@ -953,6 +993,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@@ -964,6 +1005,22 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]] [[package]]
name = "hyper-tls" name = "hyper-tls"
version = "0.6.0" version = "0.6.0"
@@ -999,9 +1056,11 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@@ -1396,6 +1455,32 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "mockall"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2"
dependencies = [
"cfg-if",
"downcast",
"fragile",
"mockall_derive",
"predicates",
"predicates-tree",
]
[[package]]
name = "mockall_derive"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898"
dependencies = [
"cfg-if",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]] [[package]]
name = "native-tls" name = "native-tls"
version = "0.2.14" version = "0.2.14"
@@ -1625,9 +1710,11 @@ dependencies = [
"itertools 0.14.0", "itertools 0.14.0",
"lazy_static", "lazy_static",
"log", "log",
"mockall",
"num", "num",
"paste", "paste",
"regex", "regex",
"reqwest",
"rstest", "rstest",
"rust_decimal", "rust_decimal",
"seq-macro", "seq-macro",
@@ -1752,6 +1839,32 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "predicates"
version = "3.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
dependencies = [
"anstyle",
"predicates-core",
]
[[package]]
name = "predicates-core"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa"
[[package]]
name = "predicates-tree"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
dependencies = [
"predicates-core",
"termtree",
]
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.3.0" version = "3.3.0"
@@ -1961,16 +2074,20 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
"encoding_rs",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-tls", "hyper-tls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime",
"mime_guess", "mime_guess",
"native-tls", "native-tls",
"percent-encoding", "percent-encoding",
@@ -2718,6 +2835,27 @@ dependencies = [
"syn 2.0.106", "syn 2.0.106",
] ]
[[package]]
name = "system-configuration"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags",
"core-foundation",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "take_mut" name = "take_mut"
version = "0.2.2" version = "0.2.2"
@@ -2819,6 +2957,12 @@ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "termtree"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.16" version = "2.0.16"
@@ -2935,6 +3079,16 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-rustls"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-stream" name = "tokio-stream"
version = "0.1.17" version = "0.1.17"
@@ -3350,6 +3504,17 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
name = "windows-registry"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.3.4" version = "0.3.4"

View File

@@ -32,6 +32,8 @@ paste = "1.0"
dptree = "0.5.1" dptree = "0.5.1"
seq-macro = "0.3.6" seq-macro = "0.3.6"
base64 = "0.22.1" base64 = "0.22.1"
mockall = "0.13.1"
reqwest = "0.12.23"
[dev-dependencies] [dev-dependencies]
rstest = "0.26.1" rstest = "0.26.1"

View File

@@ -0,0 +1,336 @@
use crate::{
bidding::BiddingState,
commands::new_listing::validations::{validate_price, SetFieldError},
db::{bid::NewBid, listing::PersistedListing, user::PersistedUser, MoneyAmount},
message::MessageType,
App, BotError, BotResult, RootDialogue,
};
use anyhow::{anyhow, Context};
use chrono::Utc;
use itertools::Itertools;
use log::{error, info};
use teloxide::types::*;
pub async fn handle_awaiting_bid_amount_input(
app: App,
listing: PersistedListing,
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());
}
};
let bid_amount_str = format!("{}", bid_amount.with_type(listing.base.currency_type));
app.bot
.send_html_message(
format!("Confirm bid amount: {bid_amount_str} - this cannot be undone!"),
Some(InlineKeyboardMarkup::default().append_row([
InlineKeyboardButton::callback(
format!("Confirm bid amount: {bid_amount_str}"),
"confirm_bid",
),
InlineKeyboardButton::callback("Cancel", "cancel_bid"),
])),
)
.await?;
dialogue
.update(BiddingState::AwaitingConfirmBidAmount(
listing.persisted.id,
bid_amount,
))
.await
.context("failed to update dialogue")?;
Ok(())
}
pub async fn handle_awaiting_confirm_bid_amount_callback(
app: App,
buyer: PersistedUser,
listing: PersistedListing,
bid_amount: MoneyAmount,
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")?;
app.bot
.send_html_message("Bid cancelled".to_string(), None)
.await?;
return Ok(());
}
_ => {
return Err(BotError::user_visible(format!(
"Invalid response {callback_data}"
)))
}
};
if listing.base.ends_at < Utc::now() {
return app
.send_message(MessageType::BidInvalidListingExpired { listing, buyer })
.await;
}
let bid = NewBid::new_basic(listing.persisted.id, buyer.persisted.id, bid_amount);
let bid = app.daos.bid.insert_bid(&bid).await?;
dialogue.exit().await.context("failed to exit dialogue")?;
app.send_message(MessageType::BidHasBeenConfirmedForBuyer {
listing: listing.clone(),
bid: bid.clone(),
})
.await?;
let other_bidder_ids = app
.daos
.bid
.bidder_ids_for_listing(listing.persisted.id)
.await?
.into_iter()
.filter(|id| *id != buyer.persisted.id)
.unique();
for buyer in app.daos.user.where_in_ids(other_bidder_ids).await? {
info!("Sending outbid message to {buyer:?}");
app.send_message(MessageType::UserHasBeenOutbidForBuyer {
listing: listing.clone(),
buyer,
})
.await?;
}
if let Some(seller) = app.daos.user.find_by_id(listing.base.seller_id).await? {
app.send_message(MessageType::BidHasBeenPlacedForSeller {
listing,
buyer,
seller,
bid,
})
.await?;
} else {
error!("Seller not found for listing {listing:?}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{db::DAOs, message_sender::MockMessageSender, test_utils::*};
use chrono::{Duration, Utc};
use dptree::{
deps,
di::{DependencyMap, Injectable},
};
use mockall::predicate::function;
use std::str::FromStr;
struct Fixtures {
deps: DependencyMap,
seller: PersistedUser,
buyer: PersistedUser,
prev_buyer: PersistedUser,
listing: PersistedListing,
}
async fn set_up_fixtures() -> Fixtures {
let deps = create_deps().await;
let seller = with_test_user(&deps, |seller| {
seller.username = Some("seller".to_string());
seller.telegram_id = 123.into()
})
.await;
let buyer = with_test_user(&deps, |buyer| {
buyer.username = Some("buyer".to_string());
buyer.telegram_id = 456.into()
})
.await;
let prev_buyer = with_test_user(&deps, |buyer| {
buyer.username = Some("prev_buyer".to_string());
buyer.telegram_id = 789.into()
})
.await;
let listing = with_test_listing(&deps, &seller, |_| {}).await;
Fixtures {
deps,
seller,
buyer,
prev_buyer,
listing,
}
}
#[tokio::test]
async fn test_confirm_bid_with_expired_listing() {
let Fixtures {
deps,
buyer,
mut listing,
..
} = set_up_fixtures().await;
// listing has already expired
listing.base.ends_at = Utc::now() - Duration::days(1);
let listing = deps
.get::<DAOs>()
.listing
.update_listing(&listing)
.await
.unwrap();
let cb_query = create_tele_callback_query(
"confirm_bid",
create_tele_user(|user| user.id = buyer.telegram_id.into()),
);
let mut message_sender = MockMessageSender::new();
{
let l = listing.clone();
let b = buyer.clone();
message_sender
.expect_send_message()
.once()
.with(function(move |m| match m {
MessageType::BidInvalidListingExpired { listing, buyer } => {
assert_eq!(listing, &l);
assert_eq!(buyer, &b);
true
}
_ => false,
}))
.returning(|_| Ok(()));
}
let deps = with_message_sender(deps, message_sender).await;
let mut deps = with_dialogue(deps, &buyer).await;
deps.insert_container(deps![
listing,
cb_query,
buyer,
MoneyAmount::from_str("100.00").unwrap()
]);
let ret = handle_awaiting_confirm_bid_amount_callback.inject(&deps)().await;
assert!(ret.is_ok(), "{ret:?}");
}
#[tokio::test]
async fn test_confirm_bid_amount() {
let Fixtures {
deps,
seller,
buyer,
prev_buyer,
listing,
} = set_up_fixtures().await;
deps.get::<DAOs>()
.bid
.insert_bid(&NewBid::new_basic(
listing.persisted.id,
prev_buyer.persisted.id,
MoneyAmount::from_str("50.00").unwrap(),
))
.await
.unwrap();
let mut message_sender = MockMessageSender::new();
{
let l = listing.clone();
let b = buyer.clone();
message_sender
.expect_send_message()
.once()
.with(function(move |m| match m {
MessageType::BidHasBeenConfirmedForBuyer { listing, bid, .. } => {
assert_eq!(listing, &l);
assert_eq!(bid.buyer_id, b.persisted.id);
assert_eq!(bid.bid_amount.cents(), 10_000);
assert_eq!(bid.listing_id, l.persisted.id);
true
}
_ => false,
}))
.returning(|_| Ok(()));
}
{
let l = listing.clone();
let b = buyer.clone();
let s = seller.clone();
message_sender
.expect_send_message()
.once()
.with(function(move |m| match m {
MessageType::BidHasBeenPlacedForSeller {
listing,
buyer,
seller,
bid,
} => {
assert_eq!(listing, &l);
assert_eq!(buyer, &b);
assert_eq!(seller, &s);
assert_eq!(bid.buyer_id, b.persisted.id);
assert_eq!(bid.bid_amount.cents(), 10_000);
assert_eq!(bid.listing_id, l.persisted.id);
true
}
_ => false,
}))
.returning(|_| Ok(()));
}
{
let l = listing.clone();
let pb = prev_buyer.clone();
message_sender
.expect_send_message()
.once()
.with(function(move |m| match m {
MessageType::UserHasBeenOutbidForBuyer { listing, buyer } => {
assert_eq!(listing, &l);
assert_eq!(buyer, &pb);
true
}
_ => false,
}))
.returning(|_| Ok(()));
}
let deps = with_message_sender(deps, message_sender).await;
let cb_query = create_tele_callback_query(
"confirm_bid",
create_tele_user(|user| user.id = buyer.telegram_id.into()),
);
let mut deps = with_dialogue(deps, &buyer).await;
deps.insert_container(deps![
listing,
cb_query,
buyer,
MoneyAmount::from_str("100.00").unwrap()
]);
let ret = handle_awaiting_confirm_bid_amount_callback.inject(&deps)().await;
assert!(ret.is_ok(), "{ret:?}");
}
}

View File

@@ -1,32 +1,36 @@
mod confirm_bid_amount_callback;
mod keyboards; mod keyboards;
use crate::{ use crate::{
bidding::confirm_bid_amount_callback::{
handle_awaiting_bid_amount_input, handle_awaiting_confirm_bid_amount_callback,
},
case, case,
commands::new_listing::validations::{validate_price, SetFieldError},
db::{ db::{
listing::{ListingFields, PersistedListing}, listing::{ListingFields, PersistedListing},
user::PersistedUser, user::PersistedUser,
ListingDbId, MoneyAmount, UserDAO, DbListingId, DbUserId, MoneyAmount, UserDAO,
}, },
dptree_utils::MapTwo,
handle_error::with_error_handler, handle_error::with_error_handler,
handler_utils::find_listing_by_id, handler_utils::find_listing_by_id,
message_utils::{send_message, MessageTarget}, message_utils::buyer_name_or_link,
start_command_data::StartCommandData, start_command_data::StartCommandData,
BotError, BotHandler, BotResult, DialogueRootState, RootDialogue, App, BotError, BotHandler, BotResult, DialogueRootState, RootDialogue,
}; };
use anyhow::{anyhow, Context}; use anyhow::Context;
use log::info; use log::info;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use teloxide::{ use teloxide::{
dispatching::UpdateFilterExt, dispatching::UpdateFilterExt,
types::{CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, Update}, types::{InlineKeyboardButton, InlineKeyboardMarkup, Update},
Bot,
}; };
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum BiddingState { pub enum BiddingState {
AwaitingBidAmount(ListingDbId), AwaitingBidAmount(DbListingId),
AwaitingConfirmBidAmount(ListingDbId, MoneyAmount), AwaitingConfirmBidAmount(DbListingId, MoneyAmount),
} }
impl From<BiddingState> for DialogueRootState { impl From<BiddingState> for DialogueRootState {
fn from(state: BiddingState) -> Self { fn from(state: BiddingState) -> Self {
@@ -40,8 +44,18 @@ pub fn bidding_handler() -> BotHandler {
Update::filter_message() Update::filter_message()
.filter_map(StartCommandData::get_from_update) .filter_map(StartCommandData::get_from_update)
.filter_map(StartCommandData::get_place_bid_on_listing_start_command) .filter_map(StartCommandData::get_place_bid_on_listing_start_command)
.branch(
dptree::entry()
.filter_map_async(find_listing_by_id)
.endpoint(with_error_handler(handle_place_bid_on_listing)),
),
)
.branch(
Update::filter_callback_query()
.filter_map(StartCommandData::get_from_callback_query)
.filter_map(StartCommandData::get_view_listing_bids_start_command)
.filter_map_async(find_listing_by_id) .filter_map_async(find_listing_by_id)
.endpoint(with_error_handler(handle_place_bid_on_listing)), .endpoint(with_error_handler(handle_view_listing_bids)),
) )
.branch( .branch(
Update::filter_message() Update::filter_message()
@@ -56,11 +70,12 @@ pub fn bidding_handler() -> BotHandler {
.chain(case![DialogueRootState::Bidding( .chain(case![DialogueRootState::Bidding(
BiddingState::AwaitingConfirmBidAmount(listing_id, bid_amount) BiddingState::AwaitingConfirmBidAmount(listing_id, bid_amount)
)]) )])
.filter_map_async( .map2(|(listing_id, bid_amount): (DbListingId, MoneyAmount)| {
async |listing_dao, (listing_id, _): (ListingDbId, MoneyAmount)| { (listing_id, bid_amount)
find_listing_by_id(listing_dao, listing_id).await })
}, .filter_map_async(async |listing_dao, listing_id| {
) find_listing_by_id(listing_dao, listing_id).await
})
.endpoint(with_error_handler( .endpoint(with_error_handler(
handle_awaiting_confirm_bid_amount_callback, handle_awaiting_confirm_bid_amount_callback,
)), )),
@@ -68,9 +83,8 @@ pub fn bidding_handler() -> BotHandler {
} }
async fn handle_place_bid_on_listing( async fn handle_place_bid_on_listing(
bot: Bot, app: App,
user_dao: UserDAO, user_dao: UserDAO,
target: MessageTarget,
user: PersistedUser, user: PersistedUser,
listing: PersistedListing, listing: PersistedListing,
dialogue: RootDialogue, dialogue: RootDialogue,
@@ -81,7 +95,7 @@ async fn handle_place_bid_on_listing(
.await? .await?
.ok_or(BotError::UserVisibleError("Seller not found".to_string()))?; .ok_or(BotError::UserVisibleError("Seller not found".to_string()))?;
let fields = match &listing.fields { let basic_auction_fields = match &listing.fields {
ListingFields::BasicAuction(fields) => fields, ListingFields::BasicAuction(fields) => fields,
_ => { _ => {
return Err(BotError::UserVisibleError( return Err(BotError::UserVisibleError(
@@ -97,116 +111,79 @@ async fn handle_place_bid_on_listing(
let mut response_lines = vec![]; let mut response_lines = vec![];
response_lines.push(format!( response_lines.push(format!(
"Place bid on listing for listing <b>{}</b>, ran by {}", "Placing a bid <b>{title}</b>, ran by {seller}",
listing.base.title, title = listing.base.title,
seller seller = buyer_name_or_link(&seller)
.username
.clone()
.unwrap_or_else(|| seller.telegram_id.to_string())
)); ));
let currency_type = listing.base.currency_type;
response_lines.push(format!("You are bidding on this listing as: {user:?}")); response_lines.push(format!("You are bidding on this listing as: {user:?}"));
response_lines.push(format!("Minimum bid: {}", fields.min_increment)); response_lines.push(format!(
"Minimum bid: {}",
basic_auction_fields.min_increment.with_type(currency_type)
));
let keyboard = InlineKeyboardMarkup::default() let keyboard = InlineKeyboardMarkup::default()
.append_row([InlineKeyboardButton::callback("Bid $1", "cancel")]); .append_row([InlineKeyboardButton::callback("Bid $1", "cancel")]);
send_message(&bot, target, response_lines.join("\n"), Some(keyboard)).await?; app.bot
.send_html_message(response_lines.join("\n"), Some(keyboard))
.await?;
Ok(()) Ok(())
} }
async fn handle_awaiting_bid_amount_input( async fn handle_view_listing_bids(
bot: Bot, app: App,
listing: PersistedListing, listing: PersistedListing,
target: MessageTarget, user: PersistedUser,
dialogue: RootDialogue,
msg: Message,
) -> BotResult { ) -> BotResult {
// parse the bid amount into a MoneyAmount if listing.base.seller_id != user.persisted.id {
let text = msg return Err(BotError::user_visible(
.text() "You are not the seller of this listing",
.ok_or(BotError::user_visible("Please enter a valid bid amount"))?; ));
let bid_amount = match validate_price(text) { }
Ok(bid_amount) => bid_amount, let currency_type = listing.base.currency_type;
Err(SetFieldError::ValidationFailed(e)) => {
return Err(BotError::user_visible(e));
}
Err(other) => {
return Err(anyhow!("Error validating bid amount: {other:?}").into());
}
};
send_message( let bids = app.daos.bid.bids_for_listing(listing.persisted.id).await?;
&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 let mut response_lines = vec![];
.update(BiddingState::AwaitingConfirmBidAmount( response_lines.push(format!(
listing.persisted.id, "🔍 <b>Bids on <i>{title}</i></b>",
bid_amount, title = listing.base.title
)) ));
.await
.context("failed to update dialogue")?; let bidding_users = app
.daos
Ok(()) .user
} .where_in_ids(bids.iter().map(|bid| bid.buyer_id))
.await?
async fn handle_awaiting_confirm_bid_amount_callback( .into_iter()
bot: Bot, .map(|bid| (bid.persisted.id, bid))
listing: PersistedListing, .collect::<HashMap<DbUserId, PersistedUser>>();
(_, bid_amount): (ListingDbId, MoneyAmount),
target: MessageTarget, if let Some(current_bid) = bids.first() {
dialogue: RootDialogue, let buyer = bidding_users
callback_query: CallbackQuery, .get(&current_bid.buyer_id)
) -> BotResult { .ok_or(BotError::internal("Buyer not found"))?;
let callback_data = callback_query response_lines.push(format!(
.data "💰 Current highest bid: <b>{current_bid}</b> from {buyer_name}",
.as_deref() current_bid = current_bid.bid_amount.with_type(currency_type),
.ok_or(BotError::user_visible("Missing data in callback query"))?; buyer_name = buyer_name_or_link(&buyer)
));
let bid_amount = match callback_data { } else {
"confirm_bid" => bid_amount, response_lines.push("💰 No bids yet".to_string());
"cancel_bid" => { }
dialogue.exit().await.context("failed to exit dialogue")?;
send_message(&bot, target, "Bid cancelled", None).await?; for bid in bids.iter() {
return Ok(()); let bidder = bidding_users
} .get(&bid.buyer_id)
_ => { .ok_or(BotError::internal("Bidder not found"))?;
return Err(BotError::user_visible(format!( response_lines.push(format!(
"Invalid response {callback_data}" "💰 Bid: <b>{bid_amount}</b> from {buyer_name}",
))) bid_amount = bid.bid_amount.with_type(currency_type),
} buyer_name = buyer_name_or_link(&bidder)
}; ));
}
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(()) Ok(())
} }

188
src/bot_message_sender.rs Normal file
View File

@@ -0,0 +1,188 @@
use anyhow::Context;
use async_trait::async_trait;
use teloxide::{
payloads::{EditMessageTextSetters, SendMessageSetters},
prelude::Requester,
types::*,
Bot,
};
use crate::{
db::{bid::PersistedBid, listing::PersistedListing, user::PersistedUser},
message::MessageType,
message_sender::{BoxedMessageSender, MessageSender},
message_utils::buyer_name_or_link,
start_command_data::StartCommandData,
BotError, BotResult, MessageTarget,
};
pub struct BotMessageSender(Bot, MessageTarget);
impl BotMessageSender {
pub fn new(bot: Bot, message_target: MessageTarget) -> Self {
Self(bot, message_target)
}
}
#[async_trait]
impl MessageSender for BotMessageSender {
fn with_target(&self, target: MessageTarget) -> BoxedMessageSender {
let clone = Self(self.0.clone(), target);
Box::new(clone)
}
async fn send_html_message(
&self,
text: String,
keyboard: Option<InlineKeyboardMarkup>,
) -> BotResult {
let target = self.1.clone();
if let Some(message_id) = target.message_id {
log::info!("Editing message in chat: {target:?}");
let mut message = self
.0
.edit_message_text(target.chat_id, message_id, &text)
.parse_mode(ParseMode::Html);
if let Some(kb) = keyboard {
message = message.reply_markup(kb);
}
message.await.context("failed to edit message")?;
} else {
log::info!("Sending message to chat: {target:?}");
let mut message = self
.0
.send_message(target.chat_id, &text)
.parse_mode(ParseMode::Html);
if let Some(kb) = keyboard {
message = message.reply_markup(kb);
}
message.await.context("failed to send message")?;
}
Ok(())
}
async fn answer_inline_query(
&self,
inline_query_id: InlineQueryId,
results: Vec<InlineQueryResult>,
) -> BotResult {
self.0
.answer_inline_query(inline_query_id, results)
.await
.map(|_| ())
.map_err(|err| BotError::InternalError(err.into()))
}
async fn answer_callback_query(&self, query_id: CallbackQueryId) -> BotResult {
self.0
.answer_callback_query(query_id)
.await
.map(|_| ())
.map_err(|err| BotError::InternalError(err.into()))
}
async fn get_me(&self) -> BotResult<Me> {
self.0
.get_me()
.await
.map_err(|err| BotError::InternalError(err.into()))
}
async fn send_message(&self, message: MessageType) -> BotResult {
match message {
MessageType::UserHasBeenOutbidForBuyer { listing, buyer } => {
self.send_user_has_been_outbid(listing, buyer).await?;
}
MessageType::BidHasBeenPlacedForSeller {
listing,
buyer,
seller,
bid,
} => {
self.send_bid_has_been_placed(listing, buyer, seller, bid)
.await?;
}
MessageType::BidHasBeenConfirmedForBuyer { listing, bid } => {
self.send_bid_has_been_confirmed(listing, bid).await?;
}
MessageType::BidInvalidListingExpired { listing, buyer } => {
self.send_bid_invalid_listing_expired(listing, buyer)
.await?;
}
}
Ok(())
}
}
impl BotMessageSender {
async fn send_user_has_been_outbid(
&self,
listing: PersistedListing,
buyer: PersistedUser,
) -> BotResult {
self.with_target(buyer.into())
.send_html_message(
format!(
"You have been outbid for {title}",
title = listing.base.title
),
None,
)
.await
}
async fn send_bid_has_been_placed(
&self,
listing: PersistedListing,
buyer: PersistedUser,
seller: PersistedUser,
bid: PersistedBid,
) -> BotResult {
self.with_target(seller.into())
.send_html_message(
format!(
"A bid was placed on {title} for <b>{bid_amount}</b> by {buyer}",
bid_amount = bid.bid_amount.with_type(listing.base.currency_type),
title = listing.base.title,
buyer = buyer_name_or_link(&buyer),
),
Some(
InlineKeyboardMarkup::default().append_row([InlineKeyboardButton::callback(
"View Details",
StartCommandData::ViewListingDetailsAsBuyer(listing.persisted.id),
)]),
),
)
.await
}
async fn send_bid_has_been_confirmed(
&self,
listing: PersistedListing,
bid: PersistedBid,
) -> BotResult {
self.send_html_message(
format!(
"Bid placed for <b>{bid_amount}</b> on {title}",
bid_amount = bid.bid_amount.with_type(listing.base.currency_type),
title = listing.base.title,
),
None,
)
.await
}
async fn send_bid_invalid_listing_expired(
&self,
listing: PersistedListing,
buyer: PersistedUser,
) -> BotResult {
self.with_target(buyer.into())
.send_html_message(
format!(
"Auction <b>{title}</b> has already ended",
title = listing.base.title
),
None,
)
.await
}
}

View File

@@ -11,6 +11,9 @@ impl BotError {
pub fn user_visible(msg: impl Into<String>) -> Self { pub fn user_visible(msg: impl Into<String>) -> Self {
Self::UserVisibleError(msg.into()) Self::UserVisibleError(msg.into())
} }
pub fn internal(msg: impl Into<String>) -> Self {
Self::InternalError(anyhow::anyhow!(msg.into()))
}
} }
pub type BotResult<T = ()> = Result<T, BotError>; pub type BotResult<T = ()> = Result<T, BotError>;

View File

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

View File

@@ -1,18 +1,16 @@
use crate::{ use crate::{App, BotResult};
message_utils::{send_message, MessageTarget},
BotResult,
};
use log::info; use log::info;
use teloxide::{types::Message, Bot}; use teloxide::types::Message;
pub async fn handle_my_bids(bot: Bot, msg: Message, target: MessageTarget) -> BotResult { pub async fn handle_my_bids(app: App, msg: Message) -> BotResult {
let response = "🎯 My Bids (Coming Soon)\n\n\ let response = "🎯 My Bids (Coming Soon)\n\n\
Here you'll be able to view:\n\ Here you'll be able to view:\n\
• Your active bids\n\ • Your active bids\n\
• Bid status (winning/outbid)\n\ • Bid status (winning/outbid)\n\
• Proxy bid settings\n\ • Proxy bid settings\n\
• Auction end times\n\n\ • Auction end times\n\n\
Feature in development! 🏗️"; Feature in development! 🏗️"
.to_string();
info!( info!(
"User {} ({}) checked their bids", "User {} ({}) checked their bids",
@@ -20,6 +18,6 @@ pub async fn handle_my_bids(bot: Bot, msg: Message, target: MessageTarget) -> Bo
msg.chat.id msg.chat.id
); );
send_message(&bot, target, response, None).await?; app.bot.send_html_message(response, None).await?;
Ok(()) Ok(())
} }

View File

@@ -1,5 +1,5 @@
use crate::{ use crate::{
db::{listing::PersistedListing, ListingDbId}, db::{listing::PersistedListing, DbListingId},
keyboard_buttons, keyboard_buttons,
}; };
use regex::Regex; use regex::Regex;
@@ -14,7 +14,7 @@ use teloxide::types::InlineKeyboardButton;
// } // }
pub enum MyListingsButtons { pub enum MyListingsButtons {
SelectListing(ListingDbId), SelectListing(DbListingId),
NewListing, NewListing,
} }
impl MyListingsButtons { impl MyListingsButtons {
@@ -32,15 +32,15 @@ impl MyListingsButtons {
InlineKeyboardButton::callback(" New Listing", "my_listings_new_listing") InlineKeyboardButton::callback(" New Listing", "my_listings_new_listing")
} }
fn encode_listing_id(listing_id: ListingDbId) -> String { fn encode_listing_id(listing_id: DbListingId) -> String {
format!("my_listings:{listing_id}") format!("my_listings:{listing_id}")
} }
fn decode_listing_id(value: &str) -> Option<ListingDbId> { fn decode_listing_id(value: &str) -> Option<DbListingId> {
let re = Regex::new(r"my_listings:(\d+)").ok()?; let re = Regex::new(r"my_listings:(\d+)").ok()?;
let caps = re.captures(value)?; let caps = re.captures(value)?;
let listing_id = caps.get(1)?.as_str().parse::<i64>().ok()?; let listing_id = caps.get(1)?.as_str().parse::<i64>().ok()?;
Some(ListingDbId::new(listing_id)) Some(DbListingId::new(listing_id))
} }
} }

View File

@@ -1,5 +1,7 @@
mod keyboard; mod keyboard;
use std::ops::Deref;
use crate::{ use crate::{
case, case,
commands::{ commands::{
@@ -13,16 +15,15 @@ use crate::{
db::{ db::{
listing::{ListingFields, PersistedListing}, listing::{ListingFields, PersistedListing},
user::PersistedUser, user::PersistedUser,
ListingDAO, ListingDbId, ListingType, DAOs, DbListingId, ListingType,
}, },
handle_error::with_error_handler, handle_error::with_error_handler,
handler_utils::{find_listing_by_id, find_or_create_db_user_from_update}, handler_utils::{find_listing_by_id, find_or_create_db_user_from_update},
message_utils::{extract_callback_data, pluralize_with_count, send_message, MessageTarget}, message_utils::{extract_callback_data, pluralize_with_count},
start_command_data::StartCommandData, start_command_data::StartCommandData,
BotError, BotResult, Command, DialogueRootState, RootDialogue, App, BotError, BotResult, Command, DialogueRootState, RootDialogue,
}; };
use anyhow::{anyhow, Context}; use anyhow::Context;
use base64::{prelude::BASE64_URL_SAFE, Engine};
use log::info; use log::info;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use teloxide::{ use teloxide::{
@@ -32,13 +33,12 @@ use teloxide::{
InlineKeyboardButton, InlineKeyboardMarkup, InlineQueryResult, InlineQueryResultArticle, InlineKeyboardButton, InlineKeyboardMarkup, InlineQueryResult, InlineQueryResultArticle,
InputMessageContent, InputMessageContentText, ParseMode, User, InputMessageContent, InputMessageContentText, ParseMode, User,
}, },
Bot,
}; };
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MyListingsState { pub enum MyListingsState {
ViewingListings, ViewingListings,
ManagingListing(ListingDbId), ManagingListing(DbListingId),
} }
impl From<MyListingsState> for DialogueRootState { impl From<MyListingsState> for DialogueRootState {
fn from(state: MyListingsState) -> Self { fn from(state: MyListingsState) -> Self {
@@ -57,7 +57,7 @@ pub fn my_listings_handler() -> Handler<'static, BotResult, DpHandlerDescription
.branch( .branch(
Update::filter_message() Update::filter_message()
.filter_map(StartCommandData::get_from_update) .filter_map(StartCommandData::get_from_update)
.filter_map(StartCommandData::get_view_listing_details_start_command) .filter_map(StartCommandData::get_view_listing_details_as_buyer_start_command)
.filter_map_async(find_listing_by_id) .filter_map_async(find_listing_by_id)
.endpoint(with_error_handler(handle_view_listing_details)), .endpoint(with_error_handler(handle_view_listing_details)),
) )
@@ -86,58 +86,43 @@ pub fn my_listings_handler() -> Handler<'static, BotResult, DpHandlerDescription
) )
} }
async fn handle_view_listing_details( async fn handle_view_listing_details(app: App, listing: PersistedListing) -> BotResult {
bot: Bot, send_listing_details_message(app, listing, None).await?;
listing: PersistedListing,
target: MessageTarget,
) -> BotResult {
send_listing_details_message(&bot, target, listing, None).await?;
Ok(()) Ok(())
} }
async fn inline_query_extract_forward_listing( async fn inline_query_extract_forward_listing(
listing_dao: ListingDAO, app: App,
inline_query: InlineQuery, inline_query: InlineQuery,
) -> Option<PersistedListing> { ) -> Option<PersistedListing> {
let query = &inline_query.query; let query = &inline_query.query;
info!("Try to extract forward listing from query: {query}"); info!("Try to extract forward listing from query: {query}");
let listing_id_str = query.split("forward_listing:").nth(1)?; let listing_id_str = query.split("forward_listing:").nth(1)?;
let listing_id = ListingDbId::new(listing_id_str.parse::<i64>().ok()?); let listing_id = DbListingId::new(listing_id_str.parse::<i64>().ok()?);
let listing = listing_dao.find_by_id(listing_id).await.unwrap_or(None)?; let listing = app
.daos
.listing
.find_by_id(listing_id)
.await
.unwrap_or(None)?;
Some(listing) Some(listing)
} }
async fn handle_forward_listing( async fn handle_forward_listing(
bot: Bot, app: App,
inline_query: InlineQuery, inline_query: InlineQuery,
listing: PersistedListing, listing: PersistedListing,
) -> BotResult { ) -> BotResult {
info!("Handling forward listing inline query for listing {listing:?}"); info!("Handling forward listing inline query for listing {listing:?}");
let bot_username = match bot
.get_me()
.await
.context("failed to get bot username")?
.username
.as_ref()
{
Some(username) => username.to_string(),
None => return Err(anyhow!("Bot username not found").into()),
};
// Create inline keyboard with auction interaction buttons // Create inline keyboard with auction interaction buttons
let keyboard = InlineKeyboardMarkup::default() let keyboard = InlineKeyboardMarkup::default()
.append_row([ .append_row([
InlineKeyboardButton::url( InlineKeyboardButton::url(
"💰 Place Bid?", "💰 Place Bid",
format!( app.url_for_start_command(StartCommandData::PlaceBidOnListing(
"tg://resolve?domain={}&start={}", listing.persisted.id,
bot_username, )),
BASE64_URL_SAFE
.encode(format!("place_bid_on_listing:{}", listing.persisted.id))
)
.parse()
.unwrap(),
), ),
InlineKeyboardButton::callback( InlineKeyboardButton::callback(
"👀 Watch", "👀 Watch",
@@ -146,13 +131,9 @@ async fn handle_forward_listing(
]) ])
.append_row([InlineKeyboardButton::url( .append_row([InlineKeyboardButton::url(
"🔗 View Full Details", "🔗 View Full Details",
format!( app.url_for_start_command(StartCommandData::ViewListingDetailsAsBuyer(
"tg://resolve?domain={}&start={}", listing.persisted.id,
bot_username, )),
BASE64_URL_SAFE.encode(format!("view_listing_details:{}", listing.persisted.id))
)
.parse()
.unwrap(),
)]); )]);
// Get the current price based on listing type // Get the current price based on listing type
@@ -175,22 +156,23 @@ async fn handle_forward_listing(
listing.base.ends_at.format("%b %d, %Y at %H:%M UTC") listing.base.ends_at.format("%b %d, %Y at %H:%M UTC")
); );
bot.answer_inline_query( app.bot
inline_query.id, .answer_inline_query(
[InlineQueryResult::Article( inline_query.id,
InlineQueryResultArticle::new( vec![InlineQueryResult::Article(
listing.persisted.id.to_string(), InlineQueryResultArticle::new(
format!("💰 {} - ${}", listing.base.title, current_price), listing.persisted.id.to_string(),
InputMessageContent::Text( format!("💰 {} - ${}", listing.base.title, current_price),
InputMessageContentText::new(message_content).parse_mode(ParseMode::Html), InputMessageContent::Text(
), InputMessageContentText::new(message_content).parse_mode(ParseMode::Html),
) ),
.description(&listing.base.title) )
.reply_markup(keyboard), // Add the inline keyboard here! .description(&listing.base.title)
)], .reply_markup(keyboard), // Add the inline keyboard here!
) )],
.await )
.map_err(|e| anyhow::anyhow!("Error answering inline query: {e:?}"))?; .await
.map_err(|e| anyhow::anyhow!("Error answering inline query: {e:?}"))?;
Ok(()) Ok(())
} }
@@ -219,22 +201,18 @@ fn get_listing_current_price(listing: &PersistedListing) -> String {
} }
async fn handle_my_listings_command_input( async fn handle_my_listings_command_input(
listing_dao: ListingDAO, app: App,
bot: Bot,
dialogue: RootDialogue, dialogue: RootDialogue,
user: PersistedUser, user: PersistedUser,
target: MessageTarget,
) -> BotResult { ) -> BotResult {
enter_my_listings(listing_dao, bot, dialogue, user, target, None).await?; enter_my_listings(app, dialogue, user, None).await?;
Ok(()) Ok(())
} }
pub async fn enter_my_listings( pub async fn enter_my_listings(
listing_dao: ListingDAO, app: App,
bot: Bot,
dialogue: RootDialogue, dialogue: RootDialogue,
user: PersistedUser, user: PersistedUser,
target: MessageTarget,
flash: Option<String>, flash: Option<String>,
) -> BotResult { ) -> BotResult {
// Transition to ViewingListings state // Transition to ViewingListings state
@@ -243,7 +221,7 @@ pub async fn enter_my_listings(
.await .await
.context("failed to update dialogue")?; .context("failed to update dialogue")?;
let listings = listing_dao.find_by_seller(user.persisted.id).await?; let listings = app.daos.listing.find_by_seller(user.persisted.id).await?;
// Create keyboard with buttons for each listing // Create keyboard with buttons for each listing
let mut keyboard = teloxide::types::InlineKeyboardMarkup::default(); let mut keyboard = teloxide::types::InlineKeyboardMarkup::default();
for listing in &listings { for listing in &listings {
@@ -255,14 +233,14 @@ pub async fn enter_my_listings(
]); ]);
if listings.is_empty() { if listings.is_empty() {
send_message( app.bot
&bot, .send_html_message(
target, "📋 <b>My Listings</b>\n\n\
"📋 <b>My Listings</b>\n\n\ You don't have any listings yet."
You don't have any listings yet.", .to_string(),
Some(keyboard), Some(keyboard),
) )
.await?; .await?;
return Ok(()); return Ok(());
} }
@@ -277,34 +255,31 @@ pub async fn enter_my_listings(
response = format!("{flash}\n\n{response}"); response = format!("{flash}\n\n{response}");
} }
send_message(&bot, target, response, Some(keyboard)).await?; app.bot.send_html_message(response, Some(keyboard)).await?;
Ok(()) Ok(())
} }
async fn handle_viewing_listings_callback( async fn handle_viewing_listings_callback(
listing_dao: ListingDAO, app: App,
bot: Bot,
dialogue: RootDialogue, dialogue: RootDialogue,
callback_query: CallbackQuery, callback_query: CallbackQuery,
user: PersistedUser, user: PersistedUser,
target: MessageTarget,
) -> BotResult { ) -> BotResult {
let data = extract_callback_data(&bot, callback_query).await?; let data = extract_callback_data(app.bot.deref(), callback_query).await?;
if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) { if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) {
return enter_main_menu(bot, dialogue, target).await; return enter_main_menu(app, dialogue).await;
} }
// Check if it's the back to menu button // Check if it's the back to menu button
let button = MyListingsButtons::try_from(data.as_str())?; let button = MyListingsButtons::try_from(data.as_str())?;
match button { match button {
MyListingsButtons::SelectListing(listing_id) => { MyListingsButtons::SelectListing(listing_id) => {
let listing = get_listing_for_user(&listing_dao, user, listing_id).await?; let listing = get_listing_for_user(&app.daos, user, listing_id).await?;
enter_show_listing_details(app, dialogue, listing).await?;
enter_show_listing_details(&bot, dialogue, listing, target).await?;
} }
MyListingsButtons::NewListing => { MyListingsButtons::NewListing => {
enter_select_new_listing_type(bot, dialogue, target).await?; enter_select_new_listing_type(app, dialogue).await?;
} }
} }
@@ -312,10 +287,9 @@ async fn handle_viewing_listings_callback(
} }
async fn enter_show_listing_details( async fn enter_show_listing_details(
bot: &Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
listing: PersistedListing, listing: PersistedListing,
target: MessageTarget,
) -> BotResult { ) -> BotResult {
let listing_id = listing.persisted.id; let listing_id = listing.persisted.id;
dialogue dialogue
@@ -335,13 +309,12 @@ async fn enter_show_listing_details(
ManageListingButtons::Delete.to_button(), ManageListingButtons::Delete.to_button(),
]) ])
.append_row([ManageListingButtons::Back.to_button()]); .append_row([ManageListingButtons::Back.to_button()]);
send_listing_details_message(bot, target, listing, Some(keyboard)).await?; send_listing_details_message(app, listing, Some(keyboard)).await?;
Ok(()) Ok(())
} }
async fn send_listing_details_message( async fn send_listing_details_message(
bot: &Bot, app: App,
target: MessageTarget,
listing: PersistedListing, listing: PersistedListing,
keyboard: Option<InlineKeyboardMarkup>, keyboard: Option<InlineKeyboardMarkup>,
) -> BotResult { ) -> BotResult {
@@ -357,52 +330,46 @@ async fn send_listing_details_message(
}; };
response_lines.push(format!("<b>{}:</b> {}", step.field_name, field_value)); response_lines.push(format!("<b>{}:</b> {}", step.field_name, field_value));
} }
send_message(bot, target, response_lines.join("\n"), keyboard).await?; app.bot
.send_html_message(response_lines.join("\n"), keyboard)
.await?;
Ok(()) Ok(())
} }
async fn handle_managing_listing_callback( async fn handle_managing_listing_callback(
listing_dao: ListingDAO, app: App,
bot: Bot,
dialogue: RootDialogue, dialogue: RootDialogue,
callback_query: CallbackQuery, callback_query: CallbackQuery,
user: PersistedUser, user: PersistedUser,
listing_id: ListingDbId, listing_id: DbListingId,
target: MessageTarget,
) -> BotResult { ) -> BotResult {
let from = callback_query.from.clone(); let from = callback_query.from.clone();
let data = extract_callback_data(&bot, callback_query).await?; let data = extract_callback_data(&app.bot, callback_query).await?;
match ManageListingButtons::try_from(data.as_str())? { match ManageListingButtons::try_from(data.as_str())? {
ManageListingButtons::PreviewMessage => { ManageListingButtons::PreviewMessage => {
let listing = listing_dao let listing = app
.daos
.listing
.find_by_id(listing_id) .find_by_id(listing_id)
.await? .await?
.ok_or(anyhow::anyhow!("Listing not found"))?; .ok_or(anyhow::anyhow!("Listing not found"))?;
send_preview_listing_message(&bot, listing, from).await?; send_preview_listing_message(app, listing, from).await?;
} }
ManageListingButtons::ForwardListing => { ManageListingButtons::ForwardListing => {
unimplemented!("Forward listing not implemented"); unimplemented!("Forward listing not implemented");
} }
ManageListingButtons::Edit => { ManageListingButtons::Edit => {
let listing = get_listing_for_user(&listing_dao, user, listing_id).await?; let listing = get_listing_for_user(&app.daos, user, listing_id).await?;
let draft = ListingDraft::from_persisted(listing); let draft = ListingDraft::from_persisted(listing);
enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?; enter_edit_listing_draft(app, draft, dialogue, None).await?;
} }
ManageListingButtons::Delete => { ManageListingButtons::Delete => {
listing_dao.delete_listing(listing_id).await?; app.daos.listing.delete_listing(listing_id).await?;
enter_my_listings( enter_my_listings(app, dialogue, user, Some("Listing deleted.".to_string())).await?;
listing_dao,
bot,
dialogue,
user,
target,
Some("Listing deleted.".to_string()),
)
.await?;
} }
ManageListingButtons::Back => { ManageListingButtons::Back => {
enter_my_listings(listing_dao, bot, dialogue, user, target, None).await?; enter_my_listings(app, dialogue, user, None).await?;
} }
} }
@@ -441,7 +408,7 @@ fn keyboard_for_listing(listing: &PersistedListing) -> InlineKeyboardMarkup {
} }
async fn send_preview_listing_message( async fn send_preview_listing_message(
bot: &Bot, app: App,
listing: PersistedListing, listing: PersistedListing,
from: User, from: User,
) -> BotResult { ) -> BotResult {
@@ -450,22 +417,23 @@ async fn send_preview_listing_message(
if let Some(description) = &listing.base.description { if let Some(description) = &listing.base.description {
response_lines.push(description.to_owned()); response_lines.push(description.to_owned());
} }
send_message(
bot, app.bot
from.into(), .with_target(from.into())
response_lines.join("\n\n"), .send_html_message(
Some(keyboard_for_listing(&listing)), response_lines.join("\n\n"),
) Some(keyboard_for_listing(&listing)),
.await?; )
.await?;
Ok(()) Ok(())
} }
async fn get_listing_for_user( async fn get_listing_for_user(
listing_dao: &ListingDAO, daos: &DAOs,
user: PersistedUser, user: PersistedUser,
listing_id: ListingDbId, listing_id: DbListingId,
) -> BotResult<PersistedListing> { ) -> BotResult<PersistedListing> {
let listing = match listing_dao.find_by_id(listing_id).await? { let listing = match daos.listing.find_by_id(listing_id).await? {
Some(listing) => listing, Some(listing) => listing,
None => { None => {
return Err(BotError::UserVisibleError("❌ Listing not found.".into())); return Err(BotError::UserVisibleError("❌ Listing not found.".into()));

View File

@@ -3,6 +3,8 @@
//! This module handles all callback query processing for buttons //! This module handles all callback query processing for buttons
//! in the new listing creation and editing workflows. //! in the new listing creation and editing workflows.
use std::str::FromStr;
use crate::{ use crate::{
commands::{ commands::{
my_listings::enter_my_listings, my_listings::enter_my_listings,
@@ -15,29 +17,25 @@ use crate::{
ui::enter_confirm_save_listing, ui::enter_confirm_save_listing,
}, },
}, },
db::{ db::{user::PersistedUser, CurrencyType, ListingDuration, ListingType, MoneyAmount},
user::PersistedUser, CurrencyType, ListingDAO, ListingDuration, ListingType, MoneyAmount,
},
message_utils::*, message_utils::*,
BotResult, RootDialogue, App, BotResult, RootDialogue,
}; };
use log::{error, info}; use log::{error, info};
use teloxide::{types::CallbackQuery, Bot}; use teloxide::types::CallbackQuery;
/// Handle callbacks during the listing type selection phase /// Handle callbacks during the listing type selection phase
pub async fn handle_selecting_listing_type_callback( pub async fn handle_selecting_listing_type_callback(
listing_dao: ListingDAO, app: App,
bot: Bot,
dialogue: RootDialogue, dialogue: RootDialogue,
user: PersistedUser, user: PersistedUser,
callback_query: CallbackQuery, callback_query: CallbackQuery,
target: MessageTarget,
) -> BotResult { ) -> BotResult {
let data = extract_callback_data(&bot, callback_query).await?; let data = extract_callback_data(&app.bot, callback_query).await?;
info!("User {target:?} selected listing type: {data:?}"); info!("User selected listing type: {data:?}");
if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) { if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) {
return enter_my_listings(listing_dao, bot, dialogue, user, target, None).await; return enter_my_listings(app, dialogue, user, None).await;
} }
// Parse the listing type from callback data // Parse the listing type from callback data
@@ -64,38 +62,34 @@ pub async fn handle_selecting_listing_type_callback(
get_step_message(ListingField::Title, listing_type) get_step_message(ListingField::Title, listing_type)
); );
send_message( app.bot
&bot, .send_html_message(response, get_keyboard_for_field(ListingField::Title))
target, .await?;
response,
get_keyboard_for_field(ListingField::Title),
)
.await?;
Ok(()) Ok(())
} }
/// Handle callbacks during the field input phase /// Handle callbacks during the field input phase
pub async fn handle_awaiting_draft_field_callback( pub async fn handle_awaiting_draft_field_callback(
bot: Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
(field, draft): (ListingField, ListingDraft), field: ListingField,
draft: ListingDraft,
callback_query: CallbackQuery, callback_query: CallbackQuery,
target: MessageTarget,
) -> BotResult { ) -> BotResult {
let data = extract_callback_data(&bot, callback_query).await?; let data = extract_callback_data(&app.bot, callback_query).await?;
info!("User {target:?} selected callback: {data:?}"); info!("User selected callback: {data:?}");
if let Ok(button) = NavKeyboardButtons::try_from(data.as_str()) { if let Ok(button) = NavKeyboardButtons::try_from(data.as_str()) {
match button { match button {
NavKeyboardButtons::Back => { NavKeyboardButtons::Back => {
return enter_select_new_listing_type(bot, dialogue, target).await; return enter_select_new_listing_type(app, dialogue).await;
} }
NavKeyboardButtons::Skip => { NavKeyboardButtons::Skip => {
return handle_skip_field(&bot, dialogue, field, draft, target).await; return handle_skip_field(app, dialogue, field, draft).await;
} }
NavKeyboardButtons::Cancel => { NavKeyboardButtons::Cancel => {
return cancel_wizard(bot, dialogue, target).await; return cancel_wizard(app, dialogue).await;
} }
} }
} }
@@ -104,23 +98,23 @@ pub async fn handle_awaiting_draft_field_callback(
match field { match field {
ListingField::Slots => { ListingField::Slots => {
let button = SlotsKeyboardButtons::try_from(data.as_str())?; let button = SlotsKeyboardButtons::try_from(data.as_str())?;
handle_slots_callback(&bot, dialogue, draft, button, target).await handle_slots_callback(app, dialogue, draft, button).await
} }
ListingField::StartTime => { ListingField::StartTime => {
let button = StartTimeKeyboardButtons::try_from(data.as_str())?; let button = StartTimeKeyboardButtons::try_from(data.as_str())?;
handle_start_time_callback(&bot, dialogue, draft, button, target).await handle_start_time_callback(app, dialogue, draft, button).await
} }
ListingField::EndTime => { ListingField::EndTime => {
let button = DurationKeyboardButtons::try_from(data.as_str())?; let button = DurationKeyboardButtons::try_from(data.as_str())?;
handle_duration_callback(&bot, dialogue, draft, button, target).await handle_duration_callback(app, dialogue, draft, button).await
} }
ListingField::MinBidIncrement => { ListingField::MinBidIncrement => {
let button = EditMinimumBidIncrementKeyboardButtons::try_from(data.as_str())?; let button = EditMinimumBidIncrementKeyboardButtons::try_from(data.as_str())?;
handle_starting_bid_amount_callback(&bot, dialogue, draft, button, target).await handle_starting_bid_amount_callback(app, dialogue, draft, button).await
} }
ListingField::CurrencyType => { ListingField::CurrencyType => {
let button = CurrencyTypeKeyboardButtons::try_from(data.as_str())?; let button = CurrencyTypeKeyboardButtons::try_from(data.as_str())?;
handle_currency_type_callback(&bot, dialogue, draft, button, target).await handle_currency_type_callback(app, dialogue, draft, button).await
} }
_ => { _ => {
error!("Unknown callback data for field {field:?}: {data}"); error!("Unknown callback data for field {field:?}: {data}");
@@ -130,11 +124,10 @@ pub async fn handle_awaiting_draft_field_callback(
} }
async fn handle_skip_field( async fn handle_skip_field(
bot: &Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
current_field: ListingField, current_field: ListingField,
draft: ListingDraft, draft: ListingDraft,
target: MessageTarget,
) -> BotResult { ) -> BotResult {
let field_name = get_field_name(current_field, draft.listing_type()); let field_name = get_field_name(current_field, draft.listing_type());
let next_field = get_next_field(current_field, draft.listing_type()); let next_field = get_next_field(current_field, draft.listing_type());
@@ -146,20 +139,21 @@ async fn handle_skip_field(
get_step_message(next_field, draft.listing_type()) get_step_message(next_field, draft.listing_type())
); );
transition_to_field(dialogue, next_field, draft).await?; transition_to_field(dialogue, next_field, draft).await?;
send_message(bot, target, response, get_keyboard_for_field(next_field)).await?; app.bot
.send_html_message(response, get_keyboard_for_field(next_field))
.await?;
} else { } else {
enter_confirm_save_listing(bot, dialogue, target, draft, Some(flash)).await?; enter_confirm_save_listing(app, dialogue, draft, Some(flash)).await?;
} }
Ok(()) Ok(())
} }
/// Handle slots selection callback /// Handle slots selection callback
async fn handle_slots_callback( async fn handle_slots_callback(
bot: &Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
mut draft: ListingDraft, mut draft: ListingDraft,
button: SlotsKeyboardButtons, button: SlotsKeyboardButtons,
target: MessageTarget,
) -> BotResult { ) -> BotResult {
let num_slots = match button { let num_slots = match button {
SlotsKeyboardButtons::OneSlot => 1, SlotsKeyboardButtons::OneSlot => 1,
@@ -180,23 +174,18 @@ async fn handle_slots_callback(
get_step_message(ListingField::StartTime, draft.listing_type()) get_step_message(ListingField::StartTime, draft.listing_type())
); );
transition_to_field(dialogue, ListingField::StartTime, draft).await?; transition_to_field(dialogue, ListingField::StartTime, draft).await?;
send_message( app.bot
bot, .send_html_message(response, get_keyboard_for_field(ListingField::StartTime))
target, .await?;
&response,
get_keyboard_for_field(ListingField::StartTime),
)
.await?;
Ok(()) Ok(())
} }
/// Handle start time selection callback /// Handle start time selection callback
async fn handle_start_time_callback( async fn handle_start_time_callback(
bot: &Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
mut draft: ListingDraft, mut draft: ListingDraft,
button: StartTimeKeyboardButtons, button: StartTimeKeyboardButtons,
target: MessageTarget,
) -> BotResult { ) -> BotResult {
let start_time = match button { let start_time = match button {
StartTimeKeyboardButtons::Now => ListingDuration::zero(), StartTimeKeyboardButtons::Now => ListingDuration::zero(),
@@ -215,23 +204,18 @@ async fn handle_start_time_callback(
get_step_message(ListingField::EndTime, draft.listing_type()) get_step_message(ListingField::EndTime, draft.listing_type())
); );
transition_to_field(dialogue, ListingField::EndTime, draft).await?; transition_to_field(dialogue, ListingField::EndTime, draft).await?;
send_message( app.bot
bot, .send_html_message(response, get_keyboard_for_field(ListingField::EndTime))
target, .await?;
&response,
get_keyboard_for_field(ListingField::EndTime),
)
.await?;
Ok(()) Ok(())
} }
/// Handle duration selection callback /// Handle duration selection callback
async fn handle_duration_callback( async fn handle_duration_callback(
bot: &Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
mut draft: ListingDraft, mut draft: ListingDraft,
button: DurationKeyboardButtons, button: DurationKeyboardButtons,
target: MessageTarget,
) -> BotResult { ) -> BotResult {
let duration = ListingDuration::days(match button { let duration = ListingDuration::days(match button {
DurationKeyboardButtons::OneDay => 1, DurationKeyboardButtons::OneDay => 1,
@@ -248,15 +232,14 @@ async fn handle_duration_callback(
.map_err(|e| anyhow::anyhow!("Error updating duration: {e:?}"))?; .map_err(|e| anyhow::anyhow!("Error updating duration: {e:?}"))?;
let flash = get_success_message(ListingField::EndTime, draft.listing_type()); let flash = get_success_message(ListingField::EndTime, draft.listing_type());
enter_confirm_save_listing(bot, dialogue, target, draft, Some(flash)).await enter_confirm_save_listing(app, dialogue, draft, Some(flash)).await
} }
async fn handle_starting_bid_amount_callback( async fn handle_starting_bid_amount_callback(
bot: &Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
mut draft: ListingDraft, mut draft: ListingDraft,
button: EditMinimumBidIncrementKeyboardButtons, button: EditMinimumBidIncrementKeyboardButtons,
target: MessageTarget,
) -> BotResult { ) -> BotResult {
let starting_bid_amount = MoneyAmount::from_str(match button { let starting_bid_amount = MoneyAmount::from_str(match button {
EditMinimumBidIncrementKeyboardButtons::OneDollar => "1.00", EditMinimumBidIncrementKeyboardButtons::OneDollar => "1.00",
@@ -273,15 +256,14 @@ async fn handle_starting_bid_amount_callback(
.map_err(|e| anyhow::anyhow!("Error updating starting bid amount: {e:?}"))?; .map_err(|e| anyhow::anyhow!("Error updating starting bid amount: {e:?}"))?;
let flash = get_success_message(ListingField::StartingBidAmount, draft.listing_type()); let flash = get_success_message(ListingField::StartingBidAmount, draft.listing_type());
enter_confirm_save_listing(bot, dialogue, target, draft, Some(flash)).await enter_confirm_save_listing(app, dialogue, draft, Some(flash)).await
} }
async fn handle_currency_type_callback( async fn handle_currency_type_callback(
bot: &Bot, app: App,
dialogue: RootDialogue, dialogue: RootDialogue,
mut draft: ListingDraft, mut draft: ListingDraft,
button: CurrencyTypeKeyboardButtons, button: CurrencyTypeKeyboardButtons,
target: MessageTarget,
) -> BotResult { ) -> BotResult {
let currency_type = match button { let currency_type = match button {
CurrencyTypeKeyboardButtons::Usd => CurrencyType::Usd, CurrencyTypeKeyboardButtons::Usd => CurrencyType::Usd,
@@ -304,13 +286,15 @@ async fn handle_currency_type_callback(
get_step_message(next_field, draft.listing_type()) get_step_message(next_field, draft.listing_type())
); );
transition_to_field(dialogue, next_field, draft).await?; transition_to_field(dialogue, next_field, draft).await?;
send_message(bot, target, &response, get_keyboard_for_field(next_field)).await?; app.bot
.send_html_message(response, get_keyboard_for_field(next_field))
.await?;
Ok(()) Ok(())
} }
/// Cancel the wizard and exit /// Cancel the wizard and exit
pub async fn cancel_wizard(bot: Bot, dialogue: RootDialogue, target: MessageTarget) -> BotResult { pub async fn cancel_wizard(app: App, dialogue: RootDialogue) -> BotResult {
info!("{target:?} cancelled new listing wizard"); info!("User cancelled new listing wizard");
enter_select_new_listing_type(bot, dialogue, target).await?; enter_select_new_listing_type(app, dialogue).await?;
Ok(()) Ok(())
} }

View File

@@ -1,5 +1,10 @@
use super::{callbacks::*, handlers::*, types::*}; use super::{callbacks::*, handlers::*, types::*};
use crate::{case, handle_error::with_error_handler, BotHandler, Command, DialogueRootState}; use crate::{
dptree_utils::{identity, MapTwo},
handle_error::with_error_handler,
BotHandler, Command, DialogueRootState,
};
use dptree::case;
use teloxide::{dptree, prelude::*, types::Update}; use teloxide::{dptree, prelude::*, types::Update};
// Create the dialogue handler tree for new listing wizard // Create the dialogue handler tree for new listing wizard
@@ -14,49 +19,44 @@ pub fn new_listing_handler() -> BotHandler {
.endpoint(with_error_handler(handle_new_listing_command)), .endpoint(with_error_handler(handle_new_listing_command)),
) )
.branch( .branch(
case![DialogueRootState::NewListing( dptree::entry()
NewListingState::AwaitingDraftField { field, draft } .chain(case![DialogueRootState::NewListing(state)])
)] .branch(
.endpoint(with_error_handler(handle_awaiting_draft_field_input)), case![NewListingState::AwaitingDraftField { field, draft }]
) .map2(identity::<(ListingField, ListingDraft)>)
.branch( .endpoint(with_error_handler(handle_awaiting_draft_field_input)),
case![DialogueRootState::NewListing( )
NewListingState::EditingDraftField { field, draft } .branch(
)] case![NewListingState::EditingDraftField { field, draft }]
.endpoint(with_error_handler(handle_editing_field_input)), .map2(identity::<(ListingField, ListingDraft)>)
.endpoint(with_error_handler(handle_editing_field_input)),
),
), ),
) )
.branch( .branch(
Update::filter_callback_query() Update::filter_callback_query()
.chain(case![DialogueRootState::NewListing(state)])
.branch( .branch(
case![DialogueRootState::NewListing( case![NewListingState::SelectingListingType]
NewListingState::SelectingListingType .endpoint(with_error_handler(handle_selecting_listing_type_callback)),
)]
.endpoint(with_error_handler(handle_selecting_listing_type_callback)),
) )
.branch( .branch(
case![DialogueRootState::NewListing( case![NewListingState::AwaitingDraftField { field, draft }]
NewListingState::AwaitingDraftField { field, draft } .map2(identity::<(ListingField, ListingDraft)>)
)] .endpoint(with_error_handler(handle_awaiting_draft_field_callback)),
.endpoint(with_error_handler(handle_awaiting_draft_field_callback)),
) )
.branch( .branch(
case![DialogueRootState::NewListing( case![NewListingState::ViewingDraft(draft)]
NewListingState::ViewingDraft(draft) .endpoint(with_error_handler(handle_viewing_draft_callback)),
)]
.endpoint(with_error_handler(handle_viewing_draft_callback)),
) )
.branch( .branch(
case![DialogueRootState::NewListing( case![NewListingState::EditingDraft(draft)]
NewListingState::EditingDraft(draft) .endpoint(with_error_handler(handle_editing_draft_callback)),
)]
.endpoint(with_error_handler(handle_editing_draft_callback)),
) )
.branch( .branch(
case![DialogueRootState::NewListing( case![NewListingState::EditingDraftField { field, draft }]
NewListingState::EditingDraftField { field, draft } .map2(identity::<(ListingField, ListingDraft)>)
)] .endpoint(with_error_handler(handle_editing_draft_field_callback)),
.endpoint(with_error_handler(handle_editing_draft_field_callback)),
), ),
) )
} }

View File

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

View File

@@ -1,3 +1,5 @@
use std::str::FromStr;
use chrono::Duration; use chrono::Duration;
use crate::{ use crate::{
@@ -8,7 +10,7 @@ use crate::{
}, },
db::{ db::{
listing::{FixedPriceListingFields, ListingFields}, listing::{FixedPriceListingFields, ListingFields},
CurrencyType, MoneyAmount, UserDbId, CurrencyType, DbUserId, MoneyAmount,
}, },
}; };
@@ -17,7 +19,7 @@ fn create_test_draft() -> ListingDraft {
has_changes: false, has_changes: false,
persisted: None, persisted: None,
base: crate::db::listing::ListingBase { base: crate::db::listing::ListingBase {
seller_id: UserDbId::new(1), seller_id: DbUserId::new(1),
title: "".to_string(), title: "".to_string(),
description: None, description: None,
currency_type: CurrencyType::Usd, currency_type: CurrencyType::Usd,

View File

@@ -4,7 +4,7 @@ use crate::{
BasicAuctionFields, BlindAuctionFields, FixedPriceListingFields, ListingBase, BasicAuctionFields, BlindAuctionFields, FixedPriceListingFields, ListingBase,
ListingFields, MultiSlotAuctionFields, PersistedListing, PersistedListingFields, ListingFields, MultiSlotAuctionFields, PersistedListing, PersistedListingFields,
}, },
CurrencyType, ListingType, MoneyAmount, UserDbId, CurrencyType, ListingType, MoneyAmount, DbUserId,
}, },
DialogueRootState, DialogueRootState,
}; };
@@ -20,7 +20,7 @@ pub struct ListingDraft {
} }
impl ListingDraft { impl ListingDraft {
pub fn new_for_seller_with_type(seller_id: UserDbId, listing_type: ListingType) -> Self { pub fn new_for_seller_with_type(seller_id: DbUserId, listing_type: ListingType) -> Self {
let fields = match listing_type { let fields = match listing_type {
ListingType::BasicAuction => ListingFields::BasicAuction(BasicAuctionFields { ListingType::BasicAuction => ListingFields::BasicAuction(BasicAuctionFields {
starting_bid: MoneyAmount::default(), starting_bid: MoneyAmount::default(),

View File

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

View File

@@ -1,3 +1,5 @@
use std::str::FromStr;
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use crate::db::{CurrencyType, MoneyAmount}; use crate::db::{CurrencyType, MoneyAmount};

View File

@@ -1,18 +1,16 @@
use crate::{ use crate::{App, BotResult};
message_utils::{send_message, MessageTarget},
BotResult,
};
use log::info; use log::info;
use teloxide::{types::Message, Bot}; use teloxide::types::Message;
pub async fn handle_settings(bot: Bot, msg: Message, target: MessageTarget) -> BotResult { pub async fn handle_settings(app: App, msg: Message) -> BotResult {
let response = "⚙️ Settings (Coming Soon)\n\n\ let response = "⚙️ Settings (Coming Soon)\n\n\
Here you'll be able to configure:\n\ Here you'll be able to configure:\n\
• Notification preferences\n\ • Notification preferences\n\
• Language settings\n\ • Language settings\n\
• Default bid increments\n\ • Default bid increments\n\
• Outbid alerts\n\n\ • Outbid alerts\n\n\
Feature in development! 🛠️"; Feature in development! 🛠️"
.to_string();
info!( info!(
"User {} ({}) accessed settings", "User {} ({}) accessed settings",
@@ -20,6 +18,6 @@ pub async fn handle_settings(bot: Bot, msg: Message, target: MessageTarget) -> B
msg.chat.id msg.chat.id
); );
send_message(&bot, target, response, None).await?; app.bot.send_html_message(response, None).await?;
Ok(()) Ok(())
} }

View File

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

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

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

View File

@@ -15,38 +15,20 @@ use crate::db::{
ListingFields, MultiSlotAuctionFields, NewListing, PersistedListing, ListingFields, MultiSlotAuctionFields, NewListing, PersistedListing,
PersistedListingFields, PersistedListingFields,
}, },
ListingDbId, ListingType, UserDbId, DbListingId, DbUserId, ListingType,
}; };
/// Data Access Object for Listing operations /// Data Access Object for Listing operations
#[derive(Clone)] #[derive(Clone)]
pub struct ListingDAO(SqlitePool); pub struct ListingDAO(SqlitePool);
const LISTING_RETURN_FIELDS: &[&str] = &[
"id",
"seller_id",
"listing_type",
"title",
"description",
"currency_type",
"starts_at",
"ends_at",
"created_at",
"updated_at",
"starting_bid",
"buy_now_price",
"min_increment",
"anti_snipe_minutes",
"slots_available",
];
impl ListingDAO { impl ListingDAO {
pub fn new(pool: SqlitePool) -> Self { pub fn new(pool: SqlitePool) -> Self {
Self(pool) Self(pool)
} }
/// Insert a new listing into the database /// Insert a new listing into the database
pub async fn insert_listing(&self, listing: NewListing) -> Result<PersistedListing> { pub async fn insert_listing(&self, listing: &NewListing) -> Result<PersistedListing> {
let now = Utc::now(); let now = Utc::now();
let binds = binds_for_listing(&listing) let binds = binds_for_listing(&listing)
@@ -59,11 +41,10 @@ impl ListingDAO {
let query_str = format!( let query_str = format!(
r#" r#"
INSERT INTO listings ({}) VALUES ({}) INSERT INTO listings ({}) VALUES ({})
RETURNING {} RETURNING *
"#, "#,
binds.bind_names().join(", "), binds.bind_names().join(", "),
binds.bind_placeholders().join(", "), binds.bind_placeholders().join(", "),
LISTING_RETURN_FIELDS.join(", ")
); );
let row = binds let row = binds
@@ -73,7 +54,7 @@ impl ListingDAO {
Ok(FromRow::from_row(&row)?) Ok(FromRow::from_row(&row)?)
} }
pub async fn update_listing(&self, listing: PersistedListing) -> Result<PersistedListing> { pub async fn update_listing(&self, listing: &PersistedListing) -> Result<PersistedListing> {
let now = Utc::now(); let now = Utc::now();
let binds = binds_for_listing(&listing).push("updated_at", &now); let binds = binds_for_listing(&listing).push("updated_at", &now);
@@ -83,13 +64,12 @@ impl ListingDAO {
SET {} SET {}
WHERE id = ? WHERE id = ?
AND seller_id = ? AND seller_id = ?
RETURNING {} RETURNING *
"#, "#,
binds binds
.bind_names() .bind_names()
.map(|name| format!("{name} = ?")) .map(|name| format!("{name} = ?"))
.join(", "), .join(", "),
LISTING_RETURN_FIELDS.join(", ")
); );
let row = binds let row = binds
@@ -102,32 +82,27 @@ impl ListingDAO {
} }
/// Find a listing by its ID /// Find a listing by its ID
pub async fn find_by_id(&self, listing_id: ListingDbId) -> Result<Option<PersistedListing>> { pub async fn find_by_id(&self, listing_id: DbListingId) -> Result<Option<PersistedListing>> {
let result = sqlx::query_as(&format!( let result = sqlx::query_as("SELECT * FROM listings WHERE id = ?")
"SELECT {} FROM listings WHERE id = ?", .bind(listing_id)
LISTING_RETURN_FIELDS.join(", ") .fetch_optional(&self.0)
)) .await?;
.bind(listing_id)
.fetch_optional(&self.0)
.await?;
Ok(result) Ok(result)
} }
/// Find all listings by a seller /// Find all listings by a seller
pub async fn find_by_seller(&self, seller_id: UserDbId) -> Result<Vec<PersistedListing>> { pub async fn find_by_seller(&self, seller_id: DbUserId) -> Result<Vec<PersistedListing>> {
let rows = sqlx::query_as(&format!( let rows =
"SELECT {} FROM listings WHERE seller_id = ? ORDER BY created_at DESC", sqlx::query_as("SELECT * FROM listings WHERE seller_id = ? ORDER BY created_at DESC")
LISTING_RETURN_FIELDS.join(", ") .bind(seller_id)
)) .fetch_all(&self.0)
.bind(seller_id) .await?;
.fetch_all(&self.0)
.await?;
Ok(rows) Ok(rows)
} }
/// Delete a listing /// Delete a listing
pub async fn delete_listing(&self, listing_id: ListingDbId) -> Result<()> { pub async fn delete_listing(&self, listing_id: DbListingId) -> Result<()> {
sqlx::query("DELETE FROM listings WHERE id = ?") sqlx::query("DELETE FROM listings WHERE id = ?")
.bind(listing_id) .bind(listing_id)
.execute(&self.0) .execute(&self.0)
@@ -148,6 +123,8 @@ fn binds_for_base(base: &ListingBase) -> BindFields {
.push("title", &base.title) .push("title", &base.title)
.push("description", &base.description) .push("description", &base.description)
.push("currency_type", &base.currency_type) .push("currency_type", &base.currency_type)
.push("starts_at", &base.starts_at)
.push("ends_at", &base.ends_at)
} }
fn binds_for_fields(fields: &ListingFields) -> BindFields { fn binds_for_fields(fields: &ListingFields) -> BindFields {

View File

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

View File

@@ -10,24 +10,13 @@ use crate::db::{
bind_fields::BindFields, bind_fields::BindFields,
models::user::NewUser, models::user::NewUser,
user::{PersistedUser, PersistedUserFields}, user::{PersistedUser, PersistedUserFields},
TelegramUserDbId, UserDbId, DbTelegramUserId, DbUserId,
}; };
/// Data Access Object for User operations /// Data Access Object for User operations
#[derive(Clone)] #[derive(Clone)]
pub struct UserDAO(SqlitePool); pub struct UserDAO(SqlitePool);
const USER_RETURN_FIELDS: &[&str] = &[
"id",
"telegram_id",
"username",
"first_name",
"last_name",
"is_banned",
"created_at",
"updated_at",
];
#[allow(unused)] #[allow(unused)]
impl UserDAO { impl UserDAO {
pub fn new(pool: SqlitePool) -> Self { pub fn new(pool: SqlitePool) -> Self {
@@ -46,11 +35,10 @@ impl UserDAO {
r#" r#"
INSERT INTO users ({}) INSERT INTO users ({})
VALUES ({}) VALUES ({})
RETURNING {} RETURNING *
"#, "#,
binds.bind_names().join(", "), binds.bind_names().join(", "),
binds.bind_placeholders().join(", "), binds.bind_placeholders().join(", "),
USER_RETURN_FIELDS.join(", ")
); );
let query = sqlx::query(&query_str); let query = sqlx::query(&query_str);
let row = binds.bind_to_query(query).fetch_one(&self.0).await?; let row = binds.bind_to_query(query).fetch_one(&self.0).await?;
@@ -59,23 +47,19 @@ impl UserDAO {
} }
/// Find a user by their ID /// Find a user by their ID
pub async fn find_by_id(&self, user_id: UserDbId) -> Result<Option<PersistedUser>> { pub async fn find_by_id(&self, user_id: DbUserId) -> Result<Option<PersistedUser>> {
Ok(sqlx::query_as::<_, PersistedUser>( Ok(
r#" sqlx::query_as::<_, PersistedUser>("SELECT * FROM users WHERE id = ? ")
SELECT id, telegram_id, first_name, last_name, username, is_banned, created_at, updated_at .bind(user_id)
FROM users .fetch_optional(&self.0)
WHERE id = ? .await?,
"#,
) )
.bind(user_id)
.fetch_optional(&self.0)
.await?)
} }
/// Find a user by their Telegram ID /// Find a user by their Telegram ID
pub async fn find_by_telegram_id( pub async fn find_by_telegram_id(
&self, &self,
telegram_id: impl Into<TelegramUserDbId>, telegram_id: impl Into<DbTelegramUserId>,
) -> Result<Option<PersistedUser>> { ) -> Result<Option<PersistedUser>> {
let telegram_id = telegram_id.into(); let telegram_id = telegram_id.into();
Ok(sqlx::query_as( Ok(sqlx::query_as(
@@ -95,7 +79,7 @@ impl UserDAO {
user: teloxide::types::User, user: teloxide::types::User,
) -> Result<PersistedUser> { ) -> Result<PersistedUser> {
let binds = BindFields::default() let binds = BindFields::default()
.push("telegram_id", &TelegramUserDbId::from(user.id)) .push("telegram_id", &DbTelegramUserId::from(user.id))
.push("username", &user.username) .push("username", &user.username)
.push("first_name", &user.first_name) .push("first_name", &user.first_name)
.push("last_name", &user.last_name); .push("last_name", &user.last_name);
@@ -108,11 +92,10 @@ impl UserDAO {
username = EXCLUDED.username, username = EXCLUDED.username,
first_name = EXCLUDED.first_name, first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name last_name = EXCLUDED.last_name
RETURNING {} RETURNING *
"#, "#,
binds.bind_names().join(", "), binds.bind_names().join(", "),
binds.bind_placeholders().join(", "), binds.bind_placeholders().join(", "),
USER_RETURN_FIELDS.join(", ")
); );
let row = binds let row = binds
@@ -147,7 +130,7 @@ impl UserDAO {
} }
/// Set a user's ban status /// Set a user's ban status
pub async fn set_ban_status(&self, user_id: UserDbId, is_banned: bool) -> Result<()> { pub async fn set_ban_status(&self, user_id: DbUserId, is_banned: bool) -> Result<()> {
sqlx::query("UPDATE users SET is_banned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") 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(is_banned) // sqlx automatically converts bool to INTEGER for SQLite
.bind(user_id) .bind(user_id)
@@ -158,7 +141,7 @@ impl UserDAO {
} }
/// Delete a user (soft delete by setting is_banned = true might be better in production) /// Delete a user (soft delete by setting is_banned = true might be better in production)
pub async fn delete_user(&self, user_id: UserDbId) -> Result<()> { pub async fn delete_user(&self, user_id: DbUserId) -> Result<()> {
sqlx::query("DELETE FROM users WHERE id = ?") sqlx::query("DELETE FROM users WHERE id = ?")
.bind(user_id) .bind(user_id)
.execute(&self.0) .execute(&self.0)
@@ -166,6 +149,29 @@ impl UserDAO {
Ok(()) Ok(())
} }
pub async fn where_in_ids(
&self,
ids: impl Iterator<Item = DbUserId>,
) -> Result<Vec<PersistedUser>> {
let mut builder =
sqlx::query_builder::QueryBuilder::new("SELECT * FROM users WHERE id IN (");
let mut count = 0;
for id in ids {
count += 1;
builder.push_bind(id);
}
if count == 0 {
return Ok(vec![]);
}
builder.push(")");
let rows = builder.build().fetch_all(&self.0).await?;
Ok(rows
.into_iter()
.map(|row| PersistedUser::from_row(&row))
.collect::<Result<Vec<_>, _>>()?)
}
} }
impl FromRow<'_, SqliteRow> for PersistedUser { impl FromRow<'_, SqliteRow> for PersistedUser {
@@ -361,7 +367,7 @@ mod tests {
let pool = create_test_dao().await; let pool = create_test_dao().await;
// Try to find a user that doesn't exist // Try to find a user that doesn't exist
let not_found = UserDAO::find_by_id(&pool, UserDbId::new(99999)) let not_found = UserDAO::find_by_id(&pool, DbUserId::new(99999))
.await .await
.expect("Database operation should succeed"); .expect("Database operation should succeed");

View File

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

View File

@@ -9,7 +9,7 @@
//! The main `Listing` enum ensures that only valid fields are accessible for each type. //! The main `Listing` enum ensures that only valid fields are accessible for each type.
//! Database mapping is handled through `ListingRow` with conversion traits. //! Database mapping is handled through `ListingRow` with conversion traits.
use crate::db::{CurrencyType, ListingDbId, ListingType, MoneyAmount, UserDbId}; use crate::db::{CurrencyType, DbListingId, DbUserId, ListingType, MoneyAmount};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt::Debug; use std::fmt::Debug;
@@ -19,14 +19,13 @@ pub type PersistedListing = Listing<PersistedListingFields>;
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
pub struct PersistedListingFields { pub struct PersistedListingFields {
pub id: ListingDbId, pub id: DbListingId,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
/// Main listing/auction entity /// Main listing/auction entity
#[derive(Debug, Clone, Eq, PartialEq)] #[derive(Debug, Clone, Eq, PartialEq)]
#[allow(unused)]
pub struct Listing<P: Debug + Clone> { pub struct Listing<P: Debug + Clone> {
pub persisted: P, pub persisted: P,
pub base: ListingBase, pub base: ListingBase,
@@ -49,9 +48,8 @@ impl<'a, P: Debug + Clone> From<&'a mut Listing<P>> for ListingBaseFieldsMut<'a>
/// Common fields shared by all listing types /// Common fields shared by all listing types
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[allow(unused)]
pub struct ListingBase { pub struct ListingBase {
pub seller_id: UserDbId, pub seller_id: DbUserId,
pub title: String, pub title: String,
pub description: Option<String>, pub description: Option<String>,
pub starts_at: DateTime<Utc>, pub starts_at: DateTime<Utc>,
@@ -72,7 +70,6 @@ impl ListingBase {
/// Fields specific to basic auction listings /// Fields specific to basic auction listings
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[allow(unused)]
pub struct BasicAuctionFields { pub struct BasicAuctionFields {
pub starting_bid: MoneyAmount, pub starting_bid: MoneyAmount,
pub buy_now_price: Option<MoneyAmount>, pub buy_now_price: Option<MoneyAmount>,
@@ -82,7 +79,6 @@ pub struct BasicAuctionFields {
/// Fields specific to multi-slot auction listings /// Fields specific to multi-slot auction listings
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[allow(unused)]
pub struct MultiSlotAuctionFields { pub struct MultiSlotAuctionFields {
pub starting_bid: MoneyAmount, pub starting_bid: MoneyAmount,
pub buy_now_price: MoneyAmount, pub buy_now_price: MoneyAmount,
@@ -93,7 +89,6 @@ pub struct MultiSlotAuctionFields {
/// Fields specific to fixed price listings /// Fields specific to fixed price listings
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[allow(unused)]
pub struct FixedPriceListingFields { pub struct FixedPriceListingFields {
pub buy_now_price: MoneyAmount, pub buy_now_price: MoneyAmount,
pub slots_available: i32, pub slots_available: i32,
@@ -101,13 +96,11 @@ pub struct FixedPriceListingFields {
/// Fields specific to blind auction listings /// Fields specific to blind auction listings
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[allow(unused)]
pub struct BlindAuctionFields { pub struct BlindAuctionFields {
pub starting_bid: MoneyAmount, pub starting_bid: MoneyAmount,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[allow(unused)]
pub enum ListingFields { pub enum ListingFields {
BasicAuction(BasicAuctionFields), BasicAuction(BasicAuctionFields),
MultiSlotAuction(MultiSlotAuctionFields), MultiSlotAuction(MultiSlotAuctionFields),
@@ -134,17 +127,19 @@ impl From<&ListingFields> for ListingType {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::str::FromStr;
use super::*; use super::*;
use crate::db::{TelegramUserDbId, UserDAO}; use crate::db::{DbTelegramUserId, UserDAO};
use chrono::Duration; use chrono::Duration;
use rstest::rstest; use rstest::rstest;
/// Create a test user using UserDAO and return their ID /// Create a test user using UserDAO and return their ID
async fn create_test_user( async fn create_test_user(
user_dao: &UserDAO, user_dao: &UserDAO,
telegram_id: TelegramUserDbId, telegram_id: DbTelegramUserId,
username: Option<&str>, username: Option<&str>,
) -> UserDbId { ) -> DbUserId {
use crate::db::models::user::NewUser; use crate::db::models::user::NewUser;
let new_user = NewUser { let new_user = NewUser {
@@ -164,7 +159,7 @@ mod tests {
} }
fn build_base_listing( fn build_base_listing(
seller_id: UserDbId, seller_id: DbUserId,
title: impl Into<String>, title: impl Into<String>,
description: Option<&str>, description: Option<&str>,
currency_type: CurrencyType, currency_type: CurrencyType,
@@ -202,7 +197,7 @@ mod tests {
// Insert using DAO // Insert using DAO
let created_listing = listing_dao let created_listing = listing_dao
.insert_listing(new_listing.clone()) .insert_listing(&new_listing)
.await .await
.expect("Failed to insert listing"); .expect("Failed to insert listing");

View File

@@ -2,24 +2,24 @@ use chrono::{DateTime, Utc};
use sqlx::FromRow; use sqlx::FromRow;
use std::fmt::Debug; use std::fmt::Debug;
use crate::db::{TelegramUserDbId, UserDbId}; use crate::db::{DbTelegramUserId, DbUserId};
pub type PersistedUser = User<PersistedUserFields>; pub type PersistedUser = DbUser<PersistedUserFields>;
pub type NewUser = User<()>; pub type NewUser = DbUser<()>;
/// Core user information /// Core user information
#[derive(Clone, FromRow)] #[derive(Clone, FromRow, PartialEq)]
#[allow(unused)] #[allow(unused)]
pub struct User<P: Debug + Clone> { pub struct DbUser<P: Debug + Clone> {
pub persisted: P, pub persisted: P,
pub telegram_id: TelegramUserDbId, pub telegram_id: DbTelegramUserId,
pub first_name: String, pub first_name: String,
pub last_name: Option<String>, pub last_name: Option<String>,
pub username: Option<String>, pub username: Option<String>,
pub is_banned: bool, pub is_banned: bool,
} }
impl Debug for User<PersistedUserFields> { impl Debug for DbUser<PersistedUserFields> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = if let Some(last_name) = self.last_name.as_deref() { let name = if let Some(last_name) = self.last_name.as_deref() {
format!("{} {}", self.first_name, last_name) format!("{} {}", self.first_name, last_name)
@@ -35,10 +35,10 @@ impl Debug for User<PersistedUserFields> {
} }
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq)]
#[allow(unused)] #[allow(unused)]
pub struct PersistedUserFields { pub struct PersistedUserFields {
pub id: UserDbId, pub id: DbUserId,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
mod currency_type; mod currency_type;
mod db_id; mod db_id;
mod listing_duration; mod listing_duration;
mod money;
mod money_amount; mod money_amount;
// Re-export all types for easy access // Re-export all types for easy access
@@ -8,4 +9,5 @@ mod money_amount;
pub use currency_type::*; pub use currency_type::*;
pub use db_id::*; pub use db_id::*;
pub use listing_duration::*; pub use listing_duration::*;
pub use money::*;
pub use money_amount::*; pub use money_amount::*;

43
src/db/types/money.rs Normal file
View File

@@ -0,0 +1,43 @@
use crate::db::{CurrencyType, MoneyAmount};
use serde::{Deserialize, Serialize};
use std::fmt::Display;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Money(CurrencyType, MoneyAmount);
impl Money {
pub fn new(currency_type: CurrencyType, money_amount: MoneyAmount) -> Self {
Self(currency_type, money_amount)
}
}
impl PartialOrd for Money {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
if self.0 != other.0 {
return None;
}
self.1.partial_cmp(&other.1)
}
}
impl Display for Money {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}{}", self.0.symbol(), self.1)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case("$100.00", CurrencyType::Usd, 100_00)]
#[case("¥100.00", CurrencyType::Jpy, 100_00)]
fn test_money_display(
#[case] expected: &str,
#[case] currency_type: CurrencyType,
#[case] amount_cents: i64,
) {
let amount = MoneyAmount::from_cents(amount_cents);
let money = Money::new(currency_type, amount);
assert_eq!(money.to_string(), expected);
}
}

View File

@@ -6,6 +6,8 @@ use sqlx::{
use std::ops::{Add, Sub}; use std::ops::{Add, Sub};
use std::str::FromStr; use std::str::FromStr;
use crate::db::{CurrencyType, Money};
/// Newtype wrapper for monetary amounts stored as integer cents /// Newtype wrapper for monetary amounts stored as integer cents
/// Stores as INTEGER in SQLite for precise comparisons and simple arithmetic /// Stores as INTEGER in SQLite for precise comparisons and simple arithmetic
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@@ -23,12 +25,6 @@ impl MoneyAmount {
Self(cents) Self(cents)
} }
/// Create a MoneyAmount from a string representation (e.g., "12.34")
pub fn from_str(s: &str) -> Result<Self, rust_decimal::Error> {
let decimal = Decimal::from_str(s)?;
Ok(Self::new(decimal))
}
/// Create a zero MoneyAmount /// Create a zero MoneyAmount
pub fn zero() -> Self { pub fn zero() -> Self {
Self(0) Self(0)
@@ -43,6 +39,20 @@ impl MoneyAmount {
pub fn to_decimal(self) -> Decimal { pub fn to_decimal(self) -> Decimal {
Decimal::new(self.0, 2) // 2 decimal places for cents Decimal::new(self.0, 2) // 2 decimal places for cents
} }
pub fn with_type(self, currency_type: CurrencyType) -> Money {
Money::new(currency_type, self)
}
}
impl FromStr for MoneyAmount {
type Err = rust_decimal::Error;
/// Create a MoneyAmount from a string representation (e.g., "12.34")
fn from_str(s: &str) -> Result<Self, Self::Err> {
let decimal = Decimal::from_str(s)?;
Ok(Self::new(decimal))
}
} }
impl Default for MoneyAmount { impl Default for MoneyAmount {
@@ -475,9 +485,8 @@ mod tests {
setup_test_data_for_queries(&pool).await; setup_test_data_for_queries(&pool).await;
let threshold_amount = MoneyAmount::from_str(threshold).unwrap(); let threshold_amount = MoneyAmount::from_str(threshold).unwrap();
let query = format!( let query =
"SELECT COUNT(*) as count FROM test_bids WHERE bid_amount {operator} ?" format!("SELECT COUNT(*) as count FROM test_bids WHERE bid_amount {operator} ?");
);
let count_row = sqlx::query(&query) let count_row = sqlx::query(&query)
.bind(threshold_amount) .bind(threshold_amount)

View File

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

View File

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

View File

@@ -2,8 +2,8 @@ use log::warn;
use teloxide::types::Update; use teloxide::types::Update;
use crate::{ use crate::{
db::{listing::PersistedListing, user::PersistedUser, ListingDAO, ListingDbId, UserDAO}, db::{listing::PersistedListing, user::PersistedUser, DbListingId, ListingDAO, UserDAO},
message_utils::MessageTarget, MessageTarget,
}; };
pub async fn find_or_create_db_user_from_update( pub async fn find_or_create_db_user_from_update(
@@ -32,7 +32,7 @@ pub async fn find_or_create_db_user(
pub async fn find_listing_by_id( pub async fn find_listing_by_id(
listing_dao: ListingDAO, listing_dao: ListingDAO,
listing_id: ListingDbId, listing_id: DbListingId,
) -> Option<PersistedListing> { ) -> Option<PersistedListing> {
listing_dao.find_by_id(listing_id).await.unwrap_or(None) listing_dao.find_by_id(listing_id).await.unwrap_or(None)
} }

View File

@@ -1,4 +1,5 @@
mod bidding; mod bidding;
mod bot_message_sender;
mod bot_result; mod bot_result;
mod commands; mod commands;
mod config; mod config;
@@ -7,6 +8,9 @@ mod dptree_utils;
mod handle_error; mod handle_error;
mod handler_utils; mod handler_utils;
mod keyboard_utils; mod keyboard_utils;
mod message;
mod message_sender;
mod message_target;
mod message_utils; mod message_utils;
mod sqlite_storage; mod sqlite_storage;
mod start_command_data; mod start_command_data;
@@ -14,25 +18,66 @@ mod start_command_data;
mod test_utils; mod test_utils;
mod wrap_endpoint; mod wrap_endpoint;
use std::sync::Arc;
use crate::bidding::{bidding_handler, BiddingState}; use crate::bidding::{bidding_handler, BiddingState};
use crate::bot_message_sender::BotMessageSender;
use crate::commands::{ use crate::commands::{
my_listings::{my_listings_handler, my_listings_inline_handler, MyListingsState}, my_listings::{my_listings_handler, my_listings_inline_handler, MyListingsState},
new_listing::{new_listing_handler, NewListingState}, new_listing::{new_listing_handler, NewListingState},
}; };
use crate::db::{ListingDAO, UserDAO}; use crate::db::DAOs;
use crate::handle_error::with_error_handler; use crate::handle_error::with_error_handler;
use crate::handler_utils::{find_or_create_db_user_from_update, update_into_message_target}; use crate::handler_utils::{find_or_create_db_user_from_update, update_into_message_target};
use crate::message::MessageType;
use crate::message_sender::BoxedMessageSender;
use crate::sqlite_storage::SqliteStorage; use crate::sqlite_storage::SqliteStorage;
use anyhow::Result; use crate::start_command_data::StartCommandData;
use anyhow::{anyhow, Result};
pub use bot_result::*; pub use bot_result::*;
use commands::*; use commands::*;
use config::Config; use config::Config;
use log::info; use log::info;
pub use message_target::MessageTarget;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use teloxide::dispatching::dialogue::serializer::Json; use teloxide::dispatching::dialogue::serializer::Json;
use teloxide::{prelude::*, types::BotCommand, utils::command::BotCommands}; use teloxide::{prelude::*, types::BotCommand, utils::command::BotCommands};
pub use wrap_endpoint::*; pub use wrap_endpoint::*;
#[derive(Clone)]
pub struct App {
pub bot: Arc<BoxedMessageSender>,
pub daos: DAOs,
pub bot_username: String,
}
impl App {
pub fn new(bot: BoxedMessageSender, daos: DAOs, bot_username: String) -> Self {
Self {
bot: Arc::new(bot),
daos,
bot_username,
}
}
pub fn url_for_start_command(&self, command: StartCommandData) -> reqwest::Url {
format!(
"tg://resolve?domain={}&start={}",
self.bot_username,
command.encode_for_start_command()
)
.parse()
.unwrap()
}
pub async fn send_message(&self, message: MessageType) -> BotResult {
self.bot.send_message(message).await
}
}
#[derive(Debug, Clone)]
struct BotUsername(String);
/// Set up the bot's command menu that appears when users tap the menu button /// Set up the bot's command menu that appears when users tap the menu button
async fn setup_bot_commands(bot: &Bot) -> Result<()> { async fn setup_bot_commands(bot: &Bot) -> Result<()> {
info!("Setting up bot command menu..."); info!("Setting up bot command menu...");
@@ -79,6 +124,48 @@ enum DialogueRootState {
type RootDialogue = Dialogue<DialogueRootState, SqliteStorage<Json>>; type RootDialogue = Dialogue<DialogueRootState, SqliteStorage<Json>>;
pub fn main_handler() -> BotHandler {
dptree::entry()
.map(|daos: DAOs| daos.user.clone())
.map(|daos: DAOs| daos.listing.clone())
.map(|daos: DAOs| daos.bid.clone())
.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().branch(
dptree::case![DialogueRootState::MainMenu]
.endpoint(with_error_handler(handle_main_menu_callback)),
),
)
.branch(
Update::filter_message()
.filter_command::<Command>()
.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(with_error_handler(unknown_message_handler)))
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
// Load and validate configuration from environment/.env file // Load and validate configuration from environment/.env file
@@ -88,65 +175,45 @@ async fn main() -> Result<()> {
let db_pool = config.create_database_pool().await?; let db_pool = config.create_database_pool().await?;
info!("Starting Pawctioneer Bot..."); info!("Starting Pawctioneer Bot...");
let bot = Bot::new(&config.telegram_token); let bot = Box::new(Bot::new(&config.telegram_token));
// Set up the bot's command menu // Set up the bot's command menu
setup_bot_commands(&bot).await?; setup_bot_commands(&bot).await?;
let dialog_storage = SqliteStorage::new(db_pool.clone(), Json).await?; let handler_with_deps = dptree::entry()
.filter_map(
|bot: Box<Bot>, update: Update, daos: DAOs, bot_username: BotUsername| {
let target = update_into_message_target(update)?;
Some(App::new(
Box::new(BotMessageSender::new(*bot, target)),
daos.clone(),
bot_username.0,
))
},
)
.chain(main_handler());
// Create dispatcher with dialogue system let dialog_storage = SqliteStorage::new(db_pool.clone(), Json).await?;
Dispatcher::builder( let daos = DAOs::new(db_pool.clone());
bot, let bot_username = bot
dptree::entry() .get_me()
.filter_map(update_into_message_target) .await?
.filter_map_async(find_or_create_db_user_from_update) .username
.branch(my_listings_inline_handler()) .as_ref()
.branch( .ok_or(anyhow!("Bot username not found"))?
dptree::entry() .clone();
.enter_dialogue::<Update, SqliteStorage<Json>, DialogueRootState>()
.branch(new_listing_handler()) Dispatcher::builder(bot, handler_with_deps)
.branch(my_listings_handler()) .dependencies(dptree::deps![
.branch(bidding_handler()) dialog_storage,
.branch( daos,
Update::filter_callback_query().branch( BotUsername(bot_username)
dptree::case![DialogueRootState::MainMenu] ])
.endpoint(with_error_handler(handle_main_menu_callback)), .enable_ctrlc_handler()
), .worker_queue_size(1)
) .build()
.branch( .dispatch()
Update::filter_message() .await;
.filter_command::<Command>()
.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(with_error_handler(unknown_message_handler))),
)
.dependencies(dptree::deps![
dialog_storage,
ListingDAO::new(db_pool.clone()),
UserDAO::new(db_pool.clone())
])
.enable_ctrlc_handler()
.worker_queue_size(1)
.build()
.dispatch()
.await;
Ok(()) Ok(())
} }
@@ -159,3 +226,33 @@ async fn unknown_message_handler(msg: Message) -> BotResult {
msg.text().unwrap_or("") msg.text().unwrap_or("")
))) )))
} }
#[cfg(test)]
mod tests {
use mockall::predicate::{always, function};
use super::*;
use crate::message_sender::MockMessageSender;
use crate::test_utils::*;
#[tokio::test]
async fn test_main_handler() {
let mut message_sender = MockMessageSender::new();
message_sender
.expect_send_html_message()
.times(1)
.with(
function(|text: &String| text.contains("Available Commands:")),
always(),
)
.returning(|_, _| Ok(()));
let mut deps = with_message_sender(create_deps().await, message_sender).await;
deps.insert(create_tele_update("/help"));
let handler = main_handler();
dptree::type_check(handler.sig(), &deps, &[]);
let result = handler.dispatch(deps).await;
assert!(matches!(result, ControlFlow::Break(Ok(()))), "{:?}", result);
}
}

23
src/message/mod.rs Normal file
View File

@@ -0,0 +1,23 @@
use crate::db::{bid::PersistedBid, listing::PersistedListing, user::PersistedUser};
#[derive(Debug, Clone, PartialEq)]
pub enum MessageType {
UserHasBeenOutbidForBuyer {
listing: PersistedListing,
buyer: PersistedUser,
},
BidHasBeenPlacedForSeller {
listing: PersistedListing,
buyer: PersistedUser,
seller: PersistedUser,
bid: PersistedBid,
},
BidHasBeenConfirmedForBuyer {
listing: PersistedListing,
bid: PersistedBid,
},
BidInvalidListingExpired {
listing: PersistedListing,
buyer: PersistedUser,
},
}

50
src/message_sender.rs Normal file
View File

@@ -0,0 +1,50 @@
use crate::{message::MessageType, BotResult, MessageTarget};
use async_trait::async_trait;
use teloxide::types::{
CallbackQueryId, InlineKeyboardMarkup, InlineQueryId, InlineQueryResult, Me,
};
#[async_trait]
pub trait MessageSender {
async fn send_html_message(
&self,
text: String,
keyboard: Option<InlineKeyboardMarkup>,
) -> BotResult;
fn with_target(&self, target: MessageTarget) -> BoxedMessageSender;
async fn answer_inline_query(
&self,
inline_query_id: InlineQueryId,
results: Vec<InlineQueryResult>,
) -> BotResult;
async fn answer_callback_query(&self, query_id: CallbackQueryId) -> BotResult;
async fn get_me(&self) -> BotResult<Me>;
async fn send_message(&self, message: MessageType) -> BotResult;
}
pub type BoxedMessageSender = Box<dyn MessageSender + Send + Sync>;
#[cfg(test)]
mockall::mock! {
pub MessageSender {}
impl Clone for MessageSender {
fn clone(&self) -> Self;
}
#[async_trait]
impl MessageSender for MessageSender {
fn with_target(&self, target: MessageTarget) -> BoxedMessageSender;
async fn send_html_message(
&self,
text: String,
keyboard: Option<InlineKeyboardMarkup>,
) -> BotResult;
async fn answer_inline_query(
&self,
inline_query_id: InlineQueryId,
results: Vec<InlineQueryResult>,
) -> BotResult;
async fn answer_callback_query(&self, query_id: CallbackQueryId) -> BotResult;
async fn get_me(&self) -> BotResult<Me>;
async fn send_message(&self, message: MessageType) -> BotResult;
}
}

84
src/message_target.rs Normal file
View File

@@ -0,0 +1,84 @@
use std::fmt::Debug;
use teloxide::types::*;
use crate::db::user::DbUser;
#[derive(Debug, Clone)]
pub struct MessageTarget {
pub chat_id: ChatId,
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 {
chat_id: val,
message_id: None,
}
}
}
impl From<Chat> for MessageTarget {
fn from(val: Chat) -> Self {
MessageTarget {
chat_id: val.id,
message_id: None,
}
}
}
impl From<User> for MessageTarget {
fn from(val: User) -> Self {
MessageTarget {
chat_id: val.id.into(),
message_id: None,
}
}
}
impl From<(User, MessageId)> for MessageTarget {
fn from(val: (User, MessageId)) -> Self {
MessageTarget {
chat_id: val.0.id.into(),
message_id: Some(val.1),
}
}
}
impl From<(Chat, MessageId)> for MessageTarget {
fn from(val: (Chat, MessageId)) -> Self {
MessageTarget {
chat_id: val.0.id,
message_id: Some(val.1),
}
}
}
impl<P: Debug + Clone> From<DbUser<P>> for MessageTarget {
fn from(val: DbUser<P>) -> Self {
MessageTarget {
chat_id: val.telegram_id.into(),
message_id: None,
}
}
}
impl TryFrom<&CallbackQuery> for MessageTarget {
type Error = ();
fn try_from(val: &CallbackQuery) -> Result<Self, Self::Error> {
Ok(MessageTarget {
chat_id: val.from.id.into(),
message_id: val.message.as_ref().map(MaybeInaccessibleMessage::id),
})
}
}

View File

@@ -1,155 +1,11 @@
use crate::BotResult; use crate::{db::user::PersistedUser, message_sender::BoxedMessageSender, BotResult};
use anyhow::{anyhow, Context}; use anyhow::anyhow;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use num::One; use num::One;
use std::fmt::Display; use std::fmt::Display;
use teloxide::{ use teloxide::types::{CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup};
payloads::{EditMessageTextSetters as _, SendMessageSetters as _},
prelude::Requester as _,
types::{
CallbackQuery, Chat, ChatId, InlineKeyboardButton, InlineKeyboardMarkup,
MaybeInaccessibleMessage, MessageId, ParseMode, User,
},
Bot,
};
#[derive(Debug, Clone, Copy)]
pub struct HandleAndId<'s> {
pub handle: Option<&'s str>,
pub id: ChatId,
}
impl<'s> Display for HandleAndId<'s> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.handle.unwrap_or("unknown"))?;
write!(f, " ({})", self.id.0)?;
Ok(())
}
}
impl<'s> HandleAndId<'s> {
pub fn from_chat(chat: &'s Chat) -> Self {
Self {
handle: chat.username(),
id: chat.id,
}
}
pub fn from_user(user: &'s User) -> Self {
Self {
handle: user.username.as_deref(),
id: user.id.into(),
}
}
}
impl<'s> From<&'s User> for HandleAndId<'s> {
fn from(val: &'s User) -> Self {
HandleAndId::from_user(val)
}
}
impl<'s> From<&'s Chat> for HandleAndId<'s> {
fn from(val: &'s Chat) -> Self {
HandleAndId::from_chat(val)
}
}
#[derive(Debug, Clone)]
pub struct MessageTarget {
pub chat_id: ChatId,
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 {
chat_id: val,
message_id: None,
}
}
}
impl From<Chat> for MessageTarget {
fn from(val: Chat) -> Self {
MessageTarget {
chat_id: val.id,
message_id: None,
}
}
}
impl From<User> for MessageTarget {
fn from(val: User) -> Self {
MessageTarget {
chat_id: val.id.into(),
message_id: None,
}
}
}
impl From<(User, MessageId)> for MessageTarget {
fn from(val: (User, MessageId)) -> Self {
MessageTarget {
chat_id: val.0.id.into(),
message_id: Some(val.1),
}
}
}
impl From<(Chat, MessageId)> for MessageTarget {
fn from(val: (Chat, MessageId)) -> Self {
MessageTarget {
chat_id: val.0.id,
message_id: Some(val.1),
}
}
}
impl TryFrom<&CallbackQuery> for MessageTarget {
type Error = ();
fn try_from(val: &CallbackQuery) -> Result<Self, Self::Error> {
Ok(MessageTarget {
chat_id: val.from.id.into(),
message_id: val.message.as_ref().map(MaybeInaccessibleMessage::id),
})
}
}
// Unified HTML message sending utility
pub async fn send_message(
bot: &Bot,
target: MessageTarget,
text: impl AsRef<str>,
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);
if let Some(kb) = keyboard {
message = message.reply_markup(kb);
}
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.context("failed to send message")?;
}
Ok(())
}
//
// ============================================================================ // ============================================================================
// KEYBOARD CREATION UTILITIES // KEYBOARD CREATION UTILITIES
// ============================================================================ // ============================================================================
@@ -160,7 +16,10 @@ pub fn create_single_button_keyboard(text: &str, callback_data: &str) -> InlineK
} }
// Extract callback data and answer callback query // Extract callback data and answer callback query
pub async fn extract_callback_data(bot: &Bot, callback_query: CallbackQuery) -> BotResult<String> { pub async fn extract_callback_data(
bot: &BoxedMessageSender,
callback_query: CallbackQuery,
) -> BotResult<String> {
let data = match callback_query.data { let data = match callback_query.data {
Some(data) => data, Some(data) => data,
None => return Err(anyhow!("Missing data in callback query"))?, None => return Err(anyhow!("Missing data in callback query"))?,
@@ -197,3 +56,17 @@ pub fn pluralize_with_count<N: One + PartialEq<N> + Display + Copy>(
pub fn format_datetime(dt: DateTime<Utc>) -> String { pub fn format_datetime(dt: DateTime<Utc>) -> String {
dt.format("%b %d, %Y %H:%M UTC").to_string() dt.format("%b %d, %Y %H:%M UTC").to_string()
} }
pub fn buyer_name_or_link(user: &PersistedUser) -> String {
let link = format!("https://t.me/{}", user.telegram_id);
let name = if let Some(last_name) = &user.last_name {
format!("{} {}", user.first_name, last_name)
} else {
user.first_name.clone()
};
if let Some(username) = &user.username {
format!("<a href='{link}'>@{username} ({name})</a>")
} else {
format!("<a href='{link}'>{name}</a>")
}
}

View File

@@ -1,28 +1,88 @@
use base64::{prelude::BASE64_URL_SAFE, Engine}; use base64::{prelude::BASE64_URL_SAFE, Engine};
use log::info; use teloxide::types::{CallbackQuery, MediaKind, MessageKind, UpdateKind};
use teloxide::types::{MediaKind, MessageKind, UpdateKind};
use crate::db::ListingDbId; use crate::db::DbListingId;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Copy)]
pub enum StartCommandData { pub enum StartCommandData {
PlaceBidOnListing(ListingDbId), PlaceBidOnListing(DbListingId),
ViewListingDetails(ListingDbId), ViewListingDetailsAsBuyer(DbListingId),
ViewListingBids(DbListingId),
} }
const PLACE_BID_ON_LISTING: &str = "place_bid_on_listing";
const VIEW_LISTING_DETAILS_AS_BUYER: &str = "view_listing_details_as_buyer";
const VIEW_LISTING_BIDS: &str = "view_listing_bids";
impl From<StartCommandData> for String { impl From<StartCommandData> for String {
fn from(value: StartCommandData) -> Self { fn from(value: StartCommandData) -> Self {
match value { match value {
StartCommandData::PlaceBidOnListing(listing_id) => { StartCommandData::PlaceBidOnListing(listing_id) => {
format!("place_bid_on_listing:{listing_id}") format!("{PLACE_BID_ON_LISTING}:{listing_id}")
} }
StartCommandData::ViewListingDetails(listing_id) => { StartCommandData::ViewListingDetailsAsBuyer(listing_id) => {
format!("view_listing_details:{listing_id}") format!("{VIEW_LISTING_DETAILS_AS_BUYER}:{listing_id}")
}
StartCommandData::ViewListingBids(listing_id) => {
format!("{VIEW_LISTING_BIDS}:{listing_id}")
} }
} }
} }
} }
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum StartCommandDataError {
MissingCommandName,
UnknownCommandName(String),
InvalidIdPart(String),
MissingIdPart,
}
impl TryFrom<String> for StartCommandData {
type Error = StartCommandDataError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::try_from(value.as_str())
}
}
impl TryFrom<&String> for StartCommandData {
type Error = StartCommandDataError;
fn try_from(value: &String) -> Result<Self, Self::Error> {
Self::try_from(value.as_str())
}
}
impl TryFrom<&str> for StartCommandData {
type Error = StartCommandDataError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
let mut parts = value.split(":").map(|s| s.trim());
let name = parts
.next()
.ok_or(StartCommandDataError::MissingCommandName)?;
let mut get_id_part = || -> Result<i64, StartCommandDataError> {
let id_part = parts.next().ok_or(StartCommandDataError::MissingIdPart)?;
id_part.parse::<i64>().map_err(|_| {
if id_part.is_empty() {
StartCommandDataError::MissingIdPart
} else {
StartCommandDataError::InvalidIdPart(id_part.to_string())
}
})
};
Ok(match name {
"" => return Err(StartCommandDataError::MissingCommandName),
PLACE_BID_ON_LISTING => Self::PlaceBidOnListing(DbListingId::new(get_id_part()?)),
VIEW_LISTING_DETAILS_AS_BUYER => {
Self::ViewListingDetailsAsBuyer(DbListingId::new(get_id_part()?))
}
VIEW_LISTING_BIDS => Self::ViewListingBids(DbListingId::new(get_id_part()?)),
_ => return Err(StartCommandDataError::UnknownCommandName(name.to_string())),
})
}
}
impl StartCommandData { impl StartCommandData {
pub fn get_from_update(update: teloxide::types::Update) -> Option<StartCommandData> { pub fn get_from_update(update: teloxide::types::Update) -> Option<StartCommandData> {
let message = match update.kind { let message = match update.kind {
@@ -40,22 +100,22 @@ impl StartCommandData {
let message = message.text.strip_prefix("/start ")?; let message = message.text.strip_prefix("/start ")?;
let decoded = BASE64_URL_SAFE.decode(message).ok()?; let decoded = BASE64_URL_SAFE.decode(message).ok()?;
let decoded = String::from_utf8(decoded).ok()?; let decoded = String::from_utf8(decoded).ok()?;
let parts = decoded.split(":").map(|s| s.trim()).collect::<Vec<&str>>(); StartCommandData::try_from(decoded.as_str()).ok()
info!("command parts: {parts:?}"); }
match parts.first()?.trim() {
"place_bid_on_listing" => Some(StartCommandData::PlaceBidOnListing(ListingDbId::new( pub fn get_from_callback_query(callback_query: CallbackQuery) -> Option<StartCommandData> {
parts.get(1)?.parse::<i64>().ok()?, let data = callback_query.data.as_ref()?;
))), StartCommandData::try_from(data.as_str()).ok()
"view_listing_details" => Some(StartCommandData::ViewListingDetails(ListingDbId::new( }
parts.get(1)?.parse::<i64>().ok()?,
))), pub fn encode_for_start_command(self) -> String {
_ => None, let as_string: String = self.into();
} BASE64_URL_SAFE.encode(as_string)
} }
pub fn get_place_bid_on_listing_start_command( pub fn get_place_bid_on_listing_start_command(
command: StartCommandData, command: StartCommandData,
) -> Option<ListingDbId> { ) -> Option<DbListingId> {
if let StartCommandData::PlaceBidOnListing(listing_id) = command { if let StartCommandData::PlaceBidOnListing(listing_id) = command {
Some(listing_id) Some(listing_id)
} else { } else {
@@ -63,13 +123,73 @@ impl StartCommandData {
} }
} }
pub fn get_view_listing_details_start_command( pub fn get_view_listing_details_as_buyer_start_command(
command: StartCommandData, command: StartCommandData,
) -> Option<ListingDbId> { ) -> Option<DbListingId> {
if let StartCommandData::ViewListingDetails(listing_id) = command { if let StartCommandData::ViewListingDetailsAsBuyer(listing_id) = command {
Some(listing_id)
} else {
None
}
}
pub fn get_view_listing_bids_start_command(command: StartCommandData) -> Option<DbListingId> {
if let StartCommandData::ViewListingBids(listing_id) = command {
Some(listing_id) Some(listing_id)
} else { } else {
None None
} }
} }
} }
#[cfg(test)]
mod tests {
use rstest::rstest;
use crate::{
db::DbListingId,
start_command_data::{StartCommandData, StartCommandDataError},
test_utils::create_tele_update,
};
#[test]
fn test_get_from_update() {
let update = create_tele_update("/start cGxhY2VfYmlkX29uX2xpc3Rpbmc6Mg==");
let command = StartCommandData::get_from_update(update);
assert_eq!(
command,
Some(StartCommandData::PlaceBidOnListing(DbListingId::new(2)))
);
}
#[test]
fn test_malformed_from_update() {
let update = create_tele_update("/start cGxhY2VfYmlkX29uX2xpc3Rpbmc6Mg");
let command = StartCommandData::get_from_update(update);
assert_eq!(command, None);
}
#[rstest]
#[case(StartCommandData::PlaceBidOnListing(DbListingId::new(1)))]
#[case(StartCommandData::ViewListingDetailsAsBuyer(DbListingId::new(2)))]
#[case(StartCommandData::ViewListingBids(DbListingId::new(3)))]
fn test_start_command_data(#[case] command: StartCommandData) {
let encoded: String = command.into();
let decoded = StartCommandData::try_from(encoded).unwrap();
assert_eq!(command, decoded);
}
#[rstest]
#[case("", StartCommandDataError::MissingCommandName)]
#[case("malformed", StartCommandDataError::UnknownCommandName("malformed".to_string()))]
#[case("place_bid_on_listing", StartCommandDataError::MissingIdPart)]
#[case("place_bid_on_listing:", StartCommandDataError::MissingIdPart)]
#[case("place_bid_on_listing:abc", StartCommandDataError::InvalidIdPart("abc".to_string()))]
fn test_malformed_start_command_data(
#[case] encoded: &str,
#[case] expected_error: StartCommandDataError,
) {
let decoded = StartCommandData::try_from(encoded);
assert_eq!(decoded, Err(expected_error));
}
}

View File

@@ -1,6 +1,26 @@
//! Test utilities including timestamp comparison macros //! Test utilities including timestamp comparison macros
use chrono::{Duration, Utc};
use dptree::di::DependencyMap;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use std::{ops::Deref, str::FromStr, sync::Arc};
use teloxide::dispatching::dialogue::serializer::Json;
use teloxide::types::*;
use crate::{
db::{
listing::{
BasicAuctionFields, ListingBase,
ListingFields::{self, BasicAuction},
NewListing, PersistedListing,
},
user::{NewUser, PersistedUser},
CurrencyType, DAOs, ListingDAO, MoneyAmount, UserDAO,
},
message_sender::MockMessageSender,
sqlite_storage::SqliteStorage,
App, DialogueRootState, RootDialogue,
};
/// Assert that two timestamps are approximately equal within a given epsilon tolerance. /// Assert that two timestamps are approximately equal within a given epsilon tolerance.
/// ///
@@ -103,6 +123,201 @@ pub async fn create_test_pool() -> SqlitePool {
pool pool
} }
pub fn create_tele_user(user_fn: impl FnOnce(&mut User) -> ()) -> User {
let mut user = User {
id: UserId(1),
username: Some("username".to_string()),
first_name: "firstname".to_string(),
last_name: Some("lastname".to_string()),
is_bot: false,
language_code: Some("en".to_string()),
is_premium: false,
added_to_attachment_menu: false,
};
user_fn(&mut user);
user
}
pub fn create_tele_private_chat(user: &User) -> Chat {
Chat {
id: ChatId(1),
kind: ChatKind::Private(ChatPrivate {
username: user.username.clone(),
first_name: Some(user.first_name.clone()),
last_name: user.last_name.clone(),
}),
}
}
pub fn create_tele_update(message_text: &str) -> Update {
let user = create_tele_user(|user| user.username = Some("sender".to_string()));
let chat = create_tele_private_chat(&user);
Update {
id: UpdateId(1),
kind: UpdateKind::Message(Message {
id: MessageId(1),
thread_id: None,
from: Some(user),
sender_chat: None,
date: Utc::now(),
chat,
is_topic_message: false,
via_bot: None,
sender_business_bot: None,
kind: MessageKind::Common(MessageCommon {
media_kind: MediaKind::Text(MediaText {
text: message_text.to_string(),
entities: vec![],
link_preview_options: None,
}),
author_signature: None,
paid_star_count: None,
effect_id: None,
forward_origin: None,
reply_to_message: None,
external_reply: None,
quote: None,
reply_to_story: None,
sender_boost_count: None,
edit_date: None,
reply_markup: None,
is_automatic_forward: false,
has_protected_content: false,
is_from_offline: false,
business_connection_id: None,
}),
}),
}
}
pub fn create_tele_callback_query(callback_data: &str, from: User) -> CallbackQuery {
CallbackQuery {
id: CallbackQueryId("query_id".to_string()),
data: Some(callback_data.to_string()),
from: from.clone(),
message: None,
chat_instance: "chat_instance".to_string(),
inline_message_id: None,
game_short_name: None,
}
}
pub async fn create_deps() -> DependencyMap {
let pool = create_test_pool().await;
let dialog_storage = SqliteStorage::new(pool.clone(), Json).await.unwrap();
let daos = DAOs::new(pool);
let me_user = create_tele_user(|user| user.username = Some("me".to_string()));
let me = Me {
user: me_user,
can_join_groups: true,
can_read_all_group_messages: true,
supports_inline_queries: true,
can_connect_to_business: true,
has_main_web_app: true,
};
dptree::deps![dialog_storage, me, daos]
}
pub async fn with_dialogue(mut deps: DependencyMap, user: &PersistedUser) -> DependencyMap {
let storage = deps.get::<Arc<SqliteStorage<Json>>>().deref().clone();
let dialogue = RootDialogue::new(storage, user.telegram_id.into());
dialogue.update(DialogueRootState::Start).await.unwrap();
deps.insert(dialogue);
deps
}
pub async fn with_message_sender(
mut deps: DependencyMap,
mock_bot: MockMessageSender,
) -> DependencyMap {
let app = App::new(
Box::new(mock_bot),
deps.get::<DAOs>().deref().clone(),
"bot_username".to_string(),
);
deps.insert(app);
deps
}
pub async fn create_test_user(dao: &UserDAO) -> PersistedUser {
dao.insert_user(&NewUser {
persisted: (),
telegram_id: 12345.into(),
first_name: "Test User".to_string(),
last_name: None,
username: Some("testuser".to_string()),
is_banned: false,
})
.await
.unwrap()
}
pub async fn with_test_user(
deps: &DependencyMap,
user_fn: impl FnOnce(&mut NewUser) -> (),
) -> PersistedUser {
let user_dao = deps.get::<DAOs>().user.clone();
let mut new_user = NewUser {
persisted: (),
telegram_id: 12345.into(),
first_name: "Test User".to_string(),
last_name: None,
username: Some("testuser".to_string()),
is_banned: false,
};
user_fn(&mut new_user);
user_dao.insert_user(&new_user).await.unwrap()
}
pub async fn with_test_listing(
deps: &DependencyMap,
seller: &PersistedUser,
listing_fn: impl FnOnce(&mut NewListing) -> (),
) -> PersistedListing {
let listing_dao = deps.get::<DAOs>().listing.clone();
let mut new_listing = NewListing {
persisted: (),
base: ListingBase {
seller_id: seller.persisted.id,
title: "Test Listing".to_string(),
description: Some("Test description".to_string()),
starts_at: Utc::now(),
ends_at: Utc::now() + Duration::days(3),
currency_type: CurrencyType::Usd,
},
fields: ListingFields::BasicAuction(BasicAuctionFields {
starting_bid: MoneyAmount::from_str("100.00").unwrap(),
buy_now_price: Some(MoneyAmount::from_str("100.00").unwrap()),
min_increment: MoneyAmount::from_str("1.00").unwrap(),
anti_snipe_minutes: Some(5),
}),
};
listing_fn(&mut new_listing);
listing_dao.insert_listing(&new_listing).await.unwrap()
}
pub async fn create_test_listing(dao: &ListingDAO, seller: &PersistedUser) -> PersistedListing {
dao.insert_listing(&NewListing {
persisted: (),
base: ListingBase {
seller_id: seller.persisted.id,
title: "Test Listing".to_string(),
description: Some("Test description".to_string()),
starts_at: Utc::now(),
ends_at: Utc::now() + Duration::days(3),
currency_type: CurrencyType::Usd,
},
fields: BasicAuction(BasicAuctionFields {
starting_bid: MoneyAmount::from_str("100.00").unwrap(),
buy_now_price: Some(MoneyAmount::from_str("100.00").unwrap()),
min_increment: MoneyAmount::from_str("1.00").unwrap(),
anti_snipe_minutes: Some(5),
}),
})
.await
.unwrap()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};

View File

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