Compare commits

...

10 Commits

Author SHA1 Message Date
Dylan Knutson
1d4d5c05ed Complete message utility adoption - fix all remaining send_html_message usage
- Created edit_html_message utility for consistent message editing
- Converted all bot.edit_message_text() calls to use edit_html_message
- Fixed markdown to HTML conversion (**Error:** → <b>Error:</b>, *text* → <i>text</i>)
- Added MessageId import to fix compilation errors
- 100% message utility adoption achieved - all direct bot message calls eliminated
2025-08-28 14:16:42 +00:00
Dylan Knutson
869526005f Complete validation function usage - fix unused validation functions
🔧 Fixed unused validation functions:
- Updated validate_duration() to handle hours (1-720) instead of days to match handler expectations
- Refactored handle_duration_input() to use validate_duration() utility
- Refactored handle_start_time_input() to use validate_start_time() utility

 All validation functions now utilized:
- validate_title() ✓ (used in handle_title_input)
- validate_price() ✓ (used in handle_price_input)
- validate_slots() ✓ (used in handle_slots_input)
- validate_duration() ✓ (used in handle_duration_input) - NOW USED
- validate_start_time() ✓ (used in handle_start_time_input) - NOW USED

🎯 Benefits:
- Eliminated all unused validation function warnings
- Consistent validation patterns across ALL input handlers
- Centralized error messages for easier maintenance
- Improved code consistency and maintainability

📊 Final refactoring status: 100% complete
- All input handlers now use validation utilities
- All validation functions are properly utilized
- No more code duplication in validation logic
2025-08-28 07:38:52 +00:00
Dylan Knutson
79ed3a7e4c Complete keyboard creation pattern refactoring
🎯 Added comprehensive keyboard creation utilities:
- create_single_button_keyboard(): For simple one-button keyboards
- create_single_row_keyboard(): For multiple buttons in one row
- create_multi_row_keyboard(): For complex multi-row layouts
- create_numeric_options_keyboard(): For numeric option patterns

🔧 Specialized keyboard functions:
- create_duration_keyboard(): Duration selection with proper callback format
- create_slots_keyboard(): Slot selection using numeric pattern
- create_confirmation_keyboard(): Create/Discard/Edit actions
- create_field_selection_keyboard(): Field editing menu
- create_start_time_keyboard(): Start time selection
- create_back_button_keyboard(): Navigation back button
- create_back_button_keyboard_with_clear(): Back + clear options
- create_skip_keyboard(): Skip action button

♻️ Refactored existing code:
- Replaced inline keyboard creation with utility function calls
- Removed duplicate keyboard creation functions
- Standardized keyboard patterns across all handlers
- Maintained backward compatibility with existing callback data formats

📊 Benefits:
- Consistent keyboard styling and behavior
- Easy to modify keyboard layouts in one place
- Reduced code duplication by ~50 lines
- Better maintainability for UI changes
- Foundation for future keyboard enhancements

 All refactoring tasks completed:
- Input handler patterns ✓
- Callback query handling ✓
- Message sending utilities ✓
- Validation logic ✓
- State transitions ✓
- Keyboard creation patterns ✓
- Logging utilities ✓
2025-08-28 07:35:14 +00:00
Dylan Knutson
0eef18ea06 Major refactoring: Deduplicate and improve new_listing.rs maintainability
 Added utility functions for code deduplication:
- send_html_message(): Unified HTML message sending with optional keyboards
- extract_callback_data(): Standardized callback query handling with error management
- validate_*(): Centralized validation functions for title, price, slots, duration, start_time
- log_user_action() & log_user_callback_action(): Consistent logging patterns
- transition_to_state(): Simplified state transitions
- handle_callback_error(): Unified error handling for callbacks

🔧 Refactored input handlers:
- handle_title_input(): Now uses validation utilities and cleaner error handling
- handle_description_input(): Simplified with utility functions
- handle_price_input(): Uses validate_price() and improved structure
- handle_slots_input(): Streamlined with validate_slots()

🔧 Refactored callback handlers:
- handle_description_callback(): Uses extract_callback_data() utility
- handle_slots_callback(): Improved structure and error handling

📊 Impact:
- Significantly improved code maintainability and consistency
- Reduced duplication across input and callback handlers
- Centralized validation logic for easier maintenance
- Better error handling and logging throughout
- Foundation for further refactoring of remaining handlers

🏗️ Code structure:
- Added comprehensive utility section at top of file
- Maintained backward compatibility
- All existing functionality preserved
2025-08-28 07:30:59 +00:00
Dylan Knutson
764c17af05 Fix dialogue handler structure and enhance duration input
- Fix handler type mismatch error by properly ordering dialogue entry
- Move .enter_dialogue() before handlers that need dialogue context
- Remove duplicate command handler branches
- Add duration callback handler for inline keyboard buttons
- Add duration keyboard with 1, 3, 7, and 14 day options
- Refactor duration processing into shared function
- Simplify slots keyboard layout to single row
- Improve code organization and error handling
2025-08-28 07:23:40 +00:00
Dylan Knutson
71fe1e60c0 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
2025-08-28 06:58:55 +00:00
Dylan Knutson
4e5283b530 rename commands 2025-08-28 01:30:30 +00:00
Dylan Knutson
8745583990 Add configuration and database setup
- Updated Cargo.toml with necessary dependencies
- Enhanced config.rs with database and application configuration
- Updated main.rs with improved bot initialization
- Added development database file
2025-08-28 01:28:31 +00:00
Dylan Knutson
af1f3271a3 chore: update rstest dependency from 0.21 to 0.26.1
- Update rstest version for better test parametrization support
- Update corresponding Cargo.lock
2025-08-28 01:19:41 +00:00
Dylan Knutson
5212175df5 refactor: organize type definitions into dedicated db/types/ directory
- Move type files to src/db/types/: currency_type, listing_id, user_id, money_amount, telegram_user_id
- Create types/mod.rs with proper re-exports for all type modules
- Update db/mod.rs to import from new types module instead of individual files
- All tests pass - no functionality changes
- Improves code organization by clearly separating types, DAOs, and models
2025-08-28 01:17:45 +00:00
30 changed files with 2094 additions and 108 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
/target
!.env.example
.env
*.db

