Skip to content

Commit e035493

Browse files
HandyS11claude
andauthored
Subsystem 1b-ii: live connection lifecycle, failover & #info status (#6)
* feat(connections): reshape ConnectionState with status + player count Replace IsHealthy (bool) with Status (ConnectionStatus enum) and add nullable PlayerCount; generate LiveConnections migration and schema test. * feat(events): add ConnectionStatusChangedEvent * feat(connections): add IConnectionStore for live-connection state and pool Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(discord): extract IUserDmSender and reuse it in DiscordOwnerNotifier Introduces IUserDmSender (Discord project) as a reusable DM primitive and replaces DiscordOwnerNotifier's direct DiscordSocketClient usage with it; fixes PairingRegistrationTests to supply a substitute IUserDmSender. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(connections): scaffold Features.Connections project and socket seam Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(connections): add RustPlusApi socket adapter shim Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(connections): add ConnectionSupervisor (connect, heartbeat, failover, restart-survival) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(connections): add ConnectionHostedService Drives IConnectionSupervisor: StartAllAsync on startup, reacts to ServerRegisteredEvent to call EnsureConnectionAsync, StopAllAsync on shutdown. Added InternalsVisibleTo DynamicProxyGenAssembly2 to allow NSubstitute to proxy the internal IConnectionSupervisor. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(workspace): live status and swap select on #info Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(workspace): re-render #info on ConnectionStatusChangedEvent Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(connections): swap module, AddConnections wiring, host registration - Add ConnectionComponentModule handling workspace:info:swap:* select menus - Add ConnectionServiceCollectionExtensions.AddConnections() DI extension - Wire ConnectionOptions validation and AddConnections() into Program.cs - Add project reference for Connections in Host.csproj - Add ConnectionRegistrationTests verifying IConnectionSupervisor and IConnectionStore resolve Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(connections): align UpsertStatus guild filter; validate heartbeat timeout < interval * style: apply ReSharper ReformatAndReorder (CI format gate) * fix(connections): address review — stable heartbeat fake, Ensure race gate, no token leak, swap value validation --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 058d8e3 commit e035493

48 files changed

Lines changed: 2694 additions & 58 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.9" />
1212
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
1313
<PackageVersion Include="Persistord.Core" Version="1.0.0-beta.1" />
14+
<PackageVersion Include="RustPlusApi" Version="2.0.0-beta.1" />
1415
<PackageVersion Include="RustPlusApi.Fcm" Version="2.0.0-beta.1" />
1516
</ItemGroup>
1617
<ItemGroup>

RustPlusBot.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
<Project Path="src/RustPlusBot.Discord/RustPlusBot.Discord.csproj" />
55
<Project Path="src/RustPlusBot.Domain/RustPlusBot.Domain.csproj" />
66
<Project Path="src/RustPlusBot.Host/RustPlusBot.Host.csproj" />
7+
<Project Path="src/RustPlusBot.Features.Connections/RustPlusBot.Features.Connections.csproj" />
78
<Project Path="src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj" />
89
<Project Path="src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj" />
910
<Project Path="src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj" />
1011
</Folder>
1112
<Folder Name="/tests/">
1213
<Project Path="tests/RustPlusBot.Abstractions.Tests/RustPlusBot.Abstractions.Tests.csproj" />
14+
<Project Path="tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj" />
1315
<Project Path="tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj" />
1416
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
1517
<Project Path="tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj" />
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace RustPlusBot.Abstractions.Events;
2+
3+
/// <summary>Published when a server's live-connection state changes, so #info can re-render.</summary>
4+
/// <param name="GuildId">The owning guild snowflake.</param>
5+
/// <param name="ServerId">The server whose connection state changed.</param>
6+
public sealed record ConnectionStatusChangedEvent(ulong GuildId, Guid ServerId);

src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using Discord.Interactions;
33
using Discord.WebSocket;
44
using Microsoft.Extensions.DependencyInjection;
5+
using RustPlusBot.Discord.Notifications;
56

67
namespace RustPlusBot.Discord;
78

@@ -28,6 +29,7 @@ public static IServiceCollection AddDiscordBot(this IServiceCollection services)
2829
{
2930
DefaultRunMode = RunMode.Async
3031
}));
32+
services.AddSingleton<IUserDmSender, DiscordUserDmSender>();
3133
services.AddHostedService<DiscordBotService>();
3234

