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::{
case,
commands::{
enter_main_menu,
my_listings::keyboard::{ManageListingButtons, MyListingsButtons},
new_listing::{enter_edit_listing_draft, ListingDraft},
},
db::{listing::PersistedListing, user::PersistedUser, ListingDAO, ListingDbId, UserDAO},
keyboard_buttons,
db::{
listing::{ListingFields, PersistedListing},
user::PersistedUser,
ListingDAO, ListingDbId, UserDAO,
},
message_utils::{extract_callback_data, pluralize_with_count, send_message, MessageTarget},
Command, DialogueRootState, HandlerResult, RootDialogue,
};
use log::info;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use teloxide::{
dispatching::{DpHandlerDescription, UpdateFilterExt},
prelude::*,
types::{InlineKeyboardButton, Message},
types::{
InlineKeyboardButton, InlineKeyboardMarkup, InlineQueryResult, InlineQueryResultArticle,
InputMessageContent, InputMessageContentText, Message, ParseMode, User,
},
Bot,
};
@@ -29,22 +39,11 @@ impl From<MyListingsState> for DialogueRootState {
}
}
keyboard_buttons! {
enum ManageListingButtons {
[
Edit("✏️ Edit", "manage_listing_edit"),
Delete("🗑️ Delete", "manage_listing_delete"),
],
[
Back("⬅️ Back", "manage_listing_back"),
]
}
}
keyboard_buttons! {
enum MyListingsButtons {
BackToMenu("⬅️ Back to Menu", "my_listings_back_to_menu"),
}
pub fn my_listings_inline_handler() -> Handler<'static, HandlerResult, DpHandlerDescription> {
Update::filter_inline_query()
.inspect(|query: InlineQuery| info!("Received inline query: {:?}", query))
.filter_map_async(inline_query_extract_forward_listing)
.endpoint(handle_forward_listing)
}
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(
db_pool: SqlitePool,
bot: Bot,
@@ -202,7 +314,21 @@ async fn show_listing_details(
bot,
target,
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?;
Ok(())
@@ -219,6 +345,14 @@ async fn handle_managing_listing_callback(
let target = (from.clone(), message_id);
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 => {
let (_, listing) =
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(())
}
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(
db_pool: &SqlitePool,
bot: &Bot,

View File

@@ -41,6 +41,20 @@ macro_rules! keyboard_buttons {
$($($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 {
fn from(value: $name) -> Self {

View File

@@ -9,7 +9,7 @@ mod sqlite_storage;
mod test_utils;
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},
};
use crate::sqlite_storage::SqliteStorage;
@@ -88,13 +88,19 @@ async fn main() -> Result<()> {
// Create dispatcher with dialogue system
Dispatcher::builder(
bot,
dptree::entry()
.branch(my_listings_inline_handler())
.branch(
dptree::entry()
.enter_dialogue::<Update, SqliteStorage<Json>, DialogueRootState>()
.branch(new_listing_handler())
.branch(my_listings_handler())
.branch(Update::filter_callback_query().branch(
dptree::case![DialogueRootState::MainMenu].endpoint(handle_main_menu_callback),
))
.branch(
Update::filter_callback_query().branch(
dptree::case![DialogueRootState::MainMenu]
.endpoint(handle_main_menu_callback),
),
)
.branch(
Update::filter_message().branch(
dptree::entry()
@@ -104,6 +110,7 @@ async fn main() -> Result<()> {
.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)),
)