diff --git a/LittleShop/Areas/Admin/Controllers/BotsController.cs b/LittleShop/Areas/Admin/Controllers/BotsController.cs index 37daced..51e0440 100644 --- a/LittleShop/Areas/Admin/Controllers/BotsController.cs +++ b/LittleShop/Areas/Admin/Controllers/BotsController.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Threading.Tasks; @@ -8,6 +9,7 @@ using Microsoft.Extensions.Logging; using LittleShop.DTOs; using LittleShop.Enums; using LittleShop.Services; +using LittleShop.Models; namespace LittleShop.Areas.Admin.Controllers; @@ -18,17 +20,20 @@ public class BotsController : Controller private readonly IBotService _botService; private readonly IBotMetricsService _metricsService; private readonly ITelegramBotManagerService _telegramManager; + private readonly IBotDiscoveryService _discoveryService; private readonly ILogger _logger; public BotsController( IBotService botService, IBotMetricsService metricsService, ITelegramBotManagerService telegramManager, + IBotDiscoveryService discoveryService, ILogger logger) { _botService = botService; _metricsService = metricsService; _telegramManager = telegramManager; + _discoveryService = discoveryService; _logger = logger; } @@ -379,4 +384,397 @@ public class BotsController : Controller return false; } } + + #region Remote Bot Discovery + + // GET: Admin/Bots/DiscoverRemote + public IActionResult DiscoverRemote() + { + return View(new DiscoveryWizardViewModel()); + } + + // POST: Admin/Bots/ProbeRemote + [HttpPost] + [ValidateAntiForgeryToken] + public async Task ProbeRemote(DiscoveryWizardViewModel model) + { + _logger.LogInformation("Probing remote TeleBot at {IpAddress}:{Port}", model.IpAddress, model.Port); + + var result = await _discoveryService.ProbeRemoteBotAsync(model.IpAddress, model.Port); + + if (result.Success && result.ProbeResponse != null) + { + model.ProbeResponse = result.ProbeResponse; + model.BotName = result.ProbeResponse.Name; + model.CurrentStep = 2; + model.SuccessMessage = "TeleBot discovered successfully!"; + + // Auto-select a personality if not already configured + if (string.IsNullOrEmpty(model.PersonalityName)) + { + var personalities = new[] { "Alan", "Dave", "Sarah", "Mike", "Emma", "Tom" }; + model.PersonalityName = personalities[new Random().Next(personalities.Length)]; + } + } + else + { + model.ErrorMessage = result.Message; + model.CurrentStep = 1; + } + + return View("DiscoverRemote", model); + } + + // POST: Admin/Bots/RegisterRemote + [HttpPost] + [ValidateAntiForgeryToken] + public async Task RegisterRemote(DiscoveryWizardViewModel model) + { + _logger.LogInformation("Registering remote bot: {BotName} at {IpAddress}:{Port}", + model.BotName, model.IpAddress, model.Port); + + if (string.IsNullOrEmpty(model.BotName)) + { + model.ErrorMessage = "Bot name is required"; + model.CurrentStep = 2; + return View("DiscoverRemote", model); + } + + try + { + // Create the bot in the database + var registrationDto = new BotRegistrationDto + { + Name = model.BotName, + Description = model.Description, + Type = BotType.Telegram, + Version = model.ProbeResponse?.Version ?? "1.0.0", + PersonalityName = model.PersonalityName, + InitialSettings = new Dictionary + { + ["discovery"] = new { remoteAddress = model.IpAddress, remotePort = model.Port } + } + }; + + var botResult = await _botService.RegisterBotAsync(registrationDto); + + // Update the bot with remote discovery info + var bot = await _botService.GetBotByIdAsync(botResult.BotId); + if (bot != null) + { + // Update remote fields directly (we'll need to add this method to IBotService) + await UpdateBotRemoteInfoAsync(botResult.BotId, model.IpAddress, model.Port, + model.ProbeResponse?.InstanceId, DiscoveryStatus.Discovered); + } + + // Initialize the remote TeleBot with the BotKey + var initResult = await _discoveryService.InitializeRemoteBotAsync(botResult.BotId, model.IpAddress, model.Port); + + if (initResult.Success) + { + // Update status to Initialized + await UpdateBotRemoteInfoAsync(botResult.BotId, model.IpAddress, model.Port, + model.ProbeResponse?.InstanceId, DiscoveryStatus.Initialized); + + model.BotId = botResult.BotId; + model.BotKey = botResult.BotKey; + model.CurrentStep = 3; + model.SuccessMessage = "Bot registered and initialized! Now enter the Telegram bot token."; + + _logger.LogInformation("Remote bot registered and initialized: {BotId}", botResult.BotId); + } + else + { + model.ErrorMessage = $"Bot registered but initialization failed: {initResult.Message}"; + model.BotId = botResult.BotId; + model.BotKey = botResult.BotKey; + model.CurrentStep = 3; + } + + return View("DiscoverRemote", model); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to register remote bot"); + model.ErrorMessage = $"Registration failed: {ex.Message}"; + model.CurrentStep = 2; + return View("DiscoverRemote", model); + } + } + + // POST: Admin/Bots/ConfigureRemote + [HttpPost] + [ValidateAntiForgeryToken] + public async Task ConfigureRemote(DiscoveryWizardViewModel model) + { + _logger.LogInformation("Configuring remote bot {BotId} with Telegram token", model.BotId); + + if (!model.BotId.HasValue) + { + model.ErrorMessage = "Bot ID is missing"; + model.CurrentStep = 1; + return View("DiscoverRemote", model); + } + + if (string.IsNullOrEmpty(model.BotToken)) + { + model.ErrorMessage = "Telegram bot token is required"; + model.CurrentStep = 3; + return View("DiscoverRemote", model); + } + + // Validate the token first + if (!await ValidateTelegramToken(model.BotToken)) + { + model.ErrorMessage = "Invalid Telegram bot token. Please check and try again."; + model.CurrentStep = 3; + return View("DiscoverRemote", model); + } + + try + { + // Push configuration to the remote TeleBot + var configResult = await _discoveryService.PushConfigurationAsync(model.BotId.Value, model.BotToken); + + if (configResult.Success) + { + // Update bot settings with the token + var bot = await _botService.GetBotByIdAsync(model.BotId.Value); + if (bot != null) + { + var settings = bot.Settings ?? new Dictionary(); + settings["telegram"] = new { botToken = model.BotToken }; + await _botService.UpdateBotSettingsAsync(model.BotId.Value, + new UpdateBotSettingsDto { Settings = settings }); + + // Update discovery status to Configured + await UpdateBotRemoteInfoAsync(model.BotId.Value, + bot.RemoteAddress ?? model.IpAddress, + bot.RemotePort ?? model.Port, + bot.RemoteInstanceId, + DiscoveryStatus.Configured); + + // Activate the bot + await _botService.UpdateBotStatusAsync(model.BotId.Value, BotStatus.Active); + } + + TempData["Success"] = $"Remote bot configured successfully! Telegram: @{configResult.TelegramUsername}"; + return RedirectToAction(nameof(Details), new { id = model.BotId.Value }); + } + else + { + model.ErrorMessage = $"Configuration failed: {configResult.Message}"; + model.CurrentStep = 3; + return View("DiscoverRemote", model); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to configure remote bot"); + model.ErrorMessage = $"Configuration failed: {ex.Message}"; + model.CurrentStep = 3; + return View("DiscoverRemote", model); + } + } + + // POST: Admin/Bots/CheckRemoteStatus/5 + [HttpPost] + [ValidateAntiForgeryToken] + public async Task CheckRemoteStatus(Guid id) + { + _logger.LogInformation("Checking remote status for bot {BotId}", id); + + var bot = await _botService.GetBotByIdAsync(id); + if (bot == null) + { + TempData["Error"] = "Bot not found"; + return RedirectToAction(nameof(Index)); + } + + if (!bot.IsRemote) + { + TempData["Error"] = "This is not a remote bot"; + return RedirectToAction(nameof(Details), new { id }); + } + + try + { + var result = await _discoveryService.ProbeRemoteBotAsync(bot.RemoteAddress!, bot.RemotePort ?? 5000); + + if (result.Success && result.ProbeResponse != null) + { + // Update discovery status + await UpdateBotRemoteInfoAsync(id, bot.RemoteAddress!, bot.RemotePort ?? 5000, + result.ProbeResponse.InstanceId, result.ProbeResponse.Status); + + var statusMessage = result.ProbeResponse.IsConfigured + ? $"Bot is online and configured. Telegram: @{result.ProbeResponse.TelegramUsername}" + : "Bot is online but not yet configured with a Telegram token."; + + TempData["Success"] = statusMessage; + } + else + { + // Update status to indicate offline + await UpdateBotRemoteInfoAsync(id, bot.RemoteAddress!, bot.RemotePort ?? 5000, + bot.RemoteInstanceId, DiscoveryStatus.Offline); + + TempData["Error"] = $"Remote bot is not responding: {result.Message}"; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to check remote status for bot {BotId}", id); + TempData["Error"] = $"Failed to check status: {ex.Message}"; + } + + return RedirectToAction(nameof(Details), new { id }); + } + + // GET: Admin/Bots/RepushConfig/5 + public async Task RepushConfig(Guid id) + { + var bot = await _botService.GetBotByIdAsync(id); + if (bot == null) + return NotFound(); + + if (!bot.IsRemote) + { + TempData["Error"] = "This is not a remote bot"; + return RedirectToAction(nameof(Details), new { id }); + } + + // Try to get existing token from settings + string? existingToken = null; + if (bot.Settings.TryGetValue("telegram", out var telegramObj)) + { + try + { + var telegramJson = JsonSerializer.Serialize(telegramObj); + var telegramDict = JsonSerializer.Deserialize>(telegramJson); + if (telegramDict?.TryGetValue("botToken", out var tokenObj) == true) + { + existingToken = tokenObj?.ToString(); + } + } + catch { } + } + + ViewData["ExistingToken"] = existingToken; + ViewData["HasExistingToken"] = !string.IsNullOrEmpty(existingToken); + + return View(bot); + } + + // POST: Admin/Bots/RepushConfig/5 + [HttpPost] + [ValidateAntiForgeryToken] + public async Task RepushConfig(Guid id, string botToken, bool useExistingToken = false) + { + _logger.LogInformation("Re-pushing configuration to remote bot {BotId}", id); + + var bot = await _botService.GetBotByIdAsync(id); + if (bot == null) + { + TempData["Error"] = "Bot not found"; + return RedirectToAction(nameof(Index)); + } + + if (!bot.IsRemote) + { + TempData["Error"] = "This is not a remote bot"; + return RedirectToAction(nameof(Details), new { id }); + } + + // If using existing token, retrieve it + if (useExistingToken) + { + if (bot.Settings.TryGetValue("telegram", out var telegramObj)) + { + try + { + var telegramJson = JsonSerializer.Serialize(telegramObj); + var telegramDict = JsonSerializer.Deserialize>(telegramJson); + if (telegramDict?.TryGetValue("botToken", out var tokenObj) == true) + { + botToken = tokenObj?.ToString() ?? string.Empty; + } + } + catch { } + } + } + + if (string.IsNullOrEmpty(botToken)) + { + TempData["Error"] = "Bot token is required"; + return RedirectToAction(nameof(RepushConfig), new { id }); + } + + // Validate the token + if (!await ValidateTelegramToken(botToken)) + { + TempData["Error"] = "Invalid Telegram bot token"; + return RedirectToAction(nameof(RepushConfig), new { id }); + } + + try + { + // First, re-initialize if needed + var probeResult = await _discoveryService.ProbeRemoteBotAsync(bot.RemoteAddress!, bot.RemotePort ?? 5000); + + if (!probeResult.Success) + { + TempData["Error"] = $"Cannot reach remote bot: {probeResult.Message}"; + return RedirectToAction(nameof(Details), new { id }); + } + + // If bot is not initialized, initialize first + if (probeResult.ProbeResponse?.Status == DiscoveryStatus.AwaitingDiscovery || + probeResult.ProbeResponse?.Status == DiscoveryStatus.Discovered) + { + var initResult = await _discoveryService.InitializeRemoteBotAsync(id, bot.RemoteAddress!, bot.RemotePort ?? 5000); + if (!initResult.Success) + { + TempData["Error"] = $"Failed to initialize remote bot: {initResult.Message}"; + return RedirectToAction(nameof(Details), new { id }); + } + } + + // Push the configuration + var configResult = await _discoveryService.PushConfigurationAsync(id, botToken); + + if (configResult.Success) + { + // Update bot settings with the token + var settings = bot.Settings ?? new Dictionary(); + settings["telegram"] = new { botToken = botToken }; + await _botService.UpdateBotSettingsAsync(id, new UpdateBotSettingsDto { Settings = settings }); + + // Update discovery status + await UpdateBotRemoteInfoAsync(id, bot.RemoteAddress!, bot.RemotePort ?? 5000, + probeResult.ProbeResponse?.InstanceId, DiscoveryStatus.Configured); + + TempData["Success"] = $"Configuration pushed successfully! Telegram: @{configResult.TelegramUsername}"; + } + else + { + TempData["Error"] = $"Failed to push configuration: {configResult.Message}"; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to re-push configuration to bot {BotId}", id); + TempData["Error"] = $"Failed to push configuration: {ex.Message}"; + } + + return RedirectToAction(nameof(Details), new { id }); + } + + // Helper method to update bot remote info + private async Task UpdateBotRemoteInfoAsync(Guid botId, string ipAddress, int port, string? instanceId, string status) + { + await _botService.UpdateRemoteInfoAsync(botId, ipAddress, port, instanceId, status); + } + + #endregion } \ No newline at end of file diff --git a/LittleShop/Areas/Admin/Views/Bots/Details.cshtml b/LittleShop/Areas/Admin/Views/Bots/Details.cshtml index 5c9927b..c3b8627 100644 --- a/LittleShop/Areas/Admin/Views/Bots/Details.cshtml +++ b/LittleShop/Areas/Admin/Views/Bots/Details.cshtml @@ -128,6 +128,77 @@ + @if (Model.IsRemote) + { +
+
+
Remote Connection
+
+
+
+
Remote Address
+
@Model.RemoteAddress:@Model.RemotePort
+ +
Instance ID
+
@(Model.RemoteInstanceId ?? "N/A")
+ +
Discovery Status
+
+ @switch (Model.DiscoveryStatus) + { + case "Configured": + @Model.DiscoveryStatus + break; + case "Initialized": + @Model.DiscoveryStatus + break; + case "Discovered": + @Model.DiscoveryStatus + break; + case "Offline": + case "Error": + @Model.DiscoveryStatus + break; + default: + @Model.DiscoveryStatus + break; + } +
+ +
Last Discovery
+
+ @if (Model.LastDiscoveryAt.HasValue) + { + @Model.LastDiscoveryAt.Value.ToString("yyyy-MM-dd HH:mm:ss") + } + else + { + Never + } +
+
+ +
+ +
+
+ @Html.AntiForgeryToken() + +
+ + @if (Model.DiscoveryStatus == "Initialized" || Model.DiscoveryStatus == "Configured") + { + + Re-push Config + + } +
+
+
+ } +
30-Day Metrics Summary
diff --git a/LittleShop/Areas/Admin/Views/Bots/DiscoverRemote.cshtml b/LittleShop/Areas/Admin/Views/Bots/DiscoverRemote.cshtml new file mode 100644 index 0000000..7a28599 --- /dev/null +++ b/LittleShop/Areas/Admin/Views/Bots/DiscoverRemote.cshtml @@ -0,0 +1,286 @@ +@model LittleShop.DTOs.DiscoveryWizardViewModel + +@{ + ViewData["Title"] = "Discover Remote TeleBot"; +} + +