3335
return services;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using Discord.WebSocket;
3+
using Microsoft.Extensions.Logging;
4+
5+
namespace RustPlusBot.Discord.Notifications;
6+
7+
/// <summary>Discord-backed <see cref="IUserDmSender"/>.</summary>
8+
/// <param name="client">The socket client.</param>
9+
/// <param name="logger">The logger.</param>
10+
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes",
11+
Justification = "Instantiated by the DI container via IUserDmSender registration.")]
12+
internal sealed partial class DiscordUserDmSender(DiscordSocketClient client, ILogger<DiscordUserDmSender> logger)
13+
: IUserDmSender
14+
{
15+
/// <inheritdoc />
16+
public async Task SendAsync(ulong userId, string message, CancellationToken cancellationToken = default)
17+
{
18+
try
19+
{
20+
// Discord.Net uses RequestOptions, not CancellationToken, for per-call cancellation, so the token is not forwarded.
21+
var user = await client.GetUserAsync(userId).ConfigureAwait(false);
22+
if (user is null)
23+
{
24+
LogUserNotFound(logger, userId);
25+
return;
26+
}
27+
28+
var dmChannel = await user.CreateDMChannelAsync().ConfigureAwait(false);
29+
await dmChannel.SendMessageAsync(message).ConfigureAwait(false);
30+
}
31+
#pragma warning disable CA1031 // Broad catch is intentional: a closed DM (or any send failure) must not break callers.
32+
catch (Exception ex)
33+
#pragma warning restore CA1031
34+
{
35+
LogDmFailed(logger, ex, userId);
36+
}
37+
}
38+
39+
[LoggerMessage(Level = LogLevel.Warning, Message = "Cannot DM user {UserId}: user not found.")]
40+
private static partial void LogUserNotFound(ILogger logger, ulong userId);
41+
42+
[LoggerMessage(Level = LogLevel.Warning, Message = "Failed to DM user {UserId}.")]
43+
private static partial void LogDmFailed(ILogger logger, Exception ex, ulong userId);
44+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace RustPlusBot.Discord.Notifications;
2+
3+
/// <summary>Sends a direct message to a Discord user. A closed DM (or any send failure) is swallowed and logged.</summary>
4+
public interface IUserDmSender
5+
{
6+
/// <summary>Best-effort DM to <paramref name="userId"/>; never throws.</summary>
7+
/// <param name="userId">The Discord user snowflake.</param>
8+
/// <param name="message">The message text.</param>
9+
/// <param name="cancellationToken">A cancellation token.</param>
10+
/// <returns>A task that completes when the send was attempted.</returns>
11+
Task SendAsync(ulong userId, string message, CancellationToken cancellationToken = default);
12+
}

src/RustPlusBot.Domain/Connections/ConnectionState.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
namespace RustPlusBot.Domain.Connections;
22

3-
/// <summary>Persisted last-known connection state per server, so the active identity survives restarts.</summary>
3+
/// <summary>Persisted last-known connection state per server, so the active identity and status survive restarts.</summary>
44
public sealed class ConnectionState
55
{
66
/// <summary>The server this state belongs to (primary key, one row per server).</summary>
@@ -12,8 +12,11 @@ public sealed class ConnectionState
1212
/// <summary>The credential currently selected as active, if any.</summary>
1313
public Guid? ActiveCredentialId { get; set; }
1414

15-
/// <summary>Whether the connection was healthy at last check.</summary>
16-
public bool IsHealthy { get; set; }
15+
/// <summary>The live-connection status.</summary>
16+
public ConnectionStatus Status { get; set; }
17+
18+
/// <summary>Last heartbeat player count, or null if unknown.</summary>
19+
public int? PlayerCount { get; set; }
1720

1821
/// <summary>When the state was last updated (UTC).</summary>
1922
public DateTimeOffset UpdatedAt { get; set; }
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace RustPlusBot.Domain.Connections;
2+
3+
/// <summary>Live-connection lifecycle state for a server's socket.</summary>
4+
public enum ConnectionStatus
5+
{
6+
/// <summary>Attempting to connect (initial, after a swap, or after a drop).</summary>
7+
Connecting = 0,
8+
9+
/// <summary>Socket up and the last heartbeat was healthy.</summary>
10+
Connected = 1,
11+
12+
/// <summary>A valid active credential exists but the server is not answering; retrying with backoff.</summary>
13+
Unreachable = 2,
14+
15+
/// <summary>No eligible credential (pool empty or every credential is Invalid).</summary>
16+
NoCredentials = 3,
17+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
namespace RustPlusBot.Features.Connections;
2+
3+
/// <summary>Connection feature configuration, bound from the "Connections" config section.</summary>
4+
public sealed class ConnectionOptions
5+
{
6+
/// <summary>How long to wait for a socket to connect before treating the server as unreachable.</summary>
7+
public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(30);
8+
9+
/// <summary>First delay before retrying after an unreachable result.</summary>
10+
public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromSeconds(5);
11+
12+
/// <summary>Cap on the exponential backoff between reconnect attempts.</summary>
13+
public TimeSpan MaxRetryDelay { get; set; } = TimeSpan.FromMinutes(5);
14+
15+
/// <summary>How often to send a heartbeat on a connected socket.</summary>
16+
public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(60);
17+
18+
/// <summary>How long a single heartbeat may take before the socket is considered unreachable.</summary>
19+
public TimeSpan HeartbeatTimeout { get; set; } = TimeSpan.FromSeconds(10);
20+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using RustPlusBot.Discord;
3+
using RustPlusBot.Features.Connections.Hosting;
4+
using RustPlusBot.Features.Connections.Listening;
5+
using RustPlusBot.Features.Connections.Supervisor;
6+
7+
namespace RustPlusBot.Features.Connections;
8+
9+
/// <summary>DI registration for the live-connection feature.</summary>
10+
public static class ConnectionServiceCollectionExtensions
11+
{
12+
/// <summary>Registers the socket source, supervisor, module seam, and hosted service.</summary>
13+
/// <param name="services">The service collection to add to.</param>
14+
/// <returns>The same service collection, for chaining.</returns>
15+
public static IServiceCollection AddConnections(this IServiceCollection services)
16+
{
17+
ArgumentNullException.ThrowIfNull(services);
18+
19+
services.AddSingleton<IRustSocketSource, RustPlusSocketSource>();
20+
services.AddSingleton<IConnectionSupervisor, ConnectionSupervisor>();
21+
22+
// Contribute this assembly's interaction modules to the Discord layer.
23+
services.AddSingleton(new InteractionModuleAssembly(typeof(ConnectionServiceCollectionExtensions).Assembly));
24+
25+
services.AddHostedService<ConnectionHostedService>();
26+
27+
return services;
28+
}
29+
}

0 commit comments

Comments
 (0)