feat: Add comprehensive edit screen to new listing wizard

- Replace individual state structs with unified ListingDraft struct
- Add EditingListing state with field selection interface
- Implement individual field editing states (Title, Description, Price, Slots, StartTime, Duration)
- Add field-specific keyboards with Back buttons and Clear functionality for description
- Update all handlers to use ListingDraft instead of separate state structs
- Rename Confirming to ViewingDraft for clarity
- Add proper validation and error handling for all field edits
- Enable seamless navigation between edit screen and confirmation
- Maintain all existing functionality while adding edit capabilities
This commit is contained in:
Dylan Knutson
2025-08-28 06:58:55 +00:00
parent 4e5283b530
commit 71fe1e60c0
26 changed files with 1928 additions and 67 deletions

4
Cargo.lock generated
View File

@@ -1548,12 +1548,16 @@ dependencies = [
"chrono",
"dotenvy",
"env_logger",
"futures",
"lazy_static",
"log",
"rstest",
"rust_decimal",
"serde",
"sqlx",
"teloxide",
"teloxide-core",
"thiserror",
"tokio",
]

View File

@@ -13,13 +13,17 @@ sqlx = { version = "0.8.6", features = [
"macros",
"rust_decimal",
] }
rust_decimal = { version = "1.33", features = ["serde"] }
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
rust_decimal = { version = "1.33" }
chrono = { version = "0.4" }
log = "0.4"
env_logger = "0.11.8"
anyhow = "1.0"
dotenvy = "0.15"
lazy_static = "1.4"
serde = "1.0.219"
futures = "0.3.31"
thiserror = "2.0.16"
teloxide-core = "0.13.0"
[dev-dependencies]
rstest = "0.26.1"

Binary file not shown.

View File

