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:
SysAdmin 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;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -8,6 +9,7 @@ using Microsoft.Extensions.Logging;
using LittleShop.DTOs; using LittleShop.DTOs;
using LittleShop.Enums; using LittleShop.Enums;
using LittleShop.Services; using LittleShop.Services;
using LittleShop.Models;
namespace LittleShop.Areas.Admin.Controllers; namespace LittleShop.Areas.Admin.Controllers;
@ -18,17 +20,20 @@ public class BotsController : Controller
private readonly IBotService _botService; private readonly IBotService _botService;
private readonly IBotMetricsService _metricsService; private readonly IBotMetricsService _metricsService;
private readonly ITelegramBotManagerService _telegramManager; private readonly ITelegramBotManagerService _telegramManager;
private readonly IBotDiscoveryService _discoveryService;
private readonly ILogger<BotsController> _logger; private readonly ILogger<BotsController> _logger;
public BotsController( public BotsController(
IBotService botService, IBotService botService,
IBotMetricsService metricsService, IBotMetricsService metricsService,
ITelegramBotManagerService telegramManager, ITelegramBotManagerService telegramManager,
IBotDiscoveryService discoveryService,
ILogger<BotsController> logger) ILogger<BotsController> logger)
{ {
_botService = botService; _botService = botService;
_metricsService = metricsService; _metricsService = metricsService;
_telegramManager = telegramManager; _telegramManager = telegramManager;
_discoveryService = discoveryService;
_logger = logger; _logger = logger;
} }
@ -379,4 +384,397 @@ public class BotsController : Controller
return false; 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
} }

View File

@ -128,6 +128,77 @@
</div> </div>
</div> </div>
@if (Model.IsRemote)
{
<div class="card mb-3 border-info">
<div class="card-header bg-info text-white">
<h5 class="mb-0"><i class="fas fa-satellite-dish"></i> Remote Connection</h5>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">Remote Address</dt>
<dd class="col-sm-8"><code>@Model.RemoteAddress:@Model.RemotePort</code></dd>
<dt class="col-sm-4">Instance ID</dt>
<dd class="col-sm-8"><code>@(Model.RemoteInstanceId ?? "N/A")</code></dd>
<dt class="col-sm-4">Discovery Status</dt>
<dd class="col-sm-8">
@switch (Model.DiscoveryStatus)
{
case "Configured":
<span class="badge bg-success">@Model.DiscoveryStatus</span>
break;
case "Initialized":
<span class="badge bg-info">@Model.DiscoveryStatus</span>
break;
case "Discovered":
<span class="badge bg-warning">@Model.DiscoveryStatus</span>
break;
case "Offline":
case "Error":
<span class="badge bg-danger">@Model.DiscoveryStatus</span>
break;
default:
<span class="badge bg-secondary">@Model.DiscoveryStatus</span>
break;
}
</dd>
<dt class="col-sm-4">Last Discovery</dt>
<dd class="col-sm-8">
@if (Model.LastDiscoveryAt.HasValue)
{
@Model.LastDiscoveryAt.Value.ToString("yyyy-MM-dd HH:mm:ss")
}
else
{
<span class="text-muted">Never</span>
}
</dd>
</dl>
<hr />
<div class="d-flex gap-2">
<form asp-area="Admin" asp-controller="Bots" asp-action="CheckRemoteStatus" asp-route-id="@Model.Id" method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-info">
<i class="fas fa-sync"></i> Check Status
</button>
</form>
@if (Model.DiscoveryStatus == "Initialized" || Model.DiscoveryStatus == "Configured")
{
<a href="/Admin/Bots/RepushConfig/@Model.Id" class="btn btn-sm btn-outline-warning">
<i class="fas fa-upload"></i> Re-push Config
</a>
}
</div>
</div>
</div>
}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0">30-Day Metrics Summary</h5> <h5 class="mb-0">30-Day Metrics Summary</h5>

View File

