"Fix-BUTTON_DATA_INVALID-and-add-multi-buy-buttons"

This commit is contained in:
sysadmin 2025-10-03 15:26:52 +01:00
parent c961dfa47a
commit 694ce15549
7 changed files with 203 additions and 61 deletions

View File

@ -29,6 +29,8 @@ namespace TeleBot.Handlers
private readonly IBotActivityTracker _activityTracker;
private readonly IConfiguration _configuration;
private readonly ILogger<CallbackHandler> _logger;
private readonly MenuBuilder _menuBuilder;
private readonly CallbackDataMapper _mapper;
public CallbackHandler(
ISessionManager sessionManager,
@ -37,7 +39,9 @@ namespace TeleBot.Handlers
IProductCarouselService carouselService,
IBotActivityTracker activityTracker,
IConfiguration configuration,
ILogger<CallbackHandler> logger)
ILogger<CallbackHandler> logger,
MenuBuilder menuBuilder,
CallbackDataMapper mapper)
{
_sessionManager = sessionManager;
_shopService = shopService;
@ -46,6 +50,8 @@ namespace TeleBot.Handlers
_activityTracker = activityTracker;
_configuration = configuration;
_logger = logger;
_menuBuilder = menuBuilder;
_mapper = mapper;
}
public async Task HandleCallbackAsync(ITelegramBotClient bot, CallbackQuery callbackQuery)
@ -115,7 +121,11 @@ namespace TeleBot.Handlers
break;
case "remove":
await HandleRemoveFromCart(bot, callbackQuery, session, Guid.Parse(data[1]));
{
var (_, productId, _, _) = _mapper.ParseCallback(callbackQuery.Data);
if (productId.HasValue)
await HandleRemoveFromCart(bot, callbackQuery, session, productId.Value);
}
break;
case "clear_cart":
@ -372,32 +382,40 @@ namespace TeleBot.Handlers
private async Task HandleQuantityChange(ITelegramBotClient bot, Message message, UserSession session, string[] data)
{
// Format: qty:productId:newQuantity
var productId = Guid.Parse(data[1]);
var quantity = int.Parse(data[2]);
// Parse callback data using mapper
var (_, productId, quantity, _) = _mapper.ParseCallback(string.Join(":", data));
var product = await _shopService.GetProductAsync(productId);
if (!productId.HasValue || !quantity.HasValue)
return;
var product = await _shopService.GetProductAsync(productId.Value);
if (product == null)
return;
session.TempData["current_quantity"] = quantity;
session.TempData["current_quantity"] = quantity.Value;
await bot.EditMessageReplyMarkupAsync(
message.Chat.Id,
message.MessageId,
MenuBuilder.ProductDetailMenu(product, quantity)
_menuBuilder.ProductDetailMenu(product, quantity.Value)
);
}
private async Task HandleAddToCart(ITelegramBotClient bot, CallbackQuery callbackQuery, UserSession session, string[] data)
{
// Format: add:productId:quantity or add:productId:quantity:multiBuyId or add:productId:quantity:multiBuyId:variant
var productId = Guid.Parse(data[1]);
var quantity = int.Parse(data[2]);
Guid? multiBuyId = data.Length > 3 && !data[3].Contains(":") ? Guid.Parse(data[3]) : null;
string? selectedVariant = data.Length > 4 ? data[4] : (data.Length > 3 && data[3].Contains(":") ? data[3] : null);
// Parse callback data using mapper
var (_, productId, quantity, multiBuyId) = _mapper.ParseCallback(string.Join(":", data));
var product = await _shopService.GetProductAsync(productId);
if (!productId.HasValue || !quantity.HasValue)
{
await bot.AnswerCallbackQueryAsync(callbackQuery.Id, "Invalid product data", showAlert: true);
return;
}
// Check for variant in data (old format compatibility)
string? selectedVariant = data.Length > 4 ? data[4] : null;
var product = await _shopService.GetProductAsync(productId.Value);
if (product == null)
{
await bot.AnswerCallbackQueryAsync(callbackQuery.Id, "Product not found", showAlert: true);
@ -407,13 +425,15 @@ namespace TeleBot.Handlers
// If product has variants but none selected, show variant selection
if (selectedVariant == null && product.Variants?.Any() == true)
{
await ShowVariantSelection(bot, callbackQuery.Message!, session, product, quantity, multiBuyId);
await ShowVariantSelection(bot, callbackQuery.Message!, session, product, quantity.Value, multiBuyId);
return;
}
// Get price based on multi-buy or base product
decimal price = product.Price;
string itemName = product.Name;
int finalQuantity = quantity.Value;
if (multiBuyId.HasValue && product.MultiBuys != null)
{
var multiBuy = product.MultiBuys.FirstOrDefault(mb => mb.Id == multiBuyId);
@ -421,7 +441,7 @@ namespace TeleBot.Handlers
{
price = multiBuy.Price;
itemName = $"{product.Name} ({multiBuy.Name})";
quantity = multiBuy.Quantity; // Use multi-buy quantity
finalQuantity = multiBuy.Quantity; // Use multi-buy quantity
}
}
@ -431,7 +451,7 @@ namespace TeleBot.Handlers
itemName += $" - {selectedVariant}";
}
session.Cart.AddItem(productId, itemName, price, quantity, multiBuyId, selectedVariant);
session.Cart.AddItem(productId.Value, itemName, price, finalQuantity, multiBuyId, selectedVariant);
// Track add to cart action
await _activityTracker.TrackActivityAsync(
@ -439,13 +459,13 @@ namespace TeleBot.Handlers
ActivityTypes.AddToCart,
$"Added to cart: {itemName}",
product,
price * quantity,
quantity
price * finalQuantity,
finalQuantity
);
await bot.AnswerCallbackQueryAsync(
callbackQuery.Id,
$"✅ Added {quantity}x {itemName} to cart",
$"✅ Added {finalQuantity}x {itemName} to cart",
showAlert: false
);
@ -469,7 +489,7 @@ namespace TeleBot.Handlers
message.MessageId,
MessageFormatter.FormatCart(session.Cart),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.CartMenu(session.Cart)
replyMarkup: _menuBuilder.CartMenu(session.Cart)
);
session.State = SessionState.ViewingCart;
}
@ -480,7 +500,7 @@ namespace TeleBot.Handlers
chatId,
MessageFormatter.FormatCart(session.Cart),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.CartMenu(session.Cart)
replyMarkup: _menuBuilder.CartMenu(session.Cart)
);
session.State = SessionState.ViewingCart;
}
@ -565,7 +585,7 @@ namespace TeleBot.Handlers
callbackQuery.Message!.Chat.Id,
MessageFormatter.FormatCart(session.Cart),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.CartMenu(session.Cart)
replyMarkup: _menuBuilder.CartMenu(session.Cart)
);
// Immediately proceed to checkout
@ -634,7 +654,7 @@ namespace TeleBot.Handlers
callbackQuery.Message!.Chat.Id,
MessageFormatter.FormatCart(session.Cart),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.CartMenu(session.Cart)
replyMarkup: _menuBuilder.CartMenu(session.Cart)
);
// Immediately proceed to checkout
@ -738,7 +758,7 @@ namespace TeleBot.Handlers
message.Chat.Id,
message.MessageId,
"❌ Failed to create order. Please try again.",
replyMarkup: MenuBuilder.CartMenu(session.Cart)
replyMarkup: _menuBuilder.CartMenu(session.Cart)
);
return;
}
@ -823,7 +843,7 @@ namespace TeleBot.Handlers
$"• Network connectivity issues\n\n" +
$"Your cart has been restored. Please try again.",
Telegram.Bot.Types.Enums.ParseMode.Markdown,
MenuBuilder.CartMenu(session.Cart)
_menuBuilder.CartMenu(session.Cart)
);
return;
}
@ -863,7 +883,7 @@ namespace TeleBot.Handlers
$"Our payment system may be undergoing maintenance.\n" +
$"Your cart has been restored. Please try again later.",
Telegram.Bot.Types.Enums.ParseMode.Markdown,
MenuBuilder.CartMenu(session.Cart)
_menuBuilder.CartMenu(session.Cart)
);
return;
}

