Compare commits

...

3 Commits

Author SHA1 Message Date
Dylan Knutson
cf02bfd6d7 Major refactor: restructure new listing command and update data models
- Refactor new_listing from single file to modular structure
- Add handler factory pattern for state management
- Improve keyboard utilities and validations
- Update database models for bid, listing, and user systems
- Add new types: listing_duration, user_row_id
- Remove deprecated user_id type
- Update Docker configuration
- Enhance test utilities and message handling
2025-08-29 06:31:19 +00:00
Dylan Knutson
a81308d577 refactor: improve case! macro tests with rstest parametrization
- Fix failing tests by correcting test expectations for parameter extraction
- Add comprehensive coverage including InnerWithMultiStruct variant
- Reduce test duplication using rstest parametrization
- Create assert_handler_result helper function to eliminate repetitive code
- Consolidate similar test patterns into unified test functions
- Ensure all macro functionality is properly tested with clear test case names

All 20 parameterized test cases now pass, covering:
- Simple variant filtering
- Single/multiple parameter extraction from enums
- Nested enum parameter extraction
- Struct field extraction (single and multiple fields)
- Proper negative test cases for non-matching variants
2025-08-28 22:02:52 +00:00
Dylan Knutson
d4ccbb884c Refactor keyboard utilities and improve user interface
- Extract keyboard utility functions to new keyboard_utils.rs module
- Update new listing command with improved keyboard handling
- Enhance message utilities with better user interaction
- Refactor user ID type handling
- Remove development database file
- Update main.rs with improved structure
2025-08-28 20:01:15 +00:00
32 changed files with 2359 additions and 1937 deletions