@ -0,0 +1,286 @@
@model LittleShop.DTOs.DiscoveryWizardViewModel
@{
ViewData["Title"] = "Discover Remote TeleBot";
}
<h1>Discover Remote TeleBot</h1>
<div class="row">
<div class="col-md-8">
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="fas fa-exclamation-triangle"></i> @Model.ErrorMessage
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="fas fa-check-circle"></i> @Model.SuccessMessage
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
@if (Model.CurrentStep == 1)
{
<!-- Step 1: Discovery -->
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-search"></i> Step 1: Discover TeleBot Instance</h5>
</div>
<div class="card-body">
<p class="text-muted">
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.
</p>
<form asp-area="Admin" asp-controller="Bots" asp-action="ProbeRemote" method="post">
@Html.AntiForgeryToken()
<div class="row mb-3">
<div class="col-md-8">
<label for="IpAddress" class="form-label">IP Address / Hostname</label>
<input name="IpAddress" id="IpAddress" value="@Model.IpAddress" class="form-control"
placeholder="e.g., 192.168.1.100 or telebot.example.com" required />
<small class="text-muted">The IP address or hostname where TeleBot is running</small>
</div>
<div class="col-md-4">
<label for="Port" class="form-label">Port</label>
<input name="Port" id="Port" type="number" value="@(Model.Port == 0 ? 5010 : Model.Port)" class="form-control"
min="1" max="65535" required />
<small class="text-muted">Default: 5010</small>
</div>
</div>
<div class="d-grid gap-2 d-md-flex">
<button type="submit" class="btn btn-primary me-md-2">
<i class="fas fa-satellite-dish"></i> Probe TeleBot
</button>
<a href="/Admin/Bots" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
}
else if (Model.CurrentStep == 2)
{
<!-- Step 2: Registration -->
<div class="card mb-3">
<div class="card-header bg-success text-white">
<h5 class="mb-0"><i class="fas fa-check-circle"></i> TeleBot Discovered!</h5>
</div>
<div class="card-body">
<table class="table table-sm">
<tr>
<th width="150">Instance ID:</th>
<td><code>@Model.ProbeResponse?.InstanceId</code></td>
</tr>
<tr>
<th>Name:</th>
<td>@Model.ProbeResponse?.Name</td>
</tr>
<tr>
<th>Version:</th>
<td>@Model.ProbeResponse?.Version</td>
</tr>
<tr>
<th>Status:</th>
<td>
<span class="badge bg-@(Model.ProbeResponse?.Status == "Bootstrap" ? "warning" : "info")">
@Model.ProbeResponse?.Status
</span>
</td>
</tr>
<tr>
<th>Address:</th>
<td>@Model.IpAddress:@Model.Port</td>
</tr>
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-robot"></i> Step 2: Register Bot</h5>
</div>
<div class="card-body">
<form asp-area="Admin" asp-controller="Bots" asp-action="RegisterRemote" method="post">
@Html.AntiForgeryToken()
<!-- Hidden fields to preserve discovery data -->
<input type="hidden" name="IpAddress" value="@Model.IpAddress" />
<input type="hidden" name="Port" value="@Model.Port" />
<input type="hidden" name="ProbeResponse.InstanceId" value="@Model.ProbeResponse?.InstanceId" />
<input type="hidden" name="ProbeResponse.Name" value="@Model.ProbeResponse?.Name" />
<input type="hidden" name="ProbeResponse.Version" value="@Model.ProbeResponse?.Version" />
<input type="hidden" name="ProbeResponse.Status" value="@Model.ProbeResponse?.Status" />
<div class="mb-3">
<label for="BotName" class="form-label">Bot Name</label>
<input name="BotName" id="BotName" value="@Model.BotName" class="form-control"
placeholder="e.g., Production TeleBot" required />
<small class="text-muted">A friendly name to identify this bot</small>
</div>
<div class="mb-3">
<label for="PersonalityName" class="form-label">Personality</label>
<select name="PersonalityName" id="PersonalityName" class="form-select">
<option value="Alan" @(Model.PersonalityName == "Alan" ? "selected" : "")>Alan (Professional)</option>
<option value="Dave" @(Model.PersonalityName == "Dave" ? "selected" : "")>Dave (Casual)</option>
<option value="Sarah" @(Model.PersonalityName == "Sarah" ? "selected" : "")>Sarah (Helpful)</option>
<option value="Mike" @(Model.PersonalityName == "Mike" ? "selected" : "")>Mike (Direct)</option>
<option value="Emma" @(Model.PersonalityName == "Emma" ? "selected" : "")>Emma (Friendly)</option>
<option value="Tom" @(Model.PersonalityName == "Tom" ? "selected" : "")>Tom (Efficient)</option>
</select>
<small class="text-muted">Bot conversation style</small>
</div>
<div class="mb-3">
<label for="Description" class="form-label">Description (Optional)</label>
<textarea name="Description" id="Description" class="form-control" rows="2"
placeholder="Brief description of this bot's purpose">@Model.Description</textarea>
</div>
<div class="d-grid gap-2 d-md-flex">
<button type="submit" class="btn btn-success me-md-2">
<i class="fas fa-link"></i> Register & Initialize
</button>
<a href="/Admin/Bots/DiscoverRemote" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Back
</a>
</div>
</form>
</div>
</div>
}
else if (Model.CurrentStep == 3)
{
<!-- Step 3: Configuration -->
<div class="card mb-3">
<div class="card-header bg-info text-white">
<h5 class="mb-0"><i class="fas fa-key"></i> Bot Registered - API Key</h5>
</div>
<div class="card-body">
@if (!string.IsNullOrEmpty(Model.BotKey))
{
<div class="alert alert-warning">
<strong>Save this Bot Key securely!</strong> It won't be shown again.
</div>
<div class="input-group mb-3">
<input type="text" class="form-control font-monospace" value="@Model.BotKey" id="botKeyInput" readonly />
<button class="btn btn-outline-secondary" type="button" onclick="copyBotKey()">
<i class="fas fa-copy"></i> Copy
</button>
</div>
}
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-telegram"></i> Step 3: Configure Telegram Token</h5>
</div>
<div class="card-body">
<p class="text-muted">
Now enter the Telegram bot token from BotFather to activate this bot.
</p>
<form asp-area="Admin" asp-controller="Bots" asp-action="ConfigureRemote" method="post">
@Html.AntiForgeryToken()
<!-- Hidden fields -->
<input type="hidden" name="BotId" value="@Model.BotId" />
<input type="hidden" name="BotKey" value="@Model.BotKey" />
<input type="hidden" name="IpAddress" value="@Model.IpAddress" />
<input type="hidden" name="Port" value="@Model.Port" />
<div class="mb-3">
<label for="BotToken" class="form-label">Telegram Bot Token</label>
<input name="BotToken" id="BotToken" value="@Model.BotToken" class="form-control font-monospace"
placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz" required />
<small class="text-muted">
Get this from <a href="https://t.me/BotFather" target="_blank">@@BotFather</a> on Telegram
</small>
</div>
<div class="d-grid gap-2 d-md-flex">
<button type="submit" class="btn btn-success me-md-2">
<i class="fas fa-rocket"></i> Configure & Activate Bot
</button>
<a href="/Admin/Bots" class="btn btn-secondary">
Skip (configure later)
</a>
</div>
</form>
</div>
</div>
}
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Wizard Progress</h5>
</div>
<div class="card-body">
<ul class="list-unstyled">
<li class="@(Model.CurrentStep == 1 ? "text-primary fw-bold" : Model.CurrentStep > 1 ? "text-success" : "text-muted")">
<i class="fas fa-@(Model.CurrentStep == 1 ? "search" : Model.CurrentStep > 1 ? "check" : "circle")"></i>
1. Discover TeleBot
</li>
<li class="@(Model.CurrentStep == 2 ? "text-primary fw-bold" : Model.CurrentStep > 2 ? "text-success" : "text-muted")">
<i class="fas fa-@(Model.CurrentStep == 2 ? "robot" : Model.CurrentStep > 2 ? "check" : "circle")"></i>
2. Register Bot
</li>
<li class="@(Model.CurrentStep == 3 ? "text-primary fw-bold" : "text-muted")">
<i class="fas fa-@(Model.CurrentStep == 3 ? "telegram" : "circle")"></i>
3. Configure Telegram
</li>
</ul>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0">Requirements</h5>
</div>
<div class="card-body">
<ul class="small">
<li>TeleBot must be running</li>
<li>Same discovery secret on both sides</li>
<li>Network connectivity to TeleBot</li>
<li>Valid Telegram bot token</li>
</ul>
</div>
</div>
@if (Model.CurrentStep >= 2)
{
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0">Connection Info</h5>
</div>
<div class="card-body">
<p class="small mb-1"><strong>Address:</strong> @Model.IpAddress</p>
<p class="small mb-0"><strong>Port:</strong> @Model.Port</p>
</div>
</div>
}
</div>
</div>
@section Scripts {
<script>
function copyBotKey() {
var input = document.getElementById('botKeyInput');
input.select();
input.setSelectionRange(0, 99999);
navigator.clipboard.writeText(input.value).then(function() {
alert('Bot Key copied to clipboard!');
});
}
</script>
}

View File

