Skip to content

Commit 0bd467a

Browse files
HandyS11claude
andauthored
Subsystem 4b: Smart Alarms (#alarms channel, pair/validate, @everyone + team-chat relay on trigger) (#23)
* feat(alarms): add SmartAlarm entity, EF config, and SmartAlarms migration * feat(alarms): add IAlarmStore + AlarmStore with toggle + record-fired * fix(alarms): register IAlarmStore in DI * refactor(alarms): revise SmartAlarm to socket-state model (LastIsActive/LastTriggeredUtc; UpdateStateAsync) * feat(alarms): add AlarmPairedEvent * feat(alarms): route alarm pairings to AlarmPairedEvent + prime alarms on connect * feat(alarms): provision #alarms channel + AlarmChannelLocator Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(alarms): scaffold Features.Alarms (component ids + localization catalog) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(alarms): add AlarmEmbedRenderer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(alarms): extract IAlarmLocalizer + fix stale catalog doc Fix 1: Update stale XML comment on AlarmLocalizationCatalog.Default to reference the actual per-slice AlarmLocalizer constructor instead of non-existent shared ILocalizer type. Fix 2: Extract IAlarmLocalizer interface (matching ISwitchLocalizer pattern) with Get(key, culture) and Get(key, culture, params args) methods. Make AlarmLocalizer implement it with <inheritdoc /> on public methods. Update AlarmEmbedRenderer ctor to accept IAlarmLocalizer instead of concrete AlarmLocalizer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(alarms): add AlarmChannelPoster + AlarmRefresher + AlarmPairingCoordinator Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(alarms): reuse open scope for coordinator culture read + assert prompt replacement Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(alarms): add AlarmStateRelay (state update, active-edge ping/relay, unreachable) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(alarms): add AlarmComponentModule + rename modal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 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> * style: apply jb cleanupcode ReformatAndReorder to alarm-slice files Whitespace-only line-wrap reformats produced by dotnet jb cleanupcode --profile=ReformatAndReorder. No semantic changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * perf(alarms): reuse loaded alarms on reconnect refresh to avoid N+1 re-fetch Add an IAlarmRefresher.RefreshAsync(SmartAlarm, ...) overload that renders an already-loaded alarm, skipping the per-alarm store.GetAsync. The id-based method now fetches-then-delegates via a shared RenderAndPostAsync helper. HandleConnectionStatusAsync passes the alarms it already listed instead of re-querying each by id. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9bf6c36 commit 0bd467a

52 files changed

Lines changed: 4344 additions & 10 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.

RustPlusBot.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<Project Path="src/RustPlusBot.Features.Connections/RustPlusBot.Features.Connections.csproj" />
99
<Project Path="src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj" />
1010
<Project Path="src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj" />
11+
<Project Path="src/RustPlusBot.Features.Alarms/RustPlusBot.Features.Alarms.csproj" />
1112
<Project Path="src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj" />
1213
<Project Path="src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj" />
1314
<Project Path="src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj" />
@@ -24,6 +25,7 @@
2425
<Project Path="tests/RustPlusBot.Features.Players.Tests/RustPlusBot.Features.Players.Tests.csproj" />
2526
<Project Path="tests/RustPlusBot.Features.Switches.Tests/RustPlusBot.Features.Switches.Tests.csproj" />
2627
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
28+
<Project Path="tests/RustPlusBot.Features.Alarms.Tests/RustPlusBot.Features.Alarms.Tests.csproj" />
2729
<Project Path="tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj" />
2830
<Project Path="tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj" />
2931
<Project Path="tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj" />
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace RustPlusBot.Abstractions.Events;
2+
3+
/// <summary>A Smart Alarm was paired in-game and needs validation before the bot manages it.</summary>
4+
/// <param name="GuildId">The owning Discord guild snowflake.</param>
5+
/// <param name="ServerId">The local Rust server id.</param>
6+
/// <param name="EntityId">The in-game smart-alarm entity id.</param>
7+
public sealed record AlarmPairedEvent(ulong GuildId, Guid ServerId, ulong EntityId);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
namespace RustPlusBot.Domain.Alarms;
2+
3+
/// <summary>A paired Smart Alarm the bot manages, surviving restarts. Guild- and server-scoped. Driven by the live socket (primed on connect, reacts to SmartDeviceTriggered) — the entity id is the switch-vs-alarm discriminant.</summary>
4+
public sealed class SmartAlarm
5+
{
6+
/// <summary>Surrogate primary key.</summary>
7+
public Guid Id { get; set; } = Guid.NewGuid();
8+
9+
/// <summary>The owning Discord guild snowflake.</summary>
10+
public ulong GuildId { get; set; }
11+
12+
/// <summary>The server this alarm belongs to (FK to RustServer, cascade delete).</summary>
13+
public Guid ServerId { get; set; }
14+
15+
/// <summary>The in-game smart-alarm entity id.</summary>
16+
public ulong EntityId { get; set; }
17+
18+
/// <summary>User-facing label; defaults to a generated "Alarm &lt;EntityId&gt;".</summary>
19+
public string Name { get; set; } = string.Empty;
20+
21+
/// <summary>The Discord message id of this alarm's embed, or null until first posted.</summary>
22+
public ulong? MessageId { get; set; }
23+
24+
/// <summary>The Discord user who accepted (validated) the pairing.</summary>
25+
public ulong PairedByUserId { get; set; }
26+
27+
/// <summary>When the alarm was accepted (UTC).</summary>
28+
public DateTimeOffset CreatedUtc { get; set; }
29+
30+
/// <summary>When true, a trigger going active pings @everyone in #alarms.</summary>
31+
public bool PingEveryone { get; set; }
32+
33+
/// <summary>When true, a trigger going active relays the message into in-game team chat.</summary>
34+
public bool RelayToTeamChat { get; set; }
35+
36+
/// <summary>The last observed on/off state from the in-game socket broadcast.</summary>
37+
public bool LastIsActive { get; set; }
38+
39+
/// <summary>When the alarm most recently went active (UTC), or null if never triggered.</summary>
40+
public DateTimeOffset? LastTriggeredUtc { get; set; }
41+
}
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+
}

0 commit comments

Comments
 (0)