View File

@ -20,19 +20,22 @@ namespace TeleBot.Handlers
private readonly IPrivacyService _privacyService;
private readonly IProductCarouselService _carouselService;
private readonly ILogger<CommandHandler> _logger;
private readonly MenuBuilder _menuBuilder;
public CommandHandler(
ISessionManager sessionManager,
ILittleShopService shopService,
IPrivacyService privacyService,
IProductCarouselService carouselService,
ILogger<CommandHandler> logger)
ILogger<CommandHandler> logger,
MenuBuilder menuBuilder)
{
_sessionManager = sessionManager;
_shopService = shopService;
_privacyService = privacyService;
_carouselService = carouselService;
_logger = logger;
_menuBuilder = menuBuilder;
}
public async Task HandleCommandAsync(ITelegramBotClient bot, Message message, string command, string? args)
@ -202,7 +205,7 @@ namespace TeleBot.Handlers
message.Chat.Id,
text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.CartMenu(session.Cart)
replyMarkup: _menuBuilder.CartMenu(session.Cart)
);
session.State = Models.SessionState.ViewingCart;

View File

@ -16,6 +16,7 @@ using TeleBot;
using TeleBot.Handlers;
using TeleBot.Services;
using TeleBot.Http;
using TeleBot.UI;
var builder = WebApplication.CreateBuilder(args);
var BrandName = "Little Shop";
@ -90,6 +91,10 @@ builder.Services.AddSingleton<ICommandHandler, CommandHandler>();
builder.Services.AddSingleton<ICallbackHandler, CallbackHandler>();
builder.Services.AddSingleton<IMessageHandler, MessageHandler>();
// Callback Data Mapper (for short IDs to avoid Telegram's 64-byte limit)
builder.Services.AddSingleton<CallbackDataMapper>();
builder.Services.AddSingleton<MenuBuilder>();
// Bot Manager Service (for registration and metrics) - Single instance with direct connection (internal API)
builder.Services.AddHttpClient<BotManagerService>()
.ConfigurePrimaryHttpMessageHandler(sp =>

View File

@ -0,0 +1,100 @@
using System.Collections.Concurrent;
namespace TeleBot.Services;
/// <summary>
/// Maps long GUIDs to short IDs for Telegram callback data (64-byte limit)
/// </summary>
public class CallbackDataMapper
{
private readonly ConcurrentDictionary<string, Guid> _shortToGuid = new();
private readonly ConcurrentDictionary<Guid, string> _guidToShort = new();
private int _nextId = 1;
private readonly object _lock = new();
/// <summary>
/// Get or create a short ID for a GUID
/// </summary>
public string GetShortId(Guid guid, string prefix = "id")
{
if (_guidToShort.TryGetValue(guid, out var existing))
return existing;
lock (_lock)
{
// Double-check after acquiring lock
if (_guidToShort.TryGetValue(guid, out existing))
return existing;
var shortId = $"{prefix}{_nextId++}";
_shortToGuid[shortId] = guid;
_guidToShort[guid] = shortId;
return shortId;
}
}
/// <summary>
/// Decode a short ID back to GUID
/// </summary>
public Guid? DecodeShortId(string shortId)
{
return _shortToGuid.TryGetValue(shortId, out var guid) ? guid : null;
}
/// <summary>
/// Build callback data with short IDs
/// Format: action:shortProductId[:quantity[:shortMultiBuyId]]
/// </summary>
public string BuildCallback(string action, Guid productId, int? quantity = null, Guid? multiBuyId = null)
{
var parts = new List<string> { action, GetShortId(productId, "p") };
if (quantity.HasValue)
parts.Add(quantity.Value.ToString());
if (multiBuyId.HasValue)
parts.Add(GetShortId(multiBuyId.Value, "mb"));
var result = string.Join(":", parts);
// Ensure we don't exceed Telegram's 64-byte limit
if (result.Length > 63)
throw new InvalidOperationException($"Callback data too long ({result.Length} bytes): {result}");
return result;
}
/// <summary>
/// Parse callback data with short IDs
/// </summary>
public (string action, Guid? productId, int? quantity, Guid? multiBuyId) ParseCallback(string callbackData)
{
var parts = callbackData.Split(':');
var action = parts[0];
Guid? productId = null;
int? quantity = null;
Guid? multiBuyId = null;
if (parts.Length > 1)
productId = DecodeShortId(parts[1]);
if (parts.Length > 2 && int.TryParse(parts[2], out var qty))
quantity = qty;
if (parts.Length > 3)
multiBuyId = DecodeShortId(parts[3]);
return (action, productId, quantity, multiBuyId);
}
/// <summary>
/// Clear old mappings (call periodically to prevent memory buildup)
/// </summary>
public void ClearMappings()
{
_shortToGuid.Clear();
_guidToShort.Clear();
_nextId = 1;
}
}

View File

@ -29,15 +29,18 @@ namespace TeleBot.Services
private readonly IConfiguration _configuration;
private readonly ILogger<ProductCarouselService> _logger;
private readonly HttpClient _httpClient;
private readonly MenuBuilder _menuBuilder;
private readonly string _imageCachePath;
public ProductCarouselService(
IConfiguration configuration,
ILogger<ProductCarouselService> logger,
HttpClient httpClient)
HttpClient httpClient,
MenuBuilder menuBuilder)
{
_configuration = configuration;
_logger = logger;
_menuBuilder = menuBuilder;
_httpClient = httpClient;
_imageCachePath = Path.Combine(Environment.CurrentDirectory, "image_cache");
@ -208,7 +211,7 @@ namespace TeleBot.Services
image,
caption: FormatProductCaption(product),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.ProductDetailMenu(product)
replyMarkup: _menuBuilder.ProductDetailMenu(product)
);
}
else
@ -218,7 +221,7 @@ namespace TeleBot.Services
chatId,
MessageFormatter.FormatProductDetail(product),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.ProductDetailMenu(product)
replyMarkup: _menuBuilder.ProductDetailMenu(product)
);
}
}
@ -231,7 +234,7 @@ namespace TeleBot.Services
chatId,
MessageFormatter.FormatProductDetail(product),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.ProductDetailMenu(product)
replyMarkup: _menuBuilder.ProductDetailMenu(product)
);
}
}