View 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/*

View File

@@ -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
View File

@@ -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",

View File

@@ -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"

Binary file not shown.

View File

@@ -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

View File

@@ -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

View 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),
),
)
}

View 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"),
}
}

File diff suppressed because it is too large Load Diff

View 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)
}
}

View 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(),
),
}
}

View File

@@ -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 {

View File

@@ -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");

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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>,

View File

@@ -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)]

View File

@@ -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>,

View File

@@ -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,

View 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);
}
}

View File

@@ -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 {

View File

@@ -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::*;

View File

@@ -7,12 +7,13 @@ 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)
@@ -24,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()
}
@@ -53,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>>,
@@ -62,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))

276
src/dptree_utils.rs Normal file
View File

@@ -0,0 +1,276 @@
#[macro_export]
macro_rules! case {
// Basic variant matching without parameters
($($variant:ident)::+) => {
dptree::filter(|x| matches!(x, $($variant)::+))
};
// Single parameter extraction
($($variant:ident)::+ ($param:ident)) => {
dptree::filter_map(|x| match x {
$($variant)::+($param) => Some($param),
_ => None,
})
};
// Nested variant matching without parameter extraction
($($variant:ident)::+ ($($inner_variant:ident)::+)) => {
dptree::filter(|x| matches!(x, $($variant)::+($($inner_variant)::+)))
};
// Nested variant matching with single parameter extraction
($($variant:ident)::+ ($($inner_variant:ident)::+ ($param:ident))) => {
dptree::filter_map(|x| match x {
$($variant)::+($($inner_variant)::+($param)) => Some($param),
_ => None,
})
};
// Multiple parameter extraction (tuple destructuring)
($($variant:ident)::+ ($($param:ident),+ $(,)?)) => {
dptree::filter_map(|x| match x {
$($variant)::+($($param),+) => Some(($($param),+)),
_ => None,
})
};
// Nested variant matching with multiple parameter extraction
($($variant:ident)::+ ($($inner_variant:ident)::+ ($($param:ident),+ $(,)?))) => {
dptree::filter_map(|x| match x {
$($variant)::+($($inner_variant)::+($($param),+)) => Some(($($param),+)),
_ => None,
})
};
// Nested variant matching with struct pattern (single field)
($($variant:ident)::+ ($($inner_variant:ident)::+ { $field:ident })) => {
dptree::filter_map(|x| match x {
$($variant)::+($($inner_variant)::+ { $field }) => Some($field),
_ => None,
})
};
// Nested variant matching with struct pattern (multiple fields)
($($variant:ident)::+ ($($inner_variant:ident)::+ { $($field:ident),+ $(,)? })) => {
dptree::filter_map(|x| match x {
$($variant)::+($($inner_variant)::+ { $($field),+ }) => Some(($($field),+)),
_ => None,
})
};
// Nested variant matching with struct pattern without extraction (filter only)
($($variant:ident)::+ ($($inner_variant:ident)::+ { .. })) => {
dptree::filter(|x| matches!(x, $($variant)::+($($inner_variant)::+ { .. })))
};
// Triple-nested variant matching with struct pattern
($($variant:ident)::+ ($($inner1:ident)::+ ($($inner2:ident)::+ { $($field:ident),+ $(,)? }))) => {
dptree::filter_map(|x| match x {
$($variant)::+($($inner1)::+($($inner2)::+ { $($field),+ })) => Some(($($field),+)),
_ => None,
})
};
}
#[cfg(test)]
mod tests {
use std::ops::ControlFlow;
use teloxide::{
dispatching::DpHandlerDescription,
dptree::{self, deps, Handler},
};
// Test enums to verify macro functionality
#[derive(Debug, Clone, PartialEq, Default)]
enum TestEnum {
#[default]
DefaultVariant,
SimpleVariant,
SingleParam(&'static str),
MultipleParams(&'static str, i32),
NestedVariant(InnerEnum),
}
#[derive(Debug, Clone, PartialEq)]
enum InnerEnum {
InnerSimple,
InnerWithParam(&'static str),
InnerWithMultiple(&'static str, i32),
InnerWithStruct { field: &'static str },
InnerWithMultiStruct { field: &'static str, number: i32 },
}
// Helper function for testing handlers with expected results
async fn assert_handler_result<T>(
handler: Handler<'static, T, DpHandlerDescription>,
input_variant: TestEnum,
expected: Option<T>,
) where
T: std::fmt::Debug + PartialEq + 'static,
{
let input = deps![input_variant];
let result = handler.dispatch(input).await;
match expected {
Some(expected_value) => assert_eq!(result, ControlFlow::Break(expected_value)),
None => assert!(matches!(result, ControlFlow::Continue(_))),
}
}
// Comprehensive tests for single value extraction (strings)
#[rstest::rstest]
// Simple variant filtering (no parameter extraction)
#[case::simple_variant_match(
case![TestEnum::SimpleVariant].endpoint(|| async { "matched" }),
TestEnum::SimpleVariant,
Some("matched")
)]
#[case::simple_variant_no_match(
case![TestEnum::SimpleVariant].endpoint(|| async { "matched" }),
TestEnum::DefaultVariant,
None
)]
// Single parameter extraction from simple enum
#[case::single_param_match(
case![TestEnum::SingleParam(p)].endpoint(|p: &'static str| async move { p }),
TestEnum::SingleParam("extracted"),
Some("extracted")
)]
#[case::single_param_no_match(
case![TestEnum::SingleParam(p)].endpoint(|p: &'static str| async move { p }),
TestEnum::DefaultVariant,
None
)]
// Single parameter extraction from nested enum
#[case::nested_single_match(
case![TestEnum::NestedVariant(InnerEnum::InnerWithParam(p))].endpoint(|p: &'static str| async move { p }),
TestEnum::NestedVariant(InnerEnum::InnerWithParam("nested")),
Some("nested")
)]
#[case::nested_single_wrong_inner_variant(
case![TestEnum::NestedVariant(InnerEnum::InnerWithParam(p))].endpoint(|p: &'static str| async move { p }),
TestEnum::NestedVariant(InnerEnum::InnerSimple),
None
)]
#[case::nested_single_wrong_outer_variant(
case![TestEnum::NestedVariant(InnerEnum::InnerWithParam(p))].endpoint(|p: &'static str| async move { p }),
TestEnum::DefaultVariant,
None
)]
// Single field extraction from nested struct
#[case::struct_field_match(
case![TestEnum::NestedVariant(InnerEnum::InnerWithStruct { field })].endpoint(|field: &'static str| async move { field }),
TestEnum::NestedVariant(InnerEnum::InnerWithStruct { field: "struct_value" }),
Some("struct_value")
)]
#[case::struct_field_no_match(
case![TestEnum::NestedVariant(InnerEnum::InnerWithStruct { field })].endpoint(|field: &'static str| async move { field }),
TestEnum::NestedVariant(InnerEnum::InnerSimple),
None
)]
#[tokio::test]
async fn test_single_value_extraction(
#[case] handler: Handler<'static, &'static str, DpHandlerDescription>,
#[case] input_variant: TestEnum,
#[case] expected: Option<&'static str>,
) {
assert_handler_result(handler, input_variant, expected).await;
}
// Test cases for multiple parameter extraction
#[rstest::rstest]
#[case(TestEnum::MultipleParams("multi", 42), Some(("multi", 42)))]
#[case(TestEnum::DefaultVariant, None)]
#[tokio::test]
async fn test_multiple_parameter_extraction(
#[case] input_variant: TestEnum,
#[case] expected_params: Option<(&'static str, i32)>,
) {
let handler: Handler<'static, (&str, i32), DpHandlerDescription> =
case![TestEnum::MultipleParams(s, n)]
.endpoint(|params: (&'static str, i32)| async move { params });
let input = deps![input_variant];
let result = handler.dispatch(input).await;
match expected_params {
Some(params) => assert_eq!(result, ControlFlow::Break(params)),
None => assert!(matches!(result, ControlFlow::Continue(_))),
}
}
// Test cases for nested multiple parameter extraction
#[rstest::rstest]
#[case(TestEnum::NestedVariant(InnerEnum::InnerWithMultiple("nested", 123)), Some(("nested", 123)))]
#[case(TestEnum::NestedVariant(InnerEnum::InnerSimple), None)]
#[case(TestEnum::DefaultVariant, None)]
#[tokio::test]
async fn test_nested_multiple_parameter_extraction(
#[case] input_variant: TestEnum,
#[case] expected_params: Option<(&'static str, i32)>,
) {
let handler: Handler<'static, (&str, i32), DpHandlerDescription> =
case![TestEnum::NestedVariant(InnerEnum::InnerWithMultiple(s, n))]
.endpoint(|params: (&'static str, i32)| async move { params });
let input = deps![input_variant];
let result = handler.dispatch(input).await;
match expected_params {
Some(params) => assert_eq!(result, ControlFlow::Break(params)),
None => assert!(matches!(result, ControlFlow::Continue(_))),
}
}
// Test cases for struct pattern extraction
#[rstest::rstest]
#[case(TestEnum::NestedVariant(InnerEnum::InnerWithStruct { field: "struct_field" }), Some("struct_field"))]
#[case(TestEnum::NestedVariant(InnerEnum::InnerSimple), None)]
#[case(TestEnum::DefaultVariant, None)]
#[tokio::test]
async fn test_struct_pattern_extraction(
#[case] input_variant: TestEnum,
#[case] expected_field: Option<&'static str>,
) {
let handler: Handler<'static, &str, DpHandlerDescription> =
case![TestEnum::NestedVariant(InnerEnum::InnerWithStruct {
field
})]
.endpoint(|field: &'static str| async move { field });
let input = deps![input_variant];
let result = handler.dispatch(input).await;
match expected_field {
Some(field) => assert_eq!(result, ControlFlow::Break(field)),
None => assert!(matches!(result, ControlFlow::Continue(_))),
}
}
// Test cases for multi-field struct pattern extraction
#[rstest::rstest]
#[case(TestEnum::NestedVariant(InnerEnum::InnerWithMultiStruct { field: "multi_field", number: 42 }), Some(("multi_field", 42)))]
#[case(TestEnum::NestedVariant(InnerEnum::InnerSimple), None)]
#[case(TestEnum::DefaultVariant, None)]
#[tokio::test]
async fn test_multi_struct_pattern_extraction(
#[case] input_variant: TestEnum,
#[case] expected_fields: Option<(&'static str, i32)>,
) {
let handler: Handler<'static, (&str, i32), DpHandlerDescription> =
case![TestEnum::NestedVariant(InnerEnum::InnerWithMultiStruct {
field,
number
})]
.endpoint(|fields: (&'static str, i32)| async move { fields });
let input = deps![input_variant];
let result = handler.dispatch(input).await;
match expected_fields {
Some(fields) => assert_eq!(result, ControlFlow::Break(fields)),
None => assert!(matches!(result, ControlFlow::Continue(_))),
}
}
}

81
src/keyboard_utils.rs Normal file
View File

@@ -0,0 +1,81 @@
#[macro_export]
macro_rules! keyboard_buttons {
($vis:vis enum $name:ident {
$($variant:ident($text:literal, $callback_data:literal),)*
}) => {
keyboard_buttons! {
$vis enum $name {
[$($variant($text, $callback_data),)*]
}
}
};
($vis:vis enum $name:ident {
$([
$($variant:ident($text:literal, $callback_data:literal),)*
]),*
}) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
$vis enum $name {
$(
$($variant,)*
)*
}
impl $name {
#[allow(unused)]
pub fn to_keyboard() -> teloxide::types::InlineKeyboardMarkup {
let markup = teloxide::types::InlineKeyboardMarkup::default();
$(
let markup = markup.append_row([
$(
teloxide::types::InlineKeyboardButton::callback($text, $callback_data),
)*
]);
)*
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<'a> TryFrom<&'a str> for $name {
type Error = &'a str;
fn try_from(value: &'a str) -> Result<Self, Self::Error> {
match value {
$($(
$callback_data => Ok(Self::$variant),
)*)*
_ => Err(value),
}
}
}
};
}
#[cfg(test)]
mod tests {
use teloxide::types::{InlineKeyboardButton, InlineKeyboardButtonKind};
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"),
}
}
#[test]
fn test_duration_keyboard_buttons() {
let button: InlineKeyboardButton = DurationKeyboardButtons::OneDay.into();
assert_eq!(button.text, "1 day");
assert_eq!(
button.kind,
InlineKeyboardButtonKind::CallbackData("duration_1_day".to_string())
);
}
}

View File

@@ -1,22 +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 log::info;
use serde::{Deserialize, Serialize};
use teloxide::dispatching::{dialogue::serializer::Json, DpHandlerDescription};
use teloxide::{prelude::*, types::BotCommand, utils::command::BotCommands};
use crate::commands::new_listing::new_listing_handler;
use crate::sqlite_storage::SqliteStorage;
pub type HandlerResult<T = ()> = anyhow::Result<T>;
pub type Handler = dptree::Handler<'static, HandlerResult, DpHandlerDescription>;
pub type HandlerResult = anyhow::Result<()>;
/// 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")]
@@ -35,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
@@ -46,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(())
}

View File

@@ -1,79 +1,211 @@
use crate::HandlerResult;
use anyhow::bail;
use num::One;
use std::fmt::Display;
use teloxide::{
payloads::{EditMessageTextSetters as _, SendMessageSetters as _},
prelude::Requester as _,
types::{Chat, ChatId, InlineKeyboardMarkup, MessageId, ParseMode, User},
types::{
CallbackQuery, Chat, ChatId, InlineKeyboardButton, InlineKeyboardMarkup, Message,
MessageId, ParseMode, User,
},
Bot,
};
use crate::HandlerResult;
pub struct UserHandleAndId<'s> {
#[derive(Debug, Clone, Copy)]
pub struct HandleAndId<'s> {
pub handle: Option<&'s str>,
pub id: Option<i64>,
pub id: ChatId,
}
impl<'s> Display for UserHandleAndId<'s> {
impl<'s> Display for HandleAndId<'s> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} ({})",
self.handle.unwrap_or("unknown"),
self.id.unwrap_or(-1)
)
write!(f, "{}", self.handle.unwrap_or("unknown"))?;
write!(f, " ({})", self.id.0)?;
Ok(())
}
}
impl<'s> UserHandleAndId<'s> {
impl<'s> HandleAndId<'s> {
pub fn from_chat(chat: &'s Chat) -> Self {
Self {
handle: chat.username(),
id: Some(chat.id.0),
id: chat.id,
}
}
pub fn from_user(user: &'s User) -> Self {
Self {
handle: user.username.as_deref(),
id: Some(user.id.0 as i64),
id: user.id.into(),
}
}
}
pub fn is_cancel_or_no(text: &str) -> bool {
is_cancel(text) || text.eq_ignore_ascii_case("no")
impl<'s> Into<HandleAndId<'s>> for &'s User {
fn into(self) -> HandleAndId<'s> {
HandleAndId::from_user(self)
}
}
impl<'s> Into<HandleAndId<'s>> for &'s Chat {
fn into(self) -> HandleAndId<'s> {
HandleAndId::from_chat(self)
}
}
pub fn is_cancel(text: &str) -> bool {
text.eq_ignore_ascii_case("/cancel")
}
#[derive(Debug, Clone)]
pub struct MessageTarget {
pub chat_id: ChatId,
pub message_id: Option<MessageId>,
}
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_html_message(
pub async fn send_message(
bot: &Bot,
chat_id: ChatId,
text: &str,
message_target: impl Into<MessageTarget>,
text: impl AsRef<str>,
keyboard: Option<InlineKeyboardMarkup>,
) -> HandlerResult {
let mut message = bot.send_message(chat_id, text).parse_mode(ParseMode::Html);
if let Some(kb) = keyboard {
message = message.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?;
}
message.await?;
Ok(())
}
pub async fn edit_html_message(
bot: &Bot,
chat_id: ChatId,
message_id: MessageId,
text: &str,
keyboard: Option<InlineKeyboardMarkup>,
) -> HandlerResult {
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);
}
edit_request.await?;
Ok(())
// ============================================================================
// KEYBOARD CREATION UTILITIES
// ============================================================================
// Create a simple single-button keyboard
pub fn create_single_button_keyboard(text: &str, callback_data: &str) -> InlineKeyboardMarkup {
InlineKeyboardMarkup::new([[InlineKeyboardButton::callback(text, callback_data)]])
}
// Create a keyboard with multiple buttons in a single row
pub fn create_single_row_keyboard(buttons: &[(&str, &str)]) -> InlineKeyboardMarkup {
let keyboard_buttons: Vec<InlineKeyboardButton> = buttons
.iter()
.map(|(text, callback_data)| InlineKeyboardButton::callback(*text, *callback_data))
.collect();
InlineKeyboardMarkup::new([keyboard_buttons])
}
// Create a keyboard with multiple rows
pub fn create_multi_row_keyboard(rows: &[&[(&str, &str)]]) -> InlineKeyboardMarkup {
let mut keyboard = InlineKeyboardMarkup::default();
for row in rows {
let buttons: Vec<InlineKeyboardButton> = row
.iter()
.map(|(text, callback_data)| InlineKeyboardButton::callback(*text, *callback_data))
.collect();
keyboard = keyboard.append_row(buttons);
}
keyboard
}
// Extract callback data and answer callback query
pub async fn extract_callback_data<'c>(
bot: &Bot,
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, 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))
}

View File

@@ -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};

View File

@@ -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