15
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",
]
@@ -1985,21 +1989,20 @@ dependencies = [
[[package]]
name = "rstest"
version = "0.21.0"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afd55a67069d6e434a95161415f5beeada95a01c7b815508a82dcb0e1593682"
checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49"
dependencies = [
"futures",
"futures-timer",
"futures-util",
"rstest_macros",
"rustc_version",
]
[[package]]
name = "rstest_macros"
version = "0.21.0"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4165dfae59a39dd41d8dec720d3cbfbc71f69744efb480a3920f5d4e0cc6798d"
checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0"
dependencies = [
"cfg-if",
"glob",

View File

@@ -4,21 +4,26 @@ version = "0.1.0"
edition = "2021"
[dependencies]
teloxide = { version = "0.17.0", features = ["macros"] }
teloxide = { version = "0.17.0", features = ["macros", "ctrlc_handler"] }
tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] }
sqlx = { version = "0.8.6", features = [
"runtime-tokio-rustls",
"sqlite",
"chrono",
"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.21"
rstest = "0.26.1"

BIN
pawctioneer_bot_dev.db Normal file

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

@@ -1,14 +1,15 @@
pub mod help;
pub mod mybids;
pub mod mylistings;
pub mod newlisting;
pub mod my_bids;
pub mod my_listings;
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 mybids::handle_my_bids;
pub use mylistings::handle_my_listings;
pub use newlisting::handle_new_listing;
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,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\

1758
src/commands/new_listing.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +0,0 @@
use log::info;
use teloxide::{prelude::*, types::Message, Bot};
pub async fn handle_new_listing(bot: Bot, msg: Message) -> ResponseResult<()> {
let response = "🏗️ New Listing or Auction Creation (Coming Soon)\n\n\
This feature will allow you to create:\n\
• Standard time-based auctions\n\
• Multi-slot auctions\n\
• Fixed price sales\n\
• Blind auctions\n\n\
Stay tuned! 🎪";
info!(
"User {} ({}) attempted to create new listing or auctionc",
msg.chat.username().unwrap_or("unknown"),
msg.chat.id
);
bot.send_message(msg.chat.id, response).await?;
Ok(())
}

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

@@ -26,8 +26,8 @@ impl Config {
///
/// The configuration is automatically validated during construction.
pub fn from_env() -> Result<Self> {
// Load .env file if present (fails silently if not found)
let _ = dotenvy::dotenv();
dotenvy::dotenv()?;
env_logger::init();
let telegram_token = env::var("TELOXIDE_TOKEN")
.context("TELOXIDE_TOKEN environment variable is required")?;

View File

@@ -1,16 +1,8 @@
pub mod currency_type;
pub mod dao;
pub mod listing_id;
pub mod models;
pub mod money_amount;
pub mod telegram_user_id;
pub mod user_id;
pub mod types;
// Re-export all models for easy access
pub use currency_type::*;
// Re-export all modules for easy access
pub use dao::*;
pub use listing_id::*;
pub use models::*;
pub use money_amount::*;
pub use telegram_user_id::*;
pub use user_id::*;
pub use types::*;

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 {

12
src/db/types/mod.rs Normal file
View File

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

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,23 @@
use anyhow::Result;
use log::info;
use teloxide::{prelude::*, utils::command::BotCommands};
mod commands;
mod config;
mod db;
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 commands::*;
use config::Config;
use crate::commands::new_listing::new_listing_handler;
use crate::sqlite_storage::SqliteStorage;
pub type HandlerResult = anyhow::Result<()>;
#[derive(BotCommands, Clone)]
#[command(rename_rule = "lowercase", description = "Auction Bot Commands")]
pub enum Command {
@@ -28,13 +35,8 @@ pub enum Command {
Settings,
}
// No longer needed - dptree will dispatch directly!
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging
env_logger::init();
// Load and validate configuration from environment/.env file
let config = Config::from_env()?;
@@ -42,24 +44,26 @@ async fn main() -> Result<()> {
let db_pool = config.create_database_pool().await?;
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(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)),
),
),
)
.dependencies(dptree::deps![db_pool])
.dependencies(dptree::deps![db_pool, dialog_storage])
.enable_ctrlc_handler()
.build()
.dispatch()

79
src/message_utils.rs Normal file
View File

@@ -0,0 +1,79 @@
use std::fmt::Display;
use teloxide::{
payloads::{EditMessageTextSetters as _, SendMessageSetters as _},
prelude::Requester as _,
types::{Chat, ChatId, InlineKeyboardMarkup, MessageId, ParseMode, User},
Bot,
};
use crate::HandlerResult;
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")
}
// Unified HTML message sending utility
pub async fn send_html_message(
bot: &Bot,
chat_id: ChatId,
text: &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);
}
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(())
}

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