diff --git a/src/EchoHub.Server/EchoHub.Server.csproj b/src/EchoHub.Server/EchoHub.Server.csproj index 30038bd..042ac65 100644 --- a/src/EchoHub.Server/EchoHub.Server.csproj +++ b/src/EchoHub.Server/EchoHub.Server.csproj @@ -12,6 +12,7 @@ all + diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index 0e675c1..90f0a76 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -100,6 +100,7 @@ while (true) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddHostedService(); builder.Services.AddHttpClient("ImageDownload", client => { client.Timeout = TimeSpan.FromSeconds(15); diff --git a/src/EchoHub.Server/Services/PresenceTracker.cs b/src/EchoHub.Server/Services/PresenceTracker.cs index 7cefde3..1da4ecd 100644 --- a/src/EchoHub.Server/Services/PresenceTracker.cs +++ b/src/EchoHub.Server/Services/PresenceTracker.cs @@ -110,4 +110,9 @@ public class PresenceTracker { return _userConnections.TryGetValue(username, out var connections) && connections.Count > 0; } + + public int GetOnlineUserCount() + { + return _userConnections.Count; + } } diff --git a/src/EchoHub.Server/Services/ServerDirectoryService.cs b/src/EchoHub.Server/Services/ServerDirectoryService.cs new file mode 100644 index 0000000..878caad --- /dev/null +++ b/src/EchoHub.Server/Services/ServerDirectoryService.cs @@ -0,0 +1,139 @@ +using Microsoft.AspNetCore.SignalR.Client; + +namespace EchoHub.Server.Services; + +public sealed class ServerDirectoryService( + IConfiguration configuration, + PresenceTracker presenceTracker, + ILogger logger) : BackgroundService +{ + private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers"; + private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(30); + + private HubConnection? _connection; + private int _lastReportedUserCount = -1; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var isPublic = configuration.GetValue("Server:PublicServer"); + if (!isPublic) + { + logger.LogInformation("PublicServer is disabled — not registering with directory"); + return; + } + + var host = configuration["Server:PublicHost"]; + var port = configuration.GetValue("Server:PublicPort"); + + if (string.IsNullOrWhiteSpace(host)) + { + logger.LogWarning("PublicServer is enabled but Server:PublicHost is not set — skipping directory registration"); + return; + } + + if (port <= 0) + { + logger.LogWarning("PublicServer is enabled but Server:PublicPort is invalid — skipping directory registration"); + return; + } + + var serverName = configuration["Server:Name"] ?? "EchoHub Server"; + var description = configuration["Server:Description"]; + + _connection = new HubConnectionBuilder() + .WithUrl(DirectoryHubUrl) + .WithAutomaticReconnect() + .Build(); + + _connection.Reconnected += async _ => + { + logger.LogInformation("Reconnected to directory — re-registering server"); + await RegisterAsync(serverName, description, host, port); + }; + + _connection.Closed += ex => + { + if (ex is not null) + logger.LogWarning(ex, "Directory connection closed with error"); + return Task.CompletedTask; + }; + + // Initial connection with retry + while (!stoppingToken.IsCancellationRequested) + { + try + { + await _connection.StartAsync(stoppingToken); + logger.LogInformation("Connected to directory at {Url}", DirectoryHubUrl); + break; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to connect to directory — retrying in 30s"); + await Task.Delay(UpdateInterval, stoppingToken); + } + } + + if (stoppingToken.IsCancellationRequested) + return; + + // Register on first connect + await RegisterAsync(serverName, description, host, port); + + // Poll user count and send updates + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(UpdateInterval, stoppingToken); + + if (_connection.State != HubConnectionState.Connected) + continue; + + var currentCount = presenceTracker.GetOnlineUserCount(); + if (currentCount == _lastReportedUserCount) + continue; + + try + { + await _connection.InvokeAsync("UpdateUserCount", currentCount, stoppingToken); + _lastReportedUserCount = currentCount; + logger.LogDebug("Updated directory user count to {Count}", currentCount); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to update user count on directory"); + } + } + } + + private async Task RegisterAsync(string name, string? description, string host, int port) + { + if (_connection?.State != HubConnectionState.Connected) + return; + + try + { + var userCount = presenceTracker.GetOnlineUserCount(); + var dto = new RegisterServerDto(name, description, host, port, userCount); + await _connection.InvokeAsync("RegisterServer", dto); + _lastReportedUserCount = userCount; + logger.LogInformation("Registered with directory as {Name} at {Host}:{Port}", name, host, port); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to register with directory"); + } + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + if (_connection is not null) + { + await _connection.DisposeAsync(); + _connection = null; + } + + await base.StopAsync(cancellationToken); + } +} + +internal record RegisterServerDto(string Name, string? Description, string Host, int Port, int UserCount); diff --git a/src/EchoHub.Server/appsettings.example.json b/src/EchoHub.Server/appsettings.example.json index deb46a5..1596469 100644 --- a/src/EchoHub.Server/appsettings.example.json +++ b/src/EchoHub.Server/appsettings.example.json @@ -10,7 +10,10 @@ }, "Server": { "Name": "My EchoHub Server", - "Description": "A self-hosted EchoHub chat server" + "Description": "A self-hosted EchoHub chat server", + "PublicServer": false, + "PublicHost": "", + "PublicPort": 5000 }, "Serilog": { "MinimumLevel": {