Skip to content

Commit 9401d6e

Browse files
HandyS11claude
andcommitted
feat(alarms): add AlarmsHostedService, AddAlarms DI, and Host wiring
Wire the smart-alarms slice live: AlarmsHostedService drives three consume loops (AlarmPairedEvent → coordinator, SmartDeviceTriggeredEvent → relay, ConnectionStatusChangedEvent → relay); AddAlarms registers all slice singletons and the hosted service; Host references the Alarms project and calls AddAlarms() after AddSwitches(). AlarmRegistrationTests covers both descriptor presence and captive-dependency-free resolution. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent aa5d9ae commit 9401d6e

5 files changed

Lines changed: 253 additions & 0 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using RustPlusBot.Discord;
3+
using RustPlusBot.Features.Alarms.Hosting;
4+
using RustPlusBot.Features.Alarms.Pairing;
5+
using RustPlusBot.Features.Alarms.Posting;
6+
using RustPlusBot.Features.Alarms.Relaying;
7+
using RustPlusBot.Features.Alarms.Rendering;
8+
9+
namespace RustPlusBot.Features.Alarms;
10+
11+
/// <summary>DI registration for the Smart Alarms feature.</summary>
12+
public static class AlarmServiceCollectionExtensions
13+
{
14+
/// <summary>Registers the localizer, renderer, poster, refresher, coordinator, relay, modules, and hosted service.</summary>
15+
/// <param name="services">The service collection to add to.</param>
16+
/// <returns>The same service collection, for chaining.</returns>
17+
public static IServiceCollection AddAlarms(this IServiceCollection services)
18+
{
19+
ArgumentNullException.ThrowIfNull(services);
20+
21+
services.AddSingleton<IAlarmLocalizer>(new AlarmLocalizer(AlarmLocalizationCatalog.Default));
22+
services.AddSingleton<AlarmEmbedRenderer>();
23+
services.AddSingleton<IAlarmChannelPoster, DiscordAlarmChannelPoster>();
24+
services.AddSingleton<IAlarmRefresher, AlarmRefresher>();
25+
services.AddSingleton<AlarmPairingCoordinator>();
26+
services.AddSingleton<AlarmStateRelay>();
27+
services.AddHostedService<AlarmsHostedService>();
28+
29+
// Contribute this assembly's interaction modules to the Discord layer.
30+
services.AddSingleton(new InteractionModuleAssembly(typeof(AlarmServiceCollectionExtensions).Assembly));
31+
32+
return services;
33+
}
34+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
using Microsoft.Extensions.Hosting;
2+
using Microsoft.Extensions.Logging;
3+
using RustPlusBot.Abstractions.Events;
4+
using RustPlusBot.Features.Alarms.Pairing;
5+
using RustPlusBot.Features.Alarms.Relaying;
6+
7+
namespace RustPlusBot.Features.Alarms.Hosting;
8+
9+
/// <summary>Runs the alarm-pairing loop, the alarm-triggered relay loop, and the connection-status relay loop.</summary>
10+
/// <param name="eventBus">The in-process event bus.</param>
11+
/// <param name="coordinator">Handles paired alarms.</param>
12+
/// <param name="relay">Re-renders alarms on trigger/connection changes.</param>
13+
/// <param name="logger">The logger.</param>
14+
internal sealed partial class AlarmsHostedService(
15+
IEventBus eventBus,
16+
AlarmPairingCoordinator coordinator,
17+
AlarmStateRelay relay,
18+
ILogger<AlarmsHostedService> logger) : IHostedService, IDisposable
19+
{
20+
private readonly CancellationTokenSource _cts = new();
21+
private Task? _pairedLoop;
22+
private Task? _statusLoop;
23+
private Task? _triggeredLoop;
24+
25+
/// <inheritdoc />
26+
public void Dispose() => _cts.Dispose();
27+
28+
/// <inheritdoc />
29+
public Task StartAsync(CancellationToken cancellationToken)
30+
{
31+
_pairedLoop = Task.Run(() => ConsumePairedAsync(_cts.Token), CancellationToken.None);
32+
_triggeredLoop = Task.Run(() => ConsumeTriggeredAsync(_cts.Token), CancellationToken.None);
33+
_statusLoop = Task.Run(() => ConsumeStatusAsync(_cts.Token), CancellationToken.None);
34+
return Task.CompletedTask;
35+
}
36+
37+
/// <inheritdoc />
38+
public async Task StopAsync(CancellationToken cancellationToken)
39+
{
40+
await _cts.CancelAsync().ConfigureAwait(false);
41+
foreach (var loop in new[]
42+
{
43+
_pairedLoop, _triggeredLoop, _statusLoop
44+
}.Where(t => t is not null))
45+
{
46+
try
47+
{
48+
#pragma warning disable VSTHRD003 // Our own loop tasks, joined on stop.
49+
await loop!.ConfigureAwait(false);
50+
#pragma warning restore VSTHRD003
51+
}
52+
catch (OperationCanceledException)
53+
{
54+
// Expected on shutdown.
55+
}
56+
}
57+
}
58+
59+
private async Task ConsumePairedAsync(CancellationToken cancellationToken)
60+
{
61+
try
62+
{
63+
await foreach (var evt in eventBus.SubscribeAsync<AlarmPairedEvent>(cancellationToken)
64+
.ConfigureAwait(false))
65+
{
66+
await coordinator.HandlePairedAsync(evt, cancellationToken).ConfigureAwait(false);
67+
}
68+
}
69+
catch (OperationCanceledException)
70+
{
71+
// Shutting down.
72+
}
73+
#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
74+
catch (Exception ex)
75+
#pragma warning restore CA1031
76+
{
77+
LogPairedLoopFaulted(logger, ex);
78+
}
79+
}
80+
81+
private async Task ConsumeTriggeredAsync(CancellationToken cancellationToken)
82+
{
83+
try
84+
{
85+
await foreach (var evt in eventBus.SubscribeAsync<SmartDeviceTriggeredEvent>(cancellationToken)
86+
.ConfigureAwait(false))
87+
{
88+
await relay.HandleTriggeredAsync(evt, cancellationToken).ConfigureAwait(false);
89+
}
90+
}
91+
catch (OperationCanceledException)
92+
{
93+
// Shutting down.
94+
}
95+
#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
96+
catch (Exception ex)
97+
#pragma warning restore CA1031
98+
{
99+
LogTriggeredLoopFaulted(logger, ex);
100+
}
101+
}
102+
103+
private async Task ConsumeStatusAsync(CancellationToken cancellationToken)
104+
{
105+
try
106+
{
107+
await foreach (var evt in eventBus.SubscribeAsync<ConnectionStatusChangedEvent>(cancellationToken)
108+
.ConfigureAwait(false))
109+
{
110+
await relay.HandleConnectionStatusAsync(evt, cancellationToken).ConfigureAwait(false);
111+
}
112+
}
113+
catch (OperationCanceledException)
114+
{
115+
// Shutting down.
116+
}
117+
#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
118+
catch (Exception ex)
119+
#pragma warning restore CA1031
120+
{
121+
LogStatusLoopFaulted(logger, ex);
122+
}
123+
}
124+
125+
[LoggerMessage(Level = LogLevel.Error, Message = "Alarm pairing loop faulted.")]
126+
private static partial void LogPairedLoopFaulted(ILogger logger, Exception exception);
127+
128+
[LoggerMessage(Level = LogLevel.Error, Message = "Alarm device-triggered relay loop faulted.")]
129+
private static partial void LogTriggeredLoopFaulted(ILogger logger, Exception exception);
130+
131+
[LoggerMessage(Level = LogLevel.Error, Message = "Alarm connection-status relay loop faulted.")]
132+
private static partial void LogStatusLoopFaulted(ILogger logger, Exception exception);
133+
}

src/RustPlusBot.Host/Program.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using RustPlusBot.Features.Connections;
1212
using RustPlusBot.Features.Events;
1313
using RustPlusBot.Features.Map;
14+
using RustPlusBot.Features.Alarms;
1415
using RustPlusBot.Features.Switches;
1516
using RustPlusBot.Features.Players;
1617
using RustPlusBot.Features.Pairing;
@@ -77,6 +78,7 @@
7778
.ValidateOnStart();
7879
builder.Services.AddMap();
7980
builder.Services.AddSwitches();
81+
builder.Services.AddAlarms();
8082

8183
var host = builder.Build();
8284

src/RustPlusBot.Host/RustPlusBot.Host.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
<ProjectReference Include="..\RustPlusBot.Features.Commands\RustPlusBot.Features.Commands.csproj" />
2626
<ProjectReference Include="..\RustPlusBot.Features.Events\RustPlusBot.Features.Events.csproj" />
2727
<ProjectReference Include="..\RustPlusBot.Features.Switches\RustPlusBot.Features.Switches.csproj" />
28+
<ProjectReference Include="..\RustPlusBot.Features.Alarms\RustPlusBot.Features.Alarms.csproj" />
2829
<ProjectReference Include="..\RustPlusBot.Features.Map\RustPlusBot.Features.Map.csproj" />
2930
<ProjectReference Include="..\RustPlusBot.Features.Players\RustPlusBot.Features.Players.csproj" />
3031
</ItemGroup>
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
using Discord.WebSocket;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using Microsoft.Extensions.Hosting;
4+
using Microsoft.Extensions.Logging;
5+
using NSubstitute;
6+
using RustPlusBot.Abstractions.Events;
7+
using RustPlusBot.Abstractions.Time;
8+
using RustPlusBot.Features.Alarms.Pairing;
9+
using RustPlusBot.Features.Alarms.Posting;
10+
using RustPlusBot.Features.Alarms.Relaying;
11+
using RustPlusBot.Features.Alarms.Rendering;
12+
using RustPlusBot.Features.Connections.Listening;
13+
using RustPlusBot.Features.Workspace.Locating;
14+
using RustPlusBot.Persistence.Alarms;
15+
using RustPlusBot.Persistence.Connections;
16+
using RustPlusBot.Persistence.Workspace;
17+
18+
namespace RustPlusBot.Features.Alarms.Tests;
19+
20+
/// <summary>Validates that <see cref="AlarmServiceCollectionExtensions.AddAlarms"/> registers all required services.</summary>
21+
public sealed class AlarmRegistrationTests
22+
{
23+
/// <summary>Verifies core service descriptors are present after calling <see cref="AlarmServiceCollectionExtensions.AddAlarms"/>.</summary>
24+
[Fact]
25+
public void AddAlarms_registers_core_services()
26+
{
27+
var services = new ServiceCollection();
28+
services.AddAlarms();
29+
30+
Assert.Contains(services, d => d.ServiceType == typeof(IAlarmLocalizer));
31+
Assert.Contains(services, d => d.ServiceType == typeof(AlarmEmbedRenderer));
32+
Assert.Contains(services, d => d.ServiceType == typeof(IAlarmChannelPoster));
33+
Assert.Contains(services, d => d.ServiceType == typeof(IAlarmRefresher));
34+
Assert.Contains(services, d => d.ServiceType == typeof(AlarmPairingCoordinator));
35+
Assert.Contains(services, d => d.ServiceType == typeof(AlarmStateRelay));
36+
Assert.Contains(services, d => d.ServiceType == typeof(IHostedService));
37+
}
38+
39+
/// <summary>Verifies the container resolves key types without captive-dependency errors when all cross-layer deps are provided.</summary>
40+
[Fact]
41+
public void AddAlarms_resolves_without_captive_dependency_errors()
42+
{
43+
var services = new ServiceCollection();
44+
45+
// Logging (required by concrete types such as DiscordAlarmChannelPoster and AlarmsHostedService)
46+
services.AddLogging();
47+
48+
// Cross-layer singletons from other slices
49+
services.AddSingleton(Substitute.For<IClock>());
50+
services.AddSingleton(Substitute.For<IEventBus>());
51+
services.AddSingleton(Substitute.For<IAlarmChannelLocator>());
52+
services.AddSingleton(Substitute.For<ITeamChatSender>());
53+
54+
// Discord
55+
var discordConfig = new DiscordSocketConfig();
56+
services.AddSingleton(new DiscordSocketClient(discordConfig));
57+
58+
// Scoped stores from Persistence
59+
services.AddScoped(_ => Substitute.For<IAlarmStore>());
60+
services.AddScoped(_ => Substitute.For<IWorkspaceStore>());
61+
services.AddScoped(_ => Substitute.For<IConnectionStore>());
62+
63+
services.AddAlarms();
64+
65+
using var provider = services.BuildServiceProvider(validateScopes: true);
66+
67+
var localizer = provider.GetRequiredService<IAlarmLocalizer>();
68+
var renderer = provider.GetRequiredService<AlarmEmbedRenderer>();
69+
var poster = provider.GetRequiredService<IAlarmChannelPoster>();
70+
var refresher = provider.GetRequiredService<IAlarmRefresher>();
71+
var coordinator = provider.GetRequiredService<AlarmPairingCoordinator>();
72+
var relay = provider.GetRequiredService<AlarmStateRelay>();
73+
var hostedService = provider.GetRequiredService<IHostedService>();
74+
75+
Assert.NotNull(localizer);
76+
Assert.NotNull(renderer);
77+
Assert.NotNull(poster);
78+
Assert.NotNull(refresher);
79+
Assert.NotNull(coordinator);
80+
Assert.NotNull(relay);
81+
Assert.NotNull(hostedService);
82+
}
83+
}

0 commit comments

Comments
 (0)