error handler wrapper helper

This commit is contained in:
Dylan Knutson
2025-09-02 19:55:15 +00:00
parent 8610b4cc52
commit c53dccbea2
14 changed files with 188 additions and 113 deletions

12
src/bot_result.rs Normal file
View File

@@ -0,0 +1,12 @@
use teloxide::dispatching::DpHandlerDescription;
#[derive(thiserror::Error, Debug)]
pub enum BotError {
#[error("User visible error: {0}")]
UserVisibleError(String),
#[error(transparent)]
InternalError(#[from] anyhow::Error),
}
pub type BotResult<T = ()> = Result<T, BotError>;
pub type BotHandler = dptree::Handler<'static, BotResult, DpHandlerDescription>;

View File

@@ -1,8 +1,10 @@
use teloxide::{prelude::*, types::Message, utils::command::BotCommands, Bot};
use crate::{
message_utils::{send_message, MessageTarget},
BotResult, Command,
};
use teloxide::{utils::command::BotCommands, Bot};
use crate::{BotResult, Command};
pub async fn handle_help(bot: Bot, msg: Message) -> BotResult {
pub async fn handle_help(bot: Bot, target: MessageTarget) -> BotResult {
let help_message = format!(
"📋 Available Commands:\n\n{}\n\n\
📧 Support: Contact @admin for help\n\
@@ -10,6 +12,6 @@ pub async fn handle_help(bot: Bot, msg: Message) -> BotResult {
Command::descriptions()
);
bot.send_message(msg.chat.id, help_message).await?;
send_message(&bot, target, help_message, None).await?;
Ok(())
}

View File

@@ -1,9 +1,11 @@
use crate::{
message_utils::{send_message, MessageTarget},
BotResult,
};
use log::info;
use teloxide::{prelude::*, types::Message, Bot};
use teloxide::{types::Message, Bot};
use crate::BotResult;
pub async fn handle_my_bids(bot: Bot, msg: Message) -> BotResult {
pub async fn handle_my_bids(bot: Bot, msg: Message, target: MessageTarget) -> BotResult {
let response = "🎯 My Bids (Coming Soon)\n\n\
Here you'll be able to view:\n\
• Your active bids\n\
@@ -18,6 +20,6 @@ pub async fn handle_my_bids(bot: Bot, msg: Message) -> BotResult {
msg.chat.id
);
bot.send_message(msg.chat.id, response).await?;
send_message(&bot, target, response, None).await?;
Ok(())
}

View File

@@ -20,8 +20,9 @@ use crate::{
find_or_create_db_user_from_message, message_into_message_target,
},
message_utils::{extract_callback_data, pluralize_with_count, send_message, MessageTarget},
BotResult, Command, DialogueRootState, RootDialogue,
BotError, BotResult, Command, DialogueRootState, RootDialogue,
};
use anyhow::{anyhow, Context};
use log::info;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
@@ -106,9 +107,15 @@ async fn handle_forward_listing(
) -> BotResult {
info!("Handling forward listing inline query for listing {listing:?}");
let bot_username = match bot.get_me().await?.username.as_ref() {
let bot_username = match bot
.get_me()
.await
.context("failed to get bot username")?
.username
.as_ref()
{
Some(username) => username.to_string(),
None => anyhow::bail!("Bot username not found"),
None => return Err(anyhow!("Bot username not found").into()),
};
// Create inline keyboard with auction interaction buttons
@@ -167,7 +174,8 @@ async fn handle_forward_listing(
.reply_markup(keyboard), // Add the inline keyboard here!
)],
)
.await?;
.await
.map_err(|e| anyhow::anyhow!("Error answering inline query: {e:?}"))?;
Ok(())
}
@@ -215,7 +223,10 @@ pub async fn enter_my_listings(
flash: Option<String>,
) -> BotResult {
// Transition to ViewingListings state
dialogue.update(MyListingsState::ViewingListings).await?;
dialogue
.update(MyListingsState::ViewingListings)
.await
.context("failed to update dialogue")?;
let listings = ListingDAO::find_by_seller(&db_pool, user.persisted.id).await?;
// Create keyboard with buttons for each listing
@@ -273,8 +284,7 @@ async fn handle_viewing_listings_callback(
let button = MyListingsButtons::try_from(data.as_str())?;
match button {
MyListingsButtons::SelectListing(listing_id) => {
let listing =
get_listing_for_user(&db_pool, &bot, user, listing_id, target.clone()).await?;
let listing = get_listing_for_user(&db_pool, user, listing_id).await?;
enter_show_listing_details(&bot, dialogue, listing, target).await?;
}
@@ -307,7 +317,8 @@ async fn enter_show_listing_details(
);
dialogue
.update(MyListingsState::ManagingListing(listing_id))
.await?;
.await
.context("failed to update dialogue")?;
let keyboard = InlineKeyboardMarkup::default()
.append_row([
ManageListingButtons::PreviewMessage.to_button(),
@@ -348,8 +359,7 @@ async fn handle_managing_listing_callback(
unimplemented!("Forward listing not implemented");
}
ManageListingButtons::Edit => {
let listing =
get_listing_for_user(&db_pool, &bot, user, listing_id, target.clone()).await?;
let listing = get_listing_for_user(&db_pool, user, listing_id).await?;
let draft = ListingDraft::from_persisted(listing);
enter_edit_listing_draft(&bot, target, draft, dialogue, None).await?;
}
@@ -426,28 +436,20 @@ async fn send_preview_listing_message(
async fn get_listing_for_user(
db_pool: &SqlitePool,
bot: &Bot,
user: PersistedUser,
listing_id: ListingDbId,
target: MessageTarget,
) -> BotResult<PersistedListing> {
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"));
return Err(BotError::UserVisibleError("❌ Listing not found.".into()));
}
};
if listing.base.seller_id != user.persisted.id {
send_message(
bot,
target,
"❌ You can only manage your own auctions.",
None,
)
.await?;
return Err(anyhow::anyhow!("User does not own listing"));
return Err(BotError::UserVisibleError(
"❌ You can only manage your own auctions.".into(),
));
}
Ok(listing)

View File

@@ -261,7 +261,8 @@ async fn handle_starting_bid_amount_callback(
EditMinimumBidIncrementKeyboardButtons::OneDollar => "1.00",
EditMinimumBidIncrementKeyboardButtons::FiveDollars => "5.00",
EditMinimumBidIncrementKeyboardButtons::TenDollars => "10.00",
})?;
})
.map_err(|e| anyhow::anyhow!("Error parsing starting bid amount: {e:?}"))?;
update_field_on_draft(
ListingField::StartingBidAmount,
@@ -307,11 +308,7 @@ async fn handle_currency_type_callback(
}
/// Cancel the wizard and exit
pub async fn cancel_wizard(
bot: Bot,
dialogue: RootDialogue,
target: MessageTarget,
) -> BotResult {
pub async fn cancel_wizard(bot: Bot, dialogue: RootDialogue, target: MessageTarget) -> BotResult {
info!("{target:?} cancelled new listing wizard");
enter_select_new_listing_type(bot, dialogue, target).await?;
Ok(())

View File

@@ -3,6 +3,8 @@
//! This module handles the core logic for processing and updating listing fields
//! during both initial creation and editing workflows.
use anyhow::Context;
use crate::commands::new_listing::messages::step_for_field;
use crate::commands::new_listing::{types::NewListingState, validations::*};
use crate::{
@@ -18,7 +20,8 @@ pub async fn transition_to_field(
) -> BotResult {
dialogue
.update(NewListingState::AwaitingDraftField { field, draft })
.await?;
.await
.context("failed to update dialogue")?;
Ok(())
}

View File

@@ -28,9 +28,9 @@ use crate::{
ListingDAO,
},
message_utils::*,
DialogueRootState, BotResult, RootDialogue,
BotResult, DialogueRootState, RootDialogue,
};
use anyhow::bail;
use anyhow::{anyhow, Context};
use log::info;
use sqlx::SqlitePool;
use teloxide::{prelude::*, types::*, Bot};
@@ -53,7 +53,8 @@ pub async fn enter_select_new_listing_type(
// Initialize the dialogue to listing type selection state
dialogue
.update(NewListingState::SelectingListingType)
.await?;
.await
.context("failed to update dialogue")?;
send_message(
&bot,
@@ -83,10 +84,10 @@ pub async fn handle_awaiting_draft_field_input(
return Ok(());
}
Err(SetFieldError::UnsupportedFieldForListingType) => {
bail!("Cannot update field {field:?} for listing type");
return Err(anyhow!("Cannot update field {field:?} for listing type").into());
}
Err(SetFieldError::FieldRequired) => {
bail!("Cannot update field {field:?} on existing listing");
return Err(anyhow!("Cannot update field {field:?} on existing listing").into());
}
};
@@ -124,10 +125,10 @@ pub async fn handle_editing_field_input(
return Ok(());
}
Err(SetFieldError::UnsupportedFieldForListingType) => {
bail!("Cannot update field {field:?} for listing type");
return Err(anyhow!("Cannot update field {field:?} for listing type").into());
}
Err(SetFieldError::FieldRequired) => {
bail!("Cannot update field {field:?} on existing listing");
return Err(anyhow!("Cannot update field {field:?} on existing listing").into());
}
};
@@ -159,7 +160,7 @@ pub async fn handle_viewing_draft_callback(
let response = "🗑️ <b>Changes Discarded</b>\n\n\
Your changes have been discarded and not saved.";
send_message(&bot, target, &response, None).await?;
dialogue.exit().await?;
dialogue.exit().await.context("failed to exit dialogue")?;
}
ConfirmationKeyboardButtons::Discard => {
info!("User {target:?} discarded listing creation");
@@ -168,7 +169,7 @@ pub async fn handle_viewing_draft_callback(
Your listing has been discarded and not created.\n\
You can start a new listing anytime with /newlisting.";
send_message(&bot, target, &response, None).await?;
dialogue.exit().await?;
dialogue.exit().await.context("failed to exit dialogue")?;
}
ConfirmationKeyboardButtons::Edit => {
info!("User {target:?} chose to edit listing");
@@ -203,7 +204,7 @@ pub async fn handle_editing_draft_callback(
FieldSelectionKeyboardButtons::StartTime => ListingField::StartTime,
FieldSelectionKeyboardButtons::Duration => ListingField::EndTime,
FieldSelectionKeyboardButtons::Done => {
return Err(anyhow::anyhow!("Done button should not be used here"))
return Err(anyhow::anyhow!("Done button should not be used here").into());
}
};
@@ -214,7 +215,8 @@ pub async fn handle_editing_draft_callback(
.update(DialogueRootState::NewListing(
NewListingState::EditingDraftField { field, draft },
))
.await?;
.await
.context("failed to update dialogue")?;
let response = format!("Editing {field:?}\n\nPrevious value: {value}");
send_message(&bot, target, response, Some(keyboard)).await?;
@@ -263,7 +265,8 @@ pub async fn enter_edit_listing_draft(
.await?;
dialogue
.update(NewListingState::EditingDraft(draft))
.await?;
.await
.context("failed to update dialogue")?;
Ok(())
}

View File

@@ -9,6 +9,7 @@ use crate::commands::new_listing::NewListingState;
use crate::db::ListingType;
use crate::RootDialogue;
use crate::{commands::new_listing::types::ListingDraft, message_utils::*, BotResult};
use anyhow::Context;
use teloxide::{types::InlineKeyboardMarkup, Bot};
/// Display the listing summary with optional flash message and keyboard
@@ -77,6 +78,7 @@ pub async fn enter_confirm_save_listing(
display_listing_summary(bot, target, &draft, Some(keyboard), flash).await?;
dialogue
.update(NewListingState::ViewingDraft(draft))
.await?;
.await
.context("failed to update dialogue")?;
Ok(())
}

View File

@@ -1,9 +1,11 @@
use crate::{
message_utils::{send_message, MessageTarget},
BotResult,
};
use log::info;
use teloxide::{prelude::*, types::Message, Bot};
use teloxide::{types::Message, Bot};
use crate::BotResult;
pub async fn handle_settings(bot: Bot, msg: Message) -> BotResult {
pub async fn handle_settings(bot: Bot, msg: Message, target: MessageTarget) -> BotResult {
let response = "⚙️ Settings (Coming Soon)\n\n\
Here you'll be able to configure:\n\
• Notification preferences\n\
@@ -18,6 +20,6 @@ pub async fn handle_settings(bot: Bot, msg: Message) -> BotResult {
msg.chat.id
);
bot.send_message(msg.chat.id, response).await?;
send_message(&bot, target, response, None).await?;
Ok(())
}

View File

@@ -1,3 +1,4 @@
use anyhow::Context;
use log::info;
use teloxide::{types::CallbackQuery, utils::command::BotCommands, Bot};
@@ -8,7 +9,7 @@ use crate::{
db::user::PersistedUser,
keyboard_buttons,
message_utils::{extract_callback_data, send_message, MessageTarget},
Command, DialogueRootState, BotResult, RootDialogue,
BotResult, Command, DialogueRootState, RootDialogue,
};
keyboard_buttons! {
@@ -35,22 +36,17 @@ pub fn get_main_menu_message() -> &'static str {
Choose an option below to get started! 🚀"
}
pub async fn handle_start(
bot: Bot,
dialogue: RootDialogue,
target: MessageTarget,
) -> BotResult {
pub async fn handle_start(bot: Bot, dialogue: RootDialogue, target: MessageTarget) -> BotResult {
enter_main_menu(bot, dialogue, target).await?;
Ok(())
}
/// Show the main menu with buttons
pub async fn enter_main_menu(
bot: Bot,
dialogue: RootDialogue,
target: MessageTarget,
) -> BotResult {
dialogue.update(DialogueRootState::MainMenu).await?;
pub async fn enter_main_menu(bot: Bot, dialogue: RootDialogue, target: MessageTarget) -> BotResult {
dialogue
.update(DialogueRootState::MainMenu)
.await
.context("failed to update dialogue")?;
send_message(
&bot,

47
src/handle_error.rs Normal file
View File

@@ -0,0 +1,47 @@
use crate::{
message_utils::{send_message, MessageTarget},
wrap_endpoint, BotError, BotResult, WrappedAsyncFn,
};
use futures::future::BoxFuture;
use std::{future::Future, pin::Pin};
use teloxide::Bot;
pub async fn handle_error(bot: Bot, target: MessageTarget, error: BotError) -> BotResult {
log::error!("Error in handler: {error}");
match error {
BotError::UserVisibleError(message) => send_message(&bot, target, message, None).await?,
BotError::InternalError(error) => {
log::error!("Internal error: {error}");
send_message(
&bot,
target,
"An internal error occurred. Please try again later.",
None,
)
.await?;
}
}
Ok(())
}
fn boxed_handle_error(
bot: Bot,
target: MessageTarget,
error: BotError,
) -> Pin<Box<dyn Future<Output = BotResult> + Send>> {
Box::pin(handle_error(bot, target, error))
}
pub type ErrorHandlerWrapped<FnBase, FnBaseArgs> = WrappedAsyncFn<
FnBase,
fn(Bot, MessageTarget, BotError) -> BoxFuture<'static, BotResult>,
BotError,
FnBaseArgs,
(Bot, MessageTarget),
>;
pub fn with_error_handler<FnBase, FnBaseArgs>(
handler: FnBase,
) -> ErrorHandlerWrapped<FnBase, FnBaseArgs> {
wrap_endpoint(handler, boxed_handle_error)
}

View File

@@ -1,15 +1,18 @@
mod bot_endpoint;
mod bot_result;
mod commands;
mod config;
mod db;
mod dptree_utils;
mod handle_error;
mod handler_utils;
mod keyboard_utils;
mod message_utils;
mod sqlite_storage;
#[cfg(test)]
mod test_utils;
mod wrap_endpoint;
use crate::handle_error::with_error_handler;
use crate::handler_utils::{callback_query_into_message_target, message_into_message_target};
use crate::sqlite_storage::SqliteStorage;
use crate::{
@@ -20,13 +23,14 @@ use crate::{
handler_utils::find_or_create_db_user_from_callback_query,
};
use anyhow::Result;
pub use bot_endpoint::*;
pub use bot_result::*;
use commands::*;
use config::Config;
use log::info;
use serde::{Deserialize, Serialize};
use teloxide::dispatching::dialogue::serializer::Json;
use teloxide::{prelude::*, types::BotCommand, utils::command::BotCommands};
pub use wrap_endpoint::*;
/// Set up the bot's command menu that appears when users tap the menu button
async fn setup_bot_commands(bot: &Bot) -> Result<()> {
@@ -73,11 +77,6 @@ enum DialogueRootState {
type RootDialogue = Dialogue<DialogueRootState, SqliteStorage<Json>>;
async fn handle_error(bot: Bot, error: BotError) -> BotResult {
log::error!("Error in handler: {error}");
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
// Load and validate configuration from environment/.env file
@@ -110,23 +109,36 @@ async fn main() -> Result<()> {
.branch(
dptree::case![DialogueRootState::MainMenu]
.filter_map_async(find_or_create_db_user_from_callback_query)
.endpoint(wrap_endpoint(
handle_main_menu_callback,
handle_error,
)),
.endpoint(with_error_handler(handle_main_menu_callback)),
),
)
.branch(
Update::filter_message()
.map(message_into_message_target)
.filter_command::<Command>()
.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(
dptree::case![Command::Start]
.endpoint(with_error_handler(handle_start)),
)
.branch(
dptree::case![Command::Help]
.endpoint(with_error_handler(handle_help)),
)
.branch(
dptree::case![Command::MyBids]
.endpoint(with_error_handler(handle_my_bids)),
)
.branch(
dptree::case![Command::Settings]
.endpoint(with_error_handler(handle_settings)),
),
),
)
.branch(Update::filter_message().endpoint(unknown_message_handler)),
.branch(
Update::filter_message()
.map(message_into_message_target)
.endpoint(with_error_handler(unknown_message_handler)),
),
)
.dependencies(dptree::deps![db_pool, dialog_storage])
.enable_ctrlc_handler()
@@ -138,17 +150,11 @@ async fn main() -> Result<()> {
Ok(())
}
async fn unknown_message_handler(bot: Bot, msg: Message) -> BotResult {
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(())
async fn unknown_message_handler(msg: Message) -> BotResult {
Err(BotError::UserVisibleError(format!(
"Unknown command: `{}`\n\n\
Try /help to see the list of commands.\
",
msg.text().unwrap_or("")
)))
}

View File

@@ -1,5 +1,5 @@
use crate::BotResult;
use anyhow::bail;
use anyhow::{anyhow, Context};
use chrono::{DateTime, Utc};
use num::One;
use std::fmt::Display;
@@ -126,7 +126,7 @@ pub async fn send_message(
if let Some(kb) = keyboard {
message = message.reply_markup(kb);
}
message.await?;
message.await.context("failed to edit message")?;
} else {
let mut message = bot
.send_message(target.chat_id, text.as_ref())
@@ -134,7 +134,7 @@ pub async fn send_message(
if let Some(kb) = keyboard {
message = message.reply_markup(kb);
}
message.await?;
message.await.context("failed to send message")?;
}
Ok(())
}
@@ -152,7 +152,7 @@ pub fn create_single_button_keyboard(text: &str, callback_data: &str) -> InlineK
pub async fn extract_callback_data(bot: &Bot, callback_query: CallbackQuery) -> BotResult<String> {
let data = match callback_query.data {
Some(data) => data,
None => bail!("Missing data in callback query"),
None => return Err(anyhow!("Missing data in callback query"))?,
};
// Answer the callback query to remove loading state

View File

@@ -2,11 +2,6 @@ use dptree::di::{CompiledFn, DependencyMap, Injectable};
use dptree::Type;
use std::sync::Arc;
use std::{collections::BTreeSet, future::Future, marker::PhantomData};
use teloxide::dispatching::DpHandlerDescription;
pub type BotError = anyhow::Error;
pub type BotResult<T = ()> = Result<T, BotError>;
pub type BotHandler = dptree::Handler<'static, BotResult, DpHandlerDescription>;
pub struct WrappedAsyncFn<FnBase, FnError, ErrorType, FnBaseArgs, FnErrorArgs> {
fn_base: FnBase,
@@ -89,7 +84,15 @@ generate_wrapped!([T1, T2, T3, T4], [E1]);
generate_wrapped!([T1, T2, T3, T4, T5], [E1]);
generate_wrapped!([T1, T2, T3, T4, T5, T6], [E1]);
pub fn wrap_endpoint<FnError, FnBase, ErrorType, FnBaseArgs, FnErrorArgs>(
generate_wrapped!([], [E1, E2]);
generate_wrapped!([T1], [E1, E2]);
generate_wrapped!([T1, T2], [E1, E2]);
generate_wrapped!([T1, T2, T3], [E1, E2]);
generate_wrapped!([T1, T2, T3, T4], [E1, E2]);
generate_wrapped!([T1, T2, T3, T4, T5], [E1, E2]);
generate_wrapped!([T1, T2, T3, T4, T5, T6], [E1, E2]);
pub fn wrap_endpoint<FnBase, FnError, ErrorType, FnBaseArgs, FnErrorArgs>(
fn_base: FnBase,
error_fn: FnError,
) -> WrappedAsyncFn<FnBase, FnError, ErrorType, FnBaseArgs, FnErrorArgs> {
@@ -98,12 +101,10 @@ pub fn wrap_endpoint<FnError, FnBase, ErrorType, FnBaseArgs, FnErrorArgs>(
#[cfg(test)]
mod tests {
use std::ops::ControlFlow;
use dptree::{deps, Handler};
use teloxide::dispatching::DpHandlerDescription;
use crate::wrap_endpoint;
use dptree::{deps, Handler};
use std::ops::ControlFlow;
use teloxide::dispatching::DpHandlerDescription;
#[derive(Debug, PartialEq, Eq)]
enum MyError {