feat: Add inline keyboard support to auction listing inline queries

- Add interactive inline keyboard with bid, watch, and share buttons
- Implement separate inline query handler before dialogue system
- Fix dispatcher structure to handle both inline queries and regular messages
- Add dynamic bot username fetching for deep links
- Support rich formatted auction listing messages with pricing info
- Handle forward_listing:ID pattern for sharing specific auctions

Resolves inline query handling and adds interactive auction sharing
This commit is contained in:
Dylan Knutson
2025-08-30 15:37:02 +00:00
parent c9a824dd88
commit 65a50b05e2
4 changed files with 260 additions and 35 deletions

View File

@@ -0,0 +1,19 @@
use crate::keyboard_buttons;
keyboard_buttons! {
pub enum MyListingsButtons {
BackToMenu("⬅️ Back to Menu", "my_listings_back_to_menu"),
}
}
keyboard_buttons! {
pub enum ManageListingButtons {
[
PreviewMessage("👀 Preview", "manage_listing_preview"),
ForwardListing("↪️ Forward", "manage_listing_forward"),
],
[Edit("✏️ Edit", "manage_listing_edit"),],
[Delete("🗑️ Delete", "manage_listing_delete"),],
[Back("⬅️ Back", "manage_listing_back"),]
}
}

View File

