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:
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -1548,12 +1548,16 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
"futures",
|
||||||
|
"lazy_static",
|
||||||
"log",
|
"log",
|
||||||
"rstest",
|
"rstest",
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
"serde",
|
"serde",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"teloxide",
|
"teloxide",
|
||||||
|
"teloxide-core",
|
||||||
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
10
Cargo.toml
10
Cargo.toml
@@ -13,13 +13,17 @@ sqlx = { version = "0.8.6", features = [
|
|||||||
"macros",
|
"macros",
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
] }
|
] }
|
||||||
rust_decimal = { version = "1.33", features = ["serde"] }
|
rust_decimal = { version = "1.33" }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4" }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11.8"
|
env_logger = "0.11.8"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
dotenvy = "0.15"
|
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]
|
[dev-dependencies]
|
||||||
rstest = "0.26.1"
|
rstest = "0.26.1"
|
||||||
|
|||||||
Binary file not shown.
@@ -1,8 +1,8 @@
|
|||||||
use teloxide::{prelude::*, types::Message, utils::command::BotCommands, Bot};
|
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!(
|
let help_message = format!(
|
||||||
"📋 Available Commands:\n\n{}\n\n\
|
"📋 Available Commands:\n\n{}\n\n\
|
||||||
📧 Support: Contact @admin for help\n\
|
📧 Support: Contact @admin for help\n\
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ pub mod start;
|
|||||||
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 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 settings::handle_settings;
|
||||||
pub use start::handle_start;
|
pub use start::handle_start;
|
||||||
|
|
||||||
|
// Note: Text message handling is now handled by the dialogue system
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use log::info;
|
use log::info;
|
||||||
use teloxide::{prelude::*, types::Message, Bot};
|
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\
|
let response = "🎯 My Bids (Coming Soon)\n\n\
|
||||||
Here you'll be able to view:\n\
|
Here you'll be able to view:\n\
|
||||||
• Your active bids\n\
|
• Your active bids\n\
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use log::info;
|
use log::info;
|
||||||
use teloxide::{prelude::*, types::Message, Bot};
|
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\
|
let response = "📊 My Listings and Auctions (Coming Soon)\n\n\
|
||||||
Here you'll be able to view and manage:\n\
|
Here you'll be able to view and manage:\n\
|
||||||
• Your active listings and auctions\n\
|
• Your active listings and auctions\n\
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
|||||||
use log::info;
|
use log::info;
|
||||||
use teloxide::{prelude::*, types::Message, Bot};
|
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\
|
let response = "⚙️ Settings (Coming Soon)\n\n\
|
||||||
Here you'll be able to configure:\n\
|
Here you'll be able to configure:\n\
|
||||||
• Notification preferences\n\
|
• Notification preferences\n\
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use log::info;
|
use log::info;
|
||||||
use teloxide::{prelude::*, types::Message, Bot};
|
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\
|
let welcome_message = "🎯 Welcome to Pawctioneer Bot! 🎯\n\n\
|
||||||
This bot helps you participate in various types of auctions:\n\
|
This bot helps you participate in various types of auctions:\n\
|
||||||
• Standard auctions with anti-sniping protection\n\
|
• Standard auctions with anti-sniping protection\n\
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::FromRow;
|
use sqlx::FromRow;
|
||||||
|
|
||||||
use crate::db::money_amount::MoneyAmount;
|
use crate::db::money_amount::MoneyAmount;
|
||||||
|
|
||||||
/// Actual bids placed on listings
|
/// Actual bids placed on listings
|
||||||
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
|
#[derive(Debug, Clone, FromRow)]
|
||||||
pub struct Bid {
|
pub struct Bid {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub listing_id: i64,
|
pub listing_id: i64,
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ use super::listing_type::ListingType;
|
|||||||
use crate::db::{listing_id::ListingId, money_amount::MoneyAmount, user_id::UserId};
|
use crate::db::{listing_id::ListingId, money_amount::MoneyAmount, user_id::UserId};
|
||||||
use chrono::{DateTime, Utc};
|
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
|
/// Common fields shared by all listing types
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ListingBase {
|
pub struct ListingBase {
|
||||||
@@ -26,13 +33,6 @@ pub struct ListingBase {
|
|||||||
pub updated_at: DateTime<Utc>,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum ListingFields {
|
pub enum ListingFields {
|
||||||
BasicAuction {
|
BasicAuction {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::FromRow;
|
use sqlx::FromRow;
|
||||||
|
|
||||||
/// Media attachments for listings
|
/// Media attachments for listings
|
||||||
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
|
#[derive(Debug, Clone, FromRow)]
|
||||||
pub struct ListingMedia {
|
pub struct ListingMedia {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub listing_id: i64,
|
pub listing_id: i64,
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Types of listings supported by the platform
|
/// 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(type_name = "TEXT")]
|
||||||
#[sqlx(rename_all = "snake_case")]
|
#[sqlx(rename_all = "snake_case")]
|
||||||
pub enum ListingType {
|
pub enum ListingType {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::FromRow;
|
use sqlx::FromRow;
|
||||||
|
|
||||||
use crate::db::money_amount::MoneyAmount;
|
use crate::db::money_amount::MoneyAmount;
|
||||||
|
|
||||||
/// Proxy bid strategies (automatic bidding settings)
|
/// Proxy bid strategies (automatic bidding settings)
|
||||||
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
|
#[derive(Debug, Clone, FromRow)]
|
||||||
pub struct ProxyBid {
|
pub struct ProxyBid {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub listing_id: i64,
|
pub listing_id: i64,
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::FromRow;
|
use sqlx::FromRow;
|
||||||
|
|
||||||
use crate::db::{TelegramUserId, UserId};
|
use crate::db::{TelegramUserId, UserId};
|
||||||
|
|
||||||
/// Core user information
|
/// Core user information
|
||||||
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
|
#[derive(Debug, Clone, FromRow)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: UserId,
|
pub id: UserId,
|
||||||
pub telegram_id: TelegramUserId,
|
pub telegram_id: TelegramUserId,
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::FromRow;
|
use sqlx::FromRow;
|
||||||
|
|
||||||
/// User preferences and settings
|
/// User preferences and settings
|
||||||
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
|
#[derive(Debug, Clone, FromRow)]
|
||||||
pub struct UserSettings {
|
pub struct UserSettings {
|
||||||
pub user_id: i64,
|
pub user_id: i64,
|
||||||
pub language_code: String,
|
pub language_code: String,
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Currency types supported by the platform
|
/// Currency types supported by the platform
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum CurrencyType {
|
pub enum CurrencyType {
|
||||||
#[serde(rename = "USD")]
|
|
||||||
USD,
|
USD,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,13 @@
|
|||||||
//! 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, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
pub struct ListingId(i64);
|
pub struct ListingId(i64);
|
||||||
|
|
||||||
impl ListingId {
|
impl ListingId {
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ impl MoneyAmount {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for MoneyAmount {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::zero()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Allow easy conversion from Decimal
|
// Allow easy conversion from Decimal
|
||||||
impl From<Decimal> for MoneyAmount {
|
impl From<Decimal> for MoneyAmount {
|
||||||
fn from(decimal: Decimal) -> Self {
|
fn from(decimal: Decimal) -> Self {
|
||||||
|
|||||||
@@ -4,14 +4,13 @@
|
|||||||
//! This newtype prevents accidentally mixing up user IDs with other ID types
|
//! This newtype prevents accidentally mixing up user 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 user IDs
|
/// 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);
|
pub struct TelegramUserId(teloxide::types::UserId);
|
||||||
|
|
||||||
impl TelegramUserId {
|
impl TelegramUserId {
|
||||||
|
|||||||
@@ -3,14 +3,13 @@
|
|||||||
//! This newtype prevents accidentally mixing up user IDs with other ID types
|
//! This newtype prevents accidentally mixing up user 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 user IDs
|
/// 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);
|
pub struct UserId(i64);
|
||||||
|
|
||||||
impl UserId {
|
impl UserId {
|
||||||
|
|||||||
55
src/main.rs
55
src/main.rs
@@ -1,16 +1,24 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use log::info;
|
|
||||||
use teloxide::{prelude::*, utils::command::BotCommands};
|
|
||||||
|
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
mod db;
|
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)]
|
#[cfg(test)]
|
||||||
mod test_utils;
|
mod test_utils;
|
||||||
|
use commands::new_listing::ListingWizardState;
|
||||||
use commands::*;
|
use commands::*;
|
||||||
use config::Config;
|
use config::Config;
|
||||||
|
|
||||||
|
use crate::sqlite_storage::SqliteStorage;
|
||||||
|
|
||||||
|
pub type HandlerResult = anyhow::Result<()>;
|
||||||
|
|
||||||
#[derive(BotCommands, Clone)]
|
#[derive(BotCommands, Clone)]
|
||||||
#[command(rename_rule = "lowercase", description = "Auction Bot Commands")]
|
#[command(rename_rule = "lowercase", description = "Auction Bot Commands")]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
@@ -39,21 +47,34 @@ 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);
|
||||||
|
|
||||||
// Create dispatcher with direct command routing
|
let dialog_storage = SqliteStorage::new(db_pool.clone(), Json).await?;
|
||||||
|
|
||||||
|
// Create dispatcher with dialogue system
|
||||||
Dispatcher::builder(
|
Dispatcher::builder(
|
||||||
bot,
|
bot,
|
||||||
Update::filter_message().branch(
|
dptree::entry()
|
||||||
dptree::entry()
|
.branch(
|
||||||
.filter_command::<Command>()
|
Update::filter_message()
|
||||||
.branch(dptree::case![Command::Start].endpoint(handle_start))
|
.enter_dialogue::<Message, SqliteStorage<Json>, ListingWizardState>()
|
||||||
.branch(dptree::case![Command::Help].endpoint(handle_help))
|
.branch(
|
||||||
.branch(dptree::case![Command::NewListing].endpoint(handle_new_listing))
|
dptree::entry()
|
||||||
.branch(dptree::case![Command::MyListings].endpoint(handle_my_listings))
|
.filter_command::<Command>()
|
||||||
.branch(dptree::case![Command::MyBids].endpoint(handle_my_bids))
|
.branch(dptree::case![Command::Start].endpoint(handle_start))
|
||||||
.branch(dptree::case![Command::Settings].endpoint(handle_settings)),
|
.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()
|
.enable_ctrlc_handler()
|
||||||
.build()
|
.build()
|
||||||
.dispatch()
|
.dispatch()
|
||||||
|
|||||||
40
src/message_utils.rs
Normal file
40
src/message_utils.rs
Normal 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
150
src/sqlite_storage.rs
Normal 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
64
src/wizard_utils.rs
Normal 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),*
|
||||||
|
}
|
||||||
|
)*
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user