littleshop/TeleBot/TeleBot/Handlers/CallbackHandler.cs
SysAdmin 1e93008df4 Fix: Variant selection now accumulates across types instead of resetting
Problem:
- Selecting Size then Color would reset Size selection
- Users could never get "Add to Cart" button with multiple variant types
- Each selection created a NEW list, wiping previous choices

Root Cause:
- HandleSetVariant created: new List<string> { variantName }
- This replaced all previous selections instead of accumulating

Fix:
1. Get existing selected variants from session
2. Find the variant type of newly selected variant
3. Remove any previous selection from the SAME type (allow changing choice)
4. Add the new selection
5. Save accumulated list back to session

Example behavior now:
- Select "Red" (Color) → selectedVariants = ["Red"]
- Select "Large" (Size) → selectedVariants = ["Red", "Large"] 
- Select "Blue" (Color) → selectedVariants = ["Blue", "Large"] 
- Can now confirm when all types selected

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 02:44:28 +01:00

1541 lines
64 KiB
C#

using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using QRCoder;
using Telegram.Bot;
using Telegram.Bot.Exceptions;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
using TeleBot.Models;
using TeleBot.Services;
using TeleBot.UI;
using LittleShop.Client.Models;
namespace TeleBot.Handlers
{
public interface ICallbackHandler
{
Task HandleCallbackAsync(ITelegramBotClient bot, CallbackQuery callbackQuery);
}
public class CallbackHandler : ICallbackHandler
{
private readonly ISessionManager _sessionManager;
private readonly ILittleShopService _shopService;
private readonly IPrivacyService _privacyService;
private readonly IProductCarouselService _carouselService;
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,
ILittleShopService shopService,
IPrivacyService privacyService,
IProductCarouselService carouselService,
IBotActivityTracker activityTracker,
IConfiguration configuration,
ILogger<CallbackHandler> logger,
MenuBuilder menuBuilder,
CallbackDataMapper mapper)
{
_sessionManager = sessionManager;
_shopService = shopService;
_privacyService = privacyService;
_carouselService = carouselService;
_activityTracker = activityTracker;
_configuration = configuration;
_logger = logger;
_menuBuilder = menuBuilder;
_mapper = mapper;
}
public async Task HandleCallbackAsync(ITelegramBotClient bot, CallbackQuery callbackQuery)
{
if (callbackQuery.Message == null || callbackQuery.Data == null)
return;
var session = await _sessionManager.GetOrCreateSessionAsync(callbackQuery.From.Id);
bool callbackAnswered = false;
try
{
// Answer callback immediately to prevent timeout
await bot.AnswerCallbackQueryAsync(callbackQuery.Id);
callbackAnswered = true;
var data = callbackQuery.Data.Split(':');
var action = data[0];
switch (action)
{
case "menu":
await HandleMainMenu(bot, callbackQuery.Message, session);
break;
case "browse":
await HandleBrowse(bot, callbackQuery.Message, session);
break;
case "category":
await HandleCategory(bot, callbackQuery.Message, session, Guid.Parse(data[1]));
break;
case "products":
if (data.Length > 1 && data[1] == "page")
{
await HandleProductsPage(bot, callbackQuery.Message, session, data);
}
else
{
await HandleProductList(bot, callbackQuery.Message, session, data);
}
break;
case "product":
{
var (_, productId, _, _) = _mapper.ParseCallback(callbackQuery.Data);
if (productId.HasValue)
await HandleProductDetail(bot, callbackQuery.Message, session, productId.Value);
}
break;
case "qty":
await HandleQuantityChange(bot, callbackQuery.Message, session, data);
break;
case "add":
await HandleAddToCart(bot, callbackQuery, session, data);
break;
case "quickbuy":
await HandleQuickBuy(bot, callbackQuery, session, data);
break;
case "quickbuyvar":
await HandleQuickBuyWithVariation(bot, callbackQuery, session, data);
break;
case "cart":
await HandleViewCart(bot, callbackQuery.Message, session);
break;
case "remove":
{
var (_, productId, _, _) = _mapper.ParseCallback(callbackQuery.Data);
if (productId.HasValue)
await HandleRemoveFromCart(bot, callbackQuery, session, productId.Value);
}
break;
case "clear_cart":
await HandleClearCart(bot, callbackQuery, session);
break;
case "checkout":
await HandleCheckout(bot, callbackQuery.Message, session);
break;
case "use_saved_address":
await HandleUseSavedAddress(bot, callbackQuery.Message, session);
break;
case "enter_new_address":
await HandleEnterNewAddress(bot, callbackQuery.Message, session);
break;
case "save_address_yes":
await HandleSaveAddressPreference(bot, callbackQuery.Message, session, true);
break;
case "save_address_no":
await HandleSaveAddressPreference(bot, callbackQuery.Message, session, false);
break;
case "confirm_order":
await HandleConfirmOrder(bot, callbackQuery.Message, session, callbackQuery.From);
break;
case "pay":
await HandlePayment(bot, callbackQuery.Message, session, data[1]);
break;
case "orders":
await HandleViewOrders(bot, callbackQuery.Message, session, callbackQuery.From);
break;
case "order":
await HandleViewOrder(bot, callbackQuery.Message, session, Guid.Parse(data[1]), callbackQuery.From);
break;
case "reviews":
await HandleViewReviews(bot, callbackQuery.Message, session);
break;
case "privacy":
await HandlePrivacySettings(bot, callbackQuery.Message, session, data.Length > 1 ? data[1] : null);
break;
case "help":
await HandleHelp(bot, callbackQuery.Message);
break;
case "support":
await HandleSupportCallback(bot, callbackQuery, session);
break;
case "refresh_conversation":
await HandleRefreshConversation(bot, callbackQuery, session);
break;
case "exit_chat":
await HandleExitChat(bot, callbackQuery, session);
break;
case "noop":
// No operation - used for display-only buttons
break;
case "cancel_support":
await HandleCancelSupport(bot, callbackQuery, session);
break;
case "selectvar":
await HandleSelectVariant(bot, callbackQuery.Message, session, data);
break;
case "setvariant":
await HandleSetVariant(bot, callbackQuery.Message, session, data);
break;
case "addvariant":
await HandleAddVariant(bot, callbackQuery.Message, session, data);
break;
case "confirmvar":
await HandleConfirmVariant(bot, callbackQuery, session, data);
break;
default:
_logger.LogWarning("Unknown callback action: {Action}", action);
break;
}
await _sessionManager.UpdateSessionAsync(session);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling callback {Data}", callbackQuery.Data);
// Only try to answer callback if not already answered
if (!callbackAnswered)
{
try
{
await bot.AnswerCallbackQueryAsync(
callbackQuery.Id,
"An error occurred. Please try again.",
showAlert: true
);
}
catch (ApiRequestException apiEx) when (apiEx.Message.Contains("query is too old"))
{
// Callback already expired, ignore
_logger.LogDebug("Callback query already expired");
}
}
}
}
private async Task HandleMainMenu(ITelegramBotClient bot, Message message, UserSession session)
{
// Send new message at bottom instead of editing old one
await bot.SendTextMessageAsync(
message.Chat.Id,
MessageFormatter.FormatWelcome(true),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.MainMenu()
);
session.State = SessionState.MainMenu;
}
private async Task HandleBrowse(ITelegramBotClient bot, Message message, UserSession session)
{
// Track browsing activity
await _activityTracker.TrackActivityAsync(
message.Chat,
ActivityTypes.Browse,
"Browsing categories"
);
var categories = await _shopService.GetCategoriesAsync();
// Send new message at bottom for navigation
await bot.SendTextMessageAsync(
message.Chat.Id,
MessageFormatter.FormatCategories(categories),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.CategoryMenu(categories)
);
session.State = SessionState.BrowsingCategories;
}
private async Task HandleCategory(ITelegramBotClient bot, Message message, UserSession session, Guid categoryId)
{
var categories = await _shopService.GetCategoriesAsync();
var category = categories.FirstOrDefault(c => c.Id == categoryId);
var products = await _shopService.GetProductsAsync(categoryId, 1);
// Track category view
await _activityTracker.TrackActivityAsync(
message.Chat,
ActivityTypes.ViewProduct,
$"Viewing category: {category?.Name ?? "Unknown"}"
);
// Edit the original message to show category header (bigger, more prominent)
var headerText = $"**{category?.Name ?? "Unknown Category"} PRODUCTS**\n\nBrowse individual products below:";
await bot.EditMessageTextAsync(
message.Chat.Id,
message.MessageId,
headerText,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.CategoryNavigationMenu(categoryId)
);
// Send individual product bubbles
if (products.Items.Any())
{
foreach (var product in products.Items)
{
// Create Buy button with short ID format
var buyButton = new InlineKeyboardMarkup(new[]
{
new[]
{
InlineKeyboardButton.WithCallbackData(
$"🛒 Buy {product.Name}",
_mapper.BuildCallback("product", product.Id)
)
}
});
await bot.SendTextMessageAsync(
message.Chat.Id,
MessageFormatter.FormatSingleProduct(product),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: buyButton
);
}
// Send navigation buttons after all products
await bot.SendTextMessageAsync(
message.Chat.Id,
".",
replyMarkup: MenuBuilder.ProductNavigationMenu(categoryId)
);
}
else
{
await bot.SendTextMessageAsync(
message.Chat.Id,
"No products available in this category.",
replyMarkup: MenuBuilder.BackToCategoriesMenu()
);
}
session.State = SessionState.ViewingProducts;
}
private async Task HandleProductList(ITelegramBotClient bot, Message message, UserSession session, string[] data)
{
// Format: products:categoryId:page or products:all:page
var page = int.Parse(data[2]);
Guid? categoryId = data[1] != "all" ? Guid.Parse(data[1]) : null;
var products = await _shopService.GetProductsAsync(categoryId, page);
string? categoryName = null;
if (categoryId.HasValue)
{
var categories = await _shopService.GetCategoriesAsync();
categoryName = categories.FirstOrDefault(c => c.Id == categoryId)?.Name;
}
// Use carousel service to send products with images
await _carouselService.SendProductCarouselAsync(bot, message.Chat.Id, products, categoryName, page);
session.State = SessionState.ViewingProducts;
}
private async Task HandleProductsPage(ITelegramBotClient bot, Message message, UserSession session, string[] data)
{
// Format: products:page:pageNumber
var page = int.Parse(data[2]);
// Get products for all categories (no specific category filter)
var products = await _shopService.GetProductsAsync(null, page);
// Use carousel service to send products with images
await _carouselService.SendProductCarouselAsync(bot, message.Chat.Id, products, "All Categories", page);
session.State = SessionState.ViewingProducts;
}
private async Task HandleProductDetail(ITelegramBotClient bot, Message message, UserSession session, Guid productId)
{
var product = await _shopService.GetProductAsync(productId);
if (product == null)
{
await bot.AnswerCallbackQueryAsync("", "Product not found", showAlert: true);
return;
}
// Track product view
await _activityTracker.TrackActivityAsync(
message.Chat,
ActivityTypes.ViewProduct,
$"Viewing product: {product.Name}",
product
);
// Store current product in temp data for quantity selection
session.TempData["current_product"] = productId;
session.TempData["current_quantity"] = 1;
// Use carousel service to send product with image
await _carouselService.SendSingleProductWithImageAsync(bot, message.Chat.Id, product);
session.State = SessionState.ViewingProduct;
}
private async Task HandleQuantityChange(ITelegramBotClient bot, Message message, UserSession session, string[] data)
{
// 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.Value;
await bot.EditMessageReplyMarkupAsync(
message.Chat.Id,
message.MessageId,
_menuBuilder.ProductDetailMenu(product, quantity.Value)
);
}
private async Task HandleAddToCart(ITelegramBotClient bot, CallbackQuery callbackQuery, UserSession session, string[] data)
{
// Parse callback data using mapper
var (_, productId, quantity, multiBuyId) = _mapper.ParseCallback(string.Join(":", data));
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);
return;
}
// 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.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);
if (multiBuy != null)
{
price = multiBuy.Price;
itemName = $"{product.Name} ({multiBuy.Name})";
finalQuantity = multiBuy.Quantity; // Use multi-buy quantity
}
}
// Add variant to item name if selected
if (!string.IsNullOrEmpty(selectedVariant))
{
itemName += $" - {selectedVariant}";
}
session.Cart.AddItem(productId.Value, itemName, price, finalQuantity, multiBuyId, selectedVariant);
// Track add to cart action
await _activityTracker.TrackActivityAsync(
callbackQuery.Message!.Chat,
ActivityTypes.AddToCart,
$"Added to cart: {itemName}",
product,
price * finalQuantity,
finalQuantity
);
await bot.AnswerCallbackQueryAsync(
callbackQuery.Id,
$"✅ Added {finalQuantity}x {itemName} to cart",
showAlert: false
);
// Send new cart message instead of editing
await SendNewCartMessage(bot, callbackQuery.Message!.Chat.Id, session);
}
private async Task HandleViewCart(ITelegramBotClient bot, Message message, UserSession session)
{
// Track cart view
await _activityTracker.TrackActivityAsync(
message.Chat,
ActivityTypes.ViewCart,
$"Viewing cart with {session.Cart.Items.Count} items",
null,
session.Cart.GetTotalAmount()
);
// Send new message at bottom instead of editing
await SendNewCartMessage(bot, message.Chat.Id, session);
}
private async Task SendNewCartMessage(ITelegramBotClient bot, long chatId, UserSession session)
{
await bot.SendTextMessageAsync(
chatId,
MessageFormatter.FormatCart(session.Cart),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: _menuBuilder.CartMenu(session.Cart)
);
session.State = SessionState.ViewingCart;
}
private async Task ShowVariantSelection(ITelegramBotClient bot, Message message, UserSession session, Product product, int quantity, Guid? multiBuyId)
{
var text = $"**{product.Name}**\n\n";
text += "Please select a variant:\n";
var buttons = new List<InlineKeyboardButton[]>();
if (product.Variants?.Any() == true)
{
foreach (var variant in product.Variants.OrderBy(v => v.SortOrder))
{
var callbackData = multiBuyId.HasValue
? $"add:{product.Id}:{quantity}:{multiBuyId}:{variant.Name}"
: $"add:{product.Id}:{quantity}::{variant.Name}";
buttons.Add(new[]
{
InlineKeyboardButton.WithCallbackData(variant.Name, callbackData)
});
}
}
buttons.Add(new[]
{
InlineKeyboardButton.WithCallbackData("« Back", $"product:{product.Id}")
});
await bot.EditMessageTextAsync(
message.Chat.Id,
message.MessageId,
text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: new InlineKeyboardMarkup(buttons)
);
}
private async Task HandleQuickBuy(ITelegramBotClient bot, CallbackQuery callbackQuery, UserSession session, string[] data)
{
// Format: quickbuy:productId:quantity
var productId = Guid.Parse(data[1]);
var quantity = int.Parse(data[2]);
var product = await _shopService.GetProductAsync(productId);
if (product == null)
{
await bot.AnswerCallbackQueryAsync(callbackQuery.Id, "Product not found", showAlert: true);
return;
}
// If variants exist, show variant selection with quickbuy flow
if (product.Variants?.Any() == true)
{
await ShowVariantSelectionForQuickBuy(bot, callbackQuery.Message!, session, product, quantity);
return;
}
// Add to cart with base product
session.Cart.AddItem(productId, product.Name, product.Price, quantity, null, null);
// Track quick buy action
await _activityTracker.TrackActivityAsync(
callbackQuery.Message!.Chat,
ActivityTypes.AddToCart,
$"Quick buy: {product.Name}",
product,
product.Price * quantity,
quantity
);
await bot.AnswerCallbackQueryAsync(
callbackQuery.Id,
$"✅ Added {quantity}x {product.Name} to cart",
showAlert: false
);
// Send cart summary in new message
await bot.SendTextMessageAsync(
callbackQuery.Message!.Chat.Id,
MessageFormatter.FormatCart(session.Cart),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: _menuBuilder.CartMenu(session.Cart)
);
// Immediately proceed to checkout
await Task.Delay(500); // Small delay for better UX
await HandleCheckout(bot, callbackQuery.Message, session);
}
private async Task ShowVariantSelectionForQuickBuy(ITelegramBotClient bot, Message message, UserSession session, Product product, int quantity)
{
var text = $"**Quick Buy: {product.Name}**\n\n";
text += "Please select a variant:\n";
var buttons = new List<InlineKeyboardButton[]>();
if (product.Variants?.Any() == true)
{
// Add buttons for each variant with quickbuy flow
foreach (var variant in product.Variants.OrderBy(v => v.SortOrder))
{
var displayName = variant.Name;
// Show price if it's different from base product price
if (variant.Price.HasValue && variant.Price.Value != product.Price)
{
displayName += $" - £{variant.Price.Value:F2}";
}
buttons.Add(new[]
{
InlineKeyboardButton.WithCallbackData(displayName, $"quickbuyvar:{product.Id}:{quantity}:{variant.Id}")
});
}
}
// Add back button
buttons.Add(new[]
{
InlineKeyboardButton.WithCallbackData("⬅️ Back", "menu")
});
await bot.SendTextMessageAsync(
message.Chat.Id,
text + "\n\n*Select an option for quick checkout:*",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: new InlineKeyboardMarkup(buttons)
);
}
private async Task HandleQuickBuyWithVariation(ITelegramBotClient bot, CallbackQuery callbackQuery, UserSession session, string[] data)
{
// Format: quickbuyvar:productId:quantity:variantId
var productId = Guid.Parse(data[1]);
var quantity = int.Parse(data[2]);
var variantId = Guid.Parse(data[3]);
var product = await _shopService.GetProductAsync(productId);
if (product == null)
{
await bot.AnswerCallbackQueryAsync(callbackQuery.Id, "Product not found", showAlert: true);
return;
}
// Find the variant
var variant = product.Variants?.FirstOrDefault(v => v.Id == variantId);
if (variant == null)
{
await bot.AnswerCallbackQueryAsync(callbackQuery.Id, "Variant not found", showAlert: true);
return;
}
// Add to cart with variant ID
var itemName = $"{product.Name} - {variant.Name}";
session.Cart.AddItem(product, quantity, multiBuyId: null, variantId: variantId);
await bot.AnswerCallbackQueryAsync(
callbackQuery.Id,
$"✅ Added {quantity}x {itemName} to cart",
showAlert: false
);
// Send cart summary in new message
await bot.SendTextMessageAsync(
callbackQuery.Message!.Chat.Id,
MessageFormatter.FormatCart(session.Cart),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: _menuBuilder.CartMenu(session.Cart)
);
// Immediately proceed to checkout
await Task.Delay(500); // Small delay for better UX
await HandleCheckout(bot, callbackQuery.Message, session);
}
private async Task HandleRemoveFromCart(ITelegramBotClient bot, CallbackQuery callbackQuery, UserSession session, Guid productId)
{
var item = session.Cart.Items.FirstOrDefault(i => i.ProductId == productId);
if (item != null)
{
session.Cart.RemoveItem(productId);
await bot.AnswerCallbackQueryAsync(
callbackQuery.Id,
$"❌ Removed {item.ProductName} from cart",
showAlert: false
);
}
await HandleViewCart(bot, callbackQuery.Message!, session);
}
private async Task HandleClearCart(ITelegramBotClient bot, CallbackQuery callbackQuery, UserSession session)
{
session.Cart.Clear();
await bot.AnswerCallbackQueryAsync(
callbackQuery.Id,
"🗑️ Cart cleared",
showAlert: false
);
await HandleViewCart(bot, callbackQuery.Message!, session);
}
private async Task HandleCheckout(ITelegramBotClient bot, Message message, UserSession session)
{
if (session.Cart.IsEmpty())
{
await bot.AnswerCallbackQueryAsync("", "Your cart is empty", showAlert: true);
return;
}
// Track checkout initiation
await _activityTracker.TrackActivityAsync(
message.Chat,
ActivityTypes.Checkout,
$"Starting checkout with {session.Cart.Items.Count} items",
null,
session.Cart.GetTotalAmount()
);
// Initialize order flow
session.OrderFlow = new OrderFlowData
{
UsePGPEncryption = session.Privacy.RequirePGP
};
session.State = SessionState.CheckoutFlow;
// Check if user has saved address
if (session.SavedAddress != null && !string.IsNullOrWhiteSpace(session.SavedAddress.Name))
{
// Offer to use saved address
var savedAddressPreview = $"{session.SavedAddress.Name}\n" +
$"{session.SavedAddress.Address}\n" +
$"{session.SavedAddress.City}\n" +
$"{session.SavedAddress.PostCode}\n" +
$"{session.SavedAddress.Country}";
await bot.SendTextMessageAsync(
message.Chat.Id,
$"📦 *Checkout - Delivery Details*\n\n" +
$"Use your saved address?\n\n" +
$"```\n{savedAddressPreview}\n```",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: _menuBuilder.UseSavedAddressMenu()
);
}
else
{
// Send new message for checkout - collect all details at once
await bot.SendTextMessageAsync(
message.Chat.Id,
"📦 *Checkout - Delivery Details*\n\n" +
"Please provide all delivery details in one message:\n\n" +
"• Full Name\n" +
"• Street Address\n" +
"• City\n" +
"• Post/Zip Code\n" +
"• Country (or leave blank for UK)\n\n" +
"_Example:_\n" +
"`John Smith\n" +
"123 Main Street\n" +
"London\n" +
"SW1A 1AA\n" +
"United Kingdom`",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown
);
}
}
private async Task HandleUseSavedAddress(ITelegramBotClient bot, Message message, UserSession session)
{
if (session.SavedAddress == null || session.OrderFlow == null)
{
await HandleEnterNewAddress(bot, message, session);
return;
}
// Copy saved address to order flow
session.OrderFlow.ShippingName = session.SavedAddress.Name;
session.OrderFlow.ShippingAddress = session.SavedAddress.Address;
session.OrderFlow.ShippingCity = session.SavedAddress.City;
session.OrderFlow.ShippingPostCode = session.SavedAddress.PostCode;
session.OrderFlow.ShippingCountry = session.SavedAddress.Country;
session.OrderFlow.CurrentStep = OrderFlowStep.ReviewingOrder;
// Show order summary
var summary = MessageFormatter.FormatOrderSummary(session.OrderFlow, session.Cart);
await bot.EditMessageTextAsync(
message.Chat.Id,
message.MessageId,
summary,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.CheckoutConfirmMenu()
);
}
private async Task HandleEnterNewAddress(ITelegramBotClient bot, Message message, UserSession session)
{
await bot.EditMessageTextAsync(
message.Chat.Id,
message.MessageId,
"📦 *Checkout - Delivery Details*\n\n" +
"Please provide all delivery details in one message:\n\n" +
"• Full Name\n" +
"• Street Address\n" +
"• City\n" +
"• Post/Zip Code\n" +
"• Country (or leave blank for UK)\n\n" +
"_Example:_\n" +
"`John Smith\n" +
"123 Main Street\n" +
"London\n" +
"SW1A 1AA\n" +
"United Kingdom`",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown
);
}
private async Task HandleSaveAddressPreference(ITelegramBotClient bot, Message message, UserSession session, bool saveAddress)
{
// Save user preference
session.Privacy.SaveShippingAddress = saveAddress;
if (saveAddress && session.OrderFlow != null)
{
// Save the address
session.SavedAddress = new SavedShippingAddress
{
Name = session.OrderFlow.ShippingName,
Address = session.OrderFlow.ShippingAddress,
City = session.OrderFlow.ShippingCity,
PostCode = session.OrderFlow.ShippingPostCode,
Country = session.OrderFlow.ShippingCountry,
SavedAt = DateTime.UtcNow
};
}
// Continue to order summary
if (session.OrderFlow != null)
{
session.OrderFlow.CurrentStep = OrderFlowStep.ReviewingOrder;
var summary = MessageFormatter.FormatOrderSummary(session.OrderFlow, session.Cart);
await bot.EditMessageTextAsync(
message.Chat.Id,
message.MessageId,
summary,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.CheckoutConfirmMenu()
);
}
}
private async Task HandleConfirmOrder(ITelegramBotClient bot, Message message, UserSession session, User telegramUser)
{
if (session.OrderFlow == null || session.Cart.IsEmpty())
{
await bot.AnswerCallbackQueryAsync("", "Invalid order state", showAlert: true);
return;
}
// Create the order with customer information
var order = await _shopService.CreateOrderAsync(
session,
telegramUser.Id,
telegramUser.Username ?? "",
$"{telegramUser.FirstName} {telegramUser.LastName}".Trim(),
telegramUser.FirstName ?? "",
telegramUser.LastName ?? "");
if (order == null)
{
await bot.EditMessageTextAsync(
message.Chat.Id,
message.MessageId,
"❌ Failed to create order. Please try again.",
replyMarkup: _menuBuilder.CartMenu(session.Cart)
);
return;
}
// Backup cart in case payment fails - we'll need to restore it
var cartBackup = System.Text.Json.JsonSerializer.Serialize(session.Cart);
session.TempData["cart_backup"] = cartBackup;
// Clear cart after successful order (will restore if payment fails)
session.Cart.Clear();
// Store order ID for payment
session.TempData["current_order_id"] = order.Id;
// Show payment options - get currencies dynamically from SilverPay support + admin settings
var currencies = await _shopService.GetAvailableCurrenciesAsync();
await bot.EditMessageTextAsync(
message.Chat.Id,
message.MessageId,
$"✅ *Order Created Successfully!*\n\n" +
$"Order ID: `{order.Id}`\n" +
$"Total: £{order.TotalAmount:F2}\n\n" +
"Select your preferred payment method:",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.PaymentMethodMenu(currencies)
);
}
private async Task HandlePayment(ITelegramBotClient bot, Message message, UserSession session, string currency)
{
if (!session.TempData.TryGetValue("current_order_id", out var orderIdObj) || orderIdObj is not Guid orderId)
{
await SafeEditMessageAsync(
bot,
message.Chat.Id,
message.MessageId,
"❌ Order not found. Please start a new order.",
Telegram.Bot.Types.Enums.ParseMode.Markdown,
MenuBuilder.MainMenu()
);
return;
}
// Show processing message
await SafeEditMessageAsync(
bot,
message.Chat.Id,
message.MessageId,
$"🔄 Creating {currency} payment...\n\nPlease wait...",
Telegram.Bot.Types.Enums.ParseMode.Markdown
);
try
{
var payment = await _shopService.CreatePaymentAsync(orderId, currency);
if (payment == null)
{
// Restore cart from backup since payment failed
if (session.TempData.TryGetValue("cart_backup", out var cartBackupObj) && cartBackupObj is string cartBackupJson)
{
try
{
session.Cart = System.Text.Json.JsonSerializer.Deserialize<ShoppingCart>(cartBackupJson) ?? new ShoppingCart();
session.TempData.Remove("cart_backup");
}
catch
{
// If restoration fails, cart remains empty
}
}
await SafeEditMessageAsync(
bot,
message.Chat.Id,
message.MessageId,
$"❌ *Payment Creation Failed*\n\n" +
$"Unable to create {currency} payment.\n" +
$"This might be due to:\n" +
$"• Payment gateway temporarily unavailable\n" +
$"• Network connectivity issues\n\n" +
$"Your cart has been restored. Please try again.",
Telegram.Bot.Types.Enums.ParseMode.Markdown,
_menuBuilder.CartMenu(session.Cart)
);
return;
}
// Payment created successfully, remove cart backup
session.TempData.Remove("cart_backup");
// Continue with display
var paymentText = MessageFormatter.FormatPayment(payment);
await DisplayPaymentInfo(bot, message, payment, paymentText);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create payment for order {OrderId} with currency {Currency}", orderId, currency);
// Restore cart from backup since payment failed
if (session.TempData.TryGetValue("cart_backup", out var cartBackupObj) && cartBackupObj is string cartBackupJson)
{
try
{
session.Cart = System.Text.Json.JsonSerializer.Deserialize<ShoppingCart>(cartBackupJson) ?? new ShoppingCart();
session.TempData.Remove("cart_backup");
}
catch
{
// If restoration fails, cart remains empty
}
}
await SafeEditMessageAsync(
bot,
message.Chat.Id,
message.MessageId,
$"❌ *Payment System Error*\n\n" +
$"Sorry, there was a technical issue creating your {currency} payment.\n\n" +
$"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)
);
return;
}
}
/// <summary>
/// Safely edit a message only if the content has changed
/// </summary>
private async Task SafeEditMessageAsync(ITelegramBotClient bot, ChatId chatId, int messageId, string newText,
Telegram.Bot.Types.Enums.ParseMode parseMode = Telegram.Bot.Types.Enums.ParseMode.Html,
InlineKeyboardMarkup? replyMarkup = null)
{
try
{
await bot.EditMessageTextAsync(chatId, messageId, newText, parseMode: parseMode, replyMarkup: replyMarkup);
}
catch (ApiRequestException apiEx) when (apiEx.Message.Contains("message is not modified"))
{
// Message content hasn't changed, this is fine
_logger.LogDebug("Attempted to edit message with identical content");
}
}
private async Task DisplayPaymentInfo(ITelegramBotClient bot, Message message, dynamic payment, string paymentText)
{
// Generate QR code if enabled
if (_configuration.GetValue<bool>("Features:EnableQRCodes"))
{
try
{
using var qrGenerator = new QRCodeGenerator();
var qrCodeData = qrGenerator.CreateQrCode(payment.WalletAddress, QRCodeGenerator.ECCLevel.Q);
using var qrCode = new PngByteQRCode(qrCodeData);
var qrCodeBytes = qrCode.GetGraphic(10);
using var stream = new System.IO.MemoryStream(qrCodeBytes);
await bot.SendPhotoAsync(
message.Chat.Id,
InputFile.FromStream(stream, "payment_qr.png"),
caption: paymentText,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown
);
// Delete the original message
await bot.DeleteMessageAsync(message.Chat.Id, message.MessageId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to generate QR code");
// Fall back to text-only
await SafeEditMessageAsync(
bot,
message.Chat.Id,
message.MessageId,
paymentText,
Telegram.Bot.Types.Enums.ParseMode.Markdown,
MenuBuilder.MainMenu()
);
}
}
else
{
await SafeEditMessageAsync(
bot,
message.Chat.Id,
message.MessageId,
paymentText,
Telegram.Bot.Types.Enums.ParseMode.Markdown,
MenuBuilder.MainMenu()
);
}
}
private async Task HandleViewOrders(ITelegramBotClient bot, Message message, UserSession session, User telegramUser)
{
// Track view orders action
await _activityTracker.TrackActivityAsync(
message.Chat,
ActivityTypes.ViewOrders,
"Viewing order history"
);
// Use new customer-based order lookup
var orders = await _shopService.GetCustomerOrdersAsync(
telegramUser.Id,
telegramUser.Username ?? "",
$"{telegramUser.FirstName} {telegramUser.LastName}".Trim(),
telegramUser.FirstName ?? "",
telegramUser.LastName ?? ""
);
// Send new message at bottom for navigation
if (!orders.Any())
{
await bot.SendTextMessageAsync(
message.Chat.Id,
"📦 *Your Orders*\n\nYou have no orders yet.",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.MainMenu()
);
}
else
{
await bot.SendTextMessageAsync(
message.Chat.Id,
$"📦 *Your Orders*\n\nFound {orders.Count} order(s):",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.OrderListMenu(orders)
);
}
session.State = SessionState.ViewingOrders;
}
private async Task HandleViewOrder(ITelegramBotClient bot, Message message, UserSession session, Guid orderId, User telegramUser)
{
var order = await _shopService.GetCustomerOrderAsync(
orderId,
telegramUser.Id,
telegramUser.Username ?? "",
$"{telegramUser.FirstName} {telegramUser.LastName}".Trim(),
telegramUser.FirstName ?? "",
telegramUser.LastName ?? ""
);
if (order == null)
{
await bot.AnswerCallbackQueryAsync("", "Order not found", showAlert: true);
return;
}
// Send new message at bottom for navigation
await bot.SendTextMessageAsync(
message.Chat.Id,
MessageFormatter.FormatOrder(order),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.MainMenu()
);
session.State = SessionState.ViewingOrder;
}
private async Task HandlePrivacySettings(ITelegramBotClient bot, Message message, UserSession session, string? setting)
{
if (setting != null)
{
switch (setting)
{
case "ephemeral":
session.Privacy.UseEphemeralMode = !session.Privacy.UseEphemeralMode;
session.IsEphemeral = session.Privacy.UseEphemeralMode;
break;
case "tor":
session.Privacy.UseTorOnly = !session.Privacy.UseTorOnly;
break;
case "pgp":
session.Privacy.RequirePGP = !session.Privacy.RequirePGP;
if (session.Privacy.RequirePGP && string.IsNullOrEmpty(session.Privacy.PGPPublicKey))
{
await bot.SendTextMessageAsync(
message.Chat.Id,
"Please set your PGP public key using:\n`/pgpkey YOUR_PUBLIC_KEY`",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown
);
}
break;
case "analytics":
session.Privacy.DisableAnalytics = !session.Privacy.DisableAnalytics;
break;
case "disappearing":
session.Privacy.EnableDisappearingMessages = !session.Privacy.EnableDisappearingMessages;
break;
case "delete":
await _sessionManager.DeleteUserDataAsync(message.From!.Id);
await bot.EditMessageTextAsync(
message.Chat.Id,
message.MessageId,
"✅ *All your data has been deleted*\n\nYou can start fresh with /start",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown
);
return;
}
}
await bot.EditMessageTextAsync(
message.Chat.Id,
message.MessageId,
MessageFormatter.FormatPrivacyPolicy(),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.PrivacyMenu(session.Privacy)
);
session.State = SessionState.PrivacySettings;
}
private async Task HandleHelp(ITelegramBotClient bot, Message message)
{
await bot.EditMessageTextAsync(
message.Chat.Id,
message.MessageId,
MessageFormatter.FormatHelp(),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.MainMenu()
);
}
private async Task HandleSupportCallback(ITelegramBotClient bot, CallbackQuery callbackQuery, UserSession session)
{
session.State = SessionState.CustomerSupport;
await ShowCustomerConversationInCallback(bot, callbackQuery, session);
await bot.AnswerCallbackQueryAsync(callbackQuery.Id);
}
private async Task HandleRefreshConversation(ITelegramBotClient bot, CallbackQuery callbackQuery, UserSession session)
{
await ShowCustomerConversationInCallback(bot, callbackQuery, session);
await bot.AnswerCallbackQueryAsync(callbackQuery.Id, "Messages refreshed");
}
private async Task HandleExitChat(ITelegramBotClient bot, CallbackQuery callbackQuery, UserSession session)
{
session.State = SessionState.MainMenu;
await bot.EditMessageTextAsync(
callbackQuery.Message!.Chat.Id,
callbackQuery.Message.MessageId,
"Chat ended. How can I help you today?",
replyMarkup: MenuBuilder.MainMenu()
);
await bot.AnswerCallbackQueryAsync(callbackQuery.Id);
}
private async Task ShowCustomerConversationInCallback(ITelegramBotClient bot, CallbackQuery callbackQuery, UserSession session)
{
try
{
var user = callbackQuery.From;
// Get conversation history for this customer
var messages = await _shopService.GetCustomerConversationAsync(
user.Id,
user.Username ?? "",
$"{user.FirstName} {user.LastName}".Trim(),
user.FirstName ?? "",
user.LastName ?? ""
);
var conversationText = "💬 *Your Messages*\n\n";
if (messages?.Any() == true)
{
conversationText += "Recent conversation:\n\n";
foreach (var msg in messages.OrderBy(m => m.CreatedAt).TakeLast(8)) // Show last 8 messages
{
var isFromBusiness = msg.Direction == 0; // AdminToCustomer
var sender = isFromBusiness ? "🏪 Shop" : "👤 You";
var time = msg.CreatedAt.ToString("MMM dd, HH:mm");
conversationText += $"*{sender}* _{time}_\n{msg.Content}\n\n";
}
}
else
{
conversationText += "No messages yet. Start a conversation by typing below!\n\n";
}
conversationText += "_Type your message or use buttons below._";
await bot.EditMessageTextAsync(
callbackQuery.Message!.Chat.Id,
callbackQuery.Message.MessageId,
conversationText,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.ConversationMenu()
);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error showing customer conversation in callback");
await bot.EditMessageTextAsync(
callbackQuery.Message!.Chat.Id,
callbackQuery.Message.MessageId,
"💬 *Customer Messages*\n\nReady to chat with our support team!\n\n_Type your message below._",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.ConversationMenu()
);
}
}
private async Task HandleCancelSupport(ITelegramBotClient bot, CallbackQuery callbackQuery, UserSession session)
{
session.State = SessionState.MainMenu;
await bot.EditMessageTextAsync(
callbackQuery.Message!.Chat.Id,
callbackQuery.Message.MessageId,
"Customer support cancelled. How can I help you today?",
replyMarkup: MenuBuilder.MainMenu()
);
await bot.AnswerCallbackQueryAsync(callbackQuery.Id);
}
private async Task HandleViewReviews(ITelegramBotClient bot, Message message, UserSession session)
{
try
{
// TODO: Fetch actual reviews from API
var reviewsText = "⭐ *Customer Reviews*\n\n" +
"Recent product reviews from verified customers:\n\n" +
"⭐⭐⭐⭐⭐ *Amazing quality!*\n" +
"_Product: Premium Item_\n" +
"Fast delivery, excellent product. Will order again!\n" +
"- Verified Customer, 2 days ago\n\n" +
"⭐⭐⭐⭐ *Good value*\n" +
"_Product: Standard Item_\n" +
"Product as described, good packaging.\n" +
"- Verified Customer, 5 days ago\n\n" +
"_Use /review after receiving your order to share your experience!_";
await bot.EditMessageTextAsync(
message.Chat.Id,
message.MessageId,
reviewsText,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.MainMenu()
);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error viewing reviews");
await bot.EditMessageTextAsync(
message.Chat.Id,
message.MessageId,
"❌ Error loading reviews. Please try again.",
replyMarkup: MenuBuilder.MainMenu()
);
}
}
private async Task HandleSelectVariant(ITelegramBotClient bot, Message message, UserSession session, string[] data)
{
// Format: selectvar:productId:quantity:multiBuyId
// Use mapper to decode short IDs back to GUIDs
var (_, productId, quantity, multiBuyId) = _mapper.ParseCallback(string.Join(":", data));
if (!productId.HasValue || !quantity.HasValue)
{
_logger.LogError("Invalid callback data for selectvar: {Data}", string.Join(":", data));
return;
}
var product = await _shopService.GetProductAsync(productId.Value);
if (product == null)
{
await bot.SendTextMessageAsync(message.Chat.Id, "Product not found.");
return;
}
// Clear any previous selections
session.TempData["selected_variants"] = new List<string>();
session.TempData["current_product"] = productId.Value;
session.TempData["current_quantity"] = quantity.Value;
session.TempData["current_multibuy"] = multiBuyId?.ToString();
await bot.EditMessageReplyMarkupAsync(
message.Chat.Id,
message.MessageId,
replyMarkup: _menuBuilder.VariantSelectionMenu(product, quantity.Value, multiBuyId?.ToString())
);
session.State = SessionState.SelectingVariants;
}
private async Task HandleSetVariant(ITelegramBotClient bot, Message message, UserSession session, string[] data)
{
// Format: setvariant:productId:variantName (for single item)
// Use mapper to decode short IDs back to GUIDs
var (_, productId, _, _) = _mapper.ParseCallback(string.Join(":", data.Take(2)));
var variantName = data[2];
if (!productId.HasValue)
{
_logger.LogError("Invalid callback data for setvariant: {Data}", string.Join(":", data));
return;
}
var product = await _shopService.GetProductAsync(productId.Value);
if (product == null) return;
// Get existing selections
var selectedVariants = session.TempData.ContainsKey("selected_variants")
? session.TempData["selected_variants"] as List<string> ?? new List<string>()
: new List<string>();
// Find the variant type for the selected variant
var selectedVariantObj = product.Variants?.FirstOrDefault(v => v.Name == variantName);
if (selectedVariantObj != null)
{
// Remove any previous selection from the same variant type
var variantsOfSameType = product.Variants
.Where(v => v.VariantType == selectedVariantObj.VariantType)
.Select(v => v.Name)
.ToList();
selectedVariants.RemoveAll(v => variantsOfSameType.Contains(v));
// Add the new selection
selectedVariants.Add(variantName);
}
session.TempData["selected_variants"] = selectedVariants;
var quantity = session.TempData.ContainsKey("current_quantity")
? (int)session.TempData["current_quantity"]
: 1;
var multiBuyId = session.TempData.ContainsKey("current_multibuy")
? session.TempData["current_multibuy"] as string
: null;
await bot.EditMessageReplyMarkupAsync(
message.Chat.Id,
message.MessageId,
replyMarkup: _menuBuilder.VariantSelectionMenu(product, quantity, multiBuyId, selectedVariants)
);
}
private async Task HandleAddVariant(ITelegramBotClient bot, Message message, UserSession session, string[] data)
{
// Format: addvariant:productId:quantity:variantName:multiBuyId (for multi-buy)
// Use mapper to decode short IDs back to GUIDs
var (_, productId, quantity, multiBuyId) = _mapper.ParseCallback(string.Join(":", data.Take(4)));
var variantName = data[3];
if (!productId.HasValue || !quantity.HasValue)
{
_logger.LogError("Invalid callback data for addvariant: {Data}", string.Join(":", data));
return;
}
var product = await _shopService.GetProductAsync(productId.Value);
if (product == null) return;
// Get current selections
var selectedVariants = session.TempData.ContainsKey("selected_variants")
? (List<string>)session.TempData["selected_variants"]
: new List<string>();
// Add variant if not at quantity limit
if (selectedVariants.Count < quantity.Value)
{
selectedVariants.Add(variantName);
session.TempData["selected_variants"] = selectedVariants;
}
await bot.EditMessageReplyMarkupAsync(
message.Chat.Id,
message.MessageId,
replyMarkup: _menuBuilder.VariantSelectionMenu(product, quantity.Value, multiBuyId?.ToString(), selectedVariants)
);
}
private async Task HandleConfirmVariant(ITelegramBotClient bot, CallbackQuery callbackQuery, UserSession session, string[] data)
{
// Format: confirmvar:productId:quantity:multiBuyId:variantList
// Use mapper to decode short IDs back to GUIDs
var (_, productId, quantity, multiBuyId) = _mapper.ParseCallback(string.Join(":", data.Take(4)));
var variantString = data[4];
var selectedVariants = variantString.Split(',').ToList();
if (!productId.HasValue || !quantity.HasValue)
{
_logger.LogError("Invalid callback data for confirmvar: {Data}", string.Join(":", data));
return;
}
var product = await _shopService.GetProductAsync(productId.Value);
if (product == null) return;
// Add to cart with selected variants
var cartItem = session.Cart.AddItem(product, quantity.Value, multiBuyId, variantId: null, selectedVariants);
// Show success message
await bot.AnswerCallbackQueryAsync(
callbackQuery.Id,
$"✅ Added {quantity.Value}x {product.Name} with {string.Join(", ", selectedVariants)} to cart!",
showAlert: true
);
// Return to product view
await HandleProductDetail(bot, callbackQuery.Message!, session, productId.Value);
// Clear temp data
session.TempData.Remove("selected_variants");
session.TempData.Remove("current_multibuy");
}
}
}