@ -7,6 +7,9 @@
<h1>Bot Management</h1> <h1>Bot Management</h1>
<p> <p>
<a href="/Admin/Bots/DiscoverRemote" class="btn btn-success">
<i class="fas fa-satellite-dish"></i> Discover Remote Bot
</a>
<a href="/Admin/Bots/Wizard" class="btn btn-primary"> <a href="/Admin/Bots/Wizard" class="btn btn-primary">
<i class="fas fa-magic"></i> Create Telegram Bot (Wizard) <i class="fas fa-magic"></i> Create Telegram Bot (Wizard)
</a> </a>
@ -136,6 +139,12 @@
{ {
<span class="badge bg-secondary ms-2">@bot.PersonalityName</span> <span class="badge bg-secondary ms-2">@bot.PersonalityName</span>
} }
@if (bot.IsRemote)
{
<span class="badge bg-info ms-1" title="Remote bot at @bot.RemoteAddress:@bot.RemotePort">
<i class="fas fa-satellite-dish"></i> Remote
</span>
}
@if (!string.IsNullOrEmpty(bot.Description)) @if (!string.IsNullOrEmpty(bot.Description))
{ {
<br /> <br />
@ -181,6 +190,37 @@
<span class="badge bg-dark">@bot.Status</span> <span class="badge bg-dark">@bot.Status</span>
break; break;
} }
@if (bot.IsRemote)
{
<br />
@switch (bot.DiscoveryStatus)
{
case "Configured":
<span class="badge bg-success small mt-1" title="Remote bot is fully configured">
<i class="fas fa-check"></i> Configured
</span>
break;
case "Initialized":
<span class="badge bg-info small mt-1" title="Remote bot initialized, awaiting config">
<i class="fas fa-clock"></i> Initialized
</span>
break;
case "Discovered":
<span class="badge bg-warning small mt-1" title="Remote bot discovered, needs setup">
<i class="fas fa-exclamation"></i> Needs Setup
</span>
break;
case "Offline":
case "Error":
<span class="badge bg-danger small mt-1" title="Remote bot is offline or errored">
<i class="fas fa-times"></i> @bot.DiscoveryStatus
</span>
break;
default:
<span class="badge bg-secondary small mt-1">@bot.DiscoveryStatus</span>
break;
}
}
</td> </td>
<td> <td>
<span class="badge bg-primary">@bot.ActiveSessions</span> <span class="badge bg-primary">@bot.ActiveSessions</span>

View File

@ -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;
}
<h1>Re-push Configuration</h1>
<h4 class="text-muted">@Model.Name</h4>
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/Admin/Bots">Bots</a></li>
<li class="breadcrumb-item"><a href="/Admin/Bots/Details/@Model.Id">@Model.Name</a></li>
<li class="breadcrumb-item active" aria-current="page">Re-push Config</li>
</ol>
</nav>
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<div class="row">
<div class="col-md-8">
<div class="card border-info">
<div class="card-header bg-info text-white">
<h5 class="mb-0"><i class="fas fa-upload"></i> Push Configuration to Remote Bot</h5>
</div>
<div class="card-body">
<p class="text-muted mb-4">
This will push the Telegram bot token to the remote TeleBot instance at
<code>@Model.RemoteAddress:@Model.RemotePort</code>.
Use this when the remote bot has been restarted and needs reconfiguration.
</p>
@if (hasExistingToken)
{
<div class="alert alert-success">
<h6><i class="fas fa-check-circle"></i> Existing Token Found</h6>
<p class="mb-2">A Telegram bot token is already stored for this bot.</p>
<form asp-area="Admin" asp-controller="Bots" asp-action="RepushConfig" asp-route-id="@Model.Id" method="post" class="d-inline">
@Html.AntiForgeryToken()
<input type="hidden" name="useExistingToken" value="true" />
<button type="submit" class="btn btn-success">
<i class="fas fa-sync"></i> Re-push Existing Token
</button>
</form>
</div>
<hr />
<p class="text-muted">Or provide a new token:</p>
}
<form asp-area="Admin" asp-controller="Bots" asp-action="RepushConfig" asp-route-id="@Model.Id" method="post">
@Html.AntiForgeryToken()
<div class="mb-3">
<label for="botToken" class="form-label">Telegram Bot Token</label>
<input type="text" class="form-control" id="botToken" name="botToken"
placeholder="123456789:ABCDefGHIjklMNOpqrsTUVwxyz" required />
<div class="form-text">
Get this from <a href="https://t.me/BotFather" target="_blank">@@BotFather</a> on Telegram.
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-upload"></i> Push New Token
</button>
<a href="/Admin/Bots/Details/@Model.Id" class="btn btn-secondary">
Cancel
</a>
</div>
</form>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0">Current Remote Status</h5>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">Remote Address</dt>
<dd class="col-sm-8"><code>@Model.RemoteAddress:@Model.RemotePort</code></dd>
<dt class="col-sm-4">Instance ID</dt>
<dd class="col-sm-8"><code>@(Model.RemoteInstanceId ?? "N/A")</code></dd>
<dt class="col-sm-4">Discovery Status</dt>
<dd class="col-sm-8">
@switch (Model.DiscoveryStatus)
{
case "Configured":
<span class="badge bg-success">@Model.DiscoveryStatus</span>
break;
case "Initialized":
<span class="badge bg-info">@Model.DiscoveryStatus</span>
break;
case "Discovered":
<span class="badge bg-warning">@Model.DiscoveryStatus</span>
break;
case "Offline":
case "Error":
<span class="badge bg-danger">@Model.DiscoveryStatus</span>
break;
default:
<span class="badge bg-secondary">@Model.DiscoveryStatus</span>
break;
}
</dd>
<dt class="col-sm-4">Last Discovery</dt>
<dd class="col-sm-8">
@if (Model.LastDiscoveryAt.HasValue)
{
@Model.LastDiscoveryAt.Value.ToString("yyyy-MM-dd HH:mm:ss")
}
else
{
<span class="text-muted">Never</span>
}
</dd>
</dl>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Instructions</h5>
</div>
<div class="card-body">
<ol class="ps-3">
<li class="mb-2">Ensure the remote TeleBot is running and accessible</li>
<li class="mb-2">If the bot was just restarted, it may be in "Awaiting Discovery" mode</li>
<li class="mb-2">Enter the Telegram bot token from @@BotFather</li>
<li class="mb-2">Click "Push New Token" to configure the remote bot</li>
</ol>
<hr />
<h6>When to use this:</h6>
<ul class="ps-3 text-muted small">
<li>After TeleBot container restart</li>
<li>When changing the Telegram bot token</li>
<li>If the remote bot lost its configuration</li>
<li>After infrastructure recovery</li>
</ul>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0">Quick Actions</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<form asp-area="Admin" asp-controller="Bots" asp-action="CheckRemoteStatus" asp-route-id="@Model.Id" method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-info w-100">
<i class="fas fa-sync"></i> Check Remote Status
</button>
</form>
<a href="/Admin/Bots/Details/@Model.Id" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Back to Details
</a>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,146 @@
using System.ComponentModel.DataAnnotations;
namespace LittleShop.DTOs;
/// <summary>
/// Input for discovering a remote TeleBot instance
/// </summary>
public class RemoteBotDiscoveryDto
{
[Required]
[StringLength(255)]
public string IpAddress { get; set; } = string.Empty;
[Range(1, 65535)]
public int Port { get; set; } = 5010;
}
/// <summary>
/// Response from TeleBot's discovery probe endpoint
/// </summary>
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; }
}
/// <summary>
/// Input for registering a discovered remote bot
/// </summary>
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;
/// <summary>
/// Instance ID from the discovery probe response
/// </summary>
public string? RemoteInstanceId { get; set; }
}
/// <summary>
/// Input for configuring a remote bot with Telegram credentials
/// </summary>
public class RemoteBotConfigureDto
{
[Required]
public Guid BotId { get; set; }
[Required]
public string BotToken { get; set; } = string.Empty;
public Dictionary<string, object>? Settings { get; set; }
}
/// <summary>
/// Result of a discovery probe operation
/// </summary>
public class DiscoveryResult
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public DiscoveryProbeResponse? ProbeResponse { get; set; }
}
/// <summary>
/// Result of initializing a remote bot
/// </summary>
public class InitializeResult
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public string? InstanceId { get; set; }
}
/// <summary>
/// Result of configuring a remote bot
/// </summary>
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; }
}
/// <summary>
/// View model for the discovery wizard
/// </summary>
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; }
}
/// <summary>
/// Discovery status constants
/// </summary>
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";
}