View File

@ -20,8 +20,10 @@ namespace TeleBot
var config = new ConfigurationBuilder().Build();
var logger = NullLogger<ProductCarouselService>.Instance;
var httpClient = new System.Net.Http.HttpClient();
var mapper = new TeleBot.Services.CallbackDataMapper();
var menuBuilder = new TeleBot.UI.MenuBuilder(mapper);
var carouselService = new ProductCarouselService(config, logger, httpClient);
var carouselService = new ProductCarouselService(config, logger, httpClient, menuBuilder);
// Test image URL validation
var validUrls = new[]
@ -43,8 +45,10 @@ namespace TeleBot
var config = new ConfigurationBuilder().Build();
var logger = NullLogger<ProductCarouselService>.Instance;
var httpClient = new System.Net.Http.HttpClient();
var mapper = new TeleBot.Services.CallbackDataMapper();
var menuBuilder = new TeleBot.UI.MenuBuilder(mapper);
var carouselService = new ProductCarouselService(config, logger, httpClient);
var carouselService = new ProductCarouselService(config, logger, httpClient, menuBuilder);
// Create a test product with image
var testProduct = new Product

View File

@ -4,11 +4,18 @@ using System.Linq;
using LittleShop.Client.Models;
using Telegram.Bot.Types.ReplyMarkups;
using TeleBot.Models;
using TeleBot.Services;
namespace TeleBot.UI
{
public static class MenuBuilder
public class MenuBuilder
{
private readonly CallbackDataMapper _mapper;
public MenuBuilder(CallbackDataMapper mapper)
{
_mapper = mapper;
}
public static InlineKeyboardMarkup MainMenu()
{
return new InlineKeyboardMarkup(new[]
@ -90,7 +97,7 @@ namespace TeleBot.UI
return new InlineKeyboardMarkup(buttons);
}
public static InlineKeyboardMarkup ProductDetailMenu(Product product, int quantity = 1)
public InlineKeyboardMarkup ProductDetailMenu(Product product, int quantity = 1)
{
var buttons = new List<InlineKeyboardButton[]>();
@ -106,14 +113,14 @@ namespace TeleBot.UI
foreach (var multiBuy in product.MultiBuys.Where(mb => mb.IsActive).OrderBy(mb => mb.Quantity))
{
var label = $"{multiBuy.Name} - ${multiBuy.Price:F2}";
var label = $"{multiBuy.Name} - £{multiBuy.Price:F2}";
if (multiBuy.Quantity > 1)
label += $" (${multiBuy.PricePerUnit:F2}/each)";
label += $" (£{multiBuy.PricePerUnit:F2}/each)";
// If has variants, need variant selection first
// Use short callback data
var callbackData = hasVariants
? $"selectvar:{product.Id}:{multiBuy.Quantity}:{multiBuy.Id}"
: $"add:{product.Id}:{multiBuy.Quantity}:{multiBuy.Id}";
? _mapper.BuildCallback("selectvar", product.Id, multiBuy.Quantity, multiBuy.Id)
: _mapper.BuildCallback("add", product.Id, multiBuy.Quantity, multiBuy.Id);
buttons.Add(new[]
{
@ -123,12 +130,12 @@ namespace TeleBot.UI
// Add regular single item option
var singleCallbackData = hasVariants
? $"selectvar:{product.Id}:1"
: $"add:{product.Id}:1";
? _mapper.BuildCallback("selectvar", product.Id, 1)
: _mapper.BuildCallback("add", product.Id, 1);
buttons.Add(new[] {
InlineKeyboardButton.WithCallbackData(
$"🛒 Single Item - ${product.Price:F2}",
$"🛒 Single Item - £{product.Price:F2}",
singleCallbackData
)
});
@ -138,21 +145,21 @@ namespace TeleBot.UI
// No multi-buys, show quantity selector
var quantityButtons = new List<InlineKeyboardButton>();
if (quantity > 1)
quantityButtons.Add(InlineKeyboardButton.WithCallbackData("", $"qty:{product.Id}:{quantity - 1}"));
quantityButtons.Add(InlineKeyboardButton.WithCallbackData("", _mapper.BuildCallback("qty", product.Id, quantity - 1)));
quantityButtons.Add(InlineKeyboardButton.WithCallbackData($"Qty: {quantity}", "noop"));
if (quantity < 10)
quantityButtons.Add(InlineKeyboardButton.WithCallbackData("", $"qty:{product.Id}:{quantity + 1}"));
quantityButtons.Add(InlineKeyboardButton.WithCallbackData("", _mapper.BuildCallback("qty", product.Id, quantity + 1)));
buttons.Add(quantityButtons.ToArray());
// Add to cart button
var addCallbackData = hasVariants
? $"selectvar:{product.Id}:{quantity}"
: $"add:{product.Id}:{quantity}";
? _mapper.BuildCallback("selectvar", product.Id, quantity)
: _mapper.BuildCallback("add", product.Id, quantity);
buttons.Add(new[] {
InlineKeyboardButton.WithCallbackData(
$"🛒 Add to Cart - ${product.Price * quantity:F2}",
$"🛒 Add to Cart - £{product.Price * quantity:F2}",
addCallbackData
)
});
@ -184,7 +191,7 @@ namespace TeleBot.UI
return new InlineKeyboardMarkup(buttons);
}
public static InlineKeyboardMarkup CartMenu(ShoppingCart cart)
public InlineKeyboardMarkup CartMenu(ShoppingCart cart)
{
var buttons = new List<InlineKeyboardButton[]>();
@ -196,7 +203,7 @@ namespace TeleBot.UI
buttons.Add(new[] {
InlineKeyboardButton.WithCallbackData(
$"❌ Remove {item.ProductName}",
$"remove:{item.ProductId}"
_mapper.BuildCallback("remove", item.ProductId)
)
});
}