Discover Remote TeleBot

+ +
+
+ @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + { + + } + + @if (!string.IsNullOrEmpty(Model.SuccessMessage)) + { + + } + + @if (Model.CurrentStep == 1) + { + +
+
+
Step 1: Discover TeleBot Instance
+
+
+

+ Enter the IP address and port of the TeleBot instance you want to connect. + The TeleBot must be running and configured with the same discovery secret. +

+ +
+ @Html.AntiForgeryToken() + +
+
+ + + The IP address or hostname where TeleBot is running +
+
+ + + Default: 5010 +
+
+ +
+ + Cancel +
+
+
+
+ } + else if (Model.CurrentStep == 2) + { + +
+
+
TeleBot Discovered!
+
+
+ + + + + + + + + + + + + + + + + + + + + +
Instance ID:@Model.ProbeResponse?.InstanceId
Name:@Model.ProbeResponse?.Name
Version:@Model.ProbeResponse?.Version
Status: + + @Model.ProbeResponse?.Status + +
Address:@Model.IpAddress:@Model.Port
+
+
+ +
+
+
Step 2: Register Bot
+
+
+
+ @Html.AntiForgeryToken() + + + + + + + + + +
+ + + A friendly name to identify this bot +
+ +
+ + + Bot conversation style +
+ +
+ + +
+ +
+ + + Back + +
+
+
+
+ } + else if (Model.CurrentStep == 3) + { + +
+
+
Bot Registered - API Key
+
+
+ @if (!string.IsNullOrEmpty(Model.BotKey)) + { +
+ Save this Bot Key securely! It won't be shown again. +
+
+ + +
+ } +
+
+ +
+
+
Step 3: Configure Telegram Token
+
+
+

+ Now enter the Telegram bot token from BotFather to activate this bot. +

+ +
+ @Html.AntiForgeryToken() + + + + + + + +
+ + + + Get this from @@BotFather on Telegram + +
+ +
+ + + Skip (configure later) + +
+
+
+
+ } +
+ +
+
+
+
Wizard Progress
+
+
+
    +
  • 1 ? "text-success" : "text-muted")"> + 1 ? "check" : "circle")"> + 1. Discover TeleBot +
  • +
  • 2 ? "text-success" : "text-muted")"> + 2 ? "check" : "circle")"> + 2. Register Bot +
  • +
  • + + 3. Configure Telegram +
  • +
+
+
+ +
+
+
Requirements
+
+
+
    +
  • TeleBot must be running
  • +
  • Same discovery secret on both sides
  • +
  • Network connectivity to TeleBot
  • +
  • Valid Telegram bot token
  • +
+
+
+ + @if (Model.CurrentStep >= 2) + { +
+
+
Connection Info
+
+
+

Address: @Model.IpAddress

+

Port: @Model.Port

+
+
+ } +
+
+ +@section Scripts { + +} diff --git a/LittleShop/Areas/Admin/Views/Bots/Index.cshtml b/LittleShop/Areas/Admin/Views/Bots/Index.cshtml index fcff5da..e65709a 100644 --- a/LittleShop/Areas/Admin/Views/Bots/Index.cshtml +++ b/LittleShop/Areas/Admin/Views/Bots/Index.cshtml @@ -7,6 +7,9 @@

Bot Management

+ + Discover Remote Bot + Create Telegram Bot (Wizard) @@ -136,6 +139,12 @@ { @bot.PersonalityName } + @if (bot.IsRemote) + { + + Remote + + } @if (!string.IsNullOrEmpty(bot.Description)) {
@@ -181,6 +190,37 @@ @bot.Status break; } + @if (bot.IsRemote) + { +
+ @switch (bot.DiscoveryStatus) + { + case "Configured": + + Configured + + break; + case "Initialized": + + Initialized + + break; + case "Discovered": + + Needs Setup + + break; + case "Offline": + case "Error": + + @bot.DiscoveryStatus + + break; + default: + @bot.DiscoveryStatus + break; + } + } @bot.ActiveSessions diff --git a/LittleShop/Areas/Admin/Views/Bots/RepushConfig.cshtml b/LittleShop/Areas/Admin/Views/Bots/RepushConfig.cshtml new file mode 100644 index 0000000..b3f3aa1 --- /dev/null +++ b/LittleShop/Areas/Admin/Views/Bots/RepushConfig.cshtml @@ -0,0 +1,177 @@ +@model LittleShop.DTOs.BotDto +@{ + ViewData["Title"] = $"Re-push Configuration - {Model.Name}"; + var hasExistingToken = (bool)(ViewData["HasExistingToken"] ?? false); + var existingToken = ViewData["ExistingToken"] as string; +} + +

Re-push Configuration

+

@Model.Name

+ + + +@if (TempData["Error"] != null) +{ + +} + +
+
+
+
+
Push Configuration to Remote Bot
+
+
+

+ This will push the Telegram bot token to the remote TeleBot instance at + @Model.RemoteAddress:@Model.RemotePort. + Use this when the remote bot has been restarted and needs reconfiguration. +

+ + @if (hasExistingToken) + { +
+
Existing Token Found
+

A Telegram bot token is already stored for this bot.

+
+ @Html.AntiForgeryToken() + + +
+
+ +
+ +

Or provide a new token:

+ } + +
+ @Html.AntiForgeryToken() +
+ + +
+ Get this from @@BotFather on Telegram. +
+
+ +
+ + + Cancel + +
+
+
+
+ +
+
+
Current Remote Status
+
+
+
+
Remote Address
+
@Model.RemoteAddress:@Model.RemotePort
+ +
Instance ID
+
@(Model.RemoteInstanceId ?? "N/A")
+ +
Discovery Status
+
+ @switch (Model.DiscoveryStatus) + { + case "Configured": + @Model.DiscoveryStatus + break; + case "Initialized": + @Model.DiscoveryStatus + break; + case "Discovered": + @Model.DiscoveryStatus + break; + case "Offline": + case "Error": + @Model.DiscoveryStatus + break; + default: + @Model.DiscoveryStatus + break; + } +
+ +
Last Discovery
+
+ @if (Model.LastDiscoveryAt.HasValue) + { + @Model.LastDiscoveryAt.Value.ToString("yyyy-MM-dd HH:mm:ss") + } + else + { + Never + } +
+
+
+
+
+ +
+
+
+
Instructions
+
+
+
    +
  1. Ensure the remote TeleBot is running and accessible
  2. +
  3. If the bot was just restarted, it may be in "Awaiting Discovery" mode
  4. +
  5. Enter the Telegram bot token from @@BotFather
  6. +
  7. Click "Push New Token" to configure the remote bot
  8. +
+ +
+ +
When to use this:
+
    +
  • After TeleBot container restart
  • +
  • When changing the Telegram bot token
  • +
  • If the remote bot lost its configuration
  • +
  • After infrastructure recovery
  • +
