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": {