diff --git a/TeleBot/TeleBot/Handlers/CallbackHandler.cs b/TeleBot/TeleBot/Handlers/CallbackHandler.cs index 86fd492..bb83fe7 100644 --- a/TeleBot/TeleBot/Handlers/CallbackHandler.cs +++ b/TeleBot/TeleBot/Handlers/CallbackHandler.cs @@ -29,6 +29,8 @@ namespace TeleBot.Handlers private readonly IBotActivityTracker _activityTracker; private readonly IConfiguration _configuration; private readonly ILogger _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 logger) + ILogger 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]); - - var product = await _shopService.GetProductAsync(productId); + // Parse callback data using mapper + var (_, productId, quantity, _) = _mapper.ParseCallback(string.Join(":", data)); + + 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; } diff --git a/TeleBot/TeleBot/Handlers/CommandHandler.cs b/TeleBot/TeleBot/Handlers/CommandHandler.cs index 884fced..4700430 100644 --- a/TeleBot/TeleBot/Handlers/CommandHandler.cs +++ b/TeleBot/TeleBot/Handlers/CommandHandler.cs @@ -20,19 +20,22 @@ namespace TeleBot.Handlers private readonly IPrivacyService _privacyService; private readonly IProductCarouselService _carouselService; private readonly ILogger _logger; - + private readonly MenuBuilder _menuBuilder; + public CommandHandler( ISessionManager sessionManager, ILittleShopService shopService, IPrivacyService privacyService, IProductCarouselService carouselService, - ILogger logger) + ILogger 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; diff --git a/TeleBot/TeleBot/Program.cs b/TeleBot/TeleBot/Program.cs index 59419cb..c792a63 100644 --- a/TeleBot/TeleBot/Program.cs +++ b/TeleBot/TeleBot/Program.cs @@ -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(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Callback Data Mapper (for short IDs to avoid Telegram's 64-byte limit) +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + // Bot Manager Service (for registration and metrics) - Single instance with direct connection (internal API) builder.Services.AddHttpClient() .ConfigurePrimaryHttpMessageHandler(sp => diff --git a/TeleBot/TeleBot/Services/CallbackDataMapper.cs b/TeleBot/TeleBot/Services/CallbackDataMapper.cs new file mode 100644 index 0000000..d25a9b4 --- /dev/null +++ b/TeleBot/TeleBot/Services/CallbackDataMapper.cs @@ -0,0 +1,100 @@ +using System.Collections.Concurrent; + +namespace TeleBot.Services; + +/// +/// Maps long GUIDs to short IDs for Telegram callback data (64-byte limit) +/// +public class CallbackDataMapper +{ + private readonly ConcurrentDictionary _shortToGuid = new(); + private readonly ConcurrentDictionary _guidToShort = new(); + private int _nextId = 1; + private readonly object _lock = new(); + + /// + /// Get or create a short ID for a GUID + /// + 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; + } + } + + /// + /// Decode a short ID back to GUID + /// + public Guid? DecodeShortId(string shortId) + { + return _shortToGuid.TryGetValue(shortId, out var guid) ? guid : null; + } + + /// + /// Build callback data with short IDs + /// Format: action:shortProductId[:quantity[:shortMultiBuyId]] + /// + public string BuildCallback(string action, Guid productId, int? quantity = null, Guid? multiBuyId = null) + { + var parts = new List { 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; + } + + /// + /// Parse callback data with short IDs + /// + 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); + } + + /// + /// Clear old mappings (call periodically to prevent memory buildup) + /// + public void ClearMappings() + { + _shortToGuid.Clear(); + _guidToShort.Clear(); + _nextId = 1; + } +} diff --git a/TeleBot/TeleBot/Services/ProductCarouselService.cs b/TeleBot/TeleBot/Services/ProductCarouselService.cs index 29f765c..fe33207 100644 --- a/TeleBot/TeleBot/Services/ProductCarouselService.cs +++ b/TeleBot/TeleBot/Services/ProductCarouselService.cs @@ -29,15 +29,18 @@ namespace TeleBot.Services private readonly IConfiguration _configuration; private readonly ILogger _logger; private readonly HttpClient _httpClient; + private readonly MenuBuilder _menuBuilder; private readonly string _imageCachePath; public ProductCarouselService( IConfiguration configuration, ILogger 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,20 +221,20 @@ namespace TeleBot.Services chatId, MessageFormatter.FormatProductDetail(product), parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown, - replyMarkup: MenuBuilder.ProductDetailMenu(product) + replyMarkup: _menuBuilder.ProductDetailMenu(product) ); } } catch (Exception ex) { _logger.LogError(ex, "Error sending single product with image"); - + // Fallback to text message await botClient.SendTextMessageAsync( chatId, MessageFormatter.FormatProductDetail(product), parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown, - replyMarkup: MenuBuilder.ProductDetailMenu(product) + replyMarkup: _menuBuilder.ProductDetailMenu(product) ); } } diff --git a/TeleBot/TeleBot/TestCarousel.cs b/TeleBot/TeleBot/TestCarousel.cs index 0684434..26e441c 100644 --- a/TeleBot/TeleBot/TestCarousel.cs +++ b/TeleBot/TeleBot/TestCarousel.cs @@ -20,8 +20,10 @@ namespace TeleBot var config = new ConfigurationBuilder().Build(); var logger = NullLogger.Instance; var httpClient = new System.Net.Http.HttpClient(); - - var carouselService = new ProductCarouselService(config, logger, httpClient); + var mapper = new TeleBot.Services.CallbackDataMapper(); + var menuBuilder = new TeleBot.UI.MenuBuilder(mapper); + + 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.Instance; var httpClient = new System.Net.Http.HttpClient(); - - var carouselService = new ProductCarouselService(config, logger, httpClient); + var mapper = new TeleBot.Services.CallbackDataMapper(); + var menuBuilder = new TeleBot.UI.MenuBuilder(mapper); + + var carouselService = new ProductCarouselService(config, logger, httpClient, menuBuilder); // Create a test product with image var testProduct = new Product diff --git a/TeleBot/TeleBot/UI/MenuBuilder.cs b/TeleBot/TeleBot/UI/MenuBuilder.cs index 5ca48d5..9296b04 100644 --- a/TeleBot/TeleBot/UI/MenuBuilder.cs +++ b/TeleBot/TeleBot/UI/MenuBuilder.cs @@ -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(); @@ -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(); 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,10 +191,10 @@ namespace TeleBot.UI return new InlineKeyboardMarkup(buttons); } - public static InlineKeyboardMarkup CartMenu(ShoppingCart cart) + public InlineKeyboardMarkup CartMenu(ShoppingCart cart) { var buttons = new List(); - + if (!cart.IsEmpty()) { // Item management buttons @@ -195,8 +202,8 @@ namespace TeleBot.UI { buttons.Add(new[] { InlineKeyboardButton.WithCallbackData( - $"❌ Remove {item.ProductName}", - $"remove:{item.ProductId}" + $"❌ Remove {item.ProductName}", + _mapper.BuildCallback("remove", item.ProductId) ) }); }