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

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

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

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

View File

@@ -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.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
builder.Services.AddSingleton<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("Ephemeral by Default: {Ephemeral}", builder.Configuration["Privacy:EphemeralByDefault"]);
Log.Information("Tor Enabled: {Tor}", builder.Configuration["Privacy:EnableTor"]);
Log.Information("Discovery endpoint: GET /api/discovery/probe");
Log.Information("LittleShop API: {ApiUrl}", builder.Configuration["LittleShop:ApiUrl"]);
Log.Information("Webhook endpoints available at /api/webhook");
await app.RunAsync();

File diff suppressed because it is too large Load Diff

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 CancellationTokenSource? _cancellationTokenSource;
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(
IConfiguration configuration,
@@ -119,7 +125,9 @@ namespace TeleBot
receiverOptions,
cancellationToken: _cancellationTokenSource.Token
);
_isRunning = true;
var me = await _botClient.GetMeAsync(cancellationToken);
_logger.LogInformation("Bot started: @{Username} ({Id})", me.Username, me.Id);
@@ -132,6 +140,7 @@ namespace TeleBot
public Task StopAsync(CancellationToken cancellationToken)
{
_isRunning = false;
_cancellationTokenSource?.Cancel();
_logger.LogInformation("Bot stopped");
return Task.CompletedTask;
@@ -273,6 +282,8 @@ namespace TeleBot
cancellationToken: _cancellationTokenSource.Token
);
_isRunning = true;
var me = await _botClient.GetMeAsync();
_logger.LogInformation("Bot restarted with new token: @{Username} ({Id})", me.Username, me.Id);

View File

@@ -2,18 +2,29 @@
"BotInfo": {
"Name": "LittleShop TeleBot",
"Description": "Privacy-focused e-commerce Telegram bot",
"Version": "1.0.0"
"Version": "1.0.0",
"InstanceId": ""
},
"BotManager": {
"ApiKey": "",
"Comment": "This will be populated after first registration with admin panel"
"Comment": "Populated by LittleShop during discovery initialization"
},
"Telegram": {
"BotToken": "8496279616:AAE7kV_riICbWxn6-MPFqcrWx7K8b4_NKq0",
"AdminChatId": "123456789",
"BotToken": "",
"AdminChatId": "",
"WebhookUrl": "",
"UseWebhook": false,
"Comment": "Bot token will be fetched from admin panel API if BotManager:ApiKey is set"
"Comment": "Bot token pushed from LittleShop during configuration"
},
"Discovery": {
"Secret": "CHANGE_THIS_SHARED_SECRET_32_CHARS",
"Enabled": true,
"Comment": "Shared secret for LittleShop discovery. Must match LittleShop BotDiscovery:SharedSecret"
},
"Liveness": {
"CheckIntervalSeconds": 30,
"FailureThreshold": 10,
"Comment": "Shutdown after 10 consecutive failures (5 minutes total)"
},
"Webhook": {
"Secret": "",