Compare commits
1 Commits
main
...
cf02bfd6d7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,12 @@
|
||||
{
|
||||
"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",
|
||||
"image": {
|
||||
"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 +24,7 @@
|
||||
"eamodio.gitlens"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
// Use 'mounts' to make the cargo cache persistent in a Docker Volume.
|
||||
// "mounts": [
|
||||
// {
|
||||
@@ -33,7 +38,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,270 @@
|
||||
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, HandleAndId, MessageTarget,
|
||||
},
|
||||
Command, DialogueRootState, HandlerResult, RootDialogue,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
use teloxide::{
|
||||
dispatching::{DpHandlerDescription, UpdateFilterExt},
|
||||
prelude::*,
|
||||
types::{InlineKeyboardButton, Message, MessageId, ParseMode},
|
||||
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"),
|
||||
}
|
||||
}
|
||||
1070
src/commands/new_listing/mod.rs
Normal file
1070
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 slots >= 1 && slots <= 1000 => 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 hours >= 1 && hours <= 720 => 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 hours >= 0 && hours <= 168 => 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(),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -121,7 +118,7 @@ impl ListingDAO {
|
||||
}
|
||||
|
||||
/// 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)
|
||||
@@ -134,37 +131,6 @@ impl ListingDAO {
|
||||
.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<_>>>()?)
|
||||
}
|
||||
|
||||
/// Delete a listing
|
||||
pub async fn delete_listing(pool: &SqlitePool, listing_id: ListingId) -> Result<()> {
|
||||
sqlx::query("DELETE FROM listings WHERE id = ?")
|
||||
@@ -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,11 +5,9 @@
|
||||
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
|
||||
@@ -35,7 +33,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 +47,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 +81,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 +96,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)
|
||||
@@ -131,6 +134,7 @@ mod tests {
|
||||
use crate::db::models::user::{NewUser, User};
|
||||
use rstest::rstest;
|
||||
use sqlx::SqlitePool;
|
||||
use teloxide::types::UserId;
|
||||
|
||||
/// Create test database for UserDAO tests
|
||||
async fn create_test_pool() -> SqlitePool {
|
||||
@@ -177,7 +181,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 +312,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,7 +10,7 @@
|
||||
//! 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
|
||||
@@ -24,7 +24,7 @@ pub struct Listing {
|
||||
#[derive(Debug, Clone)]
|
||||
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>,
|
||||
@@ -106,7 +106,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 +194,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,
|
||||
|
||||
@@ -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>,
|
||||
@@ -54,7 +54,7 @@ pub enum NewListingFields {
|
||||
|
||||
impl NewListingBase {
|
||||
pub fn new(
|
||||
seller_id: UserId,
|
||||
seller_id: UserRowId,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
starts_at: DateTime<Utc>,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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)]
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::FromRow;
|
||||
|
||||
use crate::db::{TelegramUserId, UserId};
|
||||
use crate::db::{TelegramUserId, UserRowId};
|
||||
|
||||
/// Core user information
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
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,
|
||||
|
||||
81
src/db/types/listing_duration.rs
Normal file
81
src/db/types/listing_duration.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
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)]
|
||||
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 Default for ListingDuration {
|
||||
fn default() -> Self {
|
||||
Self(0)
|
||||
}
|
||||
}
|
||||
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,14 @@
|
||||
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
|
||||
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::*;
|
||||
|
||||
@@ -11,9 +11,9 @@ 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 +25,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 +54,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 +63,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,)*
|
||||
@@ -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,19 +1,17 @@
|
||||
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::{
|
||||
CallbackQuery, Chat, ChatId, InlineKeyboardButton, InlineKeyboardMarkup, MessageId,
|
||||
ParseMode, User,
|
||||
CallbackQuery, Chat, ChatId, InlineKeyboardButton, InlineKeyboardMarkup, Message,
|
||||
MessageId, ParseMode, User,
|
||||
},
|
||||
Bot,
|
||||
};
|
||||
|
||||
use crate::HandlerResult;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct HandleAndId<'s> {
|
||||
pub handle: Option<&'s str>,
|
||||
@@ -52,45 +50,86 @@ impl<'s> Into<HandleAndId<'s>> for &'s Chat {
|
||||
}
|
||||
}
|
||||
|
||||
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 Into<MessageTarget> for ChatId {
|
||||
fn into(self) -> MessageTarget {
|
||||
MessageTarget {
|
||||
chat_id: self,
|
||||
message_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<MessageTarget> for Chat {
|
||||
fn into(self) -> MessageTarget {
|
||||
MessageTarget {
|
||||
chat_id: self.id,
|
||||
message_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<MessageTarget> for User {
|
||||
fn into(self) -> MessageTarget {
|
||||
MessageTarget {
|
||||
chat_id: self.id.into(),
|
||||
message_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<MessageTarget> for (User, MessageId) {
|
||||
fn into(self) -> MessageTarget {
|
||||
MessageTarget {
|
||||
chat_id: self.0.id.into(),
|
||||
message_id: Some(self.1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<MessageTarget> for (Chat, MessageId) {
|
||||
fn into(self) -> MessageTarget {
|
||||
MessageTarget {
|
||||
chat_id: self.0.id.into(),
|
||||
message_id: Some(self.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(
|
||||
pub async fn extract_callback_data<'c>(
|
||||
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);
|
||||
}
|
||||
|
||||
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<'a, 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