- 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>
451 lines
16 KiB
C#
451 lines
16 KiB
C#
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
|
|
}
|