+
+
+ +
+
+
Quick Actions
+
+
+
+
+ @Html.AntiForgeryToken() + +
+ + Back to Details + +
+
+
+
+
diff --git a/LittleShop/DTOs/BotDiscoveryDto.cs b/LittleShop/DTOs/BotDiscoveryDto.cs new file mode 100644 index 0000000..ad957cd --- /dev/null +++ b/LittleShop/DTOs/BotDiscoveryDto.cs @@ -0,0 +1,146 @@ +using System.ComponentModel.DataAnnotations; + +namespace LittleShop.DTOs; + +/// +/// Input for discovering a remote TeleBot instance +/// +public class RemoteBotDiscoveryDto +{ + [Required] + [StringLength(255)] + public string IpAddress { get; set; } = string.Empty; + + [Range(1, 65535)] + public int Port { get; set; } = 5010; +} + +/// +/// Response from TeleBot's discovery probe endpoint +/// +public class DiscoveryProbeResponse +{ + public string InstanceId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public bool HasToken { get; set; } + public bool IsConfigured { get; set; } + public bool IsInitialized { get; set; } + public string? TelegramUsername { get; set; } + public DateTime Timestamp { get; set; } +} + +/// +/// Input for registering a discovered remote bot +/// +public class RemoteBotRegistrationDto +{ + [Required] + [StringLength(255)] + public string IpAddress { get; set; } = string.Empty; + + [Range(1, 65535)] + public int Port { get; set; } = 5010; + + [Required] + [StringLength(100)] + public string Name { get; set; } = string.Empty; + + [StringLength(500)] + public string Description { get; set; } = string.Empty; + + [StringLength(50)] + public string PersonalityName { get; set; } = string.Empty; + + /// + /// Instance ID from the discovery probe response + /// + public string? RemoteInstanceId { get; set; } +} + +/// +/// Input for configuring a remote bot with Telegram credentials +/// +public class RemoteBotConfigureDto +{ + [Required] + public Guid BotId { get; set; } + + [Required] + public string BotToken { get; set; } = string.Empty; + + public Dictionary? Settings { get; set; } +} + +/// +/// Result of a discovery probe operation +/// +public class DiscoveryResult +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + public DiscoveryProbeResponse? ProbeResponse { get; set; } +} + +/// +/// Result of initializing a remote bot +/// +public class InitializeResult +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + public string? InstanceId { get; set; } +} + +/// +/// Result of configuring a remote bot +/// +public class ConfigureResult +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + public string? TelegramUsername { get; set; } + public string? TelegramDisplayName { get; set; } + public string? TelegramId { get; set; } +} + +/// +/// View model for the discovery wizard +/// +public class DiscoveryWizardViewModel +{ + // Step 1: Discovery + public string IpAddress { get; set; } = string.Empty; + public int Port { get; set; } = 5010; + + // Step 2: Registration (populated after probe) + public DiscoveryProbeResponse? ProbeResponse { get; set; } + public string BotName { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string PersonalityName { get; set; } = string.Empty; + + // Step 3: Configuration (populated after registration) + public Guid? BotId { get; set; } + public string? BotKey { get; set; } + public string BotToken { get; set; } = string.Empty; + + // Step tracking + public int CurrentStep { get; set; } = 1; + public string? ErrorMessage { get; set; } + public string? SuccessMessage { get; set; } +} + +/// +/// Discovery status constants +/// +public static class DiscoveryStatus +{ + public const string Local = "Local"; + public const string AwaitingDiscovery = "AwaitingDiscovery"; + public const string Discovered = "Discovered"; + public const string Initialized = "Initialized"; + public const string Configured = "Configured"; + public const string Offline = "Offline"; + public const string Error = "Error"; +} diff --git a/LittleShop/DTOs/BotDto.cs b/LittleShop/DTOs/BotDto.cs index 9313d05..3e279fa 100644 --- a/LittleShop/DTOs/BotDto.cs +++ b/LittleShop/DTOs/BotDto.cs @@ -23,7 +23,24 @@ public class BotDto public string PlatformId { get; set; } = string.Empty; public string PersonalityName { get; set; } = string.Empty; public Dictionary Settings { get; set; } = new(); - + + // Remote Discovery Fields + public string? RemoteAddress { get; set; } + public int? RemotePort { get; set; } + public DateTime? LastDiscoveryAt { get; set; } + public string DiscoveryStatus { get; set; } = "Local"; + public string? RemoteInstanceId { get; set; } + + /// + /// Indicates if this is a remotely discovered bot + /// + public bool IsRemote => !string.IsNullOrEmpty(RemoteAddress); + + /// + /// Full remote endpoint URL + /// + public string? RemoteEndpoint => IsRemote ? $"{RemoteAddress}:{RemotePort}" : null; + // Metrics summary public int TotalSessions { get; set; } public int ActiveSessions { get; set; } diff --git a/LittleShop/Models/Bot.cs b/LittleShop/Models/Bot.cs index 8513a77..2fa327d 100644 --- a/LittleShop/Models/Bot.cs +++ b/LittleShop/Models/Bot.cs @@ -51,7 +51,37 @@ public class Bot [StringLength(50)] public string PersonalityName { get; set; } = string.Empty; - + + // Remote Discovery Fields + + /// + /// IP address or hostname of the remote TeleBot instance + /// + [StringLength(255)] + public string? RemoteAddress { get; set; } + + /// + /// Port number for the remote TeleBot instance + /// + public int? RemotePort { get; set; } + + /// + /// Timestamp of last successful discovery probe + /// + public DateTime? LastDiscoveryAt { get; set; } + + /// + /// Discovery status: Local, Discovered, Initialized, Configured, Offline + /// + [StringLength(50)] + public string DiscoveryStatus { get; set; } = "Local"; + + /// + /// Instance ID returned by the remote TeleBot + /// + [StringLength(100)] + public string? RemoteInstanceId { get; set; } + // Navigation properties public virtual ICollection Metrics { get; set; } = new List(); public virtual ICollection Sessions { get; set; } = new List(); diff --git a/LittleShop/Program.cs b/LittleShop/Program.cs index f99df6d..ea1ff91 100644 --- a/LittleShop/Program.cs +++ b/LittleShop/Program.cs @@ -226,6 +226,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddHttpClient(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/LittleShop/Services/BotDiscoveryService.cs b/LittleShop/Services/BotDiscoveryService.cs new file mode 100644 index 0000000..828a3d3 --- /dev/null +++ b/LittleShop/Services/BotDiscoveryService.cs @@ -0,0 +1,450 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using LittleShop.DTOs; +using LittleShop.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace LittleShop.Services; + +/// +/// Service for discovering and configuring remote TeleBot instances. +/// Handles communication with TeleBot's discovery API endpoints. +/// +public class BotDiscoveryService : IBotDiscoveryService +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly HttpClient _httpClient; + private readonly IBotService _botService; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public BotDiscoveryService( + ILogger logger, + IConfiguration configuration, + HttpClient httpClient, + IBotService botService) + { + _logger = logger; + _configuration = configuration; + _httpClient = httpClient; + _botService = botService; + + // Configure default timeout + var timeoutSeconds = _configuration.GetValue("BotDiscovery:ConnectionTimeoutSeconds", 10); + _httpClient.Timeout = TimeSpan.FromSeconds(timeoutSeconds); + } + + public async Task ProbeRemoteBotAsync(string ipAddress, int port) + { + var endpoint = BuildEndpoint(ipAddress, port, "/api/discovery/probe"); + _logger.LogInformation("Probing remote TeleBot at {Endpoint}", endpoint); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, endpoint); + AddDiscoverySecret(request); + + var response = await _httpClient.SendAsync(request); + + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(); + var probeResponse = JsonSerializer.Deserialize(content, JsonOptions); + + _logger.LogInformation("Successfully probed TeleBot: {InstanceId}, Status: {Status}", + probeResponse?.InstanceId, probeResponse?.Status); + + return new DiscoveryResult + { + Success = true, + Message = "Discovery successful", + ProbeResponse = probeResponse + }; + } + else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + _logger.LogWarning("Discovery probe rejected: invalid discovery secret"); + return new DiscoveryResult + { + Success = false, + Message = "Invalid discovery secret. Ensure the shared secret matches on both LittleShop and TeleBot." + }; + } + else + { + var errorContent = await response.Content.ReadAsStringAsync(); + _logger.LogWarning("Discovery probe failed: {StatusCode} - {Content}", + response.StatusCode, errorContent); + return new DiscoveryResult + { + Success = false, + Message = $"Discovery failed: {response.StatusCode}" + }; + } + } + catch (TaskCanceledException) + { + _logger.LogWarning("Discovery probe timed out for {IpAddress}:{Port}", ipAddress, port); + return new DiscoveryResult + { + Success = false, + Message = "Connection timed out. Ensure the TeleBot instance is running and accessible." + }; + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "Discovery probe connection failed for {IpAddress}:{Port}", ipAddress, port); + return new DiscoveryResult + { + Success = false, + Message = $"Connection failed: {ex.Message}" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error during discovery probe"); + return new DiscoveryResult + { + Success = false, + Message = $"Unexpected error: {ex.Message}" + }; + } + } + + public async Task InitializeRemoteBotAsync(Guid botId, string ipAddress, int port) + { + var endpoint = BuildEndpoint(ipAddress, port, "/api/discovery/initialize"); + _logger.LogInformation("Initializing remote TeleBot at {Endpoint} for bot {BotId}", endpoint, botId); + + try + { + // Get the bot to retrieve the BotKey + var bot = await _botService.GetBotByIdAsync(botId); + if (bot == null) + { + return new InitializeResult + { + Success = false, + Message = "Bot not found" + }; + } + + // Get the BotKey securely + var botKey = await _botService.GetBotKeyAsync(botId); + if (string.IsNullOrEmpty(botKey)) + { + return new InitializeResult + { + Success = false, + Message = "Bot key not found" + }; + } + + var payload = new + { + BotKey = botKey, + WebhookSecret = _configuration["BotDiscovery:WebhookSecret"] ?? "", + LittleShopUrl = GetLittleShopUrl() + }; + + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); + AddDiscoverySecret(request); + request.Content = new StringContent( + JsonSerializer.Serialize(payload, JsonOptions), + Encoding.UTF8, + "application/json"); + + var response = await _httpClient.SendAsync(request); + + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(); + var initResponse = JsonSerializer.Deserialize(content, JsonOptions); + + _logger.LogInformation("Successfully initialized TeleBot: {InstanceId}", initResponse?.InstanceId); + + // Update bot's discovery status + await UpdateBotDiscoveryStatus(botId, DiscoveryStatus.Initialized, ipAddress, port, initResponse?.InstanceId); + + return new InitializeResult + { + Success = true, + Message = "TeleBot initialized successfully", + InstanceId = initResponse?.InstanceId + }; + } + else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + return new InitializeResult + { + Success = false, + Message = "Invalid discovery secret" + }; + } + else + { + var errorContent = await response.Content.ReadAsStringAsync(); + _logger.LogWarning("Initialization failed: {StatusCode} - {Content}", + response.StatusCode, errorContent); + return new InitializeResult + { + Success = false, + Message = $"Initialization failed: {response.StatusCode}" + }; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during TeleBot initialization"); + return new InitializeResult + { + Success = false, + Message = $"Initialization error: {ex.Message}" + }; + } + } + + public async Task PushConfigurationAsync(Guid botId, string botToken, Dictionary? settings = null) + { + _logger.LogInformation("Pushing configuration to bot {BotId}", botId); + + try + { + // Get the bot details + var bot = await _botService.GetBotByIdAsync(botId); + if (bot == null) + { + return new ConfigureResult + { + Success = false, + Message = "Bot not found" + }; + } + + if (string.IsNullOrEmpty(bot.RemoteAddress) || !bot.RemotePort.HasValue) + { + return new ConfigureResult + { + Success = false, + Message = "Bot does not have remote address configured" + }; + } + + // Get the BotKey securely + var botKey = await _botService.GetBotKeyAsync(botId); + if (string.IsNullOrEmpty(botKey)) + { + return new ConfigureResult + { + Success = false, + Message = "Bot key not found" + }; + } + + var endpoint = BuildEndpoint(bot.RemoteAddress, bot.RemotePort.Value, "/api/discovery/configure"); + + var payload = new + { + BotToken = botToken, + Settings = settings + }; + + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); + request.Headers.Add("X-Bot-Key", botKey); + request.Content = new StringContent( + JsonSerializer.Serialize(payload, JsonOptions), + Encoding.UTF8, + "application/json"); + + var response = await _httpClient.SendAsync(request); + + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(); + var configResponse = JsonSerializer.Deserialize(content, JsonOptions); + + _logger.LogInformation("Successfully configured TeleBot: @{Username}", configResponse?.TelegramUsername); + + // Update bot's discovery status and platform info + await UpdateBotDiscoveryStatus(botId, DiscoveryStatus.Configured, bot.RemoteAddress, bot.RemotePort.Value, bot.RemoteInstanceId); + + // Update platform info + if (!string.IsNullOrEmpty(configResponse?.TelegramUsername)) + { + await _botService.UpdatePlatformInfoAsync(botId, new UpdatePlatformInfoDto + { + PlatformUsername = configResponse.TelegramUsername, + PlatformDisplayName = configResponse.TelegramDisplayName ?? configResponse.TelegramUsername, + PlatformId = configResponse.TelegramId + }); + } + + return new ConfigureResult + { + Success = true, + Message = "Configuration pushed successfully", + TelegramUsername = configResponse?.TelegramUsername, + TelegramDisplayName = configResponse?.TelegramDisplayName, + TelegramId = configResponse?.TelegramId + }; + } + else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + return new ConfigureResult + { + Success = false, + Message = "Invalid bot key. The bot may need to be re-initialized." + }; + } + else + { + var errorContent = await response.Content.ReadAsStringAsync(); + _logger.LogWarning("Configuration push failed: {StatusCode} - {Content}", + response.StatusCode, errorContent); + return new ConfigureResult + { + Success = false, + Message = $"Configuration failed: {response.StatusCode}" + }; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error pushing configuration to TeleBot"); + return new ConfigureResult + { + Success = false, + Message = $"Configuration error: {ex.Message}" + }; + } + } + + public async Task TestConnectivityAsync(string ipAddress, int port) + { + try + { + var endpoint = BuildEndpoint(ipAddress, port, "/api/discovery/probe"); + using var request = new HttpRequestMessage(HttpMethod.Get, endpoint); + AddDiscoverySecret(request); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var response = await _httpClient.SendAsync(request, cts.Token); + + return response.IsSuccessStatusCode; + } + catch + { + return false; + } + } + + public async Task GetRemoteStatusAsync(string ipAddress, int port, string botKey) + { + try + { + var endpoint = BuildEndpoint(ipAddress, port, "/api/discovery/status"); + using var request = new HttpRequestMessage(HttpMethod.Get, endpoint); + request.Headers.Add("X-Bot-Key", botKey); + + var response = await _httpClient.SendAsync(request); + + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(content, JsonOptions); + } + + return null; + } + catch + { + return null; + } + } + + #region Private Methods + + private string BuildEndpoint(string ipAddress, int port, string path) + { + // Use HTTP for local/private networks, HTTPS for public + var scheme = IsPrivateNetwork(ipAddress) ? "http" : "https"; + return $"{scheme}://{ipAddress}:{port}{path}"; + } + + private bool IsPrivateNetwork(string ipAddress) + { + // Check if IP is in private ranges (10.x.x.x, 172.16-31.x.x, 192.168.x.x, localhost) + if (ipAddress == "localhost" || ipAddress == "127.0.0.1") + return true; + + if (System.Net.IPAddress.TryParse(ipAddress, out var ip)) + { + var bytes = ip.GetAddressBytes(); + if (bytes.Length == 4) + { + if (bytes[0] == 10) return true; + if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true; + if (bytes[0] == 192 && bytes[1] == 168) return true; + } + } + + return false; + } + + private void AddDiscoverySecret(HttpRequestMessage request) + { + var secret = _configuration["BotDiscovery:SharedSecret"]; + if (!string.IsNullOrEmpty(secret)) + { + request.Headers.Add("X-Discovery-Secret", secret); + } + } + + private string GetLittleShopUrl() + { + // Return the public URL for LittleShop API + return _configuration["BotDiscovery:LittleShopApiUrl"] + ?? _configuration["Kestrel:Endpoints:Https:Url"] + ?? "http://localhost:5000"; + } + + private async Task UpdateBotDiscoveryStatus(Guid botId, string status, string ipAddress, int port, string? instanceId) + { + var bot = await _botService.GetBotByIdAsync(botId); + if (bot != null) + { + // Update via direct database access would be better, but for now use a workaround + // This would typically be done through a dedicated method on IBotService + _logger.LogInformation("Updating bot {BotId} discovery status to {Status}", botId, status); + } + } + + #endregion + + #region Response DTOs + + private class InitializeResponse + { + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + public string? InstanceId { get; set; } + } + + private class ConfigureResponse + { + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + public string? TelegramUsername { get; set; } + public string? TelegramDisplayName { get; set; } + public string? TelegramId { get; set; } + } + + #endregion +} diff --git a/LittleShop/Services/BotService.cs b/LittleShop/Services/BotService.cs index 92c2b6c..82b6c5e 100644 --- a/LittleShop/Services/BotService.cs +++ b/LittleShop/Services/BotService.cs @@ -315,14 +315,39 @@ public class BotService : IBotService bot.PlatformUsername = dto.PlatformUsername; bot.PlatformDisplayName = dto.PlatformDisplayName; bot.PlatformId = dto.PlatformId; - + await _context.SaveChangesAsync(); - - _logger.LogInformation("Updated platform info for bot {BotId}: @{Username} ({DisplayName})", + + _logger.LogInformation("Updated platform info for bot {BotId}: @{Username} ({DisplayName})", botId, dto.PlatformUsername, dto.PlatformDisplayName); return true; } + public async Task UpdateRemoteInfoAsync(Guid botId, string remoteAddress, int remotePort, string? instanceId, string discoveryStatus) + { + var bot = await _context.Bots.FindAsync(botId); + if (bot == null) + return false; + + bot.RemoteAddress = remoteAddress; + bot.RemotePort = remotePort; + bot.RemoteInstanceId = instanceId; + bot.DiscoveryStatus = discoveryStatus; + bot.LastDiscoveryAt = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + + _logger.LogInformation("Updated remote info for bot {BotId}: {Address}:{Port} (Status: {Status})", + botId, remoteAddress, remotePort, discoveryStatus); + return true; + } + + public async Task GetBotKeyAsync(Guid botId) + { + var bot = await _context.Bots.FindAsync(botId); + return bot?.BotKey; + } + private BotDto MapToDto(Bot bot) { var settings = new Dictionary(); @@ -355,6 +380,13 @@ public class BotService : IBotService PlatformId = bot.PlatformId, PersonalityName = bot.PersonalityName, Settings = settings, + // Remote Discovery Fields + RemoteAddress = bot.RemoteAddress, + RemotePort = bot.RemotePort, + LastDiscoveryAt = bot.LastDiscoveryAt, + DiscoveryStatus = bot.DiscoveryStatus, + RemoteInstanceId = bot.RemoteInstanceId, + // Metrics TotalSessions = bot.Sessions.Count, ActiveSessions = activeSessions, TotalRevenue = totalRevenue, diff --git a/LittleShop/Services/IBotDiscoveryService.cs b/LittleShop/Services/IBotDiscoveryService.cs new file mode 100644 index 0000000..ea0dd83 --- /dev/null +++ b/LittleShop/Services/IBotDiscoveryService.cs @@ -0,0 +1,34 @@ +using LittleShop.DTOs; + +namespace LittleShop.Services; + +/// +/// Service for discovering and configuring remote TeleBot instances +/// +public interface IBotDiscoveryService +{ + /// + /// Probe a remote TeleBot instance to discover its status + /// + Task ProbeRemoteBotAsync(string ipAddress, int port); + + /// + /// Initialize a remote TeleBot instance with a BotKey + /// + Task InitializeRemoteBotAsync(Guid botId, string ipAddress, int port); + + /// + /// Push configuration (bot token and settings) to a remote TeleBot instance + /// + Task PushConfigurationAsync(Guid botId, string botToken, Dictionary? settings = null); + + /// + /// Test basic connectivity to a remote address + /// + Task TestConnectivityAsync(string ipAddress, int port); + + /// + /// Get the status of a remote TeleBot instance + /// + Task GetRemoteStatusAsync(string ipAddress, int port, string botKey); +} diff --git a/LittleShop/Services/IBotService.cs b/LittleShop/Services/IBotService.cs index 2d8a35c..803fdb3 100644 --- a/LittleShop/Services/IBotService.cs +++ b/LittleShop/Services/IBotService.cs @@ -23,4 +23,6 @@ public interface IBotService Task ValidateBotKeyAsync(string botKey); Task GenerateBotKeyAsync(); Task UpdatePlatformInfoAsync(Guid botId, UpdatePlatformInfoDto dto); + Task UpdateRemoteInfoAsync(Guid botId, string remoteAddress, int remotePort, string? instanceId, string discoveryStatus); + Task GetBotKeyAsync(Guid botId); } \ No newline at end of file diff --git a/LittleShop/appsettings.json b/LittleShop/appsettings.json index cb026e4..87b7c09 100644 --- a/LittleShop/appsettings.json +++ b/LittleShop/appsettings.json @@ -47,6 +47,14 @@ "172.16.0.0/12" ] }, + "BotDiscovery": { + "SharedSecret": "CHANGE_THIS_SHARED_SECRET_32_CHARS", + "ConnectionTimeoutSeconds": 10, + "ProbeRetryAttempts": 3, + "WebhookSecret": "", + "LittleShopApiUrl": "", + "Comment": "SharedSecret must match TeleBot Discovery:Secret. LittleShopApiUrl is the public URL for TeleBot to call back." + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/TeleBot/TeleBot/Controllers/DiscoveryController.cs b/TeleBot/TeleBot/Controllers/DiscoveryController.cs new file mode 100644 index 0000000..578a207 --- /dev/null +++ b/TeleBot/TeleBot/Controllers/DiscoveryController.cs @@ -0,0 +1,255 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using TeleBot.DTOs; +using TeleBot.Services; + +namespace TeleBot.Controllers; + +/// +/// API controller for remote discovery and configuration from LittleShop. +/// Enables server-initiated bot registration and configuration. +/// +[ApiController] +[Route("api/[controller]")] +public class DiscoveryController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly BotManagerService _botManagerService; + private readonly TelegramBotService _telegramBotService; + + public DiscoveryController( + ILogger logger, + IConfiguration configuration, + BotManagerService botManagerService, + TelegramBotService telegramBotService) + { + _logger = logger; + _configuration = configuration; + _botManagerService = botManagerService; + _telegramBotService = telegramBotService; + } + + /// + /// Probe endpoint for LittleShop to discover this TeleBot instance. + /// Returns current status and configuration state. + /// + [HttpGet("probe")] + public IActionResult Probe() + { + // Validate discovery secret + if (!ValidateDiscoverySecret()) + { + _logger.LogWarning("Discovery probe rejected: invalid or missing X-Discovery-Secret"); + return Unauthorized(new { error = "Invalid discovery secret" }); + } + + _logger.LogInformation("Discovery probe received from {RemoteIp}", GetRemoteIp()); + + var response = new DiscoveryProbeResponse + { + InstanceId = _botManagerService.InstanceId, + Name = _configuration["BotInfo:Name"] ?? "TeleBot", + Version = _configuration["BotInfo:Version"] ?? "1.0.0", + Status = _botManagerService.CurrentStatus, + HasToken = _botManagerService.HasBotToken, + IsConfigured = _botManagerService.IsConfigured, + IsInitialized = _botManagerService.IsInitialized, + TelegramUsername = _botManagerService.TelegramUsername, + Timestamp = DateTime.UtcNow + }; + + return Ok(response); + } + + /// + /// Initialize this TeleBot instance with a BotKey from LittleShop. + /// This is the first step after discovery - assigns the bot to LittleShop. + /// + [HttpPost("initialize")] + public async Task Initialize([FromBody] DiscoveryInitializeRequest request) + { + // Validate discovery secret + if (!ValidateDiscoverySecret()) + { + _logger.LogWarning("Discovery initialize rejected: invalid or missing X-Discovery-Secret"); + return Unauthorized(new { error = "Invalid discovery secret" }); + } + + if (string.IsNullOrEmpty(request.BotKey)) + { + return BadRequest(new DiscoveryInitializeResponse + { + Success = false, + Message = "BotKey is required" + }); + } + + _logger.LogInformation("Initializing TeleBot from LittleShop discovery. Remote IP: {RemoteIp}", GetRemoteIp()); + + try + { + var result = await _botManagerService.InitializeFromDiscoveryAsync( + request.BotKey, + request.WebhookSecret, + request.LittleShopUrl); + + if (result.Success) + { + _logger.LogInformation("TeleBot initialized successfully with BotKey"); + return Ok(new DiscoveryInitializeResponse + { + Success = true, + Message = "TeleBot initialized successfully", + InstanceId = _botManagerService.InstanceId + }); + } + else + { + _logger.LogWarning("TeleBot initialization failed: {Message}", result.Message); + return BadRequest(new DiscoveryInitializeResponse + { + Success = false, + Message = result.Message + }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during TeleBot initialization"); + return StatusCode(500, new DiscoveryInitializeResponse + { + Success = false, + Message = "Internal server error during initialization" + }); + } + } + + /// + /// Configure this TeleBot instance with Telegram credentials. + /// Requires prior initialization (valid BotKey). + /// + [HttpPost("configure")] + public async Task Configure([FromBody] DiscoveryConfigureRequest request) + { + // After initialization, use X-Bot-Key for authentication + if (!ValidateBotKey()) + { + _logger.LogWarning("Discovery configure rejected: invalid or missing X-Bot-Key"); + return Unauthorized(new { error = "Invalid bot key" }); + } + + if (string.IsNullOrEmpty(request.BotToken)) + { + return BadRequest(new DiscoveryConfigureResponse + { + Success = false, + Message = "BotToken is required" + }); + } + + _logger.LogInformation("Configuring TeleBot with Telegram credentials"); + + try + { + var result = await _botManagerService.ApplyRemoteConfigurationAsync( + request.BotToken, + request.Settings); + + if (result.Success) + { + _logger.LogInformation("TeleBot configured successfully. Telegram: @{Username}", result.TelegramUsername); + return Ok(new DiscoveryConfigureResponse + { + Success = true, + Message = "TeleBot configured and operational", + TelegramUsername = result.TelegramUsername, + TelegramDisplayName = result.TelegramDisplayName, + TelegramId = result.TelegramId + }); + } + else + { + _logger.LogWarning("TeleBot configuration failed: {Message}", result.Message); + return BadRequest(new DiscoveryConfigureResponse + { + Success = false, + Message = result.Message + }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during TeleBot configuration"); + return StatusCode(500, new DiscoveryConfigureResponse + { + Success = false, + Message = "Internal server error during configuration" + }); + } + } + + /// + /// Get current status of the bot (requires BotKey after initialization) + /// + [HttpGet("status")] + public IActionResult Status() + { + // Allow both discovery secret (pre-init) and bot key (post-init) + if (!ValidateDiscoverySecret() && !ValidateBotKey()) + { + return Unauthorized(new { error = "Invalid credentials" }); + } + + return Ok(new BotStatusUpdate + { + Status = _botManagerService.CurrentStatus, + IsOperational = _botManagerService.IsConfigured && _telegramBotService.IsRunning, + ActiveSessions = _botManagerService.ActiveSessionCount, + LastActivityAt = _botManagerService.LastActivityAt, + Metadata = new Dictionary + { + ["instanceId"] = _botManagerService.InstanceId, + ["version"] = _configuration["BotInfo:Version"] ?? "1.0.0", + ["telegramUsername"] = _botManagerService.TelegramUsername ?? "", + ["hasToken"] = _botManagerService.HasBotToken, + ["isInitialized"] = _botManagerService.IsInitialized + } + }); + } + + private bool ValidateDiscoverySecret() + { + var providedSecret = Request.Headers["X-Discovery-Secret"].ToString(); + var expectedSecret = _configuration["Discovery:Secret"]; + + if (string.IsNullOrEmpty(expectedSecret)) + { + _logger.LogWarning("Discovery secret not configured in appsettings.json"); + return false; + } + + return !string.IsNullOrEmpty(providedSecret) && + string.Equals(providedSecret, expectedSecret, StringComparison.Ordinal); + } + + private bool ValidateBotKey() + { + var providedKey = Request.Headers["X-Bot-Key"].ToString(); + var storedKey = _botManagerService.BotKey; + + if (string.IsNullOrEmpty(storedKey)) + { + return false; // Not initialized yet + } + + return !string.IsNullOrEmpty(providedKey) && + string.Equals(providedKey, storedKey, StringComparison.Ordinal); + } + + private string GetRemoteIp() + { + return HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + } +} diff --git a/TeleBot/TeleBot/DTOs/DiscoveryDtos.cs b/TeleBot/TeleBot/DTOs/DiscoveryDtos.cs new file mode 100644 index 0000000..a6c4dd9 --- /dev/null +++ b/TeleBot/TeleBot/DTOs/DiscoveryDtos.cs @@ -0,0 +1,137 @@ +using System.Text.Json.Serialization; + +namespace TeleBot.DTOs; + +/// +/// Response returned when LittleShop probes this TeleBot instance +/// +public class DiscoveryProbeResponse +{ + /// + /// Unique identifier for this TeleBot instance (generated on first startup) + /// + public string InstanceId { get; set; } = string.Empty; + + /// + /// Configured name of this bot + /// + public string Name { get; set; } = string.Empty; + + /// + /// TeleBot version + /// + public string Version { get; set; } = string.Empty; + + /// + /// Current operational status + /// + public string Status { get; set; } = "Bootstrap"; + + /// + /// Whether a Telegram bot token has been configured + /// + public bool HasToken { get; set; } + + /// + /// Whether the bot is fully configured and operational + /// + public bool IsConfigured { get; set; } + + /// + /// Whether this instance has been initialized (has BotKey) + /// + public bool IsInitialized { get; set; } + + /// + /// Telegram username if configured and operational + /// + public string? TelegramUsername { get; set; } + + /// + /// Timestamp of probe response + /// + public DateTime Timestamp { get; set; } = DateTime.UtcNow; +} + +/// +/// Request to initialize this TeleBot instance from LittleShop +/// +public class DiscoveryInitializeRequest +{ + /// + /// Bot key assigned by LittleShop for authentication + /// + public string BotKey { get; set; } = string.Empty; + + /// + /// Secret for webhook authentication + /// + public string WebhookSecret { get; set; } = string.Empty; + + /// + /// LittleShop API URL (if different from discovery source) + /// + public string? LittleShopUrl { get; set; } +} + +/// +/// Response after initialization +/// +public class DiscoveryInitializeResponse +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + public string? InstanceId { get; set; } +} + +/// +/// Request to configure this TeleBot instance with Telegram credentials +/// +public class DiscoveryConfigureRequest +{ + /// + /// Telegram Bot token from BotFather + /// + public string BotToken { get; set; } = string.Empty; + + /// + /// Additional settings to apply + /// + public Dictionary? Settings { get; set; } +} + +/// +/// Response after configuration +/// +public class DiscoveryConfigureResponse +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + + /// + /// Telegram bot username (e.g., @MyBot) + /// + public string? TelegramUsername { get; set; } + + /// + /// Telegram bot display name + /// + public string? TelegramDisplayName { get; set; } + + /// + /// Telegram bot ID + /// + public string? TelegramId { get; set; } +} + +/// +/// Status update sent to indicate bot operational state +/// +public class BotStatusUpdate +{ + public string Status { get; set; } = "Unknown"; + public bool IsOperational { get; set; } + public int ActiveSessions { get; set; } + public DateTime LastActivityAt { get; set; } + public Dictionary? Metadata { get; set; } +} diff --git a/TeleBot/TeleBot/Program.cs b/TeleBot/TeleBot/Program.cs index 34ced55..a3dac5e 100644 --- a/TeleBot/TeleBot/Program.cs +++ b/TeleBot/TeleBot/Program.cs @@ -106,6 +106,16 @@ builder.Services.AddHttpClient() builder.Services.AddSingleton(); builder.Services.AddHostedService(provider => provider.GetRequiredService()); +// Liveness Service - Monitors LittleShop connectivity and triggers shutdown on failure +builder.Services.AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(sp => + { + var logger = sp.GetRequiredService().CreateLogger("Liveness"); + return Socks5HttpHandler.CreateDirect(logger); + }); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(provider => provider.GetRequiredService()); + // Message Delivery Service - Single instance builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); @@ -155,6 +165,8 @@ try Log.Information("Privacy Mode: {PrivacyMode}", builder.Configuration["Privacy:Mode"]); Log.Information("Ephemeral by Default: {Ephemeral}", builder.Configuration["Privacy:EphemeralByDefault"]); Log.Information("Tor Enabled: {Tor}", builder.Configuration["Privacy:EnableTor"]); + Log.Information("Discovery endpoint: GET /api/discovery/probe"); + Log.Information("LittleShop API: {ApiUrl}", builder.Configuration["LittleShop:ApiUrl"]); Log.Information("Webhook endpoints available at /api/webhook"); await app.RunAsync(); diff --git a/TeleBot/TeleBot/Services/BotManagerService.cs b/TeleBot/TeleBot/Services/BotManagerService.cs index b4ab623..6f2f947 100644 --- a/TeleBot/TeleBot/Services/BotManagerService.cs +++ b/TeleBot/TeleBot/Services/BotManagerService.cs @@ -11,594 +11,662 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace TeleBot.Services +namespace TeleBot.Services; + +/// +/// Manages bot lifecycle, LittleShop communication, and server-initiated configuration. +/// Operates in Bootstrap mode until initialized by LittleShop discovery. +/// +public class BotManagerService : IHostedService, IDisposable { - public class BotManagerService : IHostedService, IDisposable + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + private readonly SessionManager _sessionManager; + + private Timer? _heartbeatTimer; + private Timer? _metricsTimer; + private Timer? _settingsSyncTimer; + + private string? _botKey; + private Guid? _botId; + private string? _webhookSecret; + private string? _telegramUsername; + private string? _telegramDisplayName; + private string? _telegramId; + private string? _currentBotToken; + + private readonly Dictionary _metricsBuffer; + private TelegramBotService? _telegramBotService; + private string _instanceId; + private string _currentStatus = "Bootstrap"; + private DateTime _lastActivityAt = DateTime.UtcNow; + + // Status constants + public const string STATUS_BOOTSTRAP = "Bootstrap"; + public const string STATUS_INITIALIZED = "Initialized"; + public const string STATUS_CONFIGURING = "Configuring"; + public const string STATUS_OPERATIONAL = "Operational"; + public const string STATUS_ERROR = "Error"; + + public BotManagerService( + IConfiguration configuration, + ILogger logger, + HttpClient httpClient, + SessionManager sessionManager) { - private readonly IConfiguration _configuration; - private readonly ILogger _logger; - private readonly HttpClient _httpClient; - private readonly SessionManager _sessionManager; - private Timer? _heartbeatTimer; - private Timer? _metricsTimer; - private Timer? _settingsSyncTimer; - private string? _botKey; - private Guid? _botId; - private readonly Dictionary _metricsBuffer; - private TelegramBotService? _telegramBotService; - private string? _lastKnownBotToken; + _configuration = configuration; + _logger = logger; + _httpClient = httpClient; + _sessionManager = sessionManager; + _metricsBuffer = new Dictionary(); - public BotManagerService( - IConfiguration configuration, - ILogger logger, - HttpClient httpClient, - SessionManager sessionManager) - { - _configuration = configuration; - _logger = logger; - _httpClient = httpClient; - _sessionManager = sessionManager; - _metricsBuffer = new Dictionary(); - } + // Generate or load instance ID + _instanceId = LoadOrGenerateInstanceId(); + } - public void SetTelegramBotService(TelegramBotService telegramBotService) - { - _telegramBotService = telegramBotService; - } + #region Public Properties - public async Task StartAsync(CancellationToken cancellationToken) + /// + /// Unique identifier for this TeleBot instance + /// + public string InstanceId => _instanceId; + + /// + /// Current operational status + /// + public string CurrentStatus => _currentStatus; + + /// + /// Whether a Telegram bot token has been configured + /// + public bool HasBotToken => !string.IsNullOrEmpty(_currentBotToken); + + /// + /// Whether the bot is fully configured and operational + /// + public bool IsConfigured => HasBotToken && IsInitialized; + + /// + /// Whether this instance has been initialized with a BotKey + /// + public bool IsInitialized => !string.IsNullOrEmpty(_botKey); + + /// + /// Telegram username if operational + /// + public string? TelegramUsername => _telegramUsername; + + /// + /// The BotKey assigned by LittleShop + /// + public string? BotKey => _botKey; + + /// + /// Number of active sessions + /// + public int ActiveSessionCount => _sessionManager?.GetActiveSessions().Count() ?? 0; + + /// + /// Last activity timestamp + /// + public DateTime LastActivityAt => _lastActivityAt; + + #endregion + + public void SetTelegramBotService(TelegramBotService telegramBotService) + { + _telegramBotService = telegramBotService; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("BotManagerService starting..."); + + // Check if already configured (from previous session or config file) + _botKey = _configuration["BotManager:ApiKey"]; + _currentBotToken = _configuration["Telegram:BotToken"]; + + if (!string.IsNullOrEmpty(_botKey) && _botKey != "YOUR_BOT_KEY_HERE") { - try + // Previously initialized - verify with LittleShop and start + _logger.LogInformation("Found existing BotKey, attempting to resume operation"); + _currentStatus = STATUS_INITIALIZED; + + // Start heartbeat and metrics if we have a valid token + if (!string.IsNullOrEmpty(_currentBotToken) && _currentBotToken != "YOUR_BOT_TOKEN_HERE") { - // Check if bot key exists in configuration - _botKey = _configuration["BotManager:ApiKey"]; - - if (string.IsNullOrEmpty(_botKey)) - { - // Try to find existing bot registration by Telegram username first - var botUsername = await GetTelegramBotUsernameAsync(); - - if (!string.IsNullOrEmpty(botUsername)) - { - var existingBot = await FindExistingBotByPlatformAsync(botUsername); - - if (existingBot != null) - { - _logger.LogInformation("Found existing bot registration for @{Username} (ID: {BotId}). Using existing bot.", - botUsername, existingBot.Id); - _botKey = existingBot.BotKey; - _botId = existingBot.Id; - - // Update platform info in case it changed - await UpdatePlatformInfoAsync(); - } - else - { - _logger.LogInformation("No existing bot found for @{Username}. Registering new bot.", botUsername); - await RegisterBotAsync(); - } - } - else - { - _logger.LogWarning("Could not determine bot username. Registering new bot."); - await RegisterBotAsync(); - } - } - else - { - // Authenticate existing bot - await AuthenticateBotAsync(); - } - - // Sync settings from server - await SyncSettingsAsync(); - - // Start heartbeat timer (every 30 seconds) - _heartbeatTimer = new Timer(SendHeartbeat, null, TimeSpan.Zero, TimeSpan.FromSeconds(30)); - - // Start metrics timer (every 60 seconds) - _metricsTimer = new Timer(SendMetrics, null, TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)); - - // Start settings sync timer (every 5 minutes) - _settingsSyncTimer = new Timer(SyncSettingsWithBotUpdate, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(5)); - - _logger.LogInformation("Bot manager service started successfully"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to start bot manager service"); + await StartOperationalTimersAsync(); + _currentStatus = STATUS_OPERATIONAL; } } - - public Task StopAsync(CancellationToken cancellationToken) + else { - _heartbeatTimer?.Change(Timeout.Infinite, 0); - _metricsTimer?.Change(Timeout.Infinite, 0); - _settingsSyncTimer?.Change(Timeout.Infinite, 0); + // Bootstrap mode - wait for LittleShop discovery + _currentStatus = STATUS_BOOTSTRAP; + _logger.LogInformation("TeleBot starting in Bootstrap mode. Waiting for LittleShop discovery..."); + _logger.LogInformation("Instance ID: {InstanceId}", _instanceId); + _logger.LogInformation("Discovery endpoint: GET /api/discovery/probe"); + } + } - // Send final metrics before stopping - SendMetrics(null); + public Task StopAsync(CancellationToken cancellationToken) + { + _heartbeatTimer?.Change(Timeout.Infinite, 0); + _metricsTimer?.Change(Timeout.Infinite, 0); + _settingsSyncTimer?.Change(Timeout.Infinite, 0); - _logger.LogInformation("Bot manager service stopped"); - return Task.CompletedTask; + // Send final metrics before stopping + SendMetrics(null); + + _logger.LogInformation("BotManagerService stopped"); + return Task.CompletedTask; + } + + #region Discovery Methods + + /// + /// Initialize this TeleBot instance from LittleShop discovery + /// + public async Task<(bool Success, string Message)> InitializeFromDiscoveryAsync( + string botKey, + string? webhookSecret, + string? littleShopUrl) + { + if (string.IsNullOrEmpty(botKey)) + { + return (false, "BotKey is required"); } - private async Task RegisterBotAsync() + // Check if already initialized with a different key + if (!string.IsNullOrEmpty(_botKey) && _botKey != botKey) { - var apiUrl = _configuration["LittleShop:ApiUrl"]; - var registrationData = new - { - Name = _configuration["BotInfo:Name"] ?? "TeleBot", - Description = _configuration["BotInfo:Description"] ?? "Telegram E-commerce Bot", - Type = 0, // Telegram - Version = _configuration["BotInfo:Version"] ?? "1.0.0", - InitialSettings = new Dictionary - { - ["telegram"] = new - { - botToken = _configuration["Telegram:BotToken"], - webhookUrl = _configuration["Telegram:WebhookUrl"] - }, - ["privacy"] = new - { - mode = _configuration["Privacy:Mode"], - enableTor = _configuration.GetValue("Privacy:EnableTor") - } - } - }; - - var json = JsonSerializer.Serialize(registrationData); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - var response = await _httpClient.PostAsync($"{apiUrl}/api/bots/register", content); - - if (response.IsSuccessStatusCode) - { - var responseJson = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(responseJson); - - _botKey = result?.BotKey; - _botId = result?.BotId; - - _logger.LogInformation("Bot registered successfully. Bot ID: {BotId}", _botId); - _logger.LogWarning("IMPORTANT: Save this bot key securely: {BotKey}", _botKey); - - // Update platform info immediately after registration - await UpdatePlatformInfoAsync(); - - // Save bot key to configuration or secure storage - // In production, this should be saved securely - } - else - { - _logger.LogError("Failed to register bot: {StatusCode}", response.StatusCode); - } + _logger.LogWarning("Attempted to reinitialize with different BotKey. Current: {Current}, New: {New}", + _botKey.Substring(0, 8), botKey.Substring(0, 8)); + return (false, "Already initialized with a different BotKey"); } - private async Task AuthenticateBotAsync() + try { - var apiUrl = _configuration["LittleShop:ApiUrl"]; - var authData = new { BotKey = _botKey }; - - var json = JsonSerializer.Serialize(authData); - var content = new StringContent(json, Encoding.UTF8, "application/json"); + _botKey = botKey; + _webhookSecret = webhookSecret ?? string.Empty; - var response = await _httpClient.PostAsync($"{apiUrl}/api/bots/authenticate", content); - - if (response.IsSuccessStatusCode) + // Update LittleShop URL if provided + if (!string.IsNullOrEmpty(littleShopUrl)) { - var responseJson = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(responseJson); - - _botId = result?.Id; - _logger.LogInformation("Bot authenticated successfully. Bot ID: {BotId}", _botId); - } - else - { - _logger.LogError("Failed to authenticate bot: {StatusCode}", response.StatusCode); + // Note: In production, this would update the configuration + _logger.LogInformation("LittleShop URL override: {Url}", littleShopUrl); } + + _currentStatus = STATUS_INITIALIZED; + _logger.LogInformation("TeleBot initialized with BotKey: {KeyPrefix}...", botKey.Substring(0, Math.Min(8, botKey.Length))); + + // Save BotKey for persistence (in production, save to secure storage) + await SaveConfigurationAsync(); + + return (true, "Initialized successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during initialization"); + _currentStatus = STATUS_ERROR; + return (false, $"Initialization error: {ex.Message}"); + } + } + + /// + /// Apply remote configuration (bot token and settings) from LittleShop + /// + public async Task<(bool Success, string Message, string? TelegramUsername, string? TelegramDisplayName, string? TelegramId)> ApplyRemoteConfigurationAsync( + string botToken, + Dictionary? settings) + { + if (!IsInitialized) + { + return (false, "Must be initialized before configuration", null, null, null); } - private async Task SyncSettingsAsync() + if (string.IsNullOrEmpty(botToken)) { - if (string.IsNullOrEmpty(_botKey)) return; + return (false, "BotToken is required", null, null, null); + } - var settings = await GetSettingsAsync(); + _currentStatus = STATUS_CONFIGURING; + _logger.LogInformation("Applying remote configuration..."); + + try + { + // Validate token with Telegram API + var telegramInfo = await ValidateTelegramTokenAsync(botToken); + if (telegramInfo == null) + { + _currentStatus = STATUS_INITIALIZED; + return (false, "Invalid Telegram bot token", null, null, null); + } + + // Store token and update Telegram info + _currentBotToken = botToken; + _telegramUsername = telegramInfo.Username; + _telegramDisplayName = telegramInfo.FirstName; + _telegramId = telegramInfo.Id.ToString(); + + // Apply additional settings if provided if (settings != null) { - // Apply settings to configuration - // This would update the running configuration with server settings - _logger.LogInformation("Settings synced from server"); + await ApplySettingsAsync(settings); + } + + // Start/restart the Telegram bot with new token + if (_telegramBotService != null) + { + await _telegramBotService.UpdateBotTokenAsync(botToken); + } + + // Start operational timers + await StartOperationalTimersAsync(); + + // Update platform info with LittleShop + await UpdatePlatformInfoAsync(); + + _currentStatus = STATUS_OPERATIONAL; + _lastActivityAt = DateTime.UtcNow; + + _logger.LogInformation("TeleBot configured and operational. Telegram: @{Username}", _telegramUsername); + + // Save configuration for persistence + await SaveConfigurationAsync(); + + return (true, "Configuration applied successfully", _telegramUsername, _telegramDisplayName, _telegramId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error applying remote configuration"); + _currentStatus = STATUS_ERROR; + return (false, $"Configuration error: {ex.Message}", null, null, null); + } + } + + #endregion + + #region Settings and Metrics + + public async Task?> GetSettingsAsync() + { + if (string.IsNullOrEmpty(_botKey)) return null; + + try + { + var apiUrl = _configuration["LittleShop:ApiUrl"]; + _httpClient.DefaultRequestHeaders.Clear(); + _httpClient.DefaultRequestHeaders.Add("X-Bot-Key", _botKey); + + var response = await _httpClient.GetAsync($"{apiUrl}/api/bots/settings"); + + if (response.IsSuccessStatusCode) + { + var settingsJson = await response.Content.ReadAsStringAsync(); + var settings = JsonSerializer.Deserialize>(settingsJson); + return settings; } } - - public async Task?> GetSettingsAsync() + catch (Exception ex) { - if (string.IsNullOrEmpty(_botKey)) return null; + _logger.LogError(ex, "Failed to fetch settings from API"); + } - try + return null; + } + + public void RecordMetric(string name, decimal value) + { + lock (_metricsBuffer) + { + if (_metricsBuffer.ContainsKey(name)) + _metricsBuffer[name] += value; + else + _metricsBuffer[name] = value; + } + + _lastActivityAt = DateTime.UtcNow; + } + + public async Task StartSessionAsync(string sessionIdentifier, string platform = "Telegram") + { + if (string.IsNullOrEmpty(_botKey)) return null; + + try + { + var apiUrl = _configuration["LittleShop:ApiUrl"]; + var sessionData = new { - var apiUrl = _configuration["LittleShop:ApiUrl"]; - _httpClient.DefaultRequestHeaders.Clear(); - _httpClient.DefaultRequestHeaders.Add("X-Bot-Key", _botKey); + SessionIdentifier = sessionIdentifier, + Platform = platform, + Language = "en", + Country = "", + IsAnonymous = true, + Metadata = new Dictionary() + }; - var response = await _httpClient.GetAsync($"{apiUrl}/api/bots/settings"); + var json = JsonSerializer.Serialize(sessionData); + var content = new StringContent(json, Encoding.UTF8, "application/json"); - if (response.IsSuccessStatusCode) + _httpClient.DefaultRequestHeaders.Clear(); + _httpClient.DefaultRequestHeaders.Add("X-Bot-Key", _botKey); + + var response = await _httpClient.PostAsync($"{apiUrl}/api/bots/sessions/start", content); + + if (response.IsSuccessStatusCode) + { + var responseJson = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(responseJson); + _lastActivityAt = DateTime.UtcNow; + return result?.Id; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to start session"); + } + + return null; + } + + public async Task UpdateSessionAsync(Guid sessionId, int? orderCount = null, int? messageCount = null, decimal? totalSpent = null) + { + if (string.IsNullOrEmpty(_botKey)) return; + + try + { + var apiUrl = _configuration["LittleShop:ApiUrl"]; + var updateData = new + { + OrderCount = orderCount, + MessageCount = messageCount, + TotalSpent = totalSpent + }; + + var json = JsonSerializer.Serialize(updateData); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + _httpClient.DefaultRequestHeaders.Clear(); + _httpClient.DefaultRequestHeaders.Add("X-Bot-Key", _botKey); + + await _httpClient.PutAsync($"{apiUrl}/api/bots/sessions/{sessionId}", content); + _lastActivityAt = DateTime.UtcNow; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update session"); + } + } + + #endregion + + #region Private Methods + + private string LoadOrGenerateInstanceId() + { + // Try to load from config/file + var configuredId = _configuration["BotInfo:InstanceId"]; + if (!string.IsNullOrEmpty(configuredId)) + { + return configuredId; + } + + // Generate new instance ID + var newId = $"telebot-{Guid.NewGuid():N}".Substring(0, 24); + _logger.LogInformation("Generated new instance ID: {InstanceId}", newId); + return newId; + } + + private async Task StartOperationalTimersAsync() + { + // Start heartbeat timer (every 30 seconds) + _heartbeatTimer = new Timer(SendHeartbeat, null, TimeSpan.Zero, TimeSpan.FromSeconds(30)); + + // Start metrics timer (every 60 seconds) + _metricsTimer = new Timer(SendMetrics, null, TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)); + + // Start settings sync timer (every 5 minutes) + _settingsSyncTimer = new Timer(SyncSettingsWithBotUpdate, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(5)); + + _logger.LogInformation("Operational timers started"); + await Task.CompletedTask; + } + + private async void SendHeartbeat(object? state) + { + if (string.IsNullOrEmpty(_botKey)) return; + + try + { + var apiUrl = _configuration["LittleShop:ApiUrl"]; + var activeSessions = _sessionManager.GetActiveSessions().Count(); + + var heartbeatData = new + { + Version = _configuration["BotInfo:Version"] ?? "1.0.0", + IpAddress = "REDACTED", + ActiveSessions = activeSessions, + Status = new Dictionary { - var settingsJson = await response.Content.ReadAsStringAsync(); - var settings = JsonSerializer.Deserialize>(settingsJson); - return settings; + ["healthy"] = true, + ["status"] = _currentStatus, + ["uptime"] = DateTime.UtcNow.Subtract(AppDomain.CurrentDomain.BaseDirectory != null + ? new System.IO.DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory).CreationTimeUtc + : DateTime.UtcNow).TotalSeconds } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to fetch settings from API"); - } + }; - return null; + var json = JsonSerializer.Serialize(heartbeatData); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + _httpClient.DefaultRequestHeaders.Clear(); + _httpClient.DefaultRequestHeaders.Add("X-Bot-Key", _botKey); + + await _httpClient.PostAsync($"{apiUrl}/api/bots/heartbeat", content); } - - private async void SendHeartbeat(object? state) + catch (Exception ex) { - if (string.IsNullOrEmpty(_botKey)) return; - - try - { - var apiUrl = _configuration["LittleShop:ApiUrl"]; - var activeSessions = _sessionManager.GetActiveSessions().Count(); - - var heartbeatData = new - { - Version = _configuration["BotInfo:Version"] ?? "1.0.0", - IpAddress = "REDACTED", // SECURITY: Never send real IP address - ActiveSessions = activeSessions, - Status = new Dictionary - { - ["healthy"] = true, - ["uptime"] = DateTime.UtcNow.Subtract(AppDomain.CurrentDomain.BaseDirectory != null - ? new System.IO.DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory).CreationTimeUtc - : DateTime.UtcNow).TotalSeconds - } - }; - - var json = JsonSerializer.Serialize(heartbeatData); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - _httpClient.DefaultRequestHeaders.Clear(); - _httpClient.DefaultRequestHeaders.Add("X-Bot-Key", _botKey); - - await _httpClient.PostAsync($"{apiUrl}/api/bots/heartbeat", content); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send heartbeat"); - } + _logger.LogError(ex, "Failed to send heartbeat"); } + } - private async void SendMetrics(object? state) + private async void SendMetrics(object? state) + { + if (string.IsNullOrEmpty(_botKey)) return; + + try { - if (string.IsNullOrEmpty(_botKey)) return; + var apiUrl = _configuration["LittleShop:ApiUrl"]; + var metrics = new List(); - try - { - var apiUrl = _configuration["LittleShop:ApiUrl"]; - var metrics = new List(); - - // Collect metrics from buffer - lock (_metricsBuffer) - { - foreach (var metric in _metricsBuffer) - { - metrics.Add(new - { - MetricType = GetMetricType(metric.Key), - Value = metric.Value, - Category = "Bot", - Description = metric.Key - }); - } - _metricsBuffer.Clear(); - } - - if (!metrics.Any()) return; - - var metricsData = new { Metrics = metrics }; - var json = JsonSerializer.Serialize(metricsData); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - _httpClient.DefaultRequestHeaders.Clear(); - _httpClient.DefaultRequestHeaders.Add("X-Bot-Key", _botKey); - - await _httpClient.PostAsync($"{apiUrl}/api/bots/metrics/batch", content); - - _logger.LogDebug("Sent {Count} metrics to server", metrics.Count); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send metrics"); - } - } - - public void RecordMetric(string name, decimal value) - { lock (_metricsBuffer) { - if (_metricsBuffer.ContainsKey(name)) - _metricsBuffer[name] += value; - else - _metricsBuffer[name] = value; - } - } - - public async Task StartSessionAsync(string sessionIdentifier, string platform = "Telegram") - { - if (string.IsNullOrEmpty(_botKey)) return null; - - try - { - var apiUrl = _configuration["LittleShop:ApiUrl"]; - var sessionData = new + foreach (var metric in _metricsBuffer) { - SessionIdentifier = sessionIdentifier, - Platform = platform, - Language = "en", - Country = "", - IsAnonymous = true, - Metadata = new Dictionary() - }; - - var json = JsonSerializer.Serialize(sessionData); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - _httpClient.DefaultRequestHeaders.Clear(); - _httpClient.DefaultRequestHeaders.Add("X-Bot-Key", _botKey); - - var response = await _httpClient.PostAsync($"{apiUrl}/api/bots/sessions/start", content); - - if (response.IsSuccessStatusCode) - { - var responseJson = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(responseJson); - return result?.Id; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to start session"); - } - - return null; - } - - public async Task UpdateSessionAsync(Guid sessionId, int? orderCount = null, int? messageCount = null, decimal? totalSpent = null) - { - if (string.IsNullOrEmpty(_botKey)) return; - - try - { - var apiUrl = _configuration["LittleShop:ApiUrl"]; - var updateData = new - { - OrderCount = orderCount, - MessageCount = messageCount, - TotalSpent = totalSpent - }; - - var json = JsonSerializer.Serialize(updateData); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - _httpClient.DefaultRequestHeaders.Clear(); - _httpClient.DefaultRequestHeaders.Add("X-Bot-Key", _botKey); - - await _httpClient.PutAsync($"{apiUrl}/api/bots/sessions/{sessionId}", content); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to update session"); - } - } - - private int GetMetricType(string metricName) - { - return metricName.ToLower() switch - { - "message" => 4, - "order" => 2, - "error" => 6, - "command" => 5, - _ => 7 // ApiCall - }; - } - - private async void SyncSettingsWithBotUpdate(object? state) - { - try - { - var settings = await GetSettingsAsync(); - if (settings != null && settings.ContainsKey("telegram")) - { - if (settings["telegram"] is JsonElement telegramElement) + metrics.Add(new { - var telegramSettings = telegramElement.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.ToString()); - if (telegramSettings.TryGetValue("botToken", out var token)) - { - // Check if token has changed - if (!string.IsNullOrEmpty(token) && token != _lastKnownBotToken) - { - _logger.LogInformation("Bot token has changed. Updating bot..."); - _lastKnownBotToken = token; + MetricType = GetMetricType(metric.Key), + Value = metric.Value, + Category = "Bot", + Description = metric.Key + }); + } + _metricsBuffer.Clear(); + } - // Update the TelegramBotService if available - if (_telegramBotService != null) - { - await _telegramBotService.UpdateBotTokenAsync(token); - } + if (!metrics.Any()) return; + + var metricsData = new { Metrics = metrics }; + var json = JsonSerializer.Serialize(metricsData); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + _httpClient.DefaultRequestHeaders.Clear(); + _httpClient.DefaultRequestHeaders.Add("X-Bot-Key", _botKey); + + await _httpClient.PostAsync($"{apiUrl}/api/bots/metrics/batch", content); + + _logger.LogDebug("Sent {Count} metrics to server", metrics.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send metrics"); + } + } + + private int GetMetricType(string metricName) + { + return metricName.ToLower() switch + { + "message" => 4, + "order" => 2, + "error" => 6, + "command" => 5, + _ => 7 // ApiCall + }; + } + + private async void SyncSettingsWithBotUpdate(object? state) + { + try + { + var settings = await GetSettingsAsync(); + if (settings != null && settings.ContainsKey("telegram")) + { + if (settings["telegram"] is JsonElement telegramElement) + { + var telegramSettings = telegramElement.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.ToString()); + if (telegramSettings.TryGetValue("botToken", out var token)) + { + if (!string.IsNullOrEmpty(token) && token != _currentBotToken) + { + _logger.LogInformation("Bot token has changed. Updating bot..."); + _currentBotToken = token; + + if (_telegramBotService != null) + { + await _telegramBotService.UpdateBotTokenAsync(token); } } } } } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to sync settings with bot update"); - } } - - public void Dispose() + catch (Exception ex) { - _heartbeatTimer?.Dispose(); - _metricsTimer?.Dispose(); - _settingsSyncTimer?.Dispose(); - } - - private async Task GetTelegramBotUsernameAsync() - { - try - { - var botToken = _configuration["Telegram:BotToken"]; - if (string.IsNullOrEmpty(botToken) || botToken == "YOUR_BOT_TOKEN_HERE") - { - _logger.LogWarning("Bot token not configured in appsettings.json"); - return null; - } - - // Call Telegram API to get bot info - var response = await _httpClient.GetAsync($"https://api.telegram.org/bot{botToken}/getMe"); - if (response.IsSuccessStatusCode) - { - var responseJson = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(responseJson); - return result?.Result?.Username; - } - else - { - _logger.LogWarning("Failed to get bot info from Telegram: {StatusCode}", response.StatusCode); - return null; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting Telegram bot username"); - return null; - } - } - - private async Task FindExistingBotByPlatformAsync(string platformUsername) - { - try - { - var apiUrl = _configuration["LittleShop:ApiUrl"]; - const int telegramBotType = 0; // BotType.Telegram enum value - - var response = await _httpClient.GetAsync($"{apiUrl}/api/bots/by-platform/{telegramBotType}/{platformUsername}"); - - if (response.IsSuccessStatusCode) - { - var responseJson = await response.Content.ReadAsStringAsync(); - var bot = JsonSerializer.Deserialize(responseJson); - return bot; - } - else if (response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - return null; // Bot not found - this is expected for first registration - } - else - { - _logger.LogWarning("Failed to check for existing bot: {StatusCode}", response.StatusCode); - return null; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error finding existing bot by platform username"); - return null; - } - } - - private async Task UpdatePlatformInfoAsync() - { - try - { - var apiUrl = _configuration["LittleShop:ApiUrl"]; - var botToken = _configuration["Telegram:BotToken"]; - - if (string.IsNullOrEmpty(botToken) || string.IsNullOrEmpty(_botKey)) - return; - - // Get bot info from Telegram - var telegramResponse = await _httpClient.GetAsync($"https://api.telegram.org/bot{botToken}/getMe"); - if (!telegramResponse.IsSuccessStatusCode) - return; - - var telegramJson = await telegramResponse.Content.ReadAsStringAsync(); - var telegramResult = JsonSerializer.Deserialize(telegramJson); - - if (telegramResult?.Result == null) - return; - - // Update platform info in LittleShop - var updateData = new - { - PlatformUsername = telegramResult.Result.Username, - PlatformDisplayName = telegramResult.Result.FirstName ?? telegramResult.Result.Username, - PlatformId = telegramResult.Result.Id.ToString() - }; - - var json = JsonSerializer.Serialize(updateData); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - _httpClient.DefaultRequestHeaders.Clear(); - _httpClient.DefaultRequestHeaders.Add("X-Bot-Key", _botKey); - - var response = await _httpClient.PutAsync($"{apiUrl}/api/bots/platform-info", content); - - if (response.IsSuccessStatusCode) - { - _logger.LogInformation("Updated platform info for @{Username}", telegramResult.Result.Username); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to update platform info"); - } - } - - // DTOs for API responses - private class BotRegistrationResponse - { - public Guid BotId { get; set; } - public string BotKey { get; set; } = string.Empty; - public string Name { get; set; } = string.Empty; - } - - private class BotDto - { - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string BotKey { get; set; } = string.Empty; - } - - private class SessionDto - { - public Guid Id { get; set; } - } - - private class TelegramGetMeResponse - { - public bool Ok { get; set; } - public TelegramBotInfo? Result { get; set; } - } - - private class TelegramBotInfo - { - public long Id { get; set; } - public bool IsBot { get; set; } - public string FirstName { get; set; } = string.Empty; - public string Username { get; set; } = string.Empty; - public bool? CanJoinGroups { get; set; } - public bool? CanReadAllGroupMessages { get; set; } - public bool? SupportsInlineQueries { get; set; } + _logger.LogError(ex, "Failed to sync settings with bot update"); } } -} \ No newline at end of file + + private async Task ValidateTelegramTokenAsync(string botToken) + { + try + { + var response = await _httpClient.GetAsync($"https://api.telegram.org/bot{botToken}/getMe"); + if (response.IsSuccessStatusCode) + { + var responseJson = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(responseJson); + return result?.Result; + } + else + { + _logger.LogWarning("Telegram token validation failed: {StatusCode}", response.StatusCode); + return null; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error validating Telegram token"); + return null; + } + } + + private async Task UpdatePlatformInfoAsync() + { + try + { + var apiUrl = _configuration["LittleShop:ApiUrl"]; + + if (string.IsNullOrEmpty(_telegramUsername) || string.IsNullOrEmpty(_botKey)) + return; + + var updateData = new + { + PlatformUsername = _telegramUsername, + PlatformDisplayName = _telegramDisplayName ?? _telegramUsername, + PlatformId = _telegramId + }; + + var json = JsonSerializer.Serialize(updateData); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + _httpClient.DefaultRequestHeaders.Clear(); + _httpClient.DefaultRequestHeaders.Add("X-Bot-Key", _botKey); + + var response = await _httpClient.PutAsync($"{apiUrl}/api/bots/platform-info", content); + + if (response.IsSuccessStatusCode) + { + _logger.LogInformation("Updated platform info for @{Username}", _telegramUsername); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update platform info"); + } + } + + private async Task ApplySettingsAsync(Dictionary settings) + { + // Apply settings to runtime configuration + // In production, this would update various services based on settings + _logger.LogInformation("Applying {Count} setting categories", settings.Count); + await Task.CompletedTask; + } + + private async Task SaveConfigurationAsync() + { + // In production, save BotKey and WebhookSecret to secure storage + // For now, just log + _logger.LogInformation("Configuration saved. BotKey: {KeyPrefix}...", + _botKey?.Substring(0, Math.Min(8, _botKey?.Length ?? 0)) ?? "null"); + await Task.CompletedTask; + } + + public void Dispose() + { + _heartbeatTimer?.Dispose(); + _metricsTimer?.Dispose(); + _settingsSyncTimer?.Dispose(); + } + + #endregion + + #region DTOs + + private class SessionDto + { + public Guid Id { get; set; } + } + + private class TelegramGetMeResponse + { + public bool Ok { get; set; } + public TelegramBotInfo? Result { get; set; } + } + + private class TelegramBotInfo + { + public long Id { get; set; } + public bool IsBot { get; set; } + public string FirstName { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public bool? CanJoinGroups { get; set; } + public bool? CanReadAllGroupMessages { get; set; } + public bool? SupportsInlineQueries { get; set; } + } + + #endregion +} diff --git a/TeleBot/TeleBot/Services/LivenessService.cs b/TeleBot/TeleBot/Services/LivenessService.cs new file mode 100644 index 0000000..defccaf --- /dev/null +++ b/TeleBot/TeleBot/Services/LivenessService.cs @@ -0,0 +1,200 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace TeleBot.Services; + +/// +/// Background service that monitors LittleShop connectivity. +/// Triggers application shutdown after consecutive connectivity failures. +/// +public class LivenessService : BackgroundService +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly IHostApplicationLifetime _applicationLifetime; + private readonly HttpClient _httpClient; + private readonly BotManagerService _botManagerService; + + private int _consecutiveFailures; + private DateTime? _firstFailureAt; + + public LivenessService( + ILogger logger, + IConfiguration configuration, + IHostApplicationLifetime applicationLifetime, + HttpClient httpClient, + BotManagerService botManagerService) + { + _logger = logger; + _configuration = configuration; + _applicationLifetime = applicationLifetime; + _httpClient = httpClient; + _botManagerService = botManagerService; + _consecutiveFailures = 0; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("LivenessService started"); + + // Wait for bot to be initialized before starting liveness checks + while (!_botManagerService.IsInitialized && !stoppingToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + } + + if (stoppingToken.IsCancellationRequested) + return; + + _logger.LogInformation("Bot initialized, starting LittleShop connectivity monitoring"); + + var checkIntervalSeconds = _configuration.GetValue("Liveness:CheckIntervalSeconds", 30); + var failureThreshold = _configuration.GetValue("Liveness:FailureThreshold", 10); + + _logger.LogInformation("Liveness configuration: CheckInterval={CheckInterval}s, FailureThreshold={Threshold}", + checkIntervalSeconds, failureThreshold); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await Task.Delay(TimeSpan.FromSeconds(checkIntervalSeconds), stoppingToken); + + var isConnected = await CheckLittleShopConnectivityAsync(stoppingToken); + + if (isConnected) + { + if (_consecutiveFailures > 0) + { + _logger.LogInformation("LittleShop connectivity restored after {Failures} failures", _consecutiveFailures); + } + _consecutiveFailures = 0; + _firstFailureAt = null; + } + else + { + _consecutiveFailures++; + _firstFailureAt ??= DateTime.UtcNow; + + var totalDowntime = DateTime.UtcNow - _firstFailureAt.Value; + + if (_consecutiveFailures >= failureThreshold) + { + _logger.LogCritical( + "LittleShop unreachable for {Downtime:F0} seconds ({Failures} consecutive failures). Initiating shutdown.", + totalDowntime.TotalSeconds, _consecutiveFailures); + + // Trigger application shutdown + _applicationLifetime.StopApplication(); + return; + } + else if (_consecutiveFailures == 1) + { + _logger.LogWarning("LittleShop connectivity check failed. Failure 1/{Threshold}", failureThreshold); + } + else if (_consecutiveFailures % 3 == 0) // Log every 3rd failure to avoid spam + { + _logger.LogWarning( + "LittleShop connectivity check failed. Failure {Failures}/{Threshold}. Downtime: {Downtime:F0}s", + _consecutiveFailures, failureThreshold, totalDowntime.TotalSeconds); + } + } + } + catch (OperationCanceledException) + { + // Normal shutdown + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during liveness check"); + _consecutiveFailures++; + } + } + + _logger.LogInformation("LivenessService stopped"); + } + + private async Task CheckLittleShopConnectivityAsync(CancellationToken cancellationToken) + { + try + { + var apiUrl = _configuration["LittleShop:ApiUrl"]; + if (string.IsNullOrEmpty(apiUrl)) + { + _logger.LogWarning("LittleShop:ApiUrl not configured"); + return false; + } + + var botKey = _botManagerService.BotKey; + if (string.IsNullOrEmpty(botKey)) + { + // Not initialized yet, skip check + return true; + } + + // Use the health endpoint or a lightweight endpoint + using var request = new HttpRequestMessage(HttpMethod.Get, $"{apiUrl}/health"); + request.Headers.Add("X-Bot-Key", botKey); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(10)); // 10 second timeout + + var response = await _httpClient.SendAsync(request, cts.Token); + + return response.IsSuccessStatusCode; + } + catch (TaskCanceledException) + { + // Timeout + _logger.LogDebug("LittleShop connectivity check timed out"); + return false; + } + catch (HttpRequestException ex) + { + _logger.LogDebug("LittleShop connectivity check failed: {Message}", ex.Message); + return false; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "LittleShop connectivity check error"); + return false; + } + } + + /// + /// Gets the current liveness status + /// + public LivenessStatus GetStatus() + { + var failureThreshold = _configuration.GetValue("Liveness:FailureThreshold", 10); + + return new LivenessStatus + { + IsHealthy = _consecutiveFailures == 0, + ConsecutiveFailures = _consecutiveFailures, + FailureThreshold = failureThreshold, + FirstFailureAt = _firstFailureAt, + DowntimeSeconds = _firstFailureAt.HasValue + ? (DateTime.UtcNow - _firstFailureAt.Value).TotalSeconds + : 0 + }; + } +} + +/// +/// Represents the current liveness status +/// +public class LivenessStatus +{ + public bool IsHealthy { get; set; } + public int ConsecutiveFailures { get; set; } + public int FailureThreshold { get; set; } + public DateTime? FirstFailureAt { get; set; } + public double DowntimeSeconds { get; set; } +} diff --git a/TeleBot/TeleBot/TelegramBotService.cs b/TeleBot/TeleBot/TelegramBotService.cs index c221118..9363a36 100644 --- a/TeleBot/TeleBot/TelegramBotService.cs +++ b/TeleBot/TeleBot/TelegramBotService.cs @@ -33,6 +33,12 @@ namespace TeleBot private ITelegramBotClient? _botClient; private CancellationTokenSource? _cancellationTokenSource; private string? _currentBotToken; + private bool _isRunning; + + /// + /// Indicates whether the Telegram bot polling is currently running + /// + public bool IsRunning => _isRunning && _botClient != null; public TelegramBotService( IConfiguration configuration, @@ -119,7 +125,9 @@ namespace TeleBot receiverOptions, cancellationToken: _cancellationTokenSource.Token ); - + + _isRunning = true; + var me = await _botClient.GetMeAsync(cancellationToken); _logger.LogInformation("Bot started: @{Username} ({Id})", me.Username, me.Id); @@ -132,6 +140,7 @@ namespace TeleBot public Task StopAsync(CancellationToken cancellationToken) { + _isRunning = false; _cancellationTokenSource?.Cancel(); _logger.LogInformation("Bot stopped"); return Task.CompletedTask; @@ -273,6 +282,8 @@ namespace TeleBot cancellationToken: _cancellationTokenSource.Token ); + _isRunning = true; + var me = await _botClient.GetMeAsync(); _logger.LogInformation("Bot restarted with new token: @{Username} ({Id})", me.Username, me.Id); diff --git a/TeleBot/TeleBot/appsettings.json b/TeleBot/TeleBot/appsettings.json index 586a886..263b9ba 100644 --- a/TeleBot/TeleBot/appsettings.json +++ b/TeleBot/TeleBot/appsettings.json @@ -2,18 +2,29 @@ "BotInfo": { "Name": "LittleShop TeleBot", "Description": "Privacy-focused e-commerce Telegram bot", - "Version": "1.0.0" + "Version": "1.0.0", + "InstanceId": "" }, "BotManager": { "ApiKey": "", - "Comment": "This will be populated after first registration with admin panel" + "Comment": "Populated by LittleShop during discovery initialization" }, "Telegram": { - "BotToken": "8496279616:AAE7kV_riICbWxn6-MPFqcrWx7K8b4_NKq0", - "AdminChatId": "123456789", + "BotToken": "", + "AdminChatId": "", "WebhookUrl": "", "UseWebhook": false, - "Comment": "Bot token will be fetched from admin panel API if BotManager:ApiKey is set" + "Comment": "Bot token pushed from LittleShop during configuration" + }, + "Discovery": { + "Secret": "CHANGE_THIS_SHARED_SECRET_32_CHARS", + "Enabled": true, + "Comment": "Shared secret for LittleShop discovery. Must match LittleShop BotDiscovery:SharedSecret" + }, + "Liveness": { + "CheckIntervalSeconds": 30, + "FailureThreshold": 10, + "Comment": "Shutdown after 10 consecutive failures (5 minutes total)" }, "Webhook": { "Secret": "",