View File

@ -24,6 +24,23 @@ public class BotDto
public string PersonalityName { get; set; } = string.Empty; public string PersonalityName { get; set; } = string.Empty;
public Dictionary<string, object> Settings { get; set; } = new(); public Dictionary<string, object> 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; }
/// <summary>
/// Indicates if this is a remotely discovered bot
/// </summary>
public bool IsRemote => !string.IsNullOrEmpty(RemoteAddress);
/// <summary>
/// Full remote endpoint URL
/// </summary>
public string? RemoteEndpoint => IsRemote ? $"{RemoteAddress}:{RemotePort}" : null;
// Metrics summary // Metrics summary
public int TotalSessions { get; set; } public int TotalSessions { get; set; }
public int ActiveSessions { get; set; } public int ActiveSessions { get; set; }

View File

@ -52,6 +52,36 @@ public class Bot
[StringLength(50)] [StringLength(50)]
public string PersonalityName { get; set; } = string.Empty; public string PersonalityName { get; set; } = string.Empty;
// Remote Discovery Fields
/// <summary>
/// IP address or hostname of the remote TeleBot instance
/// </summary>
[StringLength(255)]
public string? RemoteAddress { get; set; }
/// <summary>
/// Port number for the remote TeleBot instance
/// </summary>
public int? RemotePort { get; set; }
/// <summary>
/// Timestamp of last successful discovery probe
/// </summary>
public DateTime? LastDiscoveryAt { get; set; }
/// <summary>
/// Discovery status: Local, Discovered, Initialized, Configured, Offline
/// </summary>
[StringLength(50)]
public string DiscoveryStatus { get; set; } = "Local";
/// <summary>
/// Instance ID returned by the remote TeleBot
/// </summary>
[StringLength(100)]
public string? RemoteInstanceId { get; set; }
// Navigation properties // Navigation properties
public virtual ICollection<BotMetric> Metrics { get; set; } = new List<BotMetric>(); public virtual ICollection<BotMetric> Metrics { get; set; } = new List<BotMetric>();
public virtual ICollection<BotSession> Sessions { get; set; } = new List<BotSession>(); public virtual ICollection<BotSession> Sessions { get; set; } = new List<BotSession>();

View File

@ -226,6 +226,7 @@ builder.Services.AddScoped<IDataSeederService, DataSeederService>();
builder.Services.AddScoped<IBotService, BotService>(); builder.Services.AddScoped<IBotService, BotService>();
builder.Services.AddScoped<IBotMetricsService, BotMetricsService>(); builder.Services.AddScoped<IBotMetricsService, BotMetricsService>();
builder.Services.AddScoped<IBotContactService, BotContactService>(); builder.Services.AddScoped<IBotContactService, BotContactService>();
builder.Services.AddHttpClient<IBotDiscoveryService, BotDiscoveryService>();
builder.Services.AddScoped<IMessageDeliveryService, MessageDeliveryService>(); builder.Services.AddScoped<IMessageDeliveryService, MessageDeliveryService>();
builder.Services.AddScoped<ICustomerService, CustomerService>(); builder.Services.AddScoped<ICustomerService, CustomerService>();
builder.Services.AddScoped<ICustomerMessageService, CustomerMessageService>(); builder.Services.AddScoped<ICustomerMessageService, CustomerMessageService>();

View File

