Compare commits
3 Commits
83cdbb770d
...
9490ac418c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9490ac418c | ||
|
|
db04de9442 | ||
|
|
cf02bfd6d7 |
6
.devcontainer/Dockerfile.devcontainer
Normal file
6
.devcontainer/Dockerfile.devcontainer
Normal file
@@ -0,0 +1,6 @@
|
||||
FROM mcr.microsoft.com/devcontainers/rust:1-1-bookworm
|
||||
|
||||
RUN sudo apt update && \
|
||||
sudo apt install -qqy --no-install-recommends --no-install-suggests sqlite3 && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
@@ -3,7 +3,10 @@
|
||||
{
|
||||
"name": "Rust",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/rust:1-1-bookworm",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile.devcontainer",
|
||||
"context": "."
|
||||
},
|
||||
"features": {
|
||||
"ghcr.io/braun-daniel/devcontainer-features/fzf:1": {},
|
||||
"ghcr.io/stuartleeks/dev-container-features/shell-history:0": {},
|
||||
@@ -19,7 +22,7 @@
|
||||
"eamodio.gitlens"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
// Use 'mounts' to make the cargo cache persistent in a Docker Volume.
|
||||
// "mounts": [
|
||||
// {
|
||||
@@ -33,7 +36,7 @@
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "rustc --version",
|
||||
"postCreateCommand": "sudo apt update && sudo apt install -y sqlite3"
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
|
||||
45
Cargo.lock
generated
45
Cargo.lock
generated
@@ -1393,6 +1393,30 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.4"
|
||||
@@ -1410,6 +1434,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.1.0"
|
||||
@@ -1436,6 +1469,17 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -1551,6 +1595,7 @@ dependencies = [
|
||||
"futures",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"num",
|
||||
"rstest",
|
||||
"rust_decimal",
|
||||
"serde",
|
||||
|
||||
@@ -24,6 +24,7 @@ serde = "1.0.219"
|
||||
futures = "0.3.31"
|
||||
thiserror = "2.0.16"
|
||||
teloxide-core = "0.13.0"
|
||||
num = "0.4.3"
|
||||
|
||||
[dev-dependencies]
|
||||
rstest = "0.26.1"
|
||||
|
||||
@@ -5,11 +5,7 @@ pub mod new_listing;
|
||||
pub mod settings;
|
||||
pub mod start;
|
||||
|
||||
// Re-export all command handlers for easy access
|
||||
pub use help::handle_help;
|
||||
pub use my_bids::handle_my_bids;
|
||||
pub use my_listings::handle_my_listings;
|
||||
pub use settings::handle_settings;
|
||||
pub use start::handle_start;
|
||||
|
||||
// Note: Text message handling is now handled by the dialogue system
|
||||
|
||||
@@ -1,23 +1,268 @@
|
||||
use log::info;
|
||||
use teloxide::{prelude::*, types::Message, Bot};
|
||||
use crate::{
|
||||
case,
|
||||
db::{Listing, ListingDAO, ListingId, User, UserDAO},
|
||||
keyboard_buttons,
|
||||
message_utils::{extract_callback_data, pluralize_with_count, send_message, MessageTarget},
|
||||
Command, DialogueRootState, HandlerResult, RootDialogue,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
use teloxide::{
|
||||
dispatching::{DpHandlerDescription, UpdateFilterExt},
|
||||
prelude::*,
|
||||
types::{InlineKeyboardButton, Message},
|
||||
Bot,
|
||||
};
|
||||
|
||||
use crate::HandlerResult;
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum MyListingsState {
|
||||
ViewingListings,
|
||||
ManagingListing(ListingId),
|
||||
EditingListing(ListingId),
|
||||
}
|
||||
impl From<MyListingsState> for DialogueRootState {
|
||||
fn from(state: MyListingsState) -> Self {
|
||||
DialogueRootState::MyListings(state)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_my_listings(bot: Bot, msg: Message) -> HandlerResult {
|
||||
let response = "📊 My Listings and Auctions (Coming Soon)\n\n\
|
||||
Here you'll be able to view and manage:\n\
|
||||
• Your active listings and auctions\n\
|
||||
• Listing performance\n\
|
||||
• Bid history\n\
|
||||
• Winner selection (for blind auctions)\n\n\
|
||||
Feature in development! 🔧";
|
||||
keyboard_buttons! {
|
||||
enum ManageListingButtons {
|
||||
[
|
||||
Edit("✏️ Edit", "manage_listing_edit"),
|
||||
Delete("🗑️ Delete", "manage_listing_delete"),
|
||||
],
|
||||
[
|
||||
Back("⬅️ Back", "manage_listing_back"),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"User {} ({}) checked their listings",
|
||||
msg.chat.username().unwrap_or("unknown"),
|
||||
msg.chat.id
|
||||
);
|
||||
pub fn my_listings_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> {
|
||||
dptree::entry()
|
||||
.branch(
|
||||
Update::filter_message().filter_command::<Command>().branch(
|
||||
dptree::case![Command::MyListings].endpoint(handle_my_listings_command_input),
|
||||
),
|
||||
)
|
||||
.branch(
|
||||
Update::filter_callback_query()
|
||||
.branch(
|
||||
// Callback when user taps a listing ID button to manage that listing
|
||||
case![DialogueRootState::MyListings(
|
||||
MyListingsState::ViewingListings
|
||||
)]
|
||||
.endpoint(handle_viewing_listings_callback),
|
||||
)
|
||||
.branch(
|
||||
case![DialogueRootState::MyListings(
|
||||
MyListingsState::ManagingListing(listing_id)
|
||||
)]
|
||||
.endpoint(handle_managing_listing_callback),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
bot.send_message(msg.chat.id, response).await?;
|
||||
async fn handle_my_listings_command_input(
|
||||
db_pool: SqlitePool,
|
||||
bot: Bot,
|
||||
dialogue: RootDialogue,
|
||||
msg: Message,
|
||||
) -> HandlerResult {
|
||||
let from = msg.from.unwrap();
|
||||
show_listings_for_user(db_pool, dialogue, bot, from.id, msg.chat).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn show_listings_for_user(
|
||||
db_pool: SqlitePool,
|
||||
dialogue: RootDialogue,
|
||||
bot: Bot,
|
||||
user: teloxide::types::UserId,
|
||||
target: impl Into<MessageTarget>,
|
||||
) -> HandlerResult {
|
||||
// If we reach here, show the listings menu
|
||||
let user = match UserDAO::find_by_telegram_id(&db_pool, user).await? {
|
||||
Some(user) => user,
|
||||
None => {
|
||||
send_message(
|
||||
&bot,
|
||||
target,
|
||||
"You don't have an account. Try creating an auction first.",
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
return Err(anyhow::anyhow!("User not found"));
|
||||
}
|
||||
};
|
||||
|
||||
// Transition to ViewingListings state
|
||||
dialogue.update(MyListingsState::ViewingListings).await?;
|
||||
|
||||
let listings = ListingDAO::find_by_seller(&db_pool, user.id).await?;
|
||||
if listings.is_empty() {
|
||||
send_message(
|
||||
&bot,
|
||||
target,
|
||||
"📋 <b>My Listings</b>\n\n\
|
||||
You don't have any listings yet.\n\
|
||||
Use /newlisting to create your first listing!",
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Create keyboard with buttons for each listing
|
||||
let mut keyboard = teloxide::types::InlineKeyboardMarkup::default();
|
||||
for listing in &listings {
|
||||
keyboard = keyboard.append_row(vec![InlineKeyboardButton::callback(
|
||||
listing.base.title.to_string(),
|
||||
listing.base.id.to_string(),
|
||||
)]);
|
||||
}
|
||||
|
||||
let mut response = format!(
|
||||
"📋 <b>My Listings</b>\n\n\
|
||||
You have {}.\n\n",
|
||||
pluralize_with_count(listings.len(), "listing", "listings")
|
||||
);
|
||||
|
||||
// Add each listing with its ID and title
|
||||
for listing in &listings {
|
||||
response.push_str(&format!(
|
||||
"• <b>ID {}:</b> {}\n",
|
||||
listing.base.id, listing.base.title
|
||||
));
|
||||
}
|
||||
|
||||
response.push_str("\nTap a listing ID below to view details:");
|
||||
send_message(&bot, target, response, Some(keyboard)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_viewing_listings_callback(
|
||||
db_pool: SqlitePool,
|
||||
bot: Bot,
|
||||
dialogue: RootDialogue,
|
||||
callback_query: CallbackQuery,
|
||||
) -> HandlerResult {
|
||||
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
|
||||
let target = (from.clone(), message_id);
|
||||
|
||||
let listing_id = ListingId::new(data.parse::<i64>()?);
|
||||
let (_, listing) =
|
||||
get_user_and_listing(&db_pool, &bot, from.id, listing_id, target.clone()).await?;
|
||||
dialogue
|
||||
.update(MyListingsState::ManagingListing(listing_id))
|
||||
.await?;
|
||||
show_listing_details(&bot, listing, target).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn show_listing_details(
|
||||
bot: &Bot,
|
||||
listing: Listing,
|
||||
target: impl Into<MessageTarget>,
|
||||
) -> HandlerResult {
|
||||
let response = format!(
|
||||
"🔍 <b>Viewing Listing Details</b>\n\n\
|
||||
<b>Title:</b> {}\n\
|
||||
<b>Description:</b> {}\n\
|
||||
<b>ID:</b> {}",
|
||||
listing.base.title,
|
||||
listing
|
||||
.base
|
||||
.description
|
||||
.as_deref()
|
||||
.unwrap_or("No description"),
|
||||
listing.base.id
|
||||
);
|
||||
|
||||
send_message(
|
||||
bot,
|
||||
target,
|
||||
response,
|
||||
Some(ManageListingButtons::to_keyboard()),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_managing_listing_callback(
|
||||
db_pool: SqlitePool,
|
||||
bot: Bot,
|
||||
dialogue: RootDialogue,
|
||||
callback_query: CallbackQuery,
|
||||
listing_id: ListingId,
|
||||
) -> HandlerResult {
|
||||
let (data, from, message_id) = extract_callback_data(&bot, callback_query).await?;
|
||||
let target = (from.clone(), message_id);
|
||||
|
||||
let button = ManageListingButtons::try_from(data.as_str())
|
||||
.map_err(|_| anyhow::anyhow!("Invalid ManageListingButtons callback data: {}", data))?;
|
||||
|
||||
match button {
|
||||
ManageListingButtons::Edit => {
|
||||
let (_, listing) =
|
||||
get_user_and_listing(&db_pool, &bot, from.id, listing_id, target.clone()).await?;
|
||||
dialogue
|
||||
.update(MyListingsState::EditingListing(listing.base.id))
|
||||
.await?;
|
||||
}
|
||||
ManageListingButtons::Delete => {
|
||||
ListingDAO::delete_listing(&db_pool, listing_id).await?;
|
||||
send_message(&bot, target, "Listing deleted.", None).await?;
|
||||
}
|
||||
ManageListingButtons::Back => {
|
||||
dialogue.update(MyListingsState::ViewingListings).await?;
|
||||
show_listings_for_user(db_pool, dialogue, bot, from.id, target).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_user_and_listing(
|
||||
db_pool: &SqlitePool,
|
||||
bot: &Bot,
|
||||
user_id: teloxide::types::UserId,
|
||||
listing_id: ListingId,
|
||||
target: impl Into<MessageTarget>,
|
||||
) -> HandlerResult<(User, Listing)> {
|
||||
let user = match UserDAO::find_by_telegram_id(db_pool, user_id).await? {
|
||||
Some(user) => user,
|
||||
None => {
|
||||
send_message(
|
||||
bot,
|
||||
target,
|
||||
"❌ You don't have an account. Try creating an auction first.",
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
return Err(anyhow::anyhow!("User not found"));
|
||||
}
|
||||
};
|
||||
|
||||
let listing = match ListingDAO::find_by_id(db_pool, listing_id).await? {
|
||||
Some(listing) => listing,
|
||||
None => {
|
||||
send_message(bot, target, "❌ Listing not found.", None).await?;
|
||||
return Err(anyhow::anyhow!("Listing not found"));
|
||||
}
|
||||
};
|
||||
|
||||
if listing.base.seller_id != user.id {
|
||||
send_message(
|
||||
bot,
|
||||
target,
|
||||
"❌ You can only manage your own auctions.",
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
return Err(anyhow::anyhow!("User does not own listing"));
|
||||
}
|
||||
|
||||
Ok((user, listing))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
56
src/commands/new_listing/handler_factory.rs
Normal file
56
src/commands/new_listing/handler_factory.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use super::*;
|
||||
use crate::{case, Command, DialogueRootState, Handler};
|
||||
use teloxide::{dptree, prelude::*, types::Update};
|
||||
|
||||
// Create the dialogue handler tree for new listing wizard
|
||||
pub fn new_listing_handler() -> Handler {
|
||||
dptree::entry()
|
||||
.branch(
|
||||
Update::filter_message()
|
||||
.branch(
|
||||
dptree::entry()
|
||||
.filter_command::<Command>()
|
||||
.chain(case![Command::NewListing])
|
||||
.endpoint(handle_new_listing_command),
|
||||
)
|
||||
.branch(
|
||||
case![DialogueRootState::NewListing(
|
||||
NewListingState::AwaitingDraftField { field, draft }
|
||||
)]
|
||||
.endpoint(handle_awaiting_draft_field_input),
|
||||
)
|
||||
.branch(
|
||||
case![DialogueRootState::NewListing(
|
||||
NewListingState::EditingDraftField { field, draft }
|
||||
)]
|
||||
.endpoint(handle_editing_field_input),
|
||||
),
|
||||
)
|
||||
.branch(
|
||||
Update::filter_callback_query()
|
||||
.branch(
|
||||
case![DialogueRootState::NewListing(
|
||||
NewListingState::AwaitingDraftField { field, draft }
|
||||
)]
|
||||
.endpoint(handle_awaiting_draft_field_callback),
|
||||
)
|
||||
.branch(
|
||||
case![DialogueRootState::NewListing(
|
||||
NewListingState::ViewingDraft(draft)
|
||||
)]
|
||||
.endpoint(handle_viewing_draft_callback),
|
||||
)
|
||||
.branch(
|
||||
case![DialogueRootState::NewListing(
|
||||
NewListingState::EditingDraft(draft)
|
||||
)]
|
||||
.endpoint(handle_editing_draft_callback),
|
||||
)
|
||||
.branch(
|
||||
case![DialogueRootState::NewListing(
|
||||
NewListingState::EditingDraftField { field, draft }
|
||||
)]
|
||||
.endpoint(handle_editing_draft_field_callback),
|
||||
),
|
||||
)
|
||||
}
|
||||
53
src/commands/new_listing/keyboard.rs
Normal file
53
src/commands/new_listing/keyboard.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use crate::keyboard_buttons;
|
||||
|
||||
keyboard_buttons! {
|
||||
pub enum DurationKeyboardButtons {
|
||||
OneDay("1 day", "duration_1_day"),
|
||||
ThreeDays("3 days", "duration_3_days"),
|
||||
SevenDays("7 days", "duration_7_days"),
|
||||
FourteenDays("14 days", "duration_14_days"),
|
||||
}
|
||||
}
|
||||
|
||||
keyboard_buttons! {
|
||||
pub enum SlotsKeyboardButtons {
|
||||
OneSlot("1 slot", "slots_1"),
|
||||
TwoSlots("2 slots", "slots_2"),
|
||||
FiveSlots("5 slots", "slots_5"),
|
||||
TenSlots("10 slots", "slots_10"),
|
||||
}
|
||||
}
|
||||
|
||||
keyboard_buttons! {
|
||||
pub enum ConfirmationKeyboardButtons {
|
||||
Create("✅ Create", "confirm_create"),
|
||||
Edit("✏️ Edit", "confirm_edit"),
|
||||
Discard("🗑️ Discard", "confirm_discard"),
|
||||
}
|
||||
}
|
||||
|
||||
keyboard_buttons! {
|
||||
pub enum FieldSelectionKeyboardButtons {
|
||||
[
|
||||
Title("📝 Title", "edit_title"),
|
||||
Description("📄 Description", "edit_description"),
|
||||
],
|
||||
[
|
||||
Price("💰 Price", "edit_price"),
|
||||
Slots("🔢 Slots", "edit_slots"),
|
||||
],
|
||||
[
|
||||
StartTime("⏰ Start Time", "edit_start_time"),
|
||||
Duration("⏱️ Duration", "edit_duration"),
|
||||
],
|
||||
[
|
||||
Done("✅ Done", "edit_done"),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
keyboard_buttons! {
|
||||
pub enum StartTimeKeyboardButtons {
|
||||
Now("Now", "start_time_now"),
|
||||
}
|
||||
}
|
||||
1067
src/commands/new_listing/mod.rs
Normal file
1067
src/commands/new_listing/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
45
src/commands/new_listing/types.rs
Normal file
45
src/commands/new_listing/types.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use crate::{
|
||||
db::{ListingDuration, MoneyAmount},
|
||||
DialogueRootState,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct ListingDraft {
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub buy_now_price: MoneyAmount,
|
||||
pub slots_available: i32,
|
||||
pub start_delay: ListingDuration,
|
||||
pub duration: ListingDuration,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Debug)]
|
||||
pub enum ListingField {
|
||||
Title,
|
||||
Description,
|
||||
Price,
|
||||
Slots,
|
||||
StartTime,
|
||||
Duration,
|
||||
}
|
||||
|
||||
// Dialogue state for the new listing wizard
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub enum NewListingState {
|
||||
AwaitingDraftField {
|
||||
field: ListingField,
|
||||
draft: ListingDraft,
|
||||
},
|
||||
ViewingDraft(ListingDraft),
|
||||
EditingDraft(ListingDraft),
|
||||
EditingDraftField {
|
||||
field: ListingField,
|
||||
draft: ListingDraft,
|
||||
},
|
||||
}
|
||||
impl From<NewListingState> for DialogueRootState {
|
||||
fn from(state: NewListingState) -> Self {
|
||||
DialogueRootState::NewListing(state)
|
||||
}
|
||||
}
|
||||
74
src/commands/new_listing/validations.rs
Normal file
74
src/commands/new_listing/validations.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use crate::db::{ListingDuration, MoneyAmount};
|
||||
|
||||
// Common input validation functions
|
||||
pub fn validate_title(text: &str) -> Result<String, String> {
|
||||
if text.is_empty() {
|
||||
return Err("❌ Title cannot be empty. Please enter a title for your listing:".to_string());
|
||||
}
|
||||
if text.len() > 100 {
|
||||
return Err(
|
||||
"❌ Title is too long (max 100 characters). Please enter a shorter title:".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(text.to_string())
|
||||
}
|
||||
|
||||
pub fn validate_description(text: &str) -> Result<String, String> {
|
||||
if text.len() > 1000 {
|
||||
return Err(
|
||||
"❌ Description is too long (max 1000 characters). Please enter a shorter description:"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(text.to_string())
|
||||
}
|
||||
|
||||
pub fn validate_price(text: &str) -> Result<MoneyAmount, String> {
|
||||
match MoneyAmount::from_str(text) {
|
||||
Ok(amount) => {
|
||||
if amount.cents() <= 0 {
|
||||
Err("❌ Price must be greater than $0.00. Please enter a valid price:".to_string())
|
||||
} else {
|
||||
Ok(amount)
|
||||
}
|
||||
}
|
||||
Err(_) => Err(
|
||||
"❌ Invalid price format. Please enter a valid price (e.g., 10.50, 25, 0.99):"
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_slots(text: &str) -> Result<i32, String> {
|
||||
match text.parse::<i32>() {
|
||||
Ok(slots) if (1..=1000).contains(&slots) => Ok(slots),
|
||||
Ok(_) => Err(
|
||||
"❌ Number of slots must be between 1 and 1000. Please enter a valid number:"
|
||||
.to_string(),
|
||||
),
|
||||
Err(_) => Err("❌ Invalid number. Please enter a number from 1 to 1000:".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_duration(text: &str) -> Result<ListingDuration, String> {
|
||||
match text.parse::<i32>() {
|
||||
Ok(hours) if (1..=720).contains(&hours) => Ok(ListingDuration::hours(hours)), // 1 hour to 30 days
|
||||
Ok(_) => Err(
|
||||
"❌ Duration must be between 1 and 720 hours. Please enter a valid number:".to_string(),
|
||||
),
|
||||
Err(_) => Err("❌ Invalid number. Please enter number of hours (1-720):".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_start_time(text: &str) -> Result<ListingDuration, String> {
|
||||
match text.parse::<i32>() {
|
||||
Ok(hours) if (0..=168).contains(&hours) => Ok(ListingDuration::hours(hours)), // Max 1 week delay
|
||||
Ok(_) => Err(
|
||||
"❌ Start time must be between 0 and 168 hours. Please enter a valid number:"
|
||||
.to_string(),
|
||||
),
|
||||
Err(_) => Err(
|
||||
"❌ Invalid number. Please enter number of hours (0 for immediate start):".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ impl Config {
|
||||
log::info!(" Web Port: {}", self.web_port);
|
||||
|
||||
if let Some(admin_id) = self.admin_user_id {
|
||||
log::info!(" Admin User ID: {}", admin_id);
|
||||
log::info!(" Admin User ID: {admin_id}");
|
||||
} else {
|
||||
log::info!(" Admin User ID: Not set");
|
||||
}
|
||||
|
||||
@@ -10,10 +10,7 @@ use crate::db::{
|
||||
ListingBase, ListingFields,
|
||||
};
|
||||
|
||||
use super::super::{
|
||||
listing_id::ListingId, models::listing::Listing, models::listing_type::ListingType,
|
||||
user_id::UserId,
|
||||
};
|
||||
use super::super::{Listing, ListingId, ListingType, UserRowId};
|
||||
|
||||
/// Data Access Object for Listing operations
|
||||
pub struct ListingDAO;
|
||||
@@ -117,52 +114,21 @@ impl ListingDAO {
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(Self::row_to_listing).transpose()?)
|
||||
result.map(Self::row_to_listing).transpose()
|
||||
}
|
||||
|
||||
/// Find all listings by a seller
|
||||
pub async fn find_by_seller(pool: &SqlitePool, seller_id: UserId) -> Result<Vec<Listing>> {
|
||||
pub async fn find_by_seller(pool: &SqlitePool, seller_id: UserRowId) -> Result<Vec<Listing>> {
|
||||
let rows =
|
||||
sqlx::query("SELECT * FROM listings WHERE seller_id = ? ORDER BY created_at DESC")
|
||||
.bind(seller_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
rows
|
||||
.into_iter()
|
||||
.map(Self::row_to_listing)
|
||||
.collect::<Result<Vec<_>>>()?)
|
||||
}
|
||||
|
||||
/// Find all listings of a specific type
|
||||
pub async fn find_by_type(
|
||||
pool: &SqlitePool,
|
||||
listing_type: ListingType,
|
||||
) -> Result<Vec<Listing>> {
|
||||
let rows =
|
||||
sqlx::query("SELECT * FROM listings WHERE listing_type = ? ORDER BY created_at DESC")
|
||||
.bind(listing_type)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(Self::row_to_listing)
|
||||
.collect::<Result<Vec<_>>>()?)
|
||||
}
|
||||
|
||||
/// Find active listings (not ended yet)
|
||||
pub async fn find_active_listings(pool: &SqlitePool) -> Result<Vec<Listing>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT * FROM listings WHERE ends_at > CURRENT_TIMESTAMP ORDER BY ends_at ASC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(Self::row_to_listing)
|
||||
.collect::<Result<Vec<_>>>()?)
|
||||
.collect::<Result<Vec<_>>>()
|
||||
}
|
||||
|
||||
/// Delete a listing
|
||||
@@ -175,25 +141,6 @@ impl ListingDAO {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Count total listings
|
||||
pub async fn count_listings(pool: &SqlitePool) -> Result<i64> {
|
||||
let row = sqlx::query("SELECT COUNT(*) as count FROM listings")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.get("count"))
|
||||
}
|
||||
|
||||
/// Count listings by seller
|
||||
pub async fn count_by_seller(pool: &SqlitePool, seller_id: UserId) -> Result<i64> {
|
||||
let row = sqlx::query("SELECT COUNT(*) as count FROM listings WHERE seller_id = ?")
|
||||
.bind(seller_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.get("count"))
|
||||
}
|
||||
|
||||
fn row_to_listing(row: SqliteRow) -> Result<Listing> {
|
||||
let listing_type = row.get("listing_type");
|
||||
let base = ListingBase {
|
||||
|
||||
@@ -5,16 +5,15 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::TelegramUserId;
|
||||
|
||||
use super::super::{
|
||||
use crate::db::{
|
||||
models::user::{NewUser, User},
|
||||
user_id::UserId,
|
||||
TelegramUserId, UserRowId,
|
||||
};
|
||||
|
||||
/// Data Access Object for User operations
|
||||
pub struct UserDAO;
|
||||
|
||||
#[allow(unused)]
|
||||
impl UserDAO {
|
||||
/// Insert a new user into the database
|
||||
pub async fn insert_user(pool: &SqlitePool, new_user: &NewUser) -> Result<User> {
|
||||
@@ -35,7 +34,7 @@ impl UserDAO {
|
||||
}
|
||||
|
||||
/// Find a user by their ID
|
||||
pub async fn find_by_id(pool: &SqlitePool, user_id: UserId) -> Result<Option<User>> {
|
||||
pub async fn find_by_id(pool: &SqlitePool, user_id: UserRowId) -> Result<Option<User>> {
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
"SELECT id, telegram_id, username, display_name, is_banned, created_at, updated_at FROM users WHERE id = ?"
|
||||
)
|
||||
@@ -49,8 +48,9 @@ impl UserDAO {
|
||||
/// Find a user by their Telegram ID
|
||||
pub async fn find_by_telegram_id(
|
||||
pool: &SqlitePool,
|
||||
telegram_id: TelegramUserId,
|
||||
telegram_id: impl Into<TelegramUserId>,
|
||||
) -> Result<Option<User>> {
|
||||
let telegram_id = telegram_id.into();
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
"SELECT id, telegram_id, username, display_name, is_banned, created_at, updated_at FROM users WHERE telegram_id = ?"
|
||||
)
|
||||
@@ -82,7 +82,11 @@ impl UserDAO {
|
||||
}
|
||||
|
||||
/// Set a user's ban status
|
||||
pub async fn set_ban_status(pool: &SqlitePool, user_id: UserId, is_banned: bool) -> Result<()> {
|
||||
pub async fn set_ban_status(
|
||||
pool: &SqlitePool,
|
||||
user_id: UserRowId,
|
||||
is_banned: bool,
|
||||
) -> Result<()> {
|
||||
sqlx::query("UPDATE users SET is_banned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.bind(is_banned) // sqlx automatically converts bool to INTEGER for SQLite
|
||||
.bind(user_id)
|
||||
@@ -93,7 +97,7 @@ impl UserDAO {
|
||||
}
|
||||
|
||||
/// Delete a user (soft delete by setting is_banned = true might be better in production)
|
||||
pub async fn delete_user(pool: &SqlitePool, user_id: UserId) -> Result<()> {
|
||||
pub async fn delete_user(pool: &SqlitePool, user_id: UserRowId) -> Result<()> {
|
||||
sqlx::query("DELETE FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
@@ -128,9 +132,10 @@ impl UserDAO {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::models::user::{NewUser, User};
|
||||
use crate::db::models::user::NewUser;
|
||||
use rstest::rstest;
|
||||
use sqlx::SqlitePool;
|
||||
use teloxide::types::UserId;
|
||||
|
||||
/// Create test database for UserDAO tests
|
||||
async fn create_test_pool() -> SqlitePool {
|
||||
@@ -177,7 +182,7 @@ mod tests {
|
||||
assert_eq!(found_user.telegram_id, inserted_user.telegram_id);
|
||||
|
||||
// Find by telegram ID
|
||||
let found_by_telegram = UserDAO::find_by_telegram_id(&pool, 12345.into())
|
||||
let found_by_telegram = UserDAO::find_by_telegram_id(&pool, UserId(12345))
|
||||
.await
|
||||
.expect("Failed to find user by telegram_id")
|
||||
.expect("User should be found");
|
||||
@@ -308,13 +313,13 @@ mod tests {
|
||||
let pool = create_test_pool().await;
|
||||
|
||||
// Try to find a user that doesn't exist
|
||||
let not_found = UserDAO::find_by_id(&pool, UserId::new(99999))
|
||||
let not_found = UserDAO::find_by_id(&pool, UserRowId::new(99999))
|
||||
.await
|
||||
.expect("Database operation should succeed");
|
||||
|
||||
assert!(not_found.is_none());
|
||||
|
||||
let not_found_by_telegram = UserDAO::find_by_telegram_id(&pool, 88888.into())
|
||||
let not_found_by_telegram = UserDAO::find_by_telegram_id(&pool, UserId(88888))
|
||||
.await
|
||||
.expect("Database operation should succeed");
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::FromRow;
|
||||
|
||||
use crate::db::money_amount::MoneyAmount;
|
||||
use crate::db::MoneyAmount;
|
||||
|
||||
/// Actual bids placed on listings
|
||||
#[allow(unused)]
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
pub struct Bid {
|
||||
pub id: i64,
|
||||
@@ -26,6 +27,7 @@ pub struct Bid {
|
||||
}
|
||||
|
||||
/// New bid data for insertion
|
||||
#[allow(unused)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewBid {
|
||||
pub listing_id: i64,
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
//! Database mapping is handled through `ListingRow` with conversion traits.
|
||||
|
||||
use super::listing_type::ListingType;
|
||||
use crate::db::{listing_id::ListingId, money_amount::MoneyAmount, user_id::UserId};
|
||||
use crate::db::{ListingId, MoneyAmount, UserRowId};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Main listing/auction entity
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(unused)]
|
||||
pub struct Listing {
|
||||
pub base: ListingBase,
|
||||
pub fields: ListingFields,
|
||||
@@ -22,9 +23,10 @@ pub struct Listing {
|
||||
|
||||
/// Common fields shared by all listing types
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(unused)]
|
||||
pub struct ListingBase {
|
||||
pub id: ListingId,
|
||||
pub seller_id: UserId,
|
||||
pub seller_id: UserRowId,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub starts_at: DateTime<Utc>,
|
||||
@@ -34,6 +36,7 @@ pub struct ListingBase {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(unused)]
|
||||
pub enum ListingFields {
|
||||
BasicAuction {
|
||||
starting_bid: MoneyAmount,
|
||||
@@ -57,6 +60,7 @@ pub enum ListingFields {
|
||||
},
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
impl Listing {
|
||||
/// Get the listing type as an enum value
|
||||
pub fn listing_type(&self) -> ListingType {
|
||||
@@ -106,7 +110,7 @@ mod tests {
|
||||
pool: &SqlitePool,
|
||||
telegram_id: TelegramUserId,
|
||||
username: Option<&str>,
|
||||
) -> UserId {
|
||||
) -> UserRowId {
|
||||
use crate::db::{models::user::NewUser, UserDAO};
|
||||
|
||||
let new_user = NewUser {
|
||||
@@ -194,7 +198,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn build_base_listing(
|
||||
seller_id: UserId,
|
||||
seller_id: UserRowId,
|
||||
title: &str,
|
||||
description: Option<&str>,
|
||||
) -> NewListingBase {
|
||||
|
||||
@@ -3,6 +3,7 @@ use sqlx::FromRow;
|
||||
|
||||
/// Media attachments for listings
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
#[allow(unused)]
|
||||
pub struct ListingMedia {
|
||||
pub id: i64,
|
||||
pub listing_id: i64,
|
||||
|
||||
@@ -8,10 +8,6 @@ pub mod user;
|
||||
pub mod user_settings;
|
||||
|
||||
// Re-export all types for easy access
|
||||
pub use bid::*;
|
||||
pub use listing::*;
|
||||
pub use listing_media::*;
|
||||
pub use listing_type::*;
|
||||
pub use proxy_bid::*;
|
||||
pub use user::*;
|
||||
pub use user_settings::*;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::db::{ListingType, MoneyAmount, UserId};
|
||||
use crate::db::{ListingType, MoneyAmount, UserRowId};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// New listing data for insertion
|
||||
@@ -21,7 +21,7 @@ impl NewListing {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewListingBase {
|
||||
pub seller_id: UserId,
|
||||
pub seller_id: UserRowId,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub starts_at: DateTime<Utc>,
|
||||
@@ -29,6 +29,7 @@ pub struct NewListingBase {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(unused)]
|
||||
pub enum NewListingFields {
|
||||
BasicAuction {
|
||||
starting_bid: MoneyAmount,
|
||||
@@ -52,9 +53,10 @@ pub enum NewListingFields {
|
||||
},
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
impl NewListingBase {
|
||||
pub fn new(
|
||||
seller_id: UserId,
|
||||
seller_id: UserRowId,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
starts_at: DateTime<Utc>,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::FromRow;
|
||||
|
||||
use crate::db::money_amount::MoneyAmount;
|
||||
use crate::db::MoneyAmount;
|
||||
|
||||
/// Proxy bid strategies (automatic bidding settings)
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
#[allow(unused)]
|
||||
pub struct ProxyBid {
|
||||
pub id: i64,
|
||||
pub listing_id: i64,
|
||||
@@ -17,6 +18,7 @@ pub struct ProxyBid {
|
||||
|
||||
/// New proxy bid data for insertion
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(unused)]
|
||||
pub struct NewProxyBid {
|
||||
pub listing_id: i64,
|
||||
pub buyer_id: i64,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::FromRow;
|
||||
|
||||
use crate::db::{TelegramUserId, UserId};
|
||||
use crate::db::{TelegramUserId, UserRowId};
|
||||
|
||||
/// Core user information
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
#[allow(unused)]
|
||||
pub struct User {
|
||||
pub id: UserId,
|
||||
pub id: UserRowId,
|
||||
pub telegram_id: TelegramUserId,
|
||||
pub username: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
|
||||
@@ -3,6 +3,7 @@ use sqlx::FromRow;
|
||||
|
||||
/// User preferences and settings
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
#[allow(unused)]
|
||||
pub struct UserSettings {
|
||||
pub user_id: i64,
|
||||
pub language_code: String,
|
||||
|
||||
@@ -3,41 +3,38 @@ use sqlx::{
|
||||
};
|
||||
|
||||
/// Currency types supported by the platform
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum CurrencyType {
|
||||
USD,
|
||||
#[default]
|
||||
Usd,
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
impl CurrencyType {
|
||||
/// Get the currency code as a string
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
CurrencyType::USD => "USD",
|
||||
CurrencyType::Usd => "USD",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the currency symbol
|
||||
pub fn symbol(&self) -> &'static str {
|
||||
match self {
|
||||
CurrencyType::USD => "$",
|
||||
CurrencyType::Usd => "$",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse currency from string
|
||||
pub fn from_str(s: &str) -> Result<Self, String> {
|
||||
match s.to_uppercase().as_str() {
|
||||
"USD" => Ok(CurrencyType::USD),
|
||||
_ => Err(format!("Unsupported currency: {}", s)),
|
||||
"USD" => Ok(CurrencyType::Usd),
|
||||
_ => Err(format!("Unsupported currency: {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Implement Default for CurrencyType (defaults to USD)
|
||||
impl Default for CurrencyType {
|
||||
fn default() -> Self {
|
||||
CurrencyType::USD
|
||||
}
|
||||
}
|
||||
|
||||
// Implement Display for CurrencyType
|
||||
impl std::fmt::Display for CurrencyType {
|
||||
@@ -83,7 +80,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_currency_type_display() {
|
||||
let usd = CurrencyType::USD;
|
||||
let usd = CurrencyType::Usd;
|
||||
assert_eq!(usd.to_string(), "USD");
|
||||
assert_eq!(usd.symbol(), "$");
|
||||
assert_eq!(usd.as_str(), "USD");
|
||||
@@ -92,16 +89,16 @@ mod tests {
|
||||
#[test]
|
||||
fn test_currency_type_default() {
|
||||
let default_currency = CurrencyType::default();
|
||||
assert_eq!(default_currency, CurrencyType::USD);
|
||||
assert_eq!(default_currency, CurrencyType::Usd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_currency_type_parsing() {
|
||||
let parsed_currency = CurrencyType::from_str("usd").unwrap(); // Case insensitive
|
||||
assert_eq!(parsed_currency, CurrencyType::USD);
|
||||
assert_eq!(parsed_currency, CurrencyType::Usd);
|
||||
|
||||
let parsed_upper = CurrencyType::from_str("USD").unwrap();
|
||||
assert_eq!(parsed_upper, CurrencyType::USD);
|
||||
assert_eq!(parsed_upper, CurrencyType::Usd);
|
||||
|
||||
let invalid = CurrencyType::from_str("EUR");
|
||||
assert!(invalid.is_err());
|
||||
|
||||
77
src/db/types/listing_duration.rs
Normal file
77
src/db/types/listing_duration.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use chrono::Duration;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{
|
||||
encode::IsNull,
|
||||
error::BoxDynError,
|
||||
sqlite::{SqliteArgumentValue, SqliteValueRef},
|
||||
Decode, Encode, Sqlite,
|
||||
};
|
||||
use std::fmt::{self, Display};
|
||||
|
||||
use crate::message_utils::pluralize_with_count;
|
||||
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Default)]
|
||||
pub struct ListingDuration(i32);
|
||||
impl ListingDuration {
|
||||
pub fn hours(hours: i32) -> Self {
|
||||
Self(hours)
|
||||
}
|
||||
pub fn days(days: i32) -> Self {
|
||||
Self(days * 24)
|
||||
}
|
||||
pub fn zero() -> Self {
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
impl From<ListingDuration> for Duration {
|
||||
fn from(duration: ListingDuration) -> Self {
|
||||
Duration::hours(duration.0 as i64)
|
||||
}
|
||||
}
|
||||
impl Display for ListingDuration {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let days = self.0 / 24;
|
||||
let hours = self.0 % 24;
|
||||
if days > 0 {
|
||||
write!(f, "{}", pluralize_with_count(days, "day", "days"))?;
|
||||
}
|
||||
if hours > 0 {
|
||||
if days > 0 {
|
||||
write!(f, " ")?;
|
||||
}
|
||||
write!(f, "{}", pluralize_with_count(hours, "hour", "hours"))?;
|
||||
}
|
||||
if days == 0 && hours == 0 {
|
||||
write!(f, "0 hours")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Encode<'_, Sqlite> for ListingDuration {
|
||||
fn encode_by_ref(&self, args: &mut Vec<SqliteArgumentValue>) -> Result<IsNull, BoxDynError> {
|
||||
args.push(SqliteArgumentValue::Int(self.0));
|
||||
Ok(IsNull::No)
|
||||
}
|
||||
}
|
||||
impl<'r> Decode<'r, Sqlite> for ListingDuration {
|
||||
fn decode(value: SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
|
||||
let value = <i32 as Decode<Sqlite>>::decode(value)?;
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[rstest::rstest]
|
||||
#[case(ListingDuration::default(), "0 hours")]
|
||||
#[case(ListingDuration::hours(1), "1 hour")]
|
||||
#[case(ListingDuration::hours(2), "2 hours")]
|
||||
#[case(ListingDuration::days(1), "1 day")]
|
||||
#[case(ListingDuration::days(2), "2 days")]
|
||||
fn test_display(#[case] duration: ListingDuration, #[case] expected: &str) {
|
||||
assert_eq!(duration.to_string(), expected);
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,14 @@
|
||||
//! 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)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct ListingId(i64);
|
||||
|
||||
impl ListingId {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
pub mod currency_type;
|
||||
pub mod listing_id;
|
||||
pub mod money_amount;
|
||||
pub mod telegram_user_id;
|
||||
pub mod user_id;
|
||||
mod currency_type;
|
||||
mod listing_duration;
|
||||
mod listing_id;
|
||||
mod money_amount;
|
||||
mod telegram_user_id;
|
||||
mod user_row_id;
|
||||
|
||||
// Re-export all types for easy access
|
||||
#[allow(unused)]
|
||||
pub use currency_type::*;
|
||||
pub use listing_duration::*;
|
||||
pub use listing_id::*;
|
||||
pub use money_amount::*;
|
||||
pub use telegram_user_id::*;
|
||||
pub use user_id::*;
|
||||
pub use user_row_id::*;
|
||||
|
||||
@@ -35,12 +35,12 @@ impl MoneyAmount {
|
||||
}
|
||||
|
||||
/// Get the value in cents
|
||||
pub fn cents(&self) -> i64 {
|
||||
pub fn cents(self) -> i64 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Get the value as a Decimal (for display/calculation purposes)
|
||||
pub fn to_decimal(&self) -> Decimal {
|
||||
pub fn to_decimal(self) -> Decimal {
|
||||
Decimal::new(self.0, 2) // 2 decimal places for cents
|
||||
}
|
||||
}
|
||||
@@ -204,7 +204,7 @@ mod tests {
|
||||
// Insert test data
|
||||
sqlx::query("INSERT INTO test_money (amount, currency) VALUES (?, ?)")
|
||||
.bind(&amount)
|
||||
.bind(CurrencyType::USD)
|
||||
.bind(CurrencyType::Usd)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("Failed to insert test data");
|
||||
@@ -219,7 +219,7 @@ mod tests {
|
||||
let retrieved_currency: CurrencyType = row.get("currency");
|
||||
|
||||
assert_eq!(retrieved_amount, amount);
|
||||
assert_eq!(retrieved_currency, CurrencyType::USD);
|
||||
assert_eq!(retrieved_currency, CurrencyType::Usd);
|
||||
|
||||
// Verify string representation matches expected format (cent-based precision)
|
||||
assert_eq!(retrieved_amount.to_string(), expected_str);
|
||||
@@ -234,7 +234,7 @@ mod tests {
|
||||
// Test with NULL (None) value
|
||||
sqlx::query("INSERT INTO test_money (amount, currency, optional_amount) VALUES (?, ?, ?)")
|
||||
.bind(MoneyAmount::from_str("50.00").unwrap())
|
||||
.bind(CurrencyType::USD)
|
||||
.bind(CurrencyType::Usd)
|
||||
.bind(None::<MoneyAmount>)
|
||||
.execute(&pool)
|
||||
.await
|
||||
@@ -244,7 +244,7 @@ mod tests {
|
||||
let optional_amount = Some(MoneyAmount::from_str("25.75").unwrap());
|
||||
sqlx::query("INSERT INTO test_money (amount, currency, optional_amount) VALUES (?, ?, ?)")
|
||||
.bind(MoneyAmount::from_str("100.00").unwrap())
|
||||
.bind(CurrencyType::USD)
|
||||
.bind(CurrencyType::Usd)
|
||||
.bind(&optional_amount)
|
||||
.execute(&pool)
|
||||
.await
|
||||
@@ -291,7 +291,7 @@ mod tests {
|
||||
// Insert into database
|
||||
sqlx::query("INSERT INTO test_money (amount, currency) VALUES (?, ?)")
|
||||
.bind(&amount)
|
||||
.bind(CurrencyType::USD)
|
||||
.bind(CurrencyType::Usd)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("Failed to insert precision test data");
|
||||
|
||||
@@ -7,13 +7,12 @@ use sqlx::{
|
||||
encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type,
|
||||
};
|
||||
use std::fmt;
|
||||
use teloxide::types::ChatId;
|
||||
|
||||
/// Type-safe wrapper for user IDs
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct UserId(i64);
|
||||
pub struct UserRowId(i64);
|
||||
|
||||
impl UserId {
|
||||
impl UserRowId {
|
||||
/// Create a new UserId from an i64
|
||||
pub fn new(id: i64) -> Self {
|
||||
Self(id)
|
||||
@@ -25,26 +24,26 @@ impl UserId {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i64> for UserId {
|
||||
impl From<i64> for UserRowId {
|
||||
fn from(id: i64) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserId> for i64 {
|
||||
fn from(user_id: UserId) -> Self {
|
||||
impl From<UserRowId> for i64 {
|
||||
fn from(user_id: UserRowId) -> Self {
|
||||
user_id.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for UserId {
|
||||
impl fmt::Display for UserRowId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
// SQLx implementations for database compatibility
|
||||
impl Type<Sqlite> for UserId {
|
||||
impl Type<Sqlite> for UserRowId {
|
||||
fn type_info() -> SqliteTypeInfo {
|
||||
<i64 as Type<Sqlite>>::type_info()
|
||||
}
|
||||
@@ -54,7 +53,7 @@ impl Type<Sqlite> for UserId {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'q> Encode<'q, Sqlite> for UserId {
|
||||
impl<'q> Encode<'q, Sqlite> for UserRowId {
|
||||
fn encode_by_ref(
|
||||
&self,
|
||||
args: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
|
||||
@@ -63,7 +62,7 @@ impl<'q> Encode<'q, Sqlite> for UserId {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'r> Decode<'r, Sqlite> for UserId {
|
||||
impl<'r> Decode<'r, Sqlite> for UserRowId {
|
||||
fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
|
||||
let id = <i64 as Decode<'r, Sqlite>>::decode(value)?;
|
||||
Ok(Self(id))
|
||||
@@ -15,7 +15,7 @@ macro_rules! keyboard_buttons {
|
||||
$($variant:ident($text:literal, $callback_data:literal),)*
|
||||
]),*
|
||||
}) => {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
$vis enum $name {
|
||||
$(
|
||||
$($variant,)*
|
||||
@@ -35,10 +35,10 @@ macro_rules! keyboard_buttons {
|
||||
markup
|
||||
}
|
||||
}
|
||||
impl Into<teloxide::types::InlineKeyboardButton> for $name {
|
||||
fn into(self) -> teloxide::types::InlineKeyboardButton {
|
||||
match self {
|
||||
$($(Self::$variant => teloxide::types::InlineKeyboardButton::callback($text, $callback_data)),*),*
|
||||
impl From<$name> for teloxide::types::InlineKeyboardButton {
|
||||
fn from(value: $name) -> Self {
|
||||
match value {
|
||||
$($($name::$variant => teloxide::types::InlineKeyboardButton::callback($text, $callback_data)),*),*
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,8 +60,6 @@ macro_rules! keyboard_buttons {
|
||||
mod tests {
|
||||
use teloxide::types::{InlineKeyboardButton, InlineKeyboardButtonKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
keyboard_buttons! {
|
||||
pub enum DurationKeyboardButtons {
|
||||
OneDay("1 day", "duration_1_day"),
|
||||
|
||||
93
src/main.rs
93
src/main.rs
@@ -1,23 +1,45 @@
|
||||
mod commands;
|
||||
mod config;
|
||||
mod db;
|
||||
mod dptree_utils;
|
||||
mod keyboard_utils;
|
||||
mod message_utils;
|
||||
mod sqlite_storage;
|
||||
|
||||
use anyhow::Result;
|
||||
use log::info;
|
||||
use teloxide::dispatching::dialogue::serializer::Json;
|
||||
use teloxide::{prelude::*, utils::command::BotCommands};
|
||||
#[cfg(test)]
|
||||
mod test_utils;
|
||||
|
||||
use crate::commands::{
|
||||
my_listings::{my_listings_handler, MyListingsState},
|
||||
new_listing::{new_listing_handler, NewListingState},
|
||||
};
|
||||
use crate::sqlite_storage::SqliteStorage;
|
||||
use anyhow::Result;
|
||||
use commands::*;
|
||||
use config::Config;
|
||||
|
||||
use crate::commands::new_listing::new_listing_handler;
|
||||
use crate::sqlite_storage::SqliteStorage;
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use teloxide::dispatching::{dialogue::serializer::Json, DpHandlerDescription};
|
||||
use teloxide::{prelude::*, types::BotCommand, utils::command::BotCommands};
|
||||
|
||||
pub type HandlerResult<T = ()> = anyhow::Result<T>;
|
||||
pub type Handler = dptree::Handler<'static, HandlerResult, DpHandlerDescription>;
|
||||
|
||||
/// Set up the bot's command menu that appears when users tap the menu button
|
||||
async fn setup_bot_commands(bot: &Bot) -> Result<()> {
|
||||
info!("Setting up bot command menu...");
|
||||
|
||||
// Convert our Command enum to Telegram BotCommand structs
|
||||
let commands: Vec<BotCommand> = Command::bot_commands()
|
||||
.into_iter()
|
||||
.map(|cmd| BotCommand::new(cmd.command, cmd.description))
|
||||
.collect();
|
||||
|
||||
// Set the commands for the bot's menu
|
||||
bot.set_my_commands(commands).await?;
|
||||
info!("Bot command menu configured successfully");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(BotCommands, Clone)]
|
||||
#[command(rename_rule = "lowercase", description = "Auction Bot Commands")]
|
||||
@@ -36,6 +58,16 @@ pub enum Command {
|
||||
Settings,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
enum DialogueRootState {
|
||||
#[default]
|
||||
Start,
|
||||
NewListing(NewListingState),
|
||||
MyListings(MyListingsState),
|
||||
}
|
||||
|
||||
type RootDialogue = Dialogue<DialogueRootState, SqliteStorage<Json>>;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Load and validate configuration from environment/.env file
|
||||
@@ -47,28 +79,51 @@ async fn main() -> Result<()> {
|
||||
info!("Starting Pawctioneer Bot...");
|
||||
let bot = Bot::new(&config.telegram_token);
|
||||
|
||||
// Set up the bot's command menu
|
||||
setup_bot_commands(&bot).await?;
|
||||
|
||||
let dialog_storage = SqliteStorage::new(db_pool.clone(), Json).await?;
|
||||
|
||||
// Create dispatcher with dialogue system
|
||||
Dispatcher::builder(
|
||||
bot,
|
||||
dptree::entry().branch(new_listing_handler()).branch(
|
||||
Update::filter_message().branch(
|
||||
dptree::entry()
|
||||
.filter_command::<Command>()
|
||||
.branch(dptree::case![Command::Start].endpoint(handle_start))
|
||||
.branch(dptree::case![Command::Help].endpoint(handle_help))
|
||||
.branch(dptree::case![Command::MyListings].endpoint(handle_my_listings))
|
||||
.branch(dptree::case![Command::MyBids].endpoint(handle_my_bids))
|
||||
.branch(dptree::case![Command::Settings].endpoint(handle_settings)),
|
||||
),
|
||||
),
|
||||
dptree::entry()
|
||||
.enter_dialogue::<Update, SqliteStorage<Json>, DialogueRootState>()
|
||||
.branch(new_listing_handler())
|
||||
.branch(my_listings_handler())
|
||||
.branch(
|
||||
Update::filter_message().branch(
|
||||
dptree::entry()
|
||||
.filter_command::<Command>()
|
||||
.branch(dptree::case![Command::Start].endpoint(handle_start))
|
||||
.branch(dptree::case![Command::Help].endpoint(handle_help))
|
||||
.branch(dptree::case![Command::MyBids].endpoint(handle_my_bids))
|
||||
.branch(dptree::case![Command::Settings].endpoint(handle_settings)),
|
||||
),
|
||||
)
|
||||
.branch(Update::filter_message().endpoint(unknown_message_handler)),
|
||||
)
|
||||
.dependencies(dptree::deps![db_pool, dialog_storage])
|
||||
.enable_ctrlc_handler()
|
||||
.worker_queue_size(1)
|
||||
.build()
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn unknown_message_handler(bot: Bot, msg: Message) -> HandlerResult {
|
||||
bot.send_message(
|
||||
msg.chat.id,
|
||||
format!(
|
||||
"
|
||||
Unknown command: `{}`\n\n\
|
||||
Try /help to see the list of commands.\
|
||||
",
|
||||
msg.text().unwrap_or("")
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use crate::HandlerResult;
|
||||
use anyhow::bail;
|
||||
use num::One;
|
||||
use std::fmt::Display;
|
||||
use teloxide::{
|
||||
dispatching::dialogue::GetChatId,
|
||||
payloads::{EditMessageTextSetters as _, SendMessageSetters as _},
|
||||
prelude::Requester as _,
|
||||
types::{
|
||||
@@ -12,8 +12,6 @@ use teloxide::{
|
||||
Bot,
|
||||
};
|
||||
|
||||
use crate::HandlerResult;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct HandleAndId<'s> {
|
||||
pub handle: Option<&'s str>,
|
||||
@@ -41,56 +39,97 @@ impl<'s> HandleAndId<'s> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'s> Into<HandleAndId<'s>> for &'s User {
|
||||
fn into(self) -> HandleAndId<'s> {
|
||||
HandleAndId::from_user(self)
|
||||
impl<'s> From<&'s User> for HandleAndId<'s> {
|
||||
fn from(val: &'s User) -> Self {
|
||||
HandleAndId::from_user(val)
|
||||
}
|
||||
}
|
||||
impl<'s> Into<HandleAndId<'s>> for &'s Chat {
|
||||
fn into(self) -> HandleAndId<'s> {
|
||||
HandleAndId::from_chat(self)
|
||||
impl<'s> From<&'s Chat> for HandleAndId<'s> {
|
||||
fn from(val: &'s Chat) -> Self {
|
||||
HandleAndId::from_chat(val)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_cancel_or_no(text: &str) -> bool {
|
||||
is_cancel(text) || text.eq_ignore_ascii_case("no")
|
||||
}
|
||||
|
||||
pub fn is_cancel(text: &str) -> bool {
|
||||
text.eq_ignore_ascii_case("/cancel")
|
||||
}
|
||||
|
||||
// Unified HTML message sending utility
|
||||
pub async fn send_html_message(
|
||||
bot: &Bot,
|
||||
chat: impl Into<HandleAndId<'_>>,
|
||||
text: &str,
|
||||
keyboard: Option<InlineKeyboardMarkup>,
|
||||
) -> HandlerResult {
|
||||
let chat = chat.into();
|
||||
let mut message = bot.send_message(chat.id, text).parse_mode(ParseMode::Html);
|
||||
if let Some(kb) = keyboard {
|
||||
message = message.reply_markup(kb);
|
||||
}
|
||||
message.await?;
|
||||
Ok(())
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageTarget {
|
||||
pub chat_id: ChatId,
|
||||
pub message_id: Option<MessageId>,
|
||||
}
|
||||
|
||||
pub async fn edit_html_message(
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unified HTML message sending utility
|
||||
pub async fn send_message(
|
||||
bot: &Bot,
|
||||
chat: impl Into<HandleAndId<'_>>,
|
||||
message_id: MessageId,
|
||||
text: &str,
|
||||
message_target: impl Into<MessageTarget>,
|
||||
text: impl AsRef<str>,
|
||||
keyboard: Option<InlineKeyboardMarkup>,
|
||||
) -> HandlerResult {
|
||||
let chat = chat.into();
|
||||
let mut edit_request = bot
|
||||
.edit_message_text(chat.id, message_id, text)
|
||||
.parse_mode(ParseMode::Html);
|
||||
if let Some(kb) = keyboard {
|
||||
edit_request = edit_request.reply_markup(kb);
|
||||
let message_target = message_target.into();
|
||||
if let Some(message_id) = message_target.message_id {
|
||||
let mut message = bot
|
||||
.edit_message_text(message_target.chat_id, message_id, text.as_ref())
|
||||
.parse_mode(ParseMode::Html);
|
||||
if let Some(kb) = keyboard {
|
||||
message = message.reply_markup(kb);
|
||||
}
|
||||
message.await?;
|
||||
} else {
|
||||
let mut message = bot
|
||||
.send_message(message_target.chat_id, text.as_ref())
|
||||
.parse_mode(ParseMode::Html);
|
||||
if let Some(kb) = keyboard {
|
||||
message = message.reply_markup(kb);
|
||||
}
|
||||
message.await?;
|
||||
}
|
||||
edit_request.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -125,36 +164,48 @@ pub fn create_multi_row_keyboard(rows: &[&[(&str, &str)]]) -> InlineKeyboardMark
|
||||
keyboard
|
||||
}
|
||||
|
||||
// Create numeric option keyboard (common pattern for slots, duration, etc.)
|
||||
pub fn create_numeric_options_keyboard(
|
||||
options: &[(i32, &str)],
|
||||
prefix: &str,
|
||||
) -> InlineKeyboardMarkup {
|
||||
let buttons: Vec<InlineKeyboardButton> = options
|
||||
.iter()
|
||||
.map(|(value, label)| {
|
||||
InlineKeyboardButton::callback(*label, format!("{}_{}", prefix, value))
|
||||
})
|
||||
.collect();
|
||||
InlineKeyboardMarkup::new([buttons])
|
||||
}
|
||||
|
||||
// Extract callback data and answer callback query
|
||||
pub async fn extract_callback_data(
|
||||
bot: &Bot,
|
||||
callback_query: &CallbackQuery,
|
||||
) -> HandlerResult<(String, User)> {
|
||||
let data = match callback_query.data.as_deref() {
|
||||
Some(data) => data.to_string(),
|
||||
callback_query: CallbackQuery,
|
||||
) -> HandlerResult<(String, User, MessageId)> {
|
||||
let data = match callback_query.data {
|
||||
Some(data) => data,
|
||||
None => bail!("Missing data in callback query"),
|
||||
};
|
||||
|
||||
let from = callback_query.from.clone();
|
||||
|
||||
let message_id = if let Some(m) = callback_query.message {
|
||||
m.id()
|
||||
} else {
|
||||
bail!("Missing message in callback query")
|
||||
};
|
||||
|
||||
// Answer the callback query to remove loading state
|
||||
if let Err(e) = bot.answer_callback_query(callback_query.id.clone()).await {
|
||||
log::warn!("Failed to answer callback query: {}", e);
|
||||
log::warn!("Failed to answer callback query: {e}");
|
||||
}
|
||||
|
||||
Ok((data, from))
|
||||
Ok((data, from, message_id))
|
||||
}
|
||||
|
||||
pub fn pluralize<'a, N: One + PartialEq<N>>(
|
||||
count: N,
|
||||
singular: &'a str,
|
||||
plural: &'a str,
|
||||
) -> &'a str {
|
||||
if count == One::one() {
|
||||
singular
|
||||
} else {
|
||||
plural
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pluralize_with_count<N: One + PartialEq<N> + Display + Copy>(
|
||||
count: N,
|
||||
singular: &str,
|
||||
plural: &str,
|
||||
) -> String {
|
||||
format!("{} {}", count, pluralize(count, singular, plural))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ use sqlx::{sqlite::SqlitePool, Executor};
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
fmt::{Debug, Display},
|
||||
str,
|
||||
sync::Arc,
|
||||
};
|
||||
use teloxide::dispatching::dialogue::{serializer::Serializer, Storage};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Test utilities including timestamp comparison macros
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
|
||||
/// Assert that two timestamps are approximately equal within a given epsilon tolerance.
|
||||
///
|
||||
/// This macro is useful for testing timestamps that may have small variations due to
|
||||
|
||||
Reference in New Issue
Block a user