send messages to winners / losers of an auction

This commit is contained in:
Dylan Knutson
2025-09-10 03:53:48 +00:00
parent a955acbdce
commit 212a72c511
8 changed files with 334 additions and 33 deletions

View File

@@ -154,7 +154,7 @@ mod tests {
listing: PersistedListing, listing: PersistedListing,
} }
async fn set_up_fixtures() -> Fixtures { async fn set_up_fixtures() -> Fixtures {
let deps = create_deps().await; let Deps { deps, .. } = create_deps().await;
let seller = with_test_user(&deps, |seller| { let seller = with_test_user(&deps, |seller| {
seller.username = Some("seller".to_string()); seller.username = Some("seller".to_string());
seller.telegram_id = 123.into() seller.telegram_id = 123.into()

View File

@@ -168,7 +168,7 @@ async fn handle_view_listing_bids(
response_lines.push(format!( response_lines.push(format!(
"💰 Current highest bid: <b>{current_bid}</b> from {buyer_name}", "💰 Current highest bid: <b>{current_bid}</b> from {buyer_name}",
current_bid = current_bid.bid_amount.with_type(currency_type), current_bid = current_bid.bid_amount.with_type(currency_type),
buyer_name = user_name_or_link(&buyer) buyer_name = user_name_or_link(buyer)
)); ));
} else { } else {
response_lines.push("💰 No bids yet".to_string()); response_lines.push("💰 No bids yet".to_string());
@@ -181,7 +181,7 @@ async fn handle_view_listing_bids(
response_lines.push(format!( response_lines.push(format!(
"💰 Bid: <b>{bid_amount}</b> from {buyer_name}", "💰 Bid: <b>{bid_amount}</b> from {buyer_name}",
bid_amount = bid.bid_amount.with_type(currency_type), bid_amount = bid.bid_amount.with_type(currency_type),
buyer_name = user_name_or_link(&bidder) buyer_name = user_name_or_link(bidder)
)); ));
} }

View File

@@ -16,9 +16,9 @@ use crate::{
BotError, BotResult, MessageTarget, BotError, BotResult, MessageTarget,
}; };
pub struct BotMessageSender(Bot, MessageTarget); pub struct BotMessageSender(Bot, Option<MessageTarget>);
impl BotMessageSender { impl BotMessageSender {
pub fn new(bot: Bot, message_target: MessageTarget) -> Self { pub fn new(bot: Bot, message_target: Option<MessageTarget>) -> Self {
Self(bot, message_target) Self(bot, message_target)
} }
} }
@@ -26,7 +26,7 @@ impl BotMessageSender {
#[async_trait] #[async_trait]
impl MessageSender for BotMessageSender { impl MessageSender for BotMessageSender {
fn with_target(&self, target: MessageTarget) -> BoxedMessageSender { fn with_target(&self, target: MessageTarget) -> BoxedMessageSender {
let clone = Self(self.0.clone(), target); let clone = Self(self.0.clone(), Some(target));
Box::new(clone) Box::new(clone)
} }
async fn send_html_message( async fn send_html_message(
@@ -34,7 +34,10 @@ impl MessageSender for BotMessageSender {
text: String, text: String,
keyboard: Option<InlineKeyboardMarkup>, keyboard: Option<InlineKeyboardMarkup>,
) -> BotResult { ) -> BotResult {
let target = self.1.clone(); let target = self
.1
.clone()
.ok_or(BotError::internal_str("No message target"))?;
if let Some(message_id) = target.message_id { if let Some(message_id) = target.message_id {
log::info!("Editing message in chat: {target:?}"); log::info!("Editing message in chat: {target:?}");
let mut message = self let mut message = self
@@ -107,6 +110,36 @@ impl MessageSender for BotMessageSender {
self.send_bid_invalid_listing_expired(listing, buyer) self.send_bid_invalid_listing_expired(listing, buyer)
.await?; .await?;
} }
MessageType::UserHasWonListingForSeller {
listing,
buyer,
seller,
bid,
} => {
self.send_user_has_won_listing_for_seller(listing, buyer, seller, bid)
.await?;
}
MessageType::UserHasWonListingForBuyer {
listing,
buyer,
seller,
bid,
} => {
self.send_user_has_won_listing_for_buyer(listing, buyer, seller, bid)
.await?;
}
MessageType::UserHasLostListingForBuyer {
listing,
buyer,
seller,
} => {
self.send_user_has_lost_listing_for_buyer(listing, buyer, seller)
.await?;
}
MessageType::ListingExpiredWithNoWinnerForSeller { listing, seller } => {
self.send_listing_expired_with_no_winner_for_seller(listing, seller)
.await?;
}
} }
Ok(()) Ok(())
} }
@@ -186,4 +219,78 @@ impl BotMessageSender {
) )
.await .await
} }
async fn send_user_has_won_listing_for_seller(
&self,
listing: PersistedListing,
buyer: PersistedUser,
seller: PersistedUser,
bid: PersistedBid,
) -> BotResult {
self.with_target(seller.into())
.send_html_message(
format!(
"<b>{buyer}</b> has won {title} for <b>{bid_amount}</b>",
buyer = user_name_or_link(&buyer),
title = listing.base.title,
bid_amount = bid.bid_amount.with_type(listing.base.currency_type),
),
None,
)
.await
}
async fn send_user_has_won_listing_for_buyer(
&self,
listing: PersistedListing,
buyer: PersistedUser,
seller: PersistedUser,
bid: PersistedBid,
) -> BotResult {
self.with_target(buyer.into())
.send_html_message(
format!(
"You have won {title} by {seller} for <b>{bid_amount}</b>",
title = listing.base.title,
seller = user_name_or_link(&seller),
bid_amount = bid.bid_amount.with_type(listing.base.currency_type),
),
None,
)
.await
}
async fn send_user_has_lost_listing_for_buyer(
&self,
listing: PersistedListing,
buyer: PersistedUser,
seller: PersistedUser,
) -> BotResult {
self.with_target(buyer.into())
.send_html_message(
format!(
"Auction {title} by {seller} has ended, and you were not the winner",
title = listing.base.title,
seller = user_name_or_link(&seller),
),
None,
)
.await
}
async fn send_listing_expired_with_no_winner_for_seller(
&self,
listing: PersistedListing,
seller: PersistedUser,
) -> BotResult {
self.with_target(seller.into())
.send_html_message(
format!(
"Auction {title} has ended with no winner",
title = listing.base.title
),
None,
)
.await
}
} }

View File

@@ -62,7 +62,7 @@ impl ListingDAO {
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)
.push("seller_id", &listing.base.seller_id) .push("seller_id", &listing.base.seller_id)
.push("starts_at", &listing.base.starts_at) .push("starts_at", &listing.base.starts_at)
.push("ends_at", &listing.base.ends_at) .push("ends_at", &listing.base.ends_at)
@@ -89,7 +89,7 @@ impl ListingDAO {
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);
let query_str = format!( let query_str = format!(
r#" r#"

View File

@@ -5,9 +5,13 @@ use crate::{
*, *,
}; };
pub async fn task(daos: DAOs, mut listing_event: Receiver<ListingUpdatedEvent>) -> BotResult<()> { pub async fn task(
message_sender: BoxedMessageSender,
daos: DAOs,
mut listing_event: Receiver<ListingUpdatedEvent>,
) -> BotResult<()> {
loop { loop {
match task_impl(daos.clone(), &mut listing_event).await { match task_impl(&message_sender, &daos, &mut listing_event).await {
Ok(r) => return Ok(r), Ok(r) => return Ok(r),
Err(e) => { Err(e) => {
log::error!("Error in listing expiry checker task: {e}"); log::error!("Error in listing expiry checker task: {e}");
@@ -17,13 +21,17 @@ pub async fn task(daos: DAOs, mut listing_event: Receiver<ListingUpdatedEvent>)
} }
} }
async fn task_impl(daos: DAOs, listing_event: &mut Receiver<ListingUpdatedEvent>) -> BotResult<()> { async fn task_impl(
message_sender: &BoxedMessageSender,
daos: &DAOs,
listing_event: &mut Receiver<ListingUpdatedEvent>,
) -> BotResult<()> {
const MAX_CHECK_INTERVAL: Duration = Duration::seconds(10); const MAX_CHECK_INTERVAL: Duration = Duration::seconds(10);
loop { loop {
let check_again_in = let check_again_in =
if let Some(listing) = daos.listing.find_next_ending_listing().await? { if let Some(listing) = daos.listing.find_next_ending_listing().await? {
if handle_listing_expiry(&daos, &listing).await? { if handle_listing_expiry(message_sender, daos, &listing).await? {
// if this listing was expired, immediately check again on the next listing // if this listing was expired, immediately check again on the next listing
continue; continue;
} }
@@ -48,17 +56,184 @@ async fn task_impl(daos: DAOs, listing_event: &mut Receiver<ListingUpdatedEvent>
} }
} }
async fn handle_listing_expiry(daos: &DAOs, listing: &PersistedListing) -> BotResult<bool> { async fn handle_listing_expiry(
message_sender: &BoxedMessageSender,
daos: &DAOs,
listing: &PersistedListing,
) -> BotResult<bool> {
if listing.base.ends_at < Utc::now() { if listing.base.ends_at < Utc::now() {
let expired_ago = Utc::now() - listing.base.ends_at; let expired_ago = Utc::now() - listing.base.ends_at;
log::info!( log::info!(
"listing expired: {listing_id} (expired {expired_ago} ago)", "listing expired: {listing_id} (expired {expired_ago} ago)",
listing_id = listing.persisted.id, listing_id = listing.persisted.id,
); );
daos.listing let listing = daos
.listing
.set_listing_is_active(listing.persisted.id, false) .set_listing_is_active(listing.persisted.id, false)
.await?; .await?;
let seller = daos
.user
.find_by_id(listing.base.seller_id)
.await?
.ok_or(BotError::internal_str("Seller not found"))?;
let bids = daos.bid.bids_for_listing(listing.persisted.id).await?;
if let Some(bid) = bids.first() {
let buyer = daos
.user
.find_by_id(bid.buyer_id)
.await?
.ok_or(BotError::internal_str("Winning buyer not found"))?;
message_sender
.send_message(MessageType::UserHasWonListingForBuyer {
listing: listing.clone(),
buyer: buyer.clone(),
seller: seller.clone(),
bid: bid.clone(),
})
.await?;
message_sender
.send_message(MessageType::UserHasWonListingForSeller {
listing: listing.clone(),
buyer: buyer.clone(),
seller: seller.clone(),
bid: bid.clone(),
})
.await?;
// send message to all other buyers who did not win
for bid in bids.iter() {
if bid.buyer_id == buyer.persisted.id {
continue;
}
let buyer = daos
.user
.find_by_id(bid.buyer_id)
.await?
.ok_or(BotError::internal_str("Losing buyer not found"))?;
message_sender
.send_message(MessageType::UserHasLostListingForBuyer {
listing: listing.clone(),
buyer: buyer.clone(),
seller: seller.clone(),
})
.await?;
}
} else {
// there was no winner, send notification to the seller
message_sender
.send_message(MessageType::ListingExpiredWithNoWinnerForSeller {
listing: listing.clone(),
seller: seller.clone(),
})
.await?;
}
return Ok(true); return Ok(true);
} }
Ok(false) Ok(false)
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::db::bid::NewBid;
use crate::db::MoneyAmount;
use crate::message_sender::MockMessageSender;
use crate::test_utils::*;
use mockall::predicate::function;
use std::str::FromStr;
#[tokio::test]
async fn test_listing_expiry() {
let Deps { deps, .. } = create_deps().await;
let seller = with_test_user(&deps, |user| {
user.telegram_id = 123.into();
user.username = Some("seller".to_string())
})
.await;
let losing_buyer = with_test_user(&deps, |user| {
user.telegram_id = 789.into();
user.username = Some("losing_buyer".to_string())
})
.await;
let winning_buyer = with_test_user(&deps, |user| {
user.telegram_id = 456.into();
user.username = Some("buyer".to_string())
})
.await;
let listing = with_test_listing(&deps, &seller, |listing| {
listing.base.ends_at = Utc::now() - Duration::seconds(1);
})
.await;
// the losing bid
deps.get::<DAOs>()
.bid
.insert_bid(&NewBid::new_basic(
listing.persisted.id,
losing_buyer.persisted.id,
MoneyAmount::from_str("50.00").unwrap(),
))
.await
.unwrap();
// the winning bid
deps.get::<DAOs>()
.bid
.insert_bid(&NewBid::new_basic(
listing.persisted.id,
winning_buyer.persisted.id,
MoneyAmount::from_str("100.00").unwrap(),
))
.await
.unwrap();
let winner_id = winning_buyer.persisted.id;
let loser_id = losing_buyer.persisted.id;
let mut message_sender = MockMessageSender::new();
message_sender
.expect_send_message()
.with(function(move |m| match m {
MessageType::UserHasWonListingForSeller { buyer, .. } => {
buyer.persisted.id == winner_id
}
_ => false,
}))
.times(1)
.returning(|_| Ok(()));
message_sender
.expect_send_message()
.with(function(move |m| match m {
MessageType::UserHasWonListingForBuyer { buyer, .. } => {
buyer.persisted.id == winner_id
}
_ => false,
}))
.times(1)
.returning(|_| Ok(()));
message_sender
.expect_send_message()
.with(function(move |m| match m {
MessageType::UserHasLostListingForBuyer { buyer, .. } => {
buyer.persisted.id == loser_id
}
_ => false,
}))
.times(1)
.returning(|_| Ok(()));
let daos = (*deps.get::<DAOs>()).clone();
let message_sender: BoxedMessageSender = Box::new(message_sender);
handle_listing_expiry(&message_sender, &daos, &listing)
.await
.unwrap();
}
}

View File

@@ -191,6 +191,7 @@ async fn main() -> Result<()> {
let daos = DAOs::new(db_pool.clone(), listing_expiry_checker_tx); let daos = DAOs::new(db_pool.clone(), listing_expiry_checker_tx);
tokio::spawn(listing_expiry_checker_task::task( tokio::spawn(listing_expiry_checker_task::task(
Box::new(BotMessageSender::new(*bot.clone(), None)),
daos.clone(), daos.clone(),
listing_expiry_checker_rx, listing_expiry_checker_rx,
)); ));
@@ -203,7 +204,7 @@ async fn main() -> Result<()> {
|bot: Box<Bot>, update: Update, daos: DAOs, bot_username: BotUsername| { |bot: Box<Bot>, update: Update, daos: DAOs, bot_username: BotUsername| {
let target = update_into_message_target(update)?; let target = update_into_message_target(update)?;
Some(App::new( Some(App::new(
Box::new(BotMessageSender::new(*bot, target)), Box::new(BotMessageSender::new(*bot, Some(target))),
daos.clone(), daos.clone(),
bot_username.0, bot_username.0,
)) ))
@@ -278,7 +279,8 @@ mod tests {
always(), always(),
) )
.returning(|_, _| Ok(())); .returning(|_, _| Ok(()));
let mut deps = with_message_sender(create_deps().await, message_sender).await; let Deps { deps, .. } = create_deps().await;
let mut deps = with_message_sender(deps, message_sender).await;
deps.insert(create_tele_update("/help")); deps.insert(create_tele_update("/help"));
let handler = main_handler(); let handler = main_handler();
dptree::type_check(handler.sig(), &deps, &[]); dptree::type_check(handler.sig(), &deps, &[]);

View File

@@ -20,4 +20,25 @@ pub enum MessageType {
listing: PersistedListing, listing: PersistedListing,
buyer: PersistedUser, buyer: PersistedUser,
}, },
UserHasWonListingForSeller {
listing: PersistedListing,
seller: PersistedUser,
buyer: PersistedUser,
bid: PersistedBid,
},
UserHasWonListingForBuyer {
listing: PersistedListing,
buyer: PersistedUser,
seller: PersistedUser,
bid: PersistedBid,
},
UserHasLostListingForBuyer {
listing: PersistedListing,
buyer: PersistedUser,
seller: PersistedUser,
},
ListingExpiredWithNoWinnerForSeller {
listing: PersistedListing,
seller: PersistedUser,
},
} }

View File

@@ -6,6 +6,7 @@ use sqlx::SqlitePool;
use std::{ops::Deref, str::FromStr, sync::Arc}; use std::{ops::Deref, str::FromStr, sync::Arc};
use teloxide::dispatching::dialogue::serializer::Json; use teloxide::dispatching::dialogue::serializer::Json;
use teloxide::types::*; use teloxide::types::*;
use tokio::sync::mpsc::Receiver;
use crate::{ use crate::{
db::{ db::{
@@ -15,7 +16,8 @@ use crate::{
NewListing, PersistedListing, NewListing, PersistedListing,
}, },
user::{NewUser, PersistedUser}, user::{NewUser, PersistedUser},
CurrencyType, DAOs, ListingDAO, ListingEventSender, MoneyAmount, UserDAO, CurrencyType, DAOs, ListingDAO, ListingEventSender, ListingUpdatedEvent, MoneyAmount,
UserDAO,
}, },
message_sender::MockMessageSender, message_sender::MockMessageSender,
sqlite_storage::SqliteStorage, sqlite_storage::SqliteStorage,
@@ -202,7 +204,11 @@ pub fn create_tele_callback_query(callback_data: &str, from: User) -> CallbackQu
} }
} }
pub async fn create_deps() -> DependencyMap { pub struct Deps {
pub deps: DependencyMap,
pub listing_event_receiver: Receiver<ListingUpdatedEvent>,
}
pub async fn create_deps() -> Deps {
let pool = create_test_pool().await; let pool = create_test_pool().await;
let dialog_storage = SqliteStorage::new(pool.clone(), Json).await.unwrap(); let dialog_storage = SqliteStorage::new(pool.clone(), Json).await.unwrap();
let (tx, rx) = ListingEventSender::channel(); let (tx, rx) = ListingEventSender::channel();
@@ -216,7 +222,10 @@ pub async fn create_deps() -> DependencyMap {
can_connect_to_business: true, can_connect_to_business: true,
has_main_web_app: true, has_main_web_app: true,
}; };
dptree::deps![dialog_storage, me, daos, rx] Deps {
deps: dptree::deps![dialog_storage, me, daos],
listing_event_receiver: rx,
}
} }
pub async fn with_dialogue(mut deps: DependencyMap, user: &PersistedUser) -> DependencyMap { pub async fn with_dialogue(mut deps: DependencyMap, user: &PersistedUser) -> DependencyMap {
@@ -240,19 +249,6 @@ pub async fn with_message_sender(
deps 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( pub async fn with_test_user(
deps: &DependencyMap, deps: &DependencyMap,
user_fn: impl FnOnce(&mut NewUser) -> (), user_fn: impl FnOnce(&mut NewUser) -> (),