@ -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;
/// <summary>
/// Service for discovering and configuring remote TeleBot instances.
/// Handles communication with TeleBot's discovery API endpoints.
/// </summary>
public class BotDiscoveryService : IBotDiscoveryService
{
private readonly ILogger<BotDiscoveryService> _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<BotDiscoveryService> logger,
IConfiguration configuration,
HttpClient httpClient,
IBotService botService)
{
_logger = logger;
_configuration = configuration;
_httpClient = httpClient;
_botService = botService;
// Configure default timeout
var timeoutSeconds = _configuration.GetValue<int>("BotDiscovery:ConnectionTimeoutSeconds", 10);
_httpClient.Timeout = TimeSpan.FromSeconds(timeoutSeconds);
}
public async Task<DiscoveryResult> 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<DiscoveryProbeResponse>(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<InitializeResult> 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<InitializeResponse>(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<ConfigureResult> PushConfigurationAsync(Guid botId, string botToken, Dictionary<string, object>? 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<ConfigureResponse>(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<bool> 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<DiscoveryProbeResponse?> 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<DiscoveryProbeResponse>(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
}

View File

@ -323,6 +323,31 @@ public class BotService : IBotService
return true; return true;
} }
public async Task<bool> 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<string?> GetBotKeyAsync(Guid botId)
{
var bot = await _context.Bots.FindAsync(botId);
return bot?.BotKey;
}
private BotDto MapToDto(Bot bot) private BotDto MapToDto(Bot bot)
{ {
var settings = new Dictionary<string, object>(); var settings = new Dictionary<string, object>();
@ -355,6 +380,13 @@ public class BotService : IBotService
PlatformId = bot.PlatformId, PlatformId = bot.PlatformId,
PersonalityName = bot.PersonalityName, PersonalityName = bot.PersonalityName,
Settings = settings, 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, TotalSessions = bot.Sessions.Count,
ActiveSessions = activeSessions, ActiveSessions = activeSessions,
TotalRevenue = totalRevenue, TotalRevenue = totalRevenue,

View File

@ -0,0 +1,34 @@
using LittleShop.DTOs;
namespace LittleShop.Services;
/// <summary>
/// Service for discovering and configuring remote TeleBot instances
/// </summary>
public interface IBotDiscoveryService
{
/// <summary>
/// Probe a remote TeleBot instance to discover its status
/// </summary>
Task<DiscoveryResult> ProbeRemoteBotAsync(string ipAddress, int port);
/// <summary>
/// Initialize a remote TeleBot instance with a BotKey
/// </summary>
Task<InitializeResult> InitializeRemoteBotAsync(Guid botId, string ipAddress, int port);
/// <summary>
/// Push configuration (bot token and settings) to a remote TeleBot instance
/// </summary>
Task<ConfigureResult> PushConfigurationAsync(Guid botId, string botToken, Dictionary<string, object>? settings = null);
/// <summary>
/// Test basic connectivity to a remote address
/// </summary>
Task<bool> TestConnectivityAsync(string ipAddress, int port);
/// <summary>
/// Get the status of a remote TeleBot instance
/// </summary>
Task<DiscoveryProbeResponse?> GetRemoteStatusAsync(string ipAddress, int port, string botKey);
}

View File

@ -23,4 +23,6 @@ public interface IBotService
Task<bool> ValidateBotKeyAsync(string botKey); Task<bool> ValidateBotKeyAsync(string botKey);
Task<string> GenerateBotKeyAsync(); Task<string> GenerateBotKeyAsync();
Task<bool> UpdatePlatformInfoAsync(Guid botId, UpdatePlatformInfoDto dto); Task<bool> UpdatePlatformInfoAsync(Guid botId, UpdatePlatformInfoDto dto);
Task<bool> UpdateRemoteInfoAsync(Guid botId, string remoteAddress, int remotePort, string? instanceId, string discoveryStatus);
Task<string?> GetBotKeyAsync(Guid botId);
} }

View File

@ -47,6 +47,14 @@
"172.16.0.0/12" "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": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",

View File

@ -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;
/// <summary>
/// API controller for remote discovery and configuration from LittleShop.
/// Enables server-initiated bot registration and configuration.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class DiscoveryController : ControllerBase
{
private readonly ILogger<DiscoveryController> _logger;
private readonly IConfiguration _configuration;
private readonly BotManagerService _botManagerService;
private readonly TelegramBotService _telegramBotService;
public DiscoveryController(
ILogger<DiscoveryController> logger,
IConfiguration configuration,
BotManagerService botManagerService,
TelegramBotService telegramBotService)
{
_logger = logger;
_configuration = configuration;
_botManagerService = botManagerService;
_telegramBotService = telegramBotService;
}
/// <summary>
/// Probe endpoint for LittleShop to discover this TeleBot instance.
/// Returns current status and configuration state.
/// </summary>
[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);
}
/// <summary>
/// Initialize this TeleBot instance with a BotKey from LittleShop.
/// This is the first step after discovery - assigns the bot to LittleShop.
/// </summary>
[HttpPost("initialize")]
public async Task<IActionResult> 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"
});
}
}
/// <summary>
/// Configure this TeleBot instance with Telegram credentials.
/// Requires prior initialization (valid BotKey).
/// </summary>
[HttpPost("configure")]
public async Task<IActionResult> 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"
});
}
}
/// <summary>
/// Get current status of the bot (requires BotKey after initialization)
/// </summary>
[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<string, object>
{
["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";
}
}

View File

@ -0,0 +1,137 @@
using System.Text.Json.Serialization;
namespace TeleBot.DTOs;
/// <summary>
/// Response returned when LittleShop probes this TeleBot instance
/// </summary>
public class DiscoveryProbeResponse
{
/// <summary>
/// Unique identifier for this TeleBot instance (generated on first startup)
/// </summary>
public string InstanceId { get; set; } = string.Empty;
/// <summary>
/// Configured name of this bot
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// TeleBot version
/// </summary>
public string Version { get; set; } = string.Empty;
/// <summary>
/// Current operational status
/// </summary>
public string Status { get; set; } = "Bootstrap";
/// <summary>
/// Whether a Telegram bot token has been configured
/// </summary>
public bool HasToken { get; set; }
/// <summary>
/// Whether the bot is fully configured and operational
/// </summary>
public bool IsConfigured { get; set; }
/// <summary>
/// Whether this instance has been initialized (has BotKey)
/// </summary>
public bool IsInitialized { get; set; }
/// <summary>
/// Telegram username if configured and operational
/// </summary>
public string? TelegramUsername { get; set; }
/// <summary>
/// Timestamp of probe response
/// </summary>
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}
/// <summary>
/// Request to initialize this TeleBot instance from LittleShop
/// </summary>
public class DiscoveryInitializeRequest
{
/// <summary>
/// Bot key assigned by LittleShop for authentication
/// </summary>
public string BotKey { get; set; } = string.Empty;
/// <summary>
/// Secret for webhook authentication
/// </summary>
public string WebhookSecret { get; set; } = string.Empty;
/// <summary>
/// LittleShop API URL (if different from discovery source)
/// </summary>
public string? LittleShopUrl { get; set; }
}
/// <summary>
/// Response after initialization
/// </summary>
public class DiscoveryInitializeResponse
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public string? InstanceId { get; set; }
}
/// <summary>
/// Request to configure this TeleBot instance with Telegram credentials
/// </summary>
public class DiscoveryConfigureRequest
{
/// <summary>
/// Telegram Bot token from BotFather
/// </summary>
public string BotToken { get; set; } = string.Empty;
/// <summary>
/// Additional settings to apply
/// </summary>
public Dictionary<string, object>? Settings { get; set; }
}
/// <summary>
/// Response after configuration
/// </summary>
public class DiscoveryConfigureResponse
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
/// <summary>
/// Telegram bot username (e.g., @MyBot)
/// </summary>
public string? TelegramUsername { get; set; }
/// <summary>
/// Telegram bot display name
/// </summary>
public string? TelegramDisplayName { get; set; }
/// <summary>
/// Telegram bot ID
/// </summary>
public string? TelegramId { get; set; }
}
/// <summary>
/// Status update sent to indicate bot operational state
/// </summary>
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<string, object>? Metadata { get; set; }
}

View File

@ -106,6 +106,16 @@ builder.Services.AddHttpClient<BotManagerService>()
builder.Services.AddSingleton<BotManagerService>(); builder.Services.AddSingleton<BotManagerService>();
builder.Services.AddHostedService(provider => provider.GetRequiredService<BotManagerService>()); builder.Services.AddHostedService(provider => provider.GetRequiredService<BotManagerService>());
// Liveness Service - Monitors LittleShop connectivity and triggers shutdown on failure
builder.Services.AddHttpClient<LivenessService>()
.ConfigurePrimaryHttpMessageHandler(sp =>
{
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger("Liveness");
return Socks5HttpHandler.CreateDirect(logger);
});
builder.Services.AddSingleton<LivenessService>();
builder.Services.AddHostedService(provider => provider.GetRequiredService<LivenessService>());
// Message Delivery Service - Single instance // Message Delivery Service - Single instance
builder.Services.AddSingleton<MessageDeliveryService>(); builder.Services.AddSingleton<MessageDeliveryService>();
builder.Services.AddSingleton<IMessageDeliveryService>(sp => sp.GetRequiredService<MessageDeliveryService>()); builder.Services.AddSingleton<IMessageDeliveryService>(sp => sp.GetRequiredService<MessageDeliveryService>());
@ -155,6 +165,8 @@ try
Log.Information("Privacy Mode: {PrivacyMode}", builder.Configuration["Privacy:Mode"]); Log.Information("Privacy Mode: {PrivacyMode}", builder.Configuration["Privacy:Mode"]);
Log.Information("Ephemeral by Default: {Ephemeral}", builder.Configuration["Privacy:EphemeralByDefault"]); Log.Information("Ephemeral by Default: {Ephemeral}", builder.Configuration["Privacy:EphemeralByDefault"]);
Log.Information("Tor Enabled: {Tor}", builder.Configuration["Privacy:EnableTor"]); 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"); Log.Information("Webhook endpoints available at /api/webhook");
await app.RunAsync(); await app.RunAsync();

