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
This commit is contained in:
@@ -9,9 +9,6 @@ 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, 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;
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
message_utils::{is_cancel, is_cancel_or_no, UserHandleAndId},
|
message_utils::{is_cancel, is_cancel_or_no, UserHandleAndId},
|
||||||
sqlite_storage::SqliteStorage,
|
sqlite_storage::SqliteStorage,
|
||||||
HandlerResult,
|
Command, HandlerResult,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize, Default)]
|
#[derive(Clone, Serialize, Deserialize, Default)]
|
||||||
@@ -55,59 +55,131 @@ pub enum ListingWizardState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Type alias for the dialogue
|
// Type alias for the dialogue
|
||||||
pub type NewListingDialogue = Dialogue<ListingWizardState, SqliteStorage<Json>>;
|
type NewListingDialogue = Dialogue<ListingWizardState, SqliteStorage<Json>>;
|
||||||
|
|
||||||
// Create the dialogue handler tree for new listing wizard
|
// Create the dialogue handler tree for new listing wizard
|
||||||
pub fn new_listing_dialogue_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> {
|
pub fn new_listing_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> {
|
||||||
dptree::entry()
|
dptree::entry()
|
||||||
.branch(dptree::case![ListingWizardState::Start].endpoint(start_new_listing))
|
|
||||||
.branch(
|
.branch(
|
||||||
dptree::case![ListingWizardState::AwaitingTitle(state)].endpoint(handle_title_input),
|
Update::filter_message()
|
||||||
|
.enter_dialogue::<Message, SqliteStorage<Json>, ListingWizardState>()
|
||||||
|
.branch(dptree::entry().filter_command::<Command>().branch(
|
||||||
|
dptree::case![Command::NewListing].endpoint(handle_new_listing_command),
|
||||||
|
))
|
||||||
|
.branch(dptree::case![ListingWizardState::Start].endpoint(start_new_listing))
|
||||||
|
.branch(
|
||||||
|
dptree::case![ListingWizardState::AwaitingTitle(state)]
|
||||||
|
.endpoint(handle_title_input),
|
||||||
|
)
|
||||||
|
.branch(
|
||||||
|
dptree::case![ListingWizardState::AwaitingDescription(state)]
|
||||||
|
.endpoint(handle_description_input),
|
||||||
|
)
|
||||||
|
.branch(
|
||||||
|
dptree::case![ListingWizardState::AwaitingPrice(state)]
|
||||||
|
.endpoint(handle_price_input),
|
||||||
|
)
|
||||||
|
.branch(
|
||||||
|
dptree::case![ListingWizardState::AwaitingSlots(state)]
|
||||||
|
.endpoint(handle_slots_input),
|
||||||
|
)
|
||||||
|
.branch(
|
||||||
|
dptree::case![ListingWizardState::AwaitingStartTime(state)]
|
||||||
|
.endpoint(handle_start_time_input),
|
||||||
|
)
|
||||||
|
.branch(
|
||||||
|
dptree::case![ListingWizardState::AwaitingDuration(state)]
|
||||||
|
.endpoint(handle_duration_input),
|
||||||
|
)
|
||||||
|
.branch(
|
||||||
|
dptree::case![ListingWizardState::ViewingDraft(state)]
|
||||||
|
.endpoint(handle_viewing_draft),
|
||||||
|
)
|
||||||
|
.branch(
|
||||||
|
dptree::case![ListingWizardState::EditingListing(state)]
|
||||||
|
.endpoint(handle_editing_screen),
|
||||||
|
)
|
||||||
|
.branch(
|
||||||
|
dptree::case![ListingWizardState::EditingTitle(state)]
|
||||||
|
.endpoint(handle_edit_title),
|
||||||
|
)
|
||||||
|
.branch(
|
||||||
|
dptree::case![ListingWizardState::EditingDescription(state)]
|
||||||
|
.endpoint(handle_edit_description),
|
||||||
|
)
|
||||||
|
.branch(
|
||||||
|
dptree::case![ListingWizardState::EditingPrice(state)]
|
||||||
|
.endpoint(handle_edit_price),
|
||||||
|
)
|
||||||
|
.branch(
|
||||||
|
dptree::case![ListingWizardState::EditingSlots(state)]
|
||||||
|
.endpoint(handle_edit_slots),
|
||||||
|
)
|
||||||
|
.branch(
|
||||||
|
dptree::case![ListingWizardState::EditingStartTime(state)]
|
||||||
|
.endpoint(handle_edit_start_time),
|
||||||
|
)
|
||||||
|
.branch(
|
||||||
|
dptree::case![ListingWizardState::EditingDuration(state)]
|
||||||
|
.endpoint(handle_edit_duration),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.branch(
|
.branch(
|
||||||
dptree::case![ListingWizardState::AwaitingDescription(state)]
|
Update::filter_callback_query()
|
||||||
.endpoint(handle_description_input),
|
.enter_dialogue::<CallbackQuery, SqliteStorage<Json>, ListingWizardState>()
|
||||||
)
|
.branch(
|
||||||
.branch(
|
dptree::case![ListingWizardState::AwaitingDescription(state)]
|
||||||
dptree::case![ListingWizardState::AwaitingPrice(state)].endpoint(handle_price_input),
|
.endpoint(handle_description_callback),
|
||||||
)
|
)
|
||||||
.branch(
|
.branch(
|
||||||
dptree::case![ListingWizardState::AwaitingSlots(state)].endpoint(handle_slots_input),
|
dptree::case![ListingWizardState::AwaitingSlots(state)]
|
||||||
)
|
.endpoint(handle_slots_callback),
|
||||||
.branch(
|
)
|
||||||
dptree::case![ListingWizardState::AwaitingStartTime(state)]
|
.branch(
|
||||||
.endpoint(handle_start_time_input),
|
dptree::case![ListingWizardState::AwaitingStartTime(state)]
|
||||||
)
|
.endpoint(handle_start_time_callback),
|
||||||
.branch(
|
)
|
||||||
dptree::case![ListingWizardState::AwaitingDuration(state)]
|
.branch(
|
||||||
.endpoint(handle_duration_input),
|
dptree::case![ListingWizardState::AwaitingDuration(state)]
|
||||||
)
|
.endpoint(handle_duration_callback),
|
||||||
.branch(
|
)
|
||||||
dptree::case![ListingWizardState::ViewingDraft(state)].endpoint(handle_viewing_draft),
|
.branch(
|
||||||
)
|
dptree::case![ListingWizardState::ViewingDraft(state)]
|
||||||
.branch(
|
.endpoint(handle_viewing_draft_callback),
|
||||||
dptree::case![ListingWizardState::EditingListing(state)]
|
)
|
||||||
.endpoint(handle_editing_screen),
|
.branch(
|
||||||
)
|
dptree::case![ListingWizardState::EditingListing(state)]
|
||||||
.branch(dptree::case![ListingWizardState::EditingTitle(state)].endpoint(handle_edit_title))
|
.endpoint(handle_editing_callback),
|
||||||
.branch(
|
)
|
||||||
dptree::case![ListingWizardState::EditingDescription(state)]
|
.branch(
|
||||||
.endpoint(handle_edit_description),
|
dptree::case![ListingWizardState::EditingTitle(state)]
|
||||||
)
|
.endpoint(handle_edit_field_callback),
|
||||||
.branch(dptree::case![ListingWizardState::EditingPrice(state)].endpoint(handle_edit_price))
|
)
|
||||||
.branch(dptree::case![ListingWizardState::EditingSlots(state)].endpoint(handle_edit_slots))
|
.branch(
|
||||||
.branch(
|
dptree::case![ListingWizardState::EditingDescription(state)]
|
||||||
dptree::case![ListingWizardState::EditingStartTime(state)]
|
.endpoint(handle_edit_field_callback),
|
||||||
.endpoint(handle_edit_start_time),
|
)
|
||||||
)
|
.branch(
|
||||||
.branch(
|
dptree::case![ListingWizardState::EditingPrice(state)]
|
||||||
dptree::case![ListingWizardState::EditingDuration(state)]
|
.endpoint(handle_edit_field_callback),
|
||||||
.endpoint(handle_edit_duration),
|
)
|
||||||
|
.branch(
|
||||||
|
dptree::case![ListingWizardState::EditingSlots(state)]
|
||||||
|
.endpoint(handle_edit_field_callback),
|
||||||
|
)
|
||||||
|
.branch(
|
||||||
|
dptree::case![ListingWizardState::EditingStartTime(state)]
|
||||||
|
.endpoint(handle_edit_field_callback),
|
||||||
|
)
|
||||||
|
.branch(
|
||||||
|
dptree::case![ListingWizardState::EditingDuration(state)]
|
||||||
|
.endpoint(handle_edit_field_callback),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle the /newlisting command - starts the dialogue
|
// Handle the /newlisting command - starts the dialogue by setting it to Start state
|
||||||
pub async fn handle_new_listing(
|
async fn handle_new_listing_command(
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
dialogue: NewListingDialogue,
|
dialogue: NewListingDialogue,
|
||||||
msg: Message,
|
msg: Message,
|
||||||
@@ -118,6 +190,28 @@ pub async fn handle_new_listing(
|
|||||||
msg.chat.id
|
msg.chat.id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Initialize the dialogue to Start state
|
||||||
|
dialogue.update(ListingWizardState::Start).await?;
|
||||||
|
|
||||||
|
let response = "🛍️ <b>Creating New Fixed Price Listing</b>\n\n\
|
||||||
|
Let's create your fixed price listing step by step!\n\n\
|
||||||
|
<i>Step 1 of 6: Title</i>\n\
|
||||||
|
Please enter a title for your listing (max 100 characters):";
|
||||||
|
|
||||||
|
bot.send_message(msg.chat.id, response)
|
||||||
|
.parse_mode(ParseMode::Html)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the /newlisting command - starts the dialogue (called from within dialogue context)
|
||||||
|
async fn handle_new_listing(bot: Bot, dialogue: NewListingDialogue, msg: Message) -> HandlerResult {
|
||||||
|
info!(
|
||||||
|
"User {} ({}) started new fixed price listing wizard",
|
||||||
|
msg.chat.username().unwrap_or("unknown"),
|
||||||
|
msg.chat.id
|
||||||
|
);
|
||||||
|
|
||||||
let response = "🛍️ <b>Creating New Fixed Price Listing</b>\n\n\
|
let response = "🛍️ <b>Creating New Fixed Price Listing</b>\n\n\
|
||||||
Let's create your fixed price listing step by step!\n\n\
|
Let's create your fixed price listing step by step!\n\n\
|
||||||
<i>Step 1 of 6: Title</i>\n\
|
<i>Step 1 of 6: Title</i>\n\
|
||||||
@@ -275,54 +369,6 @@ pub async fn handle_description_callback(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create callback query handler for skip button, slots buttons, and start time button
|
|
||||||
pub fn new_listing_callback_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> {
|
|
||||||
dptree::entry()
|
|
||||||
.branch(
|
|
||||||
dptree::case![ListingWizardState::AwaitingDescription(state)]
|
|
||||||
.endpoint(handle_description_callback),
|
|
||||||
)
|
|
||||||
.branch(
|
|
||||||
dptree::case![ListingWizardState::AwaitingSlots(state)].endpoint(handle_slots_callback),
|
|
||||||
)
|
|
||||||
.branch(
|
|
||||||
dptree::case![ListingWizardState::AwaitingStartTime(state)]
|
|
||||||
.endpoint(handle_start_time_callback),
|
|
||||||
)
|
|
||||||
.branch(
|
|
||||||
dptree::case![ListingWizardState::ViewingDraft(state)]
|
|
||||||
.endpoint(handle_viewing_draft_callback),
|
|
||||||
)
|
|
||||||
.branch(
|
|
||||||
dptree::case![ListingWizardState::EditingListing(state)]
|
|
||||||
.endpoint(handle_editing_callback),
|
|
||||||
)
|
|
||||||
.branch(
|
|
||||||
dptree::case![ListingWizardState::EditingTitle(state)]
|
|
||||||
.endpoint(handle_edit_field_callback),
|
|
||||||
)
|
|
||||||
.branch(
|
|
||||||
dptree::case![ListingWizardState::EditingDescription(state)]
|
|
||||||
.endpoint(handle_edit_field_callback),
|
|
||||||
)
|
|
||||||
.branch(
|
|
||||||
dptree::case![ListingWizardState::EditingPrice(state)]
|
|
||||||
.endpoint(handle_edit_field_callback),
|
|
||||||
)
|
|
||||||
.branch(
|
|
||||||
dptree::case![ListingWizardState::EditingSlots(state)]
|
|
||||||
.endpoint(handle_edit_field_callback),
|
|
||||||
)
|
|
||||||
.branch(
|
|
||||||
dptree::case![ListingWizardState::EditingStartTime(state)]
|
|
||||||
.endpoint(handle_edit_field_callback),
|
|
||||||
)
|
|
||||||
.branch(
|
|
||||||
dptree::case![ListingWizardState::EditingDuration(state)]
|
|
||||||
.endpoint(handle_edit_field_callback),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_slots_callback(
|
pub async fn handle_slots_callback(
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
dialogue: NewListingDialogue,
|
dialogue: NewListingDialogue,
|
||||||
@@ -609,11 +655,21 @@ async fn process_start_time_and_respond(
|
|||||||
|
|
||||||
bot.send_message(chat_id, response)
|
bot.send_message(chat_id, response)
|
||||||
.parse_mode(ParseMode::Html)
|
.parse_mode(ParseMode::Html)
|
||||||
|
.reply_markup(create_duration_keyboard())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_duration_keyboard() -> InlineKeyboardMarkup {
|
||||||
|
InlineKeyboardMarkup::new([[
|
||||||
|
InlineKeyboardButton::callback("1 day", "duration_1_day"),
|
||||||
|
InlineKeyboardButton::callback("3 days", "duration_3_days"),
|
||||||
|
InlineKeyboardButton::callback("7 days", "duration_7_days"),
|
||||||
|
InlineKeyboardButton::callback("14 days", "duration_14_days"),
|
||||||
|
]])
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn handle_price_input(
|
pub async fn handle_price_input(
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
dialogue: NewListingDialogue,
|
dialogue: NewListingDialogue,
|
||||||
@@ -667,16 +723,12 @@ pub async fn handle_price_input(
|
|||||||
price
|
price
|
||||||
);
|
);
|
||||||
|
|
||||||
let slots_buttons = InlineKeyboardMarkup::new([
|
let slots_buttons = InlineKeyboardMarkup::new([[
|
||||||
[
|
InlineKeyboardButton::callback("1", "slots_1"),
|
||||||
InlineKeyboardButton::callback("1", "slots_1"),
|
InlineKeyboardButton::callback("2", "slots_2"),
|
||||||
InlineKeyboardButton::callback("2", "slots_2"),
|
InlineKeyboardButton::callback("5", "slots_5"),
|
||||||
],
|
InlineKeyboardButton::callback("10", "slots_10"),
|
||||||
[
|
]]);
|
||||||
InlineKeyboardButton::callback("5", "slots_5"),
|
|
||||||
InlineKeyboardButton::callback("10", "slots_10"),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
bot.send_message(chat_id, response)
|
bot.send_message(chat_id, response)
|
||||||
.parse_mode(ParseMode::Html)
|
.parse_mode(ParseMode::Html)
|
||||||
@@ -781,7 +833,7 @@ pub async fn handle_start_time_input(
|
|||||||
pub async fn handle_duration_input(
|
pub async fn handle_duration_input(
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
dialogue: NewListingDialogue,
|
dialogue: NewListingDialogue,
|
||||||
mut draft: ListingDraft,
|
draft: ListingDraft,
|
||||||
msg: Message,
|
msg: Message,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
let chat_id = msg.chat.id;
|
let chat_id = msg.chat.id;
|
||||||
@@ -818,14 +870,56 @@ pub async fn handle_duration_input(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
process_duration_and_respond(bot, dialogue, draft, chat_id, duration).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_duration_callback(
|
||||||
|
bot: Bot,
|
||||||
|
dialogue: NewListingDialogue,
|
||||||
|
draft: ListingDraft,
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
) -> HandlerResult {
|
||||||
|
let data = match callback_query.data.as_deref() {
|
||||||
|
Some(data) => data,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
let chat_id = match callback_query.message {
|
||||||
|
Some(message) => message.chat().id,
|
||||||
|
_ => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let days = match data {
|
||||||
|
"duration_1_day" => 1,
|
||||||
|
"duration_3_days" => 3,
|
||||||
|
"duration_7_days" => 7,
|
||||||
|
"duration_14_days" => 14,
|
||||||
|
_ => {
|
||||||
|
bot.send_message(
|
||||||
|
chat_id,
|
||||||
|
"❌ Invalid duration. Please enter number of days (1-14):",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process_duration_and_respond(bot, dialogue, draft, chat_id, days).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_duration_and_respond(
|
||||||
|
bot: Bot,
|
||||||
|
dialogue: NewListingDialogue,
|
||||||
|
mut draft: ListingDraft,
|
||||||
|
chat_id: ChatId,
|
||||||
|
duration: i32,
|
||||||
|
) -> HandlerResult {
|
||||||
draft.duration_hours = duration;
|
draft.duration_hours = duration;
|
||||||
dialogue
|
dialogue
|
||||||
.update(ListingWizardState::ViewingDraft(draft.clone()))
|
.update(ListingWizardState::ViewingDraft(draft.clone()))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
show_confirmation(bot, chat_id, draft).await?;
|
show_confirmation(bot, chat_id, draft).await
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn show_confirmation(bot: Bot, chat_id: ChatId, state: ListingDraft) -> HandlerResult {
|
async fn show_confirmation(bot: Bot, chat_id: ChatId, state: ListingDraft) -> HandlerResult {
|
||||||
|
|||||||
34
src/main.rs
34
src/main.rs
@@ -8,13 +8,13 @@ mod wizard_utils;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use log::info;
|
use log::info;
|
||||||
use teloxide::dispatching::dialogue::serializer::Json;
|
use teloxide::dispatching::dialogue::serializer::Json;
|
||||||
use teloxide::{prelude::*, types::CallbackQuery, utils::command::BotCommands};
|
use teloxide::{prelude::*, 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::commands::new_listing::new_listing_handler;
|
||||||
use crate::sqlite_storage::SqliteStorage;
|
use crate::sqlite_storage::SqliteStorage;
|
||||||
|
|
||||||
pub type HandlerResult = anyhow::Result<()>;
|
pub type HandlerResult = anyhow::Result<()>;
|
||||||
@@ -52,27 +52,17 @@ async fn main() -> Result<()> {
|
|||||||
// Create dispatcher with dialogue system
|
// Create dispatcher with dialogue system
|
||||||
Dispatcher::builder(
|
Dispatcher::builder(
|
||||||
bot,
|
bot,
|
||||||
dptree::entry()
|
dptree::entry().branch(new_listing_handler()).branch(
|
||||||
.branch(
|
Update::filter_message().branch(
|
||||||
Update::filter_message()
|
dptree::entry()
|
||||||
.enter_dialogue::<Message, SqliteStorage<Json>, ListingWizardState>()
|
.filter_command::<Command>()
|
||||||
.branch(
|
.branch(dptree::case![Command::Start].endpoint(handle_start))
|
||||||
dptree::entry()
|
.branch(dptree::case![Command::Help].endpoint(handle_help))
|
||||||
.filter_command::<Command>()
|
.branch(dptree::case![Command::MyListings].endpoint(handle_my_listings))
|
||||||
.branch(dptree::case![Command::Start].endpoint(handle_start))
|
.branch(dptree::case![Command::MyBids].endpoint(handle_my_bids))
|
||||||
.branch(dptree::case![Command::Help].endpoint(handle_help))
|
.branch(dptree::case![Command::Settings].endpoint(handle_settings)),
|
||||||
.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, dialog_storage])
|
.dependencies(dptree::deps![db_pool, dialog_storage])
|
||||||
.enable_ctrlc_handler()
|
.enable_ctrlc_handler()
|
||||||
|
|||||||
Reference in New Issue
Block a user