feat: Add Remote TeleBot Discovery & Configuration
All checks were successful
Build and Deploy LittleShop / Deploy to Production VPS (Manual Only) (push) Has been skipped
Build and Deploy LittleShop / Deploy to Pre-Production (CT109) (push) Successful in 1m0s

- Add discovery API endpoints to TeleBot (probe, initialize, configure, status)
- Add LivenessService for LittleShop connectivity monitoring with 5min shutdown
- Add BotDiscoveryService to LittleShop for remote bot management
- Add Admin UI: DiscoverRemote wizard, RepushConfig page, status badges
- Add remote discovery fields to Bot model (RemoteAddress, RemotePort, etc.)
- Add CheckRemoteStatus and RepushConfig controller actions
- Update Index/Details views to show remote bot indicators
- Shared secret authentication for discovery, BotKey for post-init

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-25 13:41:36 +00:00
parent cdef6f04e1
commit 521bff2c7d
21 changed files with 2931 additions and 545 deletions

View File

@@ -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<BotsController> _logger;
public BotsController(
IBotService botService,
IBotMetricsService metricsService,
ITelegramBotManagerService telegramManager,
IBotDiscoveryService discoveryService,
ILogger<BotsController> 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<IActionResult> 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<IActionResult> 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<string, object>
{
["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<IActionResult> 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<string, object>();
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<IActionResult> 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<IActionResult> 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<Dictionary<string, object>>(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<IActionResult> 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<Dictionary<string, object>>(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<string, object>();
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
}