View File

@ -11,22 +11,43 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace TeleBot.Services namespace TeleBot.Services;
{
/// <summary>
/// Manages bot lifecycle, LittleShop communication, and server-initiated configuration.
/// Operates in Bootstrap mode until initialized by LittleShop discovery.
/// </summary>
public class BotManagerService : IHostedService, IDisposable public class BotManagerService : IHostedService, IDisposable
{ {
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly ILogger<BotManagerService> _logger; private readonly ILogger<BotManagerService> _logger;
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly SessionManager _sessionManager; private readonly SessionManager _sessionManager;
private Timer? _heartbeatTimer; private Timer? _heartbeatTimer;
private Timer? _metricsTimer; private Timer? _metricsTimer;
private Timer? _settingsSyncTimer; private Timer? _settingsSyncTimer;
private string? _botKey; private string? _botKey;
private Guid? _botId; private Guid? _botId;
private string? _webhookSecret;
private string? _telegramUsername;
private string? _telegramDisplayName;
private string? _telegramId;
private string? _currentBotToken;
private readonly Dictionary<string, decimal> _metricsBuffer; private readonly Dictionary<string, decimal> _metricsBuffer;
private TelegramBotService? _telegramBotService; private TelegramBotService? _telegramBotService;
private string? _lastKnownBotToken; 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( public BotManagerService(
IConfiguration configuration, IConfiguration configuration,
@ -39,8 +60,60 @@ namespace TeleBot.Services
_httpClient = httpClient; _httpClient = httpClient;
_sessionManager = sessionManager; _sessionManager = sessionManager;
_metricsBuffer = new Dictionary<string, decimal>(); _metricsBuffer = new Dictionary<string, decimal>();
// Generate or load instance ID
_instanceId = LoadOrGenerateInstanceId();
} }
#region Public Properties
/// <summary>
/// Unique identifier for this TeleBot instance
/// </summary>
public string InstanceId => _instanceId;
/// <summary>
/// Current operational status
/// </summary>
public string CurrentStatus => _currentStatus;
/// <summary>
/// Whether a Telegram bot token has been configured
/// </summary>
public bool HasBotToken => !string.IsNullOrEmpty(_currentBotToken);
/// <summary>
/// Whether the bot is fully configured and operational
/// </summary>
public bool IsConfigured => HasBotToken && IsInitialized;
/// <summary>
/// Whether this instance has been initialized with a BotKey
/// </summary>
public bool IsInitialized => !string.IsNullOrEmpty(_botKey);
/// <summary>
/// Telegram username if operational
/// </summary>
public string? TelegramUsername => _telegramUsername;
/// <summary>
/// The BotKey assigned by LittleShop
/// </summary>
public string? BotKey => _botKey;
/// <summary>
/// Number of active sessions
/// </summary>
public int ActiveSessionCount => _sessionManager?.GetActiveSessions().Count() ?? 0;
/// <summary>
/// Last activity timestamp
/// </summary>
public DateTime LastActivityAt => _lastActivityAt;
#endregion
public void SetTelegramBotService(TelegramBotService telegramBotService) public void SetTelegramBotService(TelegramBotService telegramBotService)
{ {
_telegramBotService = telegramBotService; _telegramBotService = telegramBotService;
@ -48,65 +121,32 @@ namespace TeleBot.Services
public async Task StartAsync(CancellationToken cancellationToken) public async Task StartAsync(CancellationToken cancellationToken)
{ {
try _logger.LogInformation("BotManagerService starting...");
{
// Check if bot key exists in configuration // Check if already configured (from previous session or config file)
_botKey = _configuration["BotManager:ApiKey"]; _botKey = _configuration["BotManager:ApiKey"];
_currentBotToken = _configuration["Telegram:BotToken"];
if (string.IsNullOrEmpty(_botKey)) if (!string.IsNullOrEmpty(_botKey) && _botKey != "YOUR_BOT_KEY_HERE")
{ {
// Try to find existing bot registration by Telegram username first // Previously initialized - verify with LittleShop and start
var botUsername = await GetTelegramBotUsernameAsync(); _logger.LogInformation("Found existing BotKey, attempting to resume operation");
_currentStatus = STATUS_INITIALIZED;
if (!string.IsNullOrEmpty(botUsername)) // Start heartbeat and metrics if we have a valid token
if (!string.IsNullOrEmpty(_currentBotToken) && _currentBotToken != "YOUR_BOT_TOKEN_HERE")
{ {
var existingBot = await FindExistingBotByPlatformAsync(botUsername); await StartOperationalTimersAsync();
_currentStatus = STATUS_OPERATIONAL;
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 else
{ {
_logger.LogWarning("Could not determine bot username. Registering new bot."); // Bootstrap mode - wait for LittleShop discovery
await RegisterBotAsync(); _currentStatus = STATUS_BOOTSTRAP;
} _logger.LogInformation("TeleBot starting in Bootstrap mode. Waiting for LittleShop discovery...");
} _logger.LogInformation("Instance ID: {InstanceId}", _instanceId);
else _logger.LogInformation("Discovery endpoint: GET /api/discovery/probe");
{
// 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");
} }
} }
@ -119,99 +159,137 @@ namespace TeleBot.Services
// Send final metrics before stopping // Send final metrics before stopping
SendMetrics(null); SendMetrics(null);
_logger.LogInformation("Bot manager service stopped"); _logger.LogInformation("BotManagerService stopped");
return Task.CompletedTask; return Task.CompletedTask;
} }
private async Task RegisterBotAsync() #region Discovery Methods
/// <summary>
/// Initialize this TeleBot instance from LittleShop discovery
/// </summary>
public async Task<(bool Success, string Message)> InitializeFromDiscoveryAsync(
string botKey,
string? webhookSecret,
string? littleShopUrl)
{ {
var apiUrl = _configuration["LittleShop:ApiUrl"]; if (string.IsNullOrEmpty(botKey))
var registrationData = new
{ {
Name = _configuration["BotInfo:Name"] ?? "TeleBot", return (false, "BotKey is required");
Description = _configuration["BotInfo:Description"] ?? "Telegram E-commerce Bot",
Type = 0, // Telegram
Version = _configuration["BotInfo:Version"] ?? "1.0.0",
InitialSettings = new Dictionary<string, object>
{
["telegram"] = new
{
botToken = _configuration["Telegram:BotToken"],
webhookUrl = _configuration["Telegram:WebhookUrl"]
},
["privacy"] = new
{
mode = _configuration["Privacy:Mode"],
enableTor = _configuration.GetValue<bool>("Privacy:EnableTor")
} }
}
};
var json = JsonSerializer.Serialize(registrationData); // Check if already initialized with a different key
var content = new StringContent(json, Encoding.UTF8, "application/json"); if (!string.IsNullOrEmpty(_botKey) && _botKey != botKey)
var response = await _httpClient.PostAsync($"{apiUrl}/api/bots/register", content);
if (response.IsSuccessStatusCode)
{ {
var responseJson = await response.Content.ReadAsStringAsync(); _logger.LogWarning("Attempted to reinitialize with different BotKey. Current: {Current}, New: {New}",
var result = JsonSerializer.Deserialize<BotRegistrationResponse>(responseJson); _botKey.Substring(0, 8), botKey.Substring(0, 8));
return (false, "Already initialized with a different BotKey");
_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
try
{ {
_logger.LogError("Failed to register bot: {StatusCode}", response.StatusCode); _botKey = botKey;
_webhookSecret = webhookSecret ?? string.Empty;
// Update LittleShop URL if provided
if (!string.IsNullOrEmpty(littleShopUrl))
{
// 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}");
} }
} }
private async Task AuthenticateBotAsync() /// <summary>
/// Apply remote configuration (bot token and settings) from LittleShop
/// </summary>
public async Task<(bool Success, string Message, string? TelegramUsername, string? TelegramDisplayName, string? TelegramId)> ApplyRemoteConfigurationAsync(
string botToken,
Dictionary<string, object>? settings)
{ {
var apiUrl = _configuration["LittleShop:ApiUrl"]; if (!IsInitialized)
var authData = new { BotKey = _botKey };
var json = JsonSerializer.Serialize(authData);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync($"{apiUrl}/api/bots/authenticate", content);
if (response.IsSuccessStatusCode)
{ {
var responseJson = await response.Content.ReadAsStringAsync(); return (false, "Must be initialized before configuration", null, null, null);
var result = JsonSerializer.Deserialize<BotDto>(responseJson);
_botId = result?.Id;
_logger.LogInformation("Bot authenticated successfully. Bot ID: {BotId}", _botId);
}
else
{
_logger.LogError("Failed to authenticate bot: {StatusCode}", response.StatusCode);
}
} }
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) if (settings != null)
{ {
// Apply settings to configuration await ApplySettingsAsync(settings);
// This would update the running configuration with server settings }
_logger.LogInformation("Settings synced from server");
// 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<Dictionary<string, object>?> GetSettingsAsync() public async Task<Dictionary<string, object>?> GetSettingsAsync()
{ {
if (string.IsNullOrEmpty(_botKey)) return null; if (string.IsNullOrEmpty(_botKey)) return null;
@ -239,6 +317,123 @@ namespace TeleBot.Services
return null; 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<Guid?> StartSessionAsync(string sessionIdentifier, string platform = "Telegram")
{
if (string.IsNullOrEmpty(_botKey)) return null;
try
{
var apiUrl = _configuration["LittleShop:ApiUrl"];
var sessionData = new
{
SessionIdentifier = sessionIdentifier,
Platform = platform,
Language = "en",
Country = "",
IsAnonymous = true,
Metadata = new Dictionary<string, object>()
};
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<SessionDto>(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) private async void SendHeartbeat(object? state)
{ {
if (string.IsNullOrEmpty(_botKey)) return; if (string.IsNullOrEmpty(_botKey)) return;
@ -251,11 +446,12 @@ namespace TeleBot.Services
var heartbeatData = new var heartbeatData = new
{ {
Version = _configuration["BotInfo:Version"] ?? "1.0.0", Version = _configuration["BotInfo:Version"] ?? "1.0.0",
IpAddress = "REDACTED", // SECURITY: Never send real IP address IpAddress = "REDACTED",
ActiveSessions = activeSessions, ActiveSessions = activeSessions,
Status = new Dictionary<string, object> Status = new Dictionary<string, object>
{ {
["healthy"] = true, ["healthy"] = true,
["status"] = _currentStatus,
["uptime"] = DateTime.UtcNow.Subtract(AppDomain.CurrentDomain.BaseDirectory != null ["uptime"] = DateTime.UtcNow.Subtract(AppDomain.CurrentDomain.BaseDirectory != null
? new System.IO.DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory).CreationTimeUtc ? new System.IO.DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory).CreationTimeUtc
: DateTime.UtcNow).TotalSeconds : DateTime.UtcNow).TotalSeconds
@ -285,7 +481,6 @@ namespace TeleBot.Services
var apiUrl = _configuration["LittleShop:ApiUrl"]; var apiUrl = _configuration["LittleShop:ApiUrl"];
var metrics = new List<object>(); var metrics = new List<object>();
// Collect metrics from buffer
lock (_metricsBuffer) lock (_metricsBuffer)
{ {
foreach (var metric in _metricsBuffer) foreach (var metric in _metricsBuffer)
@ -320,85 +515,6 @@ namespace TeleBot.Services
} }
} }
public void RecordMetric(string name, decimal value)
{
lock (_metricsBuffer)
{
if (_metricsBuffer.ContainsKey(name))
_metricsBuffer[name] += value;
else
_metricsBuffer[name] = value;
}
}
public async Task<Guid?> StartSessionAsync(string sessionIdentifier, string platform = "Telegram")
{
if (string.IsNullOrEmpty(_botKey)) return null;
try
{
var apiUrl = _configuration["LittleShop:ApiUrl"];
var sessionData = new
{
SessionIdentifier = sessionIdentifier,
Platform = platform,
Language = "en",
Country = "",
IsAnonymous = true,
Metadata = new Dictionary<string, object>()
};
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<SessionDto>(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) private int GetMetricType(string metricName)
{ {
return metricName.ToLower() switch return metricName.ToLower() switch
@ -423,13 +539,11 @@ namespace TeleBot.Services
var telegramSettings = telegramElement.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.ToString()); var telegramSettings = telegramElement.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.ToString());
if (telegramSettings.TryGetValue("botToken", out var token)) if (telegramSettings.TryGetValue("botToken", out var token))
{ {
// Check if token has changed if (!string.IsNullOrEmpty(token) && token != _currentBotToken)
if (!string.IsNullOrEmpty(token) && token != _lastKnownBotToken)
{ {
_logger.LogInformation("Bot token has changed. Updating bot..."); _logger.LogInformation("Bot token has changed. Updating bot...");
_lastKnownBotToken = token; _currentBotToken = token;
// Update the TelegramBotService if available
if (_telegramBotService != null) if (_telegramBotService != null)
{ {
await _telegramBotService.UpdateBotTokenAsync(token); await _telegramBotService.UpdateBotTokenAsync(token);
@ -445,73 +559,26 @@ namespace TeleBot.Services
} }
} }
public void Dispose() private async Task<TelegramBotInfo?> ValidateTelegramTokenAsync(string botToken)
{
_heartbeatTimer?.Dispose();
_metricsTimer?.Dispose();
_settingsSyncTimer?.Dispose();
}
private async Task<string?> GetTelegramBotUsernameAsync()
{ {
try 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"); var response = await _httpClient.GetAsync($"https://api.telegram.org/bot{botToken}/getMe");
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var responseJson = await response.Content.ReadAsStringAsync(); var responseJson = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<TelegramGetMeResponse>(responseJson); var result = JsonSerializer.Deserialize<TelegramGetMeResponse>(responseJson);
return result?.Result?.Username; return result?.Result;
} }
else else
{ {
_logger.LogWarning("Failed to get bot info from Telegram: {StatusCode}", response.StatusCode); _logger.LogWarning("Telegram token validation failed: {StatusCode}", response.StatusCode);
return null; return null;
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error getting Telegram bot username"); _logger.LogError(ex, "Error validating Telegram token");
return null;
}
}
private async Task<BotDto?> 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<BotDto>(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; return null;
} }
} }
@ -521,28 +588,15 @@ namespace TeleBot.Services
try try
{ {
var apiUrl = _configuration["LittleShop:ApiUrl"]; var apiUrl = _configuration["LittleShop:ApiUrl"];
var botToken = _configuration["Telegram:BotToken"];
if (string.IsNullOrEmpty(botToken) || string.IsNullOrEmpty(_botKey)) if (string.IsNullOrEmpty(_telegramUsername) || string.IsNullOrEmpty(_botKey))
return; 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<TelegramGetMeResponse>(telegramJson);
if (telegramResult?.Result == null)
return;
// Update platform info in LittleShop
var updateData = new var updateData = new
{ {
PlatformUsername = telegramResult.Result.Username, PlatformUsername = _telegramUsername,
PlatformDisplayName = telegramResult.Result.FirstName ?? telegramResult.Result.Username, PlatformDisplayName = _telegramDisplayName ?? _telegramUsername,
PlatformId = telegramResult.Result.Id.ToString() PlatformId = _telegramId
}; };
var json = JsonSerializer.Serialize(updateData); var json = JsonSerializer.Serialize(updateData);
@ -555,7 +609,7 @@ namespace TeleBot.Services
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
_logger.LogInformation("Updated platform info for @{Username}", telegramResult.Result.Username); _logger.LogInformation("Updated platform info for @{Username}", _telegramUsername);
} }
} }
catch (Exception ex) catch (Exception ex)
@ -564,21 +618,34 @@ namespace TeleBot.Services
} }
} }
// DTOs for API responses private async Task ApplySettingsAsync(Dictionary<string, object> settings)
private class BotRegistrationResponse
{ {
public Guid BotId { get; set; } // Apply settings to runtime configuration
public string BotKey { get; set; } = string.Empty; // In production, this would update various services based on settings
public string Name { get; set; } = string.Empty; _logger.LogInformation("Applying {Count} setting categories", settings.Count);
await Task.CompletedTask;
} }
private class BotDto private async Task SaveConfigurationAsync()
{ {
public Guid Id { get; set; } // In production, save BotKey and WebhookSecret to secure storage
public string Name { get; set; } = string.Empty; // For now, just log
public string BotKey { get; set; } = string.Empty; _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 private class SessionDto
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
@ -600,5 +667,6 @@ namespace TeleBot.Services
public bool? CanReadAllGroupMessages { get; set; } public bool? CanReadAllGroupMessages { get; set; }
public bool? SupportsInlineQueries { get; set; } public bool? SupportsInlineQueries { get; set; }
} }
}
#endregion
} }

View File

@ -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;
/// <summary>
/// Background service that monitors LittleShop connectivity.
/// Triggers application shutdown after consecutive connectivity failures.
/// </summary>
public class LivenessService : BackgroundService
{
private readonly ILogger<LivenessService> _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<LivenessService> 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<int>("Liveness:CheckIntervalSeconds", 30);
var failureThreshold = _configuration.GetValue<int>("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<bool> 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;
}
}
/// <summary>
/// Gets the current liveness status
/// </summary>
public LivenessStatus GetStatus()
{
var failureThreshold = _configuration.GetValue<int>("Liveness:FailureThreshold", 10);
return new LivenessStatus
{
IsHealthy = _consecutiveFailures == 0,
ConsecutiveFailures = _consecutiveFailures,
FailureThreshold = failureThreshold,
FirstFailureAt = _firstFailureAt,
DowntimeSeconds = _firstFailureAt.HasValue
? (DateTime.UtcNow - _firstFailureAt.Value).TotalSeconds
: 0
};
}
}
/// <summary>
/// Represents the current liveness status
/// </summary>
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; }
}

View File

@ -33,6 +33,12 @@ namespace TeleBot
private ITelegramBotClient? _botClient; private ITelegramBotClient? _botClient;
private CancellationTokenSource? _cancellationTokenSource; private CancellationTokenSource? _cancellationTokenSource;
private string? _currentBotToken; private string? _currentBotToken;
private bool _isRunning;
/// <summary>
/// Indicates whether the Telegram bot polling is currently running
/// </summary>
public bool IsRunning => _isRunning && _botClient != null;
public TelegramBotService( public TelegramBotService(
IConfiguration configuration, IConfiguration configuration,
@ -120,6 +126,8 @@ namespace TeleBot
cancellationToken: _cancellationTokenSource.Token cancellationToken: _cancellationTokenSource.Token
); );
_isRunning = true;
var me = await _botClient.GetMeAsync(cancellationToken); var me = await _botClient.GetMeAsync(cancellationToken);
_logger.LogInformation("Bot started: @{Username} ({Id})", me.Username, me.Id); _logger.LogInformation("Bot started: @{Username} ({Id})", me.Username, me.Id);
@ -132,6 +140,7 @@ namespace TeleBot
public Task StopAsync(CancellationToken cancellationToken) public Task StopAsync(CancellationToken cancellationToken)
{ {
_isRunning = false;
_cancellationTokenSource?.Cancel(); _cancellationTokenSource?.Cancel();
_logger.LogInformation("Bot stopped"); _logger.LogInformation("Bot stopped");
return Task.CompletedTask; return Task.CompletedTask;
@ -273,6 +282,8 @@ namespace TeleBot
cancellationToken: _cancellationTokenSource.Token cancellationToken: _cancellationTokenSource.Token
); );
_isRunning = true;
var me = await _botClient.GetMeAsync(); var me = await _botClient.GetMeAsync();
_logger.LogInformation("Bot restarted with new token: @{Username} ({Id})", me.Username, me.Id); _logger.LogInformation("Bot restarted with new token: @{Username} ({Id})", me.Username, me.Id);

View File

@ -2,18 +2,29 @@
"BotInfo": { "BotInfo": {
"Name": "LittleShop TeleBot", "Name": "LittleShop TeleBot",
"Description": "Privacy-focused e-commerce Telegram bot", "Description": "Privacy-focused e-commerce Telegram bot",
"Version": "1.0.0" "Version": "1.0.0",
"InstanceId": ""
}, },
"BotManager": { "BotManager": {
"ApiKey": "", "ApiKey": "",
"Comment": "This will be populated after first registration with admin panel" "Comment": "Populated by LittleShop during discovery initialization"
}, },
"Telegram": { "Telegram": {
"BotToken": "8496279616:AAE7kV_riICbWxn6-MPFqcrWx7K8b4_NKq0", "BotToken": "",
"AdminChatId": "123456789", "AdminChatId": "",
"WebhookUrl": "", "WebhookUrl": "",
"UseWebhook": false, "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": { "Webhook": {
"Secret": "", "Secret": "",