littleshop/LittleShop/Services/BotDiscoveryService.cs
SysAdmin 86f19ba044
All checks were successful
Build and Deploy LittleShop / Deploy to Pre-Production (CT109) (push) Successful in 59s
Build and Deploy LittleShop / Deploy to Production VPS (Manual Only) (push) Has been skipped
feat: Add AlexHost deployment pipeline and bot control functionality
- Add Gitea Actions workflow for manual AlexHost deployment
- Add docker-compose.alexhost.yml for production deployment
- Add deploy-alexhost.sh script with server-side build support
- Add Bot Control feature (Start/Stop/Restart) for remote bot management
- Add discovery control endpoint in TeleBot
- Update TeleBot with StartPollingAsync/StopPolling/RestartPollingAsync
- Fix platform architecture issues by building on target server
- Update docker-compose configurations for all environments

Deployment tested successfully:
- TeleShop: healthy at https://teleshop.silentmary.mywire.org
- TeleBot: healthy with discovery integration
- SilverPay: connectivity verified

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 12:33:46 +00:00

568 lines
20 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;
}
}
public async Task<BotControlResult> ControlBotAsync(Guid botId, string action)
{
_logger.LogInformation("Sending control action '{Action}' to bot {BotId}", action, botId);
try
{
// Get the bot details
var bot = await _botService.GetBotByIdAsync(botId);
if (bot == null)
{
return new BotControlResult
{
Success = false,
Message = "Bot not found"
};
}
if (string.IsNullOrEmpty(bot.RemoteAddress) || !bot.RemotePort.HasValue)
{
return new BotControlResult
{
Success = false,
Message = "Bot does not have remote address configured. Control only works for remote bots."
};
}
// Get the BotKey securely
var botKey = await _botService.GetBotKeyAsync(botId);
if (string.IsNullOrEmpty(botKey))
{
return new BotControlResult
{
Success = false,
Message = "Bot key not found"
};
}
var endpoint = BuildEndpoint(bot.RemoteAddress, bot.RemotePort.Value, "/api/discovery/control");
var payload = new { Action = action };
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 controlResponse = JsonSerializer.Deserialize<BotControlResult>(content, JsonOptions);
_logger.LogInformation("Bot control action '{Action}' completed for bot {BotId}: {Success}",
action, botId, controlResponse?.Success);
return controlResponse ?? new BotControlResult
{
Success = true,
Message = $"Action '{action}' completed"
};
}
else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
return new BotControlResult
{
Success = false,
Message = "Invalid bot key. The bot may need to be re-initialized."
};
}
else
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Bot control action failed: {StatusCode} - {Content}",
response.StatusCode, errorContent);
return new BotControlResult
{
Success = false,
Message = $"Control action failed: {response.StatusCode}"
};
}
}
catch (TaskCanceledException)
{
_logger.LogWarning("Bot control timed out for bot {BotId}", botId);
return new BotControlResult
{
Success = false,
Message = "Connection timed out. The bot may be offline."
};
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "Bot control connection failed for bot {BotId}", botId);
return new BotControlResult
{
Success = false,
Message = $"Connection failed: {ex.Message}"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during bot control for bot {BotId}", botId);
return new BotControlResult
{
Success = false,
Message = $"Error: {ex.Message}"
};
}
}
#region Private Methods
private string BuildEndpoint(string ipAddress, int port, string path)
{
// Always use HTTP for discovery on custom ports
// HTTPS would require proper certificate setup which is unlikely on non-standard ports
// If HTTPS is needed, the reverse proxy should handle SSL termination
return $"http://{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 success = await _botService.UpdateRemoteInfoAsync(botId, ipAddress, port, instanceId, status);
if (success)
{
_logger.LogInformation("Updated bot {BotId} discovery status to {Status} at {Address}:{Port}",
botId, status, ipAddress, port);
}
else
{
_logger.LogWarning("Failed to update discovery status for bot {BotId}", botId);
}
}
#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
}