@@ -1,20 +1,30 @@
mod keyboard;
use crate::{ use crate::{
case, case,
commands::{ commands::{
enter_main_menu, enter_main_menu,
my_listings::keyboard::{ManageListingButtons, MyListingsButtons},
new_listing::{enter_edit_listing_draft, ListingDraft}, new_listing::{enter_edit_listing_draft, ListingDraft},
}, },
db::{listing::PersistedListing, user::PersistedUser, ListingDAO, ListingDbId, UserDAO}, db::{
keyboard_buttons, listing::{ListingFields, PersistedListing},
user::PersistedUser,
ListingDAO, ListingDbId, UserDAO,
},
message_utils::{extract_callback_data, pluralize_with_count, send_message, MessageTarget}, message_utils::{extract_callback_data, pluralize_with_count, send_message, MessageTarget},
Command, DialogueRootState, HandlerResult, RootDialogue, Command, DialogueRootState, HandlerResult, RootDialogue,
}; };
use log::info;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::SqlitePool; use sqlx::SqlitePool;
use teloxide::{ use teloxide::{
dispatching::{DpHandlerDescription, UpdateFilterExt}, dispatching::{DpHandlerDescription, UpdateFilterExt},
prelude::*, prelude::*,
types::{InlineKeyboardButton, Message}, types::{
InlineKeyboardButton, InlineKeyboardMarkup, InlineQueryResult, InlineQueryResultArticle,
InputMessageContent, InputMessageContentText, Message, ParseMode, User,
},
Bot, Bot,
}; };
@@ -29,22 +39,11 @@ impl From<MyListingsState> for DialogueRootState {
} }
} }
keyboard_buttons! { pub fn my_listings_inline_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> {
enum ManageListingButtons { Update::filter_inline_query()
[ .inspect(|query: InlineQuery| info!("Received inline query: {:?}", query))
Edit("✏️ Edit", "manage_listing_edit"), .filter_map_async(inline_query_extract_forward_listing)
Delete("🗑️ Delete", "manage_listing_delete"), .endpoint(handle_forward_listing)
],
[
Back("⬅️ Back", "manage_listing_back"),
]
}
}
keyboard_buttons! {
enum MyListingsButtons {
BackToMenu("⬅️ Back to Menu", "my_listings_back_to_menu"),
}
} }
pub fn my_listings_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> { pub fn my_listings_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> {
@@ -72,6 +71,119 @@ pub fn my_listings_handler() -> Handler<'static, HandlerResult, DpHandlerDescrip
) )
} }
async fn inline_query_extract_forward_listing(
db_pool: SqlitePool,
inline_query: InlineQuery,
) -> Option<PersistedListing> {
let query = &inline_query.query;
info!("Try to extract forward listing from query: {}", query);
let listing_id_str = query.split("forward_listing:").nth(1)?;
let listing_id = ListingDbId::new(listing_id_str.parse::<i64>().ok()?);
let listing = ListingDAO::find_by_id(&db_pool, listing_id)
.await
.unwrap_or(None)?;
Some(listing)
}
async fn handle_forward_listing(
bot: Bot,
inline_query: InlineQuery,
listing: PersistedListing,
) -> HandlerResult {
info!(
"Handling forward listing inline query for listing {:?}",
listing
);
let bot_username = match bot.get_me().await?.username.as_ref() {
Some(username) => username.to_string(),
None => anyhow::bail!("Bot username not found"),
};
// Create inline keyboard with auction interaction buttons
let keyboard = InlineKeyboardMarkup::default()
.append_row([
InlineKeyboardButton::callback(
"💰 Place Bid",
format!("inline_bid:{}", listing.persisted.id),
),
InlineKeyboardButton::callback(
"👀 Watch",
format!("inline_watch:{}", listing.persisted.id),
),
])
.append_row([InlineKeyboardButton::url(
"🔗 View Full Details",
format!(
"https://t.me/{}?start=listing:{}",
bot_username, listing.persisted.id
)
.parse()
.unwrap(),
)]);
// Get the current price based on listing type
let current_price = get_listing_current_price(&listing);
// Create a more detailed message content for the shared listing
let message_content = format!(
"🎯 <b>{}</b>\n\n\
📝 {}\n\n\
💰 <b>Current Price:</b> ${}\n\
<b>Ends:</b> {}\n\n\
<i>Use the buttons below to interact! </i>",
listing.base.title,
listing
.base
.description
.as_deref()
.unwrap_or("No description"),
current_price,
listing.persisted.end_at.format("%b %d, %Y at %H:%M UTC")
);
bot.answer_inline_query(
inline_query.id,
[InlineQueryResult::Article(
InlineQueryResultArticle::new(
listing.persisted.id.to_string(),
format!("💰 {} - ${}", listing.base.title, current_price),
InputMessageContent::Text(
InputMessageContentText::new(message_content).parse_mode(ParseMode::Html),
),
)
.description(&listing.base.title)
.reply_markup(keyboard), // Add the inline keyboard here!
)],
)
.await?;
Ok(())
}
/// Helper function to get the current price of a listing based on its type
fn get_listing_current_price(listing: &PersistedListing) -> String {
use crate::db::listing::ListingFields;
match &listing.fields {
ListingFields::BasicAuction(fields) => {
// For basic auctions, show starting bid (in a real app, you'd show current highest bid)
format!("{}", fields.starting_bid)
}
ListingFields::MultiSlotAuction(fields) => {
// For multi-slot auctions, show starting bid
format!("{}", fields.starting_bid)
}
ListingFields::FixedPriceListing(fields) => {
// For fixed price listings, show the fixed price
format!("{}", fields.buy_now_price)
}
ListingFields::BlindAuction(fields) => {
// For blind auctions, show starting bid
format!("{}", fields.starting_bid)
}
}
}
async fn handle_my_listings_command_input( async fn handle_my_listings_command_input(
db_pool: SqlitePool, db_pool: SqlitePool,
bot: Bot, bot: Bot,
@@ -202,7 +314,21 @@ async fn show_listing_details(
bot, bot,
target, target,
response, response,
Some(ManageListingButtons::to_keyboard()), Some(
InlineKeyboardMarkup::default()
.append_row([
ManageListingButtons::PreviewMessage.to_button(),
InlineKeyboardButton::switch_inline_query(
ManageListingButtons::ForwardListing.title(),
format!("forward_listing:{}", listing.persisted.id),
),
])
.append_row([
ManageListingButtons::Edit.to_button(),
ManageListingButtons::Delete.to_button(),
])
.append_row([ManageListingButtons::Back.to_button()]),
),
) )
.await?; .await?;
Ok(()) Ok(())
@@ -219,6 +345,14 @@ async fn handle_managing_listing_callback(
let target = (from.clone(), message_id); let target = (from.clone(), message_id);
match ManageListingButtons::try_from(data.as_str())? { match ManageListingButtons::try_from(data.as_str())? {
ManageListingButtons::PreviewMessage => {
let (_, listing) =
get_user_and_listing(&db_pool, &bot, from.id, listing_id, target.clone()).await?;
send_preview_listing_message(&bot, listing, from).await?;
}
ManageListingButtons::ForwardListing => {
unimplemented!("Forward listing not implemented");
}
ManageListingButtons::Edit => { ManageListingButtons::Edit => {
let (_, listing) = let (_, listing) =
get_user_and_listing(&db_pool, &bot, from.id, listing_id, target.clone()).await?; get_user_and_listing(&db_pool, &bot, from.id, listing_id, target.clone()).await?;
@@ -238,6 +372,57 @@ async fn handle_managing_listing_callback(
Ok(()) Ok(())
} }
fn keyboard_for_listing(listing: &PersistedListing) -> InlineKeyboardMarkup {
let mut keyboard = InlineKeyboardMarkup::default();
match listing.fields {
ListingFields::FixedPriceListing(_) => {
keyboard = keyboard.append_row([InlineKeyboardButton::callback(
"Buy",
"fixed_price_listing_buy",
)]);
}
ListingFields::BasicAuction(_) => {
keyboard = keyboard.append_row([
InlineKeyboardButton::callback("Submit Bid", "basic_auction_bid"),
InlineKeyboardButton::callback("Buy It Now", "basic_auction_buy_it_now"),
]);
}
ListingFields::MultiSlotAuction(_) => {
keyboard = keyboard.append_row([InlineKeyboardButton::callback(
"Submit Bid",
"multi_slot_auction_submit_bid",
)]);
}
ListingFields::BlindAuction(_) => {
keyboard = keyboard.append_row([InlineKeyboardButton::callback(
"Submit Bid",
"blind_auction_submit_bid",
)]);
}
};
keyboard
}
async fn send_preview_listing_message(
bot: &Bot,
listing: PersistedListing,
from: User,
) -> HandlerResult {
let mut response_lines = vec![];
response_lines.push(format!("<b>{}</b>", &listing.base.title));
if let Some(description) = &listing.base.description {
response_lines.push(format!("{}", description));
}
send_message(
bot,
from,
response_lines.join("\n\n"),
Some(keyboard_for_listing(&listing)),
)
.await?;
Ok(())
}
async fn get_user_and_listing( async fn get_user_and_listing(
db_pool: &SqlitePool, db_pool: &SqlitePool,
bot: &Bot, bot: &Bot,

View File

@@ -41,6 +41,20 @@ macro_rules! keyboard_buttons {
$($($name::$variant => teloxide::types::InlineKeyboardButton::callback($text, $callback_data)),*),* $($($name::$variant => teloxide::types::InlineKeyboardButton::callback($text, $callback_data)),*),*
} }
} }
#[allow(unused)]
pub fn to_switch_inline_query(self) -> teloxide::types::InlineKeyboardButton {
match self {
$($($name::$variant => teloxide::types::InlineKeyboardButton::switch_inline_query($text, $callback_data)),*),*
}
}
#[allow(unused)]
pub fn title(self) -> &'static str {
match self {
$($($name::$variant => $text),*),*
}
}
} }
impl From<$name> for teloxide::types::InlineKeyboardButton { impl From<$name> for teloxide::types::InlineKeyboardButton {
fn from(value: $name) -> Self { fn from(value: $name) -> Self {

View File

@@ -9,7 +9,7 @@ mod sqlite_storage;
mod test_utils; mod test_utils;
use crate::commands::{ use crate::commands::{
my_listings::{my_listings_handler, MyListingsState}, my_listings::{my_listings_handler, my_listings_inline_handler, MyListingsState},
new_listing::{new_listing_handler, NewListingState}, new_listing::{new_listing_handler, NewListingState},
}; };
use crate::sqlite_storage::SqliteStorage; use crate::sqlite_storage::SqliteStorage;
@@ -89,21 +89,28 @@ async fn main() -> Result<()> {
Dispatcher::builder( Dispatcher::builder(
bot, bot,
dptree::entry() dptree::entry()
.enter_dialogue::<Update, SqliteStorage<Json>, DialogueRootState>() .branch(my_listings_inline_handler())
.branch(new_listing_handler())
.branch(my_listings_handler())
.branch(Update::filter_callback_query().branch(
dptree::case![DialogueRootState::MainMenu].endpoint(handle_main_menu_callback),
))
.branch( .branch(
Update::filter_message().branch( dptree::entry()
dptree::entry() .enter_dialogue::<Update, SqliteStorage<Json>, DialogueRootState>()
.filter_command::<Command>() .branch(new_listing_handler())
.branch(dptree::case![Command::Start].endpoint(handle_start)) .branch(my_listings_handler())
.branch(dptree::case![Command::Help].endpoint(handle_help)) .branch(
.branch(dptree::case![Command::MyBids].endpoint(handle_my_bids)) Update::filter_callback_query().branch(
.branch(dptree::case![Command::Settings].endpoint(handle_settings)), dptree::case![DialogueRootState::MainMenu]
), .endpoint(handle_main_menu_callback),
),
)
.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::MyBids].endpoint(handle_my_bids))
.branch(dptree::case![Command::Settings].endpoint(handle_settings)),
),
),
) )
.branch(Update::filter_message().endpoint(unknown_message_handler)), .branch(Update::filter_message().endpoint(unknown_message_handler)),
) )