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
This commit is contained in:
Dylan Knutson
2025-08-29 06:31:19 +00:00
parent a81308d577
commit cf02bfd6d7
30 changed files with 1936 additions and 1546 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", "name": "Rust",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile // 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": { "features": {
"ghcr.io/braun-daniel/devcontainer-features/fzf:1": {}, "ghcr.io/braun-daniel/devcontainer-features/fzf:1": {},
"ghcr.io/stuartleeks/dev-container-features/shell-history:0": {}, "ghcr.io/stuartleeks/dev-container-features/shell-history:0": {},
@@ -19,7 +24,7 @@
"eamodio.gitlens" "eamodio.gitlens"
] ]
} }
} },
// Use 'mounts' to make the cargo cache persistent in a Docker Volume. // Use 'mounts' to make the cargo cache persistent in a Docker Volume.
// "mounts": [ // "mounts": [
// { // {
@@ -33,7 +38,7 @@
// Use 'forwardPorts' to make a list of ports inside the container available locally. // Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [], // "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created. // 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. // Configure tool-specific properties.
// "customizations": {}, // "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. // 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", "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]] [[package]]
name = "num-bigint-dig" name = "num-bigint-dig"
version = "0.8.4" version = "0.8.4"
@@ -1410,6 +1434,15 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
@@ -1436,6 +1469,17 @@ dependencies = [
"num-traits", "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]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -1551,6 +1595,7 @@ dependencies = [
"futures", "futures",
"lazy_static", "lazy_static",
"log", "log",
"num",
"rstest", "rstest",
"rust_decimal", "rust_decimal",
"serde", "serde",

View File

@@ -24,6 +24,7 @@ serde = "1.0.219"
futures = "0.3.31" futures = "0.3.31"
thiserror = "2.0.16" thiserror = "2.0.16"
teloxide-core = "0.13.0" teloxide-core = "0.13.0"
num = "0.4.3"
[dev-dependencies] [dev-dependencies]
rstest = "0.26.1" rstest = "0.26.1"

View File

@@ -5,11 +5,7 @@ pub mod new_listing;
pub mod settings; pub mod settings;
pub mod start; pub mod start;
// Re-export all command handlers for easy access
pub use help::handle_help; pub use help::handle_help;
pub use my_bids::handle_my_bids; pub use my_bids::handle_my_bids;
pub use my_listings::handle_my_listings;
pub use settings::handle_settings; pub use settings::handle_settings;
pub use start::handle_start; 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 crate::{
use teloxide::{prelude::*, types::Message, Bot}; 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 { keyboard_buttons! {
let response = "📊 My Listings and Auctions (Coming Soon)\n\n\ enum ManageListingButtons {
Here you'll be able to view and manage:\n\ [
• Your active listings and auctions\n\ Edit("✏️ Edit", "manage_listing_edit"),
• Listing performance\n\ Delete("🗑️ Delete", "manage_listing_delete"),
• Bid history\n\ ],
• Winner selection (for blind auctions)\n\n\ [
Feature in development! 🔧"; Back("⬅️ Back", "manage_listing_back"),
]
}
}
info!( pub fn my_listings_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> {
"User {} ({}) checked their listings", dptree::entry()
msg.chat.username().unwrap_or("unknown"), .branch(
msg.chat.id 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(()) 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, ListingBase, ListingFields,
}; };
use super::super::{ use super::super::{Listing, ListingId, ListingType, UserRowId};
listing_id::ListingId, models::listing::Listing, models::listing_type::ListingType,
user_id::UserId,
};
/// Data Access Object for Listing operations /// Data Access Object for Listing operations
pub struct ListingDAO; pub struct ListingDAO;
@@ -121,7 +118,7 @@ impl ListingDAO {
} }
/// Find all listings by a seller /// 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 = let rows =
sqlx::query("SELECT * FROM listings WHERE seller_id = ? ORDER BY created_at DESC") sqlx::query("SELECT * FROM listings WHERE seller_id = ? ORDER BY created_at DESC")
.bind(seller_id) .bind(seller_id)
@@ -134,37 +131,6 @@ impl ListingDAO {
.collect::<Result<Vec<_>>>()?) .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 /// Delete a listing
pub async fn delete_listing(pool: &SqlitePool, listing_id: ListingId) -> Result<()> { pub async fn delete_listing(pool: &SqlitePool, listing_id: ListingId) -> Result<()> {
sqlx::query("DELETE FROM listings WHERE id = ?") sqlx::query("DELETE FROM listings WHERE id = ?")
@@ -175,25 +141,6 @@ impl ListingDAO {
Ok(()) 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> { fn row_to_listing(row: SqliteRow) -> Result<Listing> {
let listing_type = row.get("listing_type"); let listing_type = row.get("listing_type");
let base = ListingBase { let base = ListingBase {

View File

@@ -5,11 +5,9 @@
use anyhow::Result; use anyhow::Result;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use crate::db::TelegramUserId; use crate::db::{
use super::super::{
models::user::{NewUser, User}, models::user::{NewUser, User},
user_id::UserId, TelegramUserId, UserRowId,
}; };
/// Data Access Object for User operations /// Data Access Object for User operations
@@ -35,7 +33,7 @@ impl UserDAO {
} }
/// Find a user by their ID /// 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>( let user = sqlx::query_as::<_, User>(
"SELECT id, telegram_id, username, display_name, is_banned, created_at, updated_at FROM users WHERE id = ?" "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 /// Find a user by their Telegram ID
pub async fn find_by_telegram_id( pub async fn find_by_telegram_id(
pool: &SqlitePool, pool: &SqlitePool,
telegram_id: TelegramUserId, telegram_id: impl Into<TelegramUserId>,
) -> Result<Option<User>> { ) -> Result<Option<User>> {
let telegram_id = telegram_id.into();
let user = sqlx::query_as::<_, User>( let user = sqlx::query_as::<_, User>(
"SELECT id, telegram_id, username, display_name, is_banned, created_at, updated_at FROM users WHERE telegram_id = ?" "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 /// 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 = ?") sqlx::query("UPDATE users SET is_banned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.bind(is_banned) // sqlx automatically converts bool to INTEGER for SQLite .bind(is_banned) // sqlx automatically converts bool to INTEGER for SQLite
.bind(user_id) .bind(user_id)
@@ -93,7 +96,7 @@ impl UserDAO {
} }
/// Delete a user (soft delete by setting is_banned = true might be better in production) /// Delete a user (soft delete by setting is_banned = true might be better in production)
pub async fn delete_user(pool: &SqlitePool, user_id: UserId) -> Result<()> { pub async fn delete_user(pool: &SqlitePool, user_id: UserRowId) -> Result<()> {
sqlx::query("DELETE FROM users WHERE id = ?") sqlx::query("DELETE FROM users WHERE id = ?")
.bind(user_id) .bind(user_id)
.execute(pool) .execute(pool)
@@ -131,6 +134,7 @@ mod tests {
use crate::db::models::user::{NewUser, User}; use crate::db::models::user::{NewUser, User};
use rstest::rstest; use rstest::rstest;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use teloxide::types::UserId;
/// Create test database for UserDAO tests /// Create test database for UserDAO tests
async fn create_test_pool() -> SqlitePool { async fn create_test_pool() -> SqlitePool {
@@ -177,7 +181,7 @@ mod tests {
assert_eq!(found_user.telegram_id, inserted_user.telegram_id); assert_eq!(found_user.telegram_id, inserted_user.telegram_id);
// Find by 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 .await
.expect("Failed to find user by telegram_id") .expect("Failed to find user by telegram_id")
.expect("User should be found"); .expect("User should be found");
@@ -308,13 +312,13 @@ mod tests {
let pool = create_test_pool().await; let pool = create_test_pool().await;
// Try to find a user that doesn't exist // Try to find a user that doesn't exist
let not_found = UserDAO::find_by_id(&pool, UserId::new(99999)) let not_found = UserDAO::find_by_id(&pool, UserRowId::new(99999))
.await .await
.expect("Database operation should succeed"); .expect("Database operation should succeed");
assert!(not_found.is_none()); 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 .await
.expect("Database operation should succeed"); .expect("Database operation should succeed");

View File

@@ -1,9 +1,10 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::FromRow; use sqlx::FromRow;
use crate::db::money_amount::MoneyAmount; use crate::db::MoneyAmount;
/// Actual bids placed on listings /// Actual bids placed on listings
#[allow(unused)]
#[derive(Debug, Clone, FromRow)] #[derive(Debug, Clone, FromRow)]
pub struct Bid { pub struct Bid {
pub id: i64, pub id: i64,
@@ -26,6 +27,7 @@ pub struct Bid {
} }
/// New bid data for insertion /// New bid data for insertion
#[allow(unused)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct NewBid { pub struct NewBid {
pub listing_id: i64, pub listing_id: i64,

View File

@@ -10,7 +10,7 @@
//! Database mapping is handled through `ListingRow` with conversion traits. //! Database mapping is handled through `ListingRow` with conversion traits.
use super::listing_type::ListingType; 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}; use chrono::{DateTime, Utc};
/// Main listing/auction entity /// Main listing/auction entity
@@ -24,7 +24,7 @@ pub struct Listing {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ListingBase { pub struct ListingBase {
pub id: ListingId, pub id: ListingId,
pub seller_id: UserId, pub seller_id: UserRowId,
pub title: String, pub title: String,
pub description: Option<String>, pub description: Option<String>,
pub starts_at: DateTime<Utc>, pub starts_at: DateTime<Utc>,
@@ -106,7 +106,7 @@ mod tests {
pool: &SqlitePool, pool: &SqlitePool,
telegram_id: TelegramUserId, telegram_id: TelegramUserId,
username: Option<&str>, username: Option<&str>,
) -> UserId { ) -> UserRowId {
use crate::db::{models::user::NewUser, UserDAO}; use crate::db::{models::user::NewUser, UserDAO};
let new_user = NewUser { let new_user = NewUser {
@@ -194,7 +194,7 @@ mod tests {
} }
fn build_base_listing( fn build_base_listing(
seller_id: UserId, seller_id: UserRowId,
title: &str, title: &str,
description: Option<&str>, description: Option<&str>,
) -> NewListingBase { ) -> NewListingBase {

View File

@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// Media attachments for listings /// Media attachments for listings
#[derive(Debug, Clone, FromRow)] #[derive(Debug, Clone, FromRow)]
#[allow(unused)]
pub struct ListingMedia { pub struct ListingMedia {
pub id: i64, pub id: i64,
pub listing_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}; use chrono::{DateTime, Utc};
/// New listing data for insertion /// New listing data for insertion
@@ -21,7 +21,7 @@ impl NewListing {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct NewListingBase { pub struct NewListingBase {
pub seller_id: UserId, pub seller_id: UserRowId,
pub title: String, pub title: String,
pub description: Option<String>, pub description: Option<String>,
pub starts_at: DateTime<Utc>, pub starts_at: DateTime<Utc>,
@@ -54,7 +54,7 @@ pub enum NewListingFields {
impl NewListingBase { impl NewListingBase {
pub fn new( pub fn new(
seller_id: UserId, seller_id: UserRowId,
title: String, title: String,
description: Option<String>, description: Option<String>,
starts_at: DateTime<Utc>, starts_at: DateTime<Utc>,

View File

@@ -1,7 +1,7 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::FromRow; use sqlx::FromRow;
use crate::db::money_amount::MoneyAmount; use crate::db::MoneyAmount;
/// Proxy bid strategies (automatic bidding settings) /// Proxy bid strategies (automatic bidding settings)
#[derive(Debug, Clone, FromRow)] #[derive(Debug, Clone, FromRow)]

View File

@@ -1,12 +1,12 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::FromRow; use sqlx::FromRow;
use crate::db::{TelegramUserId, UserId}; use crate::db::{TelegramUserId, UserRowId};
/// Core user information /// Core user information
#[derive(Debug, Clone, FromRow)] #[derive(Debug, Clone, FromRow)]
pub struct User { pub struct User {
pub id: UserId, pub id: UserRowId,
pub telegram_id: TelegramUserId, pub telegram_id: TelegramUserId,
pub username: Option<String>, pub username: Option<String>,
pub display_name: Option<String>, pub display_name: Option<String>,

View File

@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// User preferences and settings /// User preferences and settings
#[derive(Debug, Clone, FromRow)] #[derive(Debug, Clone, FromRow)]
#[allow(unused)]
pub struct UserSettings { pub struct UserSettings {
pub user_id: i64, pub user_id: i64,
pub language_code: String, 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 //! This newtype prevents accidentally mixing up listing IDs with other ID types
//! while maintaining compatibility with the database layer through SQLx traits. //! while maintaining compatibility with the database layer through SQLx traits.
use serde::{Deserialize, Serialize};
use sqlx::{ use sqlx::{
encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type, encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type,
}; };
use std::fmt; use std::fmt;
/// Type-safe wrapper for listing IDs /// 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); pub struct ListingId(i64);
impl ListingId { impl ListingId {

View File

@@ -1,12 +1,14 @@
pub mod currency_type; mod currency_type;
pub mod listing_id; mod listing_duration;
pub mod money_amount; mod listing_id;
pub mod telegram_user_id; mod money_amount;
pub mod user_id; mod telegram_user_id;
mod user_row_id;
// Re-export all types for easy access // Re-export all types for easy access
pub use currency_type::*; pub use currency_type::*;
pub use listing_duration::*;
pub use listing_id::*; pub use listing_id::*;
pub use money_amount::*; pub use money_amount::*;
pub use telegram_user_id::*; pub use telegram_user_id::*;
pub use user_id::*; pub use user_row_id::*;

View File

@@ -11,9 +11,9 @@ use teloxide::types::ChatId;
/// Type-safe wrapper for user IDs /// Type-safe wrapper for user IDs
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[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 /// Create a new UserId from an i64
pub fn new(id: i64) -> Self { pub fn new(id: i64) -> Self {
Self(id) Self(id)
@@ -25,26 +25,26 @@ impl UserId {
} }
} }
impl From<i64> for UserId { impl From<i64> for UserRowId {
fn from(id: i64) -> Self { fn from(id: i64) -> Self {
Self(id) Self(id)
} }
} }
impl From<UserId> for i64 { impl From<UserRowId> for i64 {
fn from(user_id: UserId) -> Self { fn from(user_id: UserRowId) -> Self {
user_id.0 user_id.0
} }
} }
impl fmt::Display for UserId { impl fmt::Display for UserRowId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0) write!(f, "{}", self.0)
} }
} }
// SQLx implementations for database compatibility // SQLx implementations for database compatibility
impl Type<Sqlite> for UserId { impl Type<Sqlite> for UserRowId {
fn type_info() -> SqliteTypeInfo { fn type_info() -> SqliteTypeInfo {
<i64 as Type<Sqlite>>::type_info() <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( fn encode_by_ref(
&self, &self,
args: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>, 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> { fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
let id = <i64 as Decode<'r, Sqlite>>::decode(value)?; let id = <i64 as Decode<'r, Sqlite>>::decode(value)?;
Ok(Self(id)) Ok(Self(id))

View File

@@ -15,7 +15,7 @@ macro_rules! keyboard_buttons {
$($variant:ident($text:literal, $callback_data:literal),)* $($variant:ident($text:literal, $callback_data:literal),)*
]),* ]),*
}) => { }) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
$vis enum $name { $vis enum $name {
$( $(
$($variant,)* $($variant,)*
@@ -60,8 +60,6 @@ macro_rules! keyboard_buttons {
mod tests { mod tests {
use teloxide::types::{InlineKeyboardButton, InlineKeyboardButtonKind}; use teloxide::types::{InlineKeyboardButton, InlineKeyboardButtonKind};
use super::*;
keyboard_buttons! { keyboard_buttons! {
pub enum DurationKeyboardButtons { pub enum DurationKeyboardButtons {
OneDay("1 day", "duration_1_day"), OneDay("1 day", "duration_1_day"),

View File

@@ -1,23 +1,45 @@
mod commands; mod commands;
mod config; mod config;
mod db; mod db;
mod dptree_utils;
mod keyboard_utils; mod keyboard_utils;
mod message_utils; mod message_utils;
mod sqlite_storage; mod sqlite_storage;
use anyhow::Result;
use log::info;
use teloxide::dispatching::dialogue::serializer::Json;
use teloxide::{prelude::*, utils::command::BotCommands};
#[cfg(test)] #[cfg(test)]
mod test_utils; 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 commands::*;
use config::Config; use config::Config;
use log::info;
use crate::commands::new_listing::new_listing_handler; use serde::{Deserialize, Serialize};
use crate::sqlite_storage::SqliteStorage; use teloxide::dispatching::{dialogue::serializer::Json, DpHandlerDescription};
use teloxide::{prelude::*, types::BotCommand, utils::command::BotCommands};
pub type HandlerResult<T = ()> = anyhow::Result<T>; 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)] #[derive(BotCommands, Clone)]
#[command(rename_rule = "lowercase", description = "Auction Bot Commands")] #[command(rename_rule = "lowercase", description = "Auction Bot Commands")]
@@ -36,6 +58,16 @@ pub enum Command {
Settings, 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] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
// Load and validate configuration from environment/.env file // Load and validate configuration from environment/.env file
@@ -47,28 +79,51 @@ async fn main() -> Result<()> {
info!("Starting Pawctioneer Bot..."); info!("Starting Pawctioneer Bot...");
let bot = Bot::new(&config.telegram_token); 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?; let dialog_storage = SqliteStorage::new(db_pool.clone(), Json).await?;
// Create dispatcher with dialogue system // Create dispatcher with dialogue system
Dispatcher::builder( Dispatcher::builder(
bot, bot,
dptree::entry().branch(new_listing_handler()).branch( dptree::entry()
Update::filter_message().branch( .enter_dialogue::<Update, SqliteStorage<Json>, DialogueRootState>()
dptree::entry() .branch(new_listing_handler())
.filter_command::<Command>() .branch(my_listings_handler())
.branch(dptree::case![Command::Start].endpoint(handle_start)) .branch(
.branch(dptree::case![Command::Help].endpoint(handle_help)) Update::filter_message().branch(
.branch(dptree::case![Command::MyListings].endpoint(handle_my_listings)) dptree::entry()
.branch(dptree::case![Command::MyBids].endpoint(handle_my_bids)) .filter_command::<Command>()
.branch(dptree::case![Command::Settings].endpoint(handle_settings)), .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]) .dependencies(dptree::deps![db_pool, dialog_storage])
.enable_ctrlc_handler() .enable_ctrlc_handler()
.worker_queue_size(1)
.build() .build()
.dispatch() .dispatch()
.await; .await;
Ok(()) 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,19 +1,17 @@
use std::fmt::Display; use crate::HandlerResult;
use anyhow::bail; use anyhow::bail;
use num::One;
use std::fmt::Display;
use teloxide::{ use teloxide::{
dispatching::dialogue::GetChatId,
payloads::{EditMessageTextSetters as _, SendMessageSetters as _}, payloads::{EditMessageTextSetters as _, SendMessageSetters as _},
prelude::Requester as _, prelude::Requester as _,
types::{ types::{
CallbackQuery, Chat, ChatId, InlineKeyboardButton, InlineKeyboardMarkup, MessageId, CallbackQuery, Chat, ChatId, InlineKeyboardButton, InlineKeyboardMarkup, Message,
ParseMode, User, MessageId, ParseMode, User,
}, },
Bot, Bot,
}; };
use crate::HandlerResult;
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct HandleAndId<'s> { pub struct HandleAndId<'s> {
pub handle: Option<&'s str>, 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 { pub fn is_cancel(text: &str) -> bool {
text.eq_ignore_ascii_case("/cancel") text.eq_ignore_ascii_case("/cancel")
} }
// Unified HTML message sending utility #[derive(Debug, Clone)]
pub async fn send_html_message( pub struct MessageTarget {
bot: &Bot, pub chat_id: ChatId,
chat: impl Into<HandleAndId<'_>>, pub message_id: Option<MessageId>,
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(())
} }
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, bot: &Bot,
chat: impl Into<HandleAndId<'_>>, message_target: impl Into<MessageTarget>,
message_id: MessageId, text: impl AsRef<str>,
text: &str,
keyboard: Option<InlineKeyboardMarkup>, keyboard: Option<InlineKeyboardMarkup>,
) -> HandlerResult { ) -> HandlerResult {
let chat = chat.into(); let message_target = message_target.into();
let mut edit_request = bot if let Some(message_id) = message_target.message_id {
.edit_message_text(chat.id, message_id, text) let mut message = bot
.parse_mode(ParseMode::Html); .edit_message_text(message_target.chat_id, message_id, text.as_ref())
if let Some(kb) = keyboard { .parse_mode(ParseMode::Html);
edit_request = edit_request.reply_markup(kb); 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(()) Ok(())
} }
@@ -125,36 +164,48 @@ pub fn create_multi_row_keyboard(rows: &[&[(&str, &str)]]) -> InlineKeyboardMark
keyboard 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 // Extract callback data and answer callback query
pub async fn extract_callback_data( pub async fn extract_callback_data<'c>(
bot: &Bot, bot: &Bot,
callback_query: &CallbackQuery, callback_query: CallbackQuery,
) -> HandlerResult<(String, User)> { ) -> HandlerResult<(String, User, MessageId)> {
let data = match callback_query.data.as_deref() { let data = match callback_query.data {
Some(data) => data.to_string(), Some(data) => data,
None => bail!("Missing data in callback query"), None => bail!("Missing data in callback query"),
}; };
let from = callback_query.from.clone(); 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 // Answer the callback query to remove loading state
if let Err(e) = bot.answer_callback_query(callback_query.id.clone()).await { if let Err(e) = bot.answer_callback_query(callback_query.id.clone()).await {
log::warn!("Failed to answer callback query: {}", e); log::warn!("Failed to answer callback query: {}", e);
} }
Ok((data, from)) Ok((data, from, message_id))
}
pub fn pluralize<'a, N: One + PartialEq<N>>(
count: N,
singular: &'a str,
plural: &'a str,
) -> &'a str {
if count == One::one() {
singular
} else {
plural
}
}
pub fn pluralize_with_count<'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::{ use std::{
convert::Infallible, convert::Infallible,
fmt::{Debug, Display}, fmt::{Debug, Display},
str,
sync::Arc, sync::Arc,
}; };
use teloxide::dispatching::dialogue::{serializer::Serializer, Storage}; use teloxide::dispatching::dialogue::{serializer::Serializer, Storage};

View File

@@ -1,7 +1,5 @@
//! Test utilities including timestamp comparison macros //! Test utilities including timestamp comparison macros
use chrono::{DateTime, Duration, Utc};
/// Assert that two timestamps are approximately equal within a given epsilon tolerance. /// 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 /// This macro is useful for testing timestamps that may have small variations due to