Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e094017
feat(alarms): add SmartAlarm entity, EF config, and SmartAlarms migra…
HandyS11 Jun 22, 2026
9092d01
feat(alarms): add IAlarmStore + AlarmStore with toggle + record-fired
HandyS11 Jun 22, 2026
f5930b7
fix(alarms): register IAlarmStore in DI
HandyS11 Jun 22, 2026
14c0432
refactor(alarms): revise SmartAlarm to socket-state model (LastIsActi…
HandyS11 Jun 22, 2026
1ffe826
feat(alarms): add AlarmPairedEvent
HandyS11 Jun 22, 2026
c9aa0b1
feat(alarms): route alarm pairings to AlarmPairedEvent + prime alarms…
HandyS11 Jun 22, 2026
1329415
feat(alarms): provision #alarms channel + AlarmChannelLocator
HandyS11 Jun 22, 2026
1788dc4
feat(alarms): scaffold Features.Alarms (component ids + localization …
HandyS11 Jun 22, 2026
4891830
feat(alarms): add AlarmEmbedRenderer
HandyS11 Jun 22, 2026
032d540
refactor(alarms): extract IAlarmLocalizer + fix stale catalog doc
HandyS11 Jun 22, 2026
b82c320
feat(alarms): add AlarmChannelPoster + AlarmRefresher + AlarmPairingC…
HandyS11 Jun 22, 2026
ff4b7fc
refactor(alarms): reuse open scope for coordinator culture read + ass…
HandyS11 Jun 22, 2026
ab69627
feat(alarms): add AlarmStateRelay (state update, active-edge ping/rel…
HandyS11 Jun 22, 2026
aa5d9ae
feat(alarms): add AlarmComponentModule + rename modal
HandyS11 Jun 22, 2026
9401d6e
feat(alarms): add AlarmsHostedService, AddAlarms DI, and Host wiring
HandyS11 Jun 22, 2026
78477d0
style: apply jb cleanupcode ReformatAndReorder to alarm-slice files
HandyS11 Jun 22, 2026
c4d545e
perf(alarms): reuse loaded alarms on reconnect refresh to avoid N+1 r…
HandyS11 Jun 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions RustPlusBot.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<Project Path="src/RustPlusBot.Features.Connections/RustPlusBot.Features.Connections.csproj" />
<Project Path="src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj" />
<Project Path="src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj" />
<Project Path="src/RustPlusBot.Features.Alarms/RustPlusBot.Features.Alarms.csproj" />
<Project Path="src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj" />
<Project Path="src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj" />
<Project Path="src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj" />
Expand All @@ -24,6 +25,7 @@
<Project Path="tests/RustPlusBot.Features.Players.Tests/RustPlusBot.Features.Players.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Switches.Tests/RustPlusBot.Features.Switches.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Alarms.Tests/RustPlusBot.Features.Alarms.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj" />
<Project Path="tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj" />
Expand Down
7 changes: 7 additions & 0 deletions src/RustPlusBot.Abstractions/Events/AlarmPairedEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace RustPlusBot.Abstractions.Events;

/// <summary>A Smart Alarm was paired in-game and needs validation before the bot manages it.</summary>
/// <param name="GuildId">The owning Discord guild snowflake.</param>
/// <param name="ServerId">The local Rust server id.</param>
/// <param name="EntityId">The in-game smart-alarm entity id.</param>
public sealed record AlarmPairedEvent(ulong GuildId, Guid ServerId, ulong EntityId);
41 changes: 41 additions & 0 deletions src/RustPlusBot.Domain/Alarms/SmartAlarm.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
namespace RustPlusBot.Domain.Alarms;

/// <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>
public sealed class SmartAlarm
{
/// <summary>Surrogate primary key.</summary>
public Guid Id { get; set; } = Guid.NewGuid();

/// <summary>The owning Discord guild snowflake.</summary>
public ulong GuildId { get; set; }

/// <summary>The server this alarm belongs to (FK to RustServer, cascade delete).</summary>
public Guid ServerId { get; set; }

/// <summary>The in-game smart-alarm entity id.</summary>
public ulong EntityId { get; set; }

/// <summary>User-facing label; defaults to a generated "Alarm &lt;EntityId&gt;".</summary>
public string Name { get; set; } = string.Empty;

/// <summary>The Discord message id of this alarm's embed, or null until first posted.</summary>
public ulong? MessageId { get; set; }

/// <summary>The Discord user who accepted (validated) the pairing.</summary>
public ulong PairedByUserId { get; set; }

/// <summary>When the alarm was accepted (UTC).</summary>
public DateTimeOffset CreatedUtc { get; set; }

/// <summary>When true, a trigger going active pings @everyone in #alarms.</summary>
public bool PingEveryone { get; set; }

/// <summary>When true, a trigger going active relays the message into in-game team chat.</summary>
public bool RelayToTeamChat { get; set; }

/// <summary>The last observed on/off state from the in-game socket broadcast.</summary>
public bool LastIsActive { get; set; }

/// <summary>When the alarm most recently went active (UTC), or null if never triggered.</summary>
public DateTimeOffset? LastTriggeredUtc { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Microsoft.Extensions.DependencyInjection;
using RustPlusBot.Discord;
using RustPlusBot.Features.Alarms.Hosting;
using RustPlusBot.Features.Alarms.Pairing;
using RustPlusBot.Features.Alarms.Posting;
using RustPlusBot.Features.Alarms.Relaying;
using RustPlusBot.Features.Alarms.Rendering;

namespace RustPlusBot.Features.Alarms;

/// <summary>DI registration for the Smart Alarms feature.</summary>
public static class AlarmServiceCollectionExtensions
{
/// <summary>Registers the localizer, renderer, poster, refresher, coordinator, relay, modules, and hosted service.</summary>
/// <param name="services">The service collection to add to.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddAlarms(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);

services.AddSingleton<IAlarmLocalizer>(new AlarmLocalizer(AlarmLocalizationCatalog.Default));
services.AddSingleton<AlarmEmbedRenderer>();
services.AddSingleton<IAlarmChannelPoster, DiscordAlarmChannelPoster>();
services.AddSingleton<IAlarmRefresher, AlarmRefresher>();
services.AddSingleton<AlarmPairingCoordinator>();
services.AddSingleton<AlarmStateRelay>();
services.AddHostedService<AlarmsHostedService>();

// Contribute this assembly's interaction modules to the Discord layer.
services.AddSingleton(new InteractionModuleAssembly(typeof(AlarmServiceCollectionExtensions).Assembly));

return services;
}
}
133 changes: 133 additions & 0 deletions src/RustPlusBot.Features.Alarms/Hosting/AlarmsHostedService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Features.Alarms.Pairing;
using RustPlusBot.Features.Alarms.Relaying;

namespace RustPlusBot.Features.Alarms.Hosting;

/// <summary>Runs the alarm-pairing loop, the alarm-triggered relay loop, and the connection-status relay loop.</summary>
/// <param name="eventBus">The in-process event bus.</param>
/// <param name="coordinator">Handles paired alarms.</param>
/// <param name="relay">Re-renders alarms on trigger/connection changes.</param>
/// <param name="logger">The logger.</param>
internal sealed partial class AlarmsHostedService(
IEventBus eventBus,
AlarmPairingCoordinator coordinator,
AlarmStateRelay relay,
ILogger<AlarmsHostedService> logger) : IHostedService, IDisposable
{
private readonly CancellationTokenSource _cts = new();
private Task? _pairedLoop;
private Task? _statusLoop;
private Task? _triggeredLoop;

/// <inheritdoc />
public void Dispose() => _cts.Dispose();

/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_pairedLoop = Task.Run(() => ConsumePairedAsync(_cts.Token), CancellationToken.None);
_triggeredLoop = Task.Run(() => ConsumeTriggeredAsync(_cts.Token), CancellationToken.None);
_statusLoop = Task.Run(() => ConsumeStatusAsync(_cts.Token), CancellationToken.None);
return Task.CompletedTask;
}

/// <inheritdoc />
public async Task StopAsync(CancellationToken cancellationToken)
{
await _cts.CancelAsync().ConfigureAwait(false);
foreach (var loop in new[]
{
_pairedLoop, _triggeredLoop, _statusLoop
}.Where(t => t is not null))
{
try
{
#pragma warning disable VSTHRD003 // Our own loop tasks, joined on stop.
await loop!.ConfigureAwait(false);
#pragma warning restore VSTHRD003
}
catch (OperationCanceledException)
{
// Expected on shutdown.
}
}
}

private async Task ConsumePairedAsync(CancellationToken cancellationToken)
{
try
{
await foreach (var evt in eventBus.SubscribeAsync<AlarmPairedEvent>(cancellationToken)
.ConfigureAwait(false))
{
await coordinator.HandlePairedAsync(evt, cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Shutting down.
}
#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
catch (Exception ex)
#pragma warning restore CA1031
{
LogPairedLoopFaulted(logger, ex);
}
}

private async Task ConsumeTriggeredAsync(CancellationToken cancellationToken)
{
try
{
await foreach (var evt in eventBus.SubscribeAsync<SmartDeviceTriggeredEvent>(cancellationToken)
.ConfigureAwait(false))
{
await relay.HandleTriggeredAsync(evt, cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Shutting down.
}
#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
catch (Exception ex)
#pragma warning restore CA1031
{
LogTriggeredLoopFaulted(logger, ex);
}
}

private async Task ConsumeStatusAsync(CancellationToken cancellationToken)
{
try
{
await foreach (var evt in eventBus.SubscribeAsync<ConnectionStatusChangedEvent>(cancellationToken)
.ConfigureAwait(false))
{
await relay.HandleConnectionStatusAsync(evt, cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Shutting down.
}
#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
catch (Exception ex)
#pragma warning restore CA1031
{
LogStatusLoopFaulted(logger, ex);
}
}

[LoggerMessage(Level = LogLevel.Error, Message = "Alarm pairing loop faulted.")]
private static partial void LogPairedLoopFaulted(ILogger logger, Exception exception);

[LoggerMessage(Level = LogLevel.Error, Message = "Alarm device-triggered relay loop faulted.")]
private static partial void LogTriggeredLoopFaulted(ILogger logger, Exception exception);

[LoggerMessage(Level = LogLevel.Error, Message = "Alarm connection-status relay loop faulted.")]
private static partial void LogStatusLoopFaulted(ILogger logger, Exception exception);
}
Loading