Files
pawctioneer-bot/src/main.rs
2025-09-10 03:53:48 +00:00

292 lines
8.9 KiB
Rust

mod bidding;
mod bot_message_sender;
mod bot_result;
mod commands;
mod config;
mod db;
mod dptree_utils;
mod handle_error;
mod handler_utils;
mod keyboard_utils;
mod listing_expiry_checker_task;
mod message;
mod message_sender;
mod message_target;
mod message_utils;
mod sqlite_storage;
mod start_command_data;
#[cfg(test)]
mod test_utils;
mod wrap_endpoint;
use std::sync::Arc;
use crate::bidding::{bidding_handler, BiddingState};
use crate::bot_message_sender::BotMessageSender;
use crate::commands::{
my_listings::{my_listings_handler, my_listings_inline_handler, MyListingsState},
new_listing::{new_listing_handler, NewListingState},
};
use crate::db::user::PersistedUser;
use crate::db::{DAOs, ListingEventSender};
use crate::handle_error::with_error_handler;
use crate::handler_utils::{find_or_create_db_user_from_update, update_into_message_target};
use crate::message::MessageType;
use crate::message_sender::BoxedMessageSender;
use crate::sqlite_storage::SqliteStorage;
use crate::start_command_data::StartCommandData;
use anyhow::{anyhow, Result};
pub use bot_result::*;
use chrono::{Duration, Utc};
use commands::*;
use config::Config;
use log::info;
pub use message_target::MessageTarget;
use serde::{Deserialize, Serialize};
use teloxide::dispatching::dialogue::serializer::Json;
use teloxide::types::Me;
use teloxide::{prelude::*, types::BotCommand, utils::command::BotCommands};
pub use wrap_endpoint::*;
#[derive(Clone)]
pub struct App {
pub bot: Arc<BoxedMessageSender>,
pub daos: DAOs,
pub bot_username: String,
}
impl App {
pub fn new(bot: BoxedMessageSender, daos: DAOs, bot_username: String) -> Self {
Self {
bot: Arc::new(bot),
daos,
bot_username,
}
}
pub fn url_for_start_command(&self, command: StartCommandData) -> reqwest::Url {
format!(
"tg://resolve?domain={}&start={}",
self.bot_username,
command.encode_for_start_command()
)
.parse()
.unwrap()
}
pub async fn send_message(&self, message: MessageType) -> BotResult {
self.bot.send_message(message).await
}
}
#[derive(Debug, Clone)]
struct BotUsername(String);
/// Set up the bot's command menu that appears when users tap the menu button
async fn setup_bot_commands(bot: &Bot) -> Result<()> {
info!("Setting up bot command menu...");
// Convert our Command enum to Telegram BotCommand structs
let commands: Vec<BotCommand> = Command::bot_commands()
.into_iter()
.map(|cmd| BotCommand::new(cmd.command, cmd.description))
.collect();
// Set the commands for the bot's menu
bot.set_my_commands(commands).await?;
info!("Bot command menu configured successfully");
Ok(())
}
#[derive(BotCommands, Clone)]
#[command(rename_rule = "lowercase", description = "Auction Bot Commands")]
pub enum Command {
#[command(description = "Show welcome message")]
Start,
#[command(description = "Show help message")]
Help,
#[command(description = "Create a new listing or auction")]
NewListing,
#[command(description = "View your listings and auctions")]
MyListings,
#[command(description = "View your active bids")]
MyBids,
#[command(description = "Configure notifications")]
Settings,
}
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
enum DialogueRootState {
#[default]
Start,
MainMenu,
NewListing(NewListingState),
MyListings(MyListingsState),
Bidding(BiddingState),
}
type RootDialogue = Dialogue<DialogueRootState, SqliteStorage<Json>>;
pub fn main_handler() -> BotHandler {
dptree::entry()
.map(|daos: DAOs| daos.user.clone())
.map(|daos: DAOs| daos.listing.clone())
.map(|daos: DAOs| daos.bid.clone())
.filter_map_async(find_or_create_db_user_from_update)
.branch(
Update::filter_message()
.filter(filter_forwarded_from_pawctioneer_bot)
.endpoint(handle_forwarded_from_pawctioneer_bot),
)
.branch(my_listings_inline_handler())
.branch(
dptree::entry()
.enter_dialogue::<Update, SqliteStorage<Json>, DialogueRootState>()
.branch(new_listing_handler())
.branch(my_listings_handler())
.branch(bidding_handler())
.branch(
Update::filter_callback_query().branch(
dptree::case![DialogueRootState::MainMenu]
.endpoint(with_error_handler(handle_main_menu_callback)),
),
)
.branch(
Update::filter_message()
.filter_command::<Command>()
.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(with_error_handler(unknown_message_handler)))
}
#[tokio::main]
async fn main() -> Result<()> {
// Load and validate configuration from environment/.env file
let config = Config::from_env()?;
// Create database connection pool
let db_pool = config.create_database_pool().await?;
info!("Starting Pawctioneer Bot...");
let bot = Box::new(Bot::new(&config.telegram_token));
// set up the listing expiry checker task
let (listing_expiry_checker_tx, listing_expiry_checker_rx) = ListingEventSender::channel();
let daos = DAOs::new(db_pool.clone(), listing_expiry_checker_tx);
tokio::spawn(listing_expiry_checker_task::task(
Box::new(BotMessageSender::new(*bot.clone(), None)),
daos.clone(),
listing_expiry_checker_rx,
));
// Set up the bot's command menu
setup_bot_commands(&bot).await?;
let handler_with_deps = dptree::entry()
.filter_map(
|bot: Box<Bot>, update: Update, daos: DAOs, bot_username: BotUsername| {
let target = update_into_message_target(update)?;
Some(App::new(
Box::new(BotMessageSender::new(*bot, Some(target))),
daos.clone(),
bot_username.0,
))
},
)
.chain(main_handler());
let dialog_storage = SqliteStorage::new(db_pool.clone(), Json).await?;
let bot_username = bot
.get_me()
.await?
.username
.as_ref()
.ok_or(anyhow!("Bot username not found"))?
.clone();
Dispatcher::builder(bot, handler_with_deps)
.dependencies(dptree::deps![
dialog_storage,
daos,
BotUsername(bot_username)
])
.enable_ctrlc_handler()
.worker_queue_size(1)
.build()
.dispatch()
.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("")
)))
}
fn filter_forwarded_from_pawctioneer_bot(message: Message, me: Me) -> bool {
if let Some(bot) = message.via_bot {
if bot.id == me.user.id {
return true;
}
}
false
}
async fn handle_forwarded_from_pawctioneer_bot(user: PersistedUser) -> BotResult {
info!("Received forwarded message from Pawctioneer Bot from {user:?}, ignoring...");
Ok(())
}
#[cfg(test)]
mod tests {
use mockall::predicate::{always, function};
use super::*;
use crate::message_sender::MockMessageSender;
use crate::test_utils::*;
#[tokio::test]
async fn test_main_handler() {
let mut message_sender = MockMessageSender::new();
message_sender
.expect_send_html_message()
.times(1)
.with(
function(|text: &String| text.contains("Available Commands:")),
always(),
)
.returning(|_, _| Ok(()));
let Deps { deps, .. } = create_deps().await;
let mut deps = with_message_sender(deps, message_sender).await;
deps.insert(create_tele_update("/help"));
let handler = main_handler();
dptree::type_check(handler.sig(), &deps, &[]);
let result = handler.dispatch(deps).await;
assert!(matches!(result, ControlFlow::Break(Ok(()))), "{:?}", result);
}
}