@@ -1,8 +1,8 @@
use teloxide::{prelude::*, types::Message, utils::command::BotCommands, Bot};
use crate::Command;
use crate::{Command, HandlerResult};
pub async fn handle_help(bot: Bot, msg: Message) -> ResponseResult<()> {
pub async fn handle_help(bot: Bot, msg: Message) -> HandlerResult {
let help_message = format!(
"📋 Available Commands:\n\n{}\n\n\
📧 Support: Contact @admin for help\n\

View File

@@ -9,6 +9,10 @@ pub mod start;
pub use help::handle_help;
pub use my_bids::handle_my_bids;
pub use my_listings::handle_my_listings;
pub use new_listing::handle_new_listing;
pub use new_listing::{
handle_new_listing, new_listing_callback_handler, new_listing_dialogue_handler,
};
pub use settings::handle_settings;
pub use start::handle_start;
// Note: Text message handling is now handled by the dialogue system

View File

@@ -1,7 +1,9 @@
use log::info;
use teloxide::{prelude::*, types::Message, Bot};
pub async fn handle_my_bids(bot: Bot, msg: Message) -> ResponseResult<()> {
use crate::HandlerResult;
pub async fn handle_my_bids(bot: Bot, msg: Message) -> HandlerResult {
let response = "🎯 My Bids (Coming Soon)\n\n\
Here you'll be able to view:\n\
• Your active bids\n\

View File

@@ -1,7 +1,9 @@
use log::info;
use teloxide::{prelude::*, types::Message, Bot};
pub async fn handle_my_listings(bot: Bot, msg: Message) -> ResponseResult<()> {
use crate::HandlerResult;
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\

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
use log::info;
use teloxide::{prelude::*, types::Message, Bot};
pub async fn handle_settings(bot: Bot, msg: Message) -> ResponseResult<()> {
use crate::HandlerResult;
pub async fn handle_settings(bot: Bot, msg: Message) -> HandlerResult {
let response = "⚙️ Settings (Coming Soon)\n\n\
Here you'll be able to configure:\n\
• Notification preferences\n\

View File

@@ -1,7 +1,9 @@
use log::info;
use teloxide::{prelude::*, types::Message, Bot};
pub async fn handle_start(bot: Bot, msg: Message) -> ResponseResult<()> {
use crate::HandlerResult;
pub async fn handle_start(bot: Bot, msg: Message) -> HandlerResult {
let welcome_message = "🎯 Welcome to Pawctioneer Bot! 🎯\n\n\
This bot helps you participate in various types of auctions:\n\
• Standard auctions with anti-sniping protection\n\

View File

@@ -1,11 +1,10 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use crate::db::money_amount::MoneyAmount;
/// Actual bids placed on listings
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
#[derive(Debug, Clone, FromRow)]
pub struct Bid {
pub id: i64,
pub listing_id: i64,

View File

@@ -13,6 +13,13 @@ use super::listing_type::ListingType;
use crate::db::{listing_id::ListingId, money_amount::MoneyAmount, user_id::UserId};
use chrono::{DateTime, Utc};
/// Main listing/auction entity
#[derive(Debug, Clone)]
pub struct Listing {
pub base: ListingBase,
pub fields: ListingFields,
}
/// Common fields shared by all listing types
#[derive(Debug, Clone)]
pub struct ListingBase {
@@ -26,13 +33,6 @@ pub struct ListingBase {
pub updated_at: DateTime<Utc>,
}
/// Main listing/auction entity enum
#[derive(Debug, Clone)]
pub struct Listing {
pub base: ListingBase,
pub fields: ListingFields,
}
#[derive(Debug, Clone)]
pub enum ListingFields {
BasicAuction {

View File

@@ -1,9 +1,8 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
/// Media attachments for listings
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
#[derive(Debug, Clone, FromRow)]
pub struct ListingMedia {
pub id: i64,
pub listing_id: i64,

View File

@@ -1,7 +1,5 @@
use serde::{Deserialize, Serialize};
/// Types of listings supported by the platform
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[derive(Debug, Clone, PartialEq, Eq, sqlx::Type)]
#[sqlx(type_name = "TEXT")]
#[sqlx(rename_all = "snake_case")]
pub enum ListingType {

View File

@@ -1,11 +1,10 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use crate::db::money_amount::MoneyAmount;
/// Proxy bid strategies (automatic bidding settings)
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
#[derive(Debug, Clone, FromRow)]
pub struct ProxyBid {
pub id: i64,
pub listing_id: i64,

View File

@@ -1,11 +1,10 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use crate::db::{TelegramUserId, UserId};
/// Core user information
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
#[derive(Debug, Clone, FromRow)]
pub struct User {
pub id: UserId,
pub telegram_id: TelegramUserId,

View File

@@ -1,9 +1,8 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
/// User preferences and settings
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
#[derive(Debug, Clone, FromRow)]
pub struct UserSettings {
pub user_id: i64,
pub language_code: String,

View File

@@ -1,12 +1,10 @@
use serde::{Deserialize, Serialize};
use sqlx::{
encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type,
};
/// Currency types supported by the platform
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CurrencyType {
#[serde(rename = "USD")]
USD,
}

View File

@@ -3,14 +3,13 @@
//! 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, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ListingId(i64);
impl ListingId {

View File

@@ -45,6 +45,12 @@ impl MoneyAmount {
}
}
impl Default for MoneyAmount {
fn default() -> Self {
Self::zero()
}
}
// Allow easy conversion from Decimal
impl From<Decimal> for MoneyAmount {
fn from(decimal: Decimal) -> Self {

View File

@@ -4,14 +4,13 @@
//! This newtype prevents accidentally mixing up user 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 user IDs
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct TelegramUserId(teloxide::types::UserId);
impl TelegramUserId {

View File

@@ -3,14 +3,13 @@
//! This newtype prevents accidentally mixing up user 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 user IDs
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct UserId(i64);
impl UserId {

View File

@@ -1,16 +1,24 @@
use anyhow::Result;
use log::info;
use teloxide::{prelude::*, utils::command::BotCommands};
mod commands;
mod config;
mod db;
mod message_utils;
mod sqlite_storage;
mod wizard_utils;
use anyhow::Result;
use log::info;
use teloxide::dispatching::dialogue::serializer::Json;
use teloxide::{prelude::*, types::CallbackQuery, utils::command::BotCommands};
#[cfg(test)]
mod test_utils;
use commands::new_listing::ListingWizardState;
use commands::*;
use config::Config;
use crate::sqlite_storage::SqliteStorage;
pub type HandlerResult = anyhow::Result<()>;
#[derive(BotCommands, Clone)]
#[command(rename_rule = "lowercase", description = "Auction Bot Commands")]
pub enum Command {
@@ -39,21 +47,34 @@ async fn main() -> Result<()> {
info!("Starting Pawctioneer Bot...");
let bot = Bot::new(&config.telegram_token);
// Create dispatcher with direct command routing
let dialog_storage = SqliteStorage::new(db_pool.clone(), Json).await?;
// Create dispatcher with dialogue system
Dispatcher::builder(
bot,
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::NewListing].endpoint(handle_new_listing))
.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()
.branch(
Update::filter_message()
.enter_dialogue::<Message, SqliteStorage<Json>, ListingWizardState>()
.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::NewListing].endpoint(handle_new_listing))
.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)),
)
.branch(new_listing_dialogue_handler()),
)
.branch(
Update::filter_callback_query()
.enter_dialogue::<CallbackQuery, SqliteStorage<Json>, ListingWizardState>()
.branch(new_listing_callback_handler()),
),
)
.dependencies(dptree::deps![db_pool])
.dependencies(dptree::deps![db_pool, dialog_storage])
.enable_ctrlc_handler()
.build()
.dispatch()

40
src/message_utils.rs Normal file
View File

@@ -0,0 +1,40 @@
use std::fmt::Display;
use teloxide::types::{Chat, User};
pub struct UserHandleAndId<'s> {
pub handle: Option<&'s str>,
pub id: Option<i64>,
}
impl<'s> Display for UserHandleAndId<'s> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} ({})",
self.handle.unwrap_or("unknown"),
self.id.unwrap_or(-1)
)
}
}
impl<'s> UserHandleAndId<'s> {
pub fn from_chat(chat: &'s Chat) -> Self {
Self {
handle: chat.username(),
id: Some(chat.id.0),
}
}
pub fn from_user(user: &'s User) -> Self {
Self {
handle: user.username.as_deref(),
id: Some(user.id.0 as i64),
}
}
}
pub fn is_cancel_or_no(text: &str) -> bool {
is_cancel(text) || text.eq_ignore_ascii_case("no")
}
pub fn is_cancel(text: &str) -> bool {
text.eq_ignore_ascii_case("/cancel")
}

150
src/sqlite_storage.rs Normal file
View File

@@ -0,0 +1,150 @@
use futures::future::BoxFuture;
use serde::{de::DeserializeOwned, Serialize};
use sqlx::{sqlite::SqlitePool, Executor};
use std::{
convert::Infallible,
fmt::{Debug, Display},
str,
sync::Arc,
};
use teloxide::dispatching::dialogue::{serializer::Serializer, Storage};
use teloxide_core::types::ChatId;
use thiserror::Error;
/// A persistent dialogue storage based on [SQLite](https://www.sqlite.org/).
pub struct SqliteStorage<S> {
pool: SqlitePool,
serializer: S,
}
/// An error returned from [`SqliteStorage`].
#[derive(Debug, Error)]
pub enum SqliteStorageError<SE>
where
SE: Debug + Display,
{
#[error("dialogue serialization error: {0}")]
SerdeError(SE),
#[error("sqlite error: {0}")]
SqliteError(#[from] sqlx::Error),
/// Returned from [`SqliteStorage::remove_dialogue`].
#[error("row not found")]
DialogueNotFound,
}
impl<S> SqliteStorage<S> {
pub async fn new(
pool: SqlitePool,
serializer: S,
) -> Result<Arc<Self>, SqliteStorageError<Infallible>> {
sqlx::query(
"
CREATE TABLE IF NOT EXISTS teloxide_dialogues (
chat_id BIGINT PRIMARY KEY,
dialogue BLOB NOT NULL
);
",
)
.execute(&pool)
.await?;
Ok(Arc::new(Self { pool, serializer }))
}
}
impl<S, D> Storage<D> for SqliteStorage<S>
where
S: Send + Sync + Serializer<D> + 'static,
D: Send + Serialize + DeserializeOwned + 'static,
<S as Serializer<D>>::Error: Debug + Display,
{
type Error = SqliteStorageError<<S as Serializer<D>>::Error>;
/// Returns [`sqlx::Error::RowNotFound`] if a dialogue does not exist.
fn remove_dialogue(
self: Arc<Self>,
ChatId(chat_id): ChatId,
) -> BoxFuture<'static, Result<(), Self::Error>> {
Box::pin(async move {
let deleted_rows_count =
sqlx::query("DELETE FROM teloxide_dialogues WHERE chat_id = ?")
.bind(chat_id)
.execute(&self.pool)
.await?
.rows_affected();
if deleted_rows_count == 0 {
return Err(SqliteStorageError::DialogueNotFound);
}
Ok(())
})
}
fn update_dialogue(
self: Arc<Self>,
ChatId(chat_id): ChatId,
dialogue: D,
) -> BoxFuture<'static, Result<(), Self::Error>> {
Box::pin(async move {
let d = self
.serializer
.serialize(&dialogue)
.map_err(SqliteStorageError::SerdeError)?;
self.pool
.acquire()
.await?
.execute(
sqlx::query(
"
INSERT INTO teloxide_dialogues VALUES (?, ?)
ON CONFLICT(chat_id) DO UPDATE SET dialogue=excluded.dialogue
",
)
.bind(chat_id)
.bind(d),
)
.await?;
Ok(())
})
}
fn get_dialogue(
self: Arc<Self>,
chat_id: ChatId,
) -> BoxFuture<'static, Result<Option<D>, Self::Error>> {
Box::pin(async move {
get_dialogue(&self.pool, chat_id)
.await?
.map(|d| {
self.serializer
.deserialize(&d)
.map_err(SqliteStorageError::SerdeError)
})
.transpose()
})
}
}
async fn get_dialogue(
pool: &SqlitePool,
ChatId(chat_id): ChatId,
) -> Result<Option<Vec<u8>>, sqlx::Error> {
#[derive(sqlx::FromRow)]
struct DialogueDbRow {
dialogue: Vec<u8>,
}
let bytes = sqlx::query_as::<_, DialogueDbRow>(
"SELECT dialogue FROM teloxide_dialogues WHERE chat_id = ?",
)
.bind(chat_id)
.fetch_optional(pool)
.await?
.map(|r| r.dialogue);
Ok(bytes)
}

64
src/wizard_utils.rs Normal file
View File

@@ -0,0 +1,64 @@
/// Define a set of states for a wizard.
/// Example:
/// ```
/// wizard_step_states! {
/// struct AwaitingDescription {
/// title: String,
/// }
/// struct AwaitingPrice {
/// title: String,
/// description: Option<String>,
/// },
/// struct AwaitingSlots {
/// title: String,
/// description: Option<String>,
/// price: Option<MoneyAmount>,
/// }
/// }
/// ```
/// Constructs structs for each step of the wizard, and
/// generators for converting one step into the next.
/// The above example would generate the following structs:
/// ```
/// struct AwaitingDescription {
/// title: String,
/// }
/// struct AwaitingPrice {
/// title: String,
/// description: Option<String>,
/// }
/// struct AwaitingSlots {
/// title: String,
/// description: Option<String>,
/// price: Option<MoneyAmount>,
/// }
/// ```
/// And the following implementations:
/// ```
/// impl AwaitingDescription {
/// fn add_description(self, description: Option<String>) -> AwaitingPrice {
/// AwaitingPrice {
/// title: self.title,
/// description,
/// }
/// }
/// }
/// impl AwaitingPrice {
/// fn add_buy_now_price(self, buy_now_price: MoneyAmount) -> AwaitingSlots {
/// AwaitingSlots {
/// title: self.title,
/// description: self.description,
/// buy_now_price,
/// }
/// }
/// }
/// ```
macro_rules! wizard_step_states {
($($vis:vis struct $name:ident { $($field_vis:vis $field:ident: $ty:ty),* $(,)? }),*) => {
$(
$vis struct $name {
$($field_vis $field: $ty),*
}
)*
};
}