diff --git a/RustPlusBot.slnx b/RustPlusBot.slnx
index 3ccce97..1c9ca94 100644
--- a/RustPlusBot.slnx
+++ b/RustPlusBot.slnx
@@ -8,6 +8,7 @@
+
@@ -24,6 +25,7 @@
+
diff --git a/src/RustPlusBot.Abstractions/Events/AlarmPairedEvent.cs b/src/RustPlusBot.Abstractions/Events/AlarmPairedEvent.cs
new file mode 100644
index 0000000..2ebd92a
--- /dev/null
+++ b/src/RustPlusBot.Abstractions/Events/AlarmPairedEvent.cs
@@ -0,0 +1,7 @@
+namespace RustPlusBot.Abstractions.Events;
+
+/// A Smart Alarm was paired in-game and needs validation before the bot manages it.
+/// The owning Discord guild snowflake.
+/// The local Rust server id.
+/// The in-game smart-alarm entity id.
+public sealed record AlarmPairedEvent(ulong GuildId, Guid ServerId, ulong EntityId);
diff --git a/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs b/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs
new file mode 100644
index 0000000..4033a9a
--- /dev/null
+++ b/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs
@@ -0,0 +1,41 @@
+namespace RustPlusBot.Domain.Alarms;
+
+/// 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.
+public sealed class SmartAlarm
+{
+ /// Surrogate primary key.
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ /// The owning Discord guild snowflake.
+ public ulong GuildId { get; set; }
+
+ /// The server this alarm belongs to (FK to RustServer, cascade delete).
+ public Guid ServerId { get; set; }
+
+ /// The in-game smart-alarm entity id.
+ public ulong EntityId { get; set; }
+
+ /// User-facing label; defaults to a generated "Alarm <EntityId>".
+ public string Name { get; set; } = string.Empty;
+
+ /// The Discord message id of this alarm's embed, or null until first posted.
+ public ulong? MessageId { get; set; }
+
+ /// The Discord user who accepted (validated) the pairing.
+ public ulong PairedByUserId { get; set; }
+
+ /// When the alarm was accepted (UTC).
+ public DateTimeOffset CreatedUtc { get; set; }
+
+ /// When true, a trigger going active pings @everyone in #alarms.
+ public bool PingEveryone { get; set; }
+
+ /// When true, a trigger going active relays the message into in-game team chat.
+ public bool RelayToTeamChat { get; set; }
+
+ /// The last observed on/off state from the in-game socket broadcast.
+ public bool LastIsActive { get; set; }
+
+ /// When the alarm most recently went active (UTC), or null if never triggered.
+ public DateTimeOffset? LastTriggeredUtc { get; set; }
+}
diff --git a/src/RustPlusBot.Features.Alarms/AlarmServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Alarms/AlarmServiceCollectionExtensions.cs
new file mode 100644
index 0000000..dff1355
--- /dev/null
+++ b/src/RustPlusBot.Features.Alarms/AlarmServiceCollectionExtensions.cs
@@ -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;
+
+/// DI registration for the Smart Alarms feature.
+public static class AlarmServiceCollectionExtensions
+{
+ /// Registers the localizer, renderer, poster, refresher, coordinator, relay, modules, and hosted service.
+ /// The service collection to add to.
+ /// The same service collection, for chaining.
+ public static IServiceCollection AddAlarms(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ services.AddSingleton(new AlarmLocalizer(AlarmLocalizationCatalog.Default));
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddHostedService();
+
+ // Contribute this assembly's interaction modules to the Discord layer.
+ services.AddSingleton(new InteractionModuleAssembly(typeof(AlarmServiceCollectionExtensions).Assembly));
+
+ return services;
+ }
+}
diff --git a/src/RustPlusBot.Features.Alarms/Hosting/AlarmsHostedService.cs b/src/RustPlusBot.Features.Alarms/Hosting/AlarmsHostedService.cs
new file mode 100644
index 0000000..044d070
--- /dev/null
+++ b/src/RustPlusBot.Features.Alarms/Hosting/AlarmsHostedService.cs
@@ -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;
+
+/// Runs the alarm-pairing loop, the alarm-triggered relay loop, and the connection-status relay loop.
+/// The in-process event bus.
+/// Handles paired alarms.
+/// Re-renders alarms on trigger/connection changes.
+/// The logger.
+internal sealed partial class AlarmsHostedService(
+ IEventBus eventBus,
+ AlarmPairingCoordinator coordinator,
+ AlarmStateRelay relay,
+ ILogger logger) : IHostedService, IDisposable
+{
+ private readonly CancellationTokenSource _cts = new();
+ private Task? _pairedLoop;
+ private Task? _statusLoop;
+ private Task? _triggeredLoop;
+
+ ///
+ public void Dispose() => _cts.Dispose();
+
+ ///
+ 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;
+ }
+
+ ///
+ 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(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(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(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);
+}
diff --git a/src/RustPlusBot.Features.Alarms/Modules/AlarmComponentModule.cs b/src/RustPlusBot.Features.Alarms/Modules/AlarmComponentModule.cs
new file mode 100644
index 0000000..6342659
--- /dev/null
+++ b/src/RustPlusBot.Features.Alarms/Modules/AlarmComponentModule.cs
@@ -0,0 +1,217 @@
+using System.Globalization;
+using Discord;
+using Discord.Interactions;
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Features.Alarms.Pairing;
+using RustPlusBot.Features.Alarms.Relaying;
+using RustPlusBot.Features.Alarms.Rendering;
+using RustPlusBot.Persistence.Alarms;
+
+namespace RustPlusBot.Features.Alarms.Modules;
+
+/// Thin handler for the #alarms pairing prompt + control buttons + rename modal. Any guild member.
+/// Creates a short-lived DI scope per interaction.
+/// Re-renders the alarm embed on demand.
+public sealed class AlarmComponentModule(
+ IServiceScopeFactory scopeFactory,
+ IAlarmRefresher refresher) : InteractionModuleBase
+{
+ /// Accepts a pending pairing prompt and starts managing the alarm.
+ /// The "{serverId}:{entityId}" custom-id tail.
+ [ComponentInteraction(AlarmComponentIds.AcceptPrefix + "*")]
+ public async Task AcceptAsync(string tail)
+ {
+ if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null)
+ {
+ await RespondAsync("That control wasn't valid.", ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ await DeferAsync(ephemeral: true).ConfigureAwait(false);
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var coordinator = scope.ServiceProvider.GetRequiredService();
+ var accepted = await coordinator
+ .TryAcceptAsync(Context.Guild.Id, serverId, entityId, Context.User.Id, CancellationToken.None)
+ .ConfigureAwait(false);
+ await FollowupAsync(accepted ? "Alarm added." : "That alarm is already managed.", ephemeral: true)
+ .ConfigureAwait(false);
+ }
+ }
+
+ /// Dismisses a pending pairing prompt and removes the transient prompt message.
+ /// The "{serverId}:{entityId}" custom-id tail.
+ [ComponentInteraction(AlarmComponentIds.DismissPrefix + "*")]
+ public async Task DismissAsync(string tail)
+ {
+ if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null)
+ {
+ await RespondAsync("That control wasn't valid.", ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var coordinator = scope.ServiceProvider.GetRequiredService();
+ coordinator.TryDismiss(Context.Guild.Id, serverId, entityId);
+ }
+
+ // Delete the actual prompt message that hosts this button (the component interaction's source
+ // message) — not the ephemeral interaction response. Best-effort: a delete failure is non-fatal.
+ await DeletePromptMessageSafeAsync().ConfigureAwait(false);
+ await RespondAsync("Dismissed.", ephemeral: true).ConfigureAwait(false);
+ }
+
+ /// Toggles the @everyone ping setting for this alarm.
+ /// The "{serverId}:{entityId}" custom-id tail.
+ [ComponentInteraction(AlarmComponentIds.PingTogglePrefix + "*")]
+ public async Task PingToggleAsync(string tail)
+ {
+ if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null)
+ {
+ await RespondAsync("That control wasn't valid.", ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ await DeferAsync(ephemeral: true).ConfigureAwait(false);
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ var current = await store.GetAsync(Context.Guild.Id, serverId, entityId, CancellationToken.None)
+ .ConfigureAwait(false);
+ if (current is null)
+ {
+ await FollowupAsync("That alarm isn't managed.", ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ await store
+ .SetPingEveryoneAsync(Context.Guild.Id, serverId, entityId, !current.PingEveryone,
+ CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+
+ await refresher.RefreshAsync(Context.Guild.Id, serverId, entityId, unreachable: false, CancellationToken.None)
+ .ConfigureAwait(false);
+ await FollowupAsync("Updated.", ephemeral: true).ConfigureAwait(false);
+ }
+
+ /// Toggles the relay-to-team-chat setting for this alarm.
+ /// The "{serverId}:{entityId}" custom-id tail.
+ [ComponentInteraction(AlarmComponentIds.RelayTogglePrefix + "*")]
+ public async Task RelayToggleAsync(string tail)
+ {
+ if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null)
+ {
+ await RespondAsync("That control wasn't valid.", ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ await DeferAsync(ephemeral: true).ConfigureAwait(false);
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ var current = await store.GetAsync(Context.Guild.Id, serverId, entityId, CancellationToken.None)
+ .ConfigureAwait(false);
+ if (current is null)
+ {
+ await FollowupAsync("That alarm isn't managed.", ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ await store
+ .SetRelayToTeamChatAsync(Context.Guild.Id, serverId, entityId, !current.RelayToTeamChat,
+ CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+
+ await refresher.RefreshAsync(Context.Guild.Id, serverId, entityId, unreachable: false, CancellationToken.None)
+ .ConfigureAwait(false);
+ await FollowupAsync("Updated.", ephemeral: true).ConfigureAwait(false);
+ }
+
+ /// Opens the rename modal, carrying the target tail in the modal custom id.
+ /// The "{serverId}:{entityId}" custom-id tail.
+ [ComponentInteraction(AlarmComponentIds.RenamePrefix + "*")]
+ public async Task RenamePromptAsync(string tail)
+ {
+ if (!TryParse(tail, out _, out _) || Context.Guild is null)
+ {
+ await RespondAsync("That control wasn't valid.", ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ // The modal id carries the same tail so the submit handler can route.
+ await RespondWithModalAsync(AlarmComponentIds.RenameModalPrefix + tail)
+ .ConfigureAwait(false);
+ }
+
+ /// Persists the new name, then refreshes the alarm embed.
+ /// The "{serverId}:{entityId}" custom-id tail.
+ /// The submitted rename modal.
+ [ModalInteraction(AlarmComponentIds.RenameModalPrefix + "*")]
+ public async Task RenameSubmitAsync(string tail, AlarmRenameModal modal)
+ {
+ ArgumentNullException.ThrowIfNull(modal);
+ if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null)
+ {
+ await RespondAsync("That control wasn't valid.", ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ var name = string.IsNullOrWhiteSpace(modal.Name)
+ ? "Alarm " + entityId.ToString(CultureInfo.InvariantCulture)
+ : modal.Name.Trim();
+ await DeferAsync(ephemeral: true).ConfigureAwait(false);
+
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ await store.RenameAsync(Context.Guild.Id, serverId, entityId, name, CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+
+ await refresher.RefreshAsync(Context.Guild.Id, serverId, entityId, unreachable: false, CancellationToken.None)
+ .ConfigureAwait(false);
+ await FollowupAsync("Renamed.", ephemeral: true).ConfigureAwait(false);
+ }
+
+ private static bool TryParse(string tail, out Guid serverId, out ulong entityId)
+ {
+ serverId = Guid.Empty;
+ entityId = 0UL;
+ if (tail is null)
+ {
+ return false;
+ }
+
+ var parts = tail.Split(':');
+ return parts.Length == 2
+ && Guid.TryParse(parts[0], out serverId)
+ && ulong.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out entityId);
+ }
+
+ private async Task DeletePromptMessageSafeAsync()
+ {
+ try
+ {
+ // The source message of a component interaction is the prompt that carries the button.
+ if (Context.Interaction is IComponentInteraction component)
+ {
+ await component.Message.DeleteAsync().ConfigureAwait(false);
+ }
+ }
+#pragma warning disable CA1031 // Best-effort prompt cleanup; a delete failure is non-fatal.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ // Ignore: the prompt is transient and harmless if it lingers.
+ _ = ex;
+ }
+ }
+}
diff --git a/src/RustPlusBot.Features.Alarms/Modules/AlarmRenameModal.cs b/src/RustPlusBot.Features.Alarms/Modules/AlarmRenameModal.cs
new file mode 100644
index 0000000..a4a00e1
--- /dev/null
+++ b/src/RustPlusBot.Features.Alarms/Modules/AlarmRenameModal.cs
@@ -0,0 +1,17 @@
+using Discord;
+using Discord.Interactions;
+using RustPlusBot.Features.Alarms.Rendering;
+
+namespace RustPlusBot.Features.Alarms.Modules;
+
+/// The modal that collects a new alarm name. Handled by .
+public sealed class AlarmRenameModal : IModal
+{
+ /// The new name.
+ [InputLabel("Alarm name")]
+ [ModalTextInput(AlarmComponentIds.RenameInputId, TextInputStyle.Short, maxLength: 128)]
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ public string Title => "Rename alarm";
+}
diff --git a/src/RustPlusBot.Features.Alarms/Pairing/AlarmPairingCoordinator.cs b/src/RustPlusBot.Features.Alarms/Pairing/AlarmPairingCoordinator.cs
new file mode 100644
index 0000000..f40b9e4
--- /dev/null
+++ b/src/RustPlusBot.Features.Alarms/Pairing/AlarmPairingCoordinator.cs
@@ -0,0 +1,137 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.Alarms.Posting;
+using RustPlusBot.Features.Alarms.Rendering;
+using RustPlusBot.Features.Workspace.Locating;
+using RustPlusBot.Persistence.Alarms;
+using RustPlusBot.Persistence.Workspace;
+
+namespace RustPlusBot.Features.Alarms.Pairing;
+
+/// Turns an into an "Add it?" prompt and, on Accept, a managed alarm.
+/// Opens scopes for the scoped alarm/workspace stores.
+/// Resolves the #alarms channel id.
+/// Posts/edits alarm + prompt messages.
+/// Renders the prompt and alarm embeds.
+internal sealed class AlarmPairingCoordinator(
+ IServiceScopeFactory scopeFactory,
+ IAlarmChannelLocator locator,
+ IAlarmChannelPoster poster,
+ AlarmEmbedRenderer renderer)
+{
+ private readonly ConcurrentDictionary<(ulong Guild, Guid Server, ulong Entity), Pending> _pending = new();
+
+ /// Gets the held default name for a pending pairing, or null.
+ /// The guild id.
+ /// The server id.
+ /// The alarm entity id.
+ /// The held default name, or null when no pending pairing exists.
+ public string? PendingName(ulong guildId, Guid serverId, ulong entityId) =>
+ _pending.TryGetValue((guildId, serverId, entityId), out var p) ? p.DefaultName : null;
+
+ /// Handles a paired alarm: ignore if already managed, else post the prompt and hold pending state.
+ /// The paired-alarm event.
+ /// A token to cancel the operation.
+ /// A task that completes when the prompt has been posted (or the alarm was ignored).
+ public async Task HandlePairedAsync(AlarmPairedEvent evt, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(evt);
+
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ if (await store.ExistsAsync(evt.GuildId, evt.ServerId, evt.EntityId, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ return;
+ }
+
+ var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken)
+ .ConfigureAwait(false);
+ if (channelId is not { } channel)
+ {
+ return;
+ }
+
+ var culture = await GetCultureAsync(scope.ServiceProvider, evt.GuildId, cancellationToken)
+ .ConfigureAwait(false);
+ var defaultName = $"Alarm {evt.EntityId}";
+ var (embed, components) = renderer.RenderPrompt(evt.ServerId, evt.EntityId, defaultName, culture);
+ var messageId = await poster.EnsureAsync(channel, null, embed, components, cancellationToken)
+ .ConfigureAwait(false);
+ _pending[(evt.GuildId, evt.ServerId, evt.EntityId)] = new Pending(defaultName, messageId);
+ }
+ }
+
+ /// Accepts a pending pairing: persist + replace prompt with the alarm embed. Race-guarded.
+ /// The guild id.
+ /// The server id.
+ /// The alarm entity id.
+ /// The id of the user who accepted the pairing.
+ /// A token to cancel the operation.
+ /// True when the alarm was persisted; false when it was already managed (race).
+ public async Task TryAcceptAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ ulong acceptingUserId,
+ CancellationToken cancellationToken)
+ {
+ _pending.TryGetValue((guildId, serverId, entityId), out var pending);
+ var name = pending?.DefaultName ?? $"Alarm {entityId}";
+
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ if (await store.ExistsAsync(guildId, serverId, entityId, cancellationToken).ConfigureAwait(false))
+ {
+ _pending.TryRemove((guildId, serverId, entityId), out _);
+ return false;
+ }
+
+ var added = await store.AddAsync(guildId, serverId, entityId, name, acceptingUserId, cancellationToken)
+ .ConfigureAwait(false);
+
+ var channelId = await locator.GetChannelIdAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
+ if (channelId is { } channel)
+ {
+ var culture = await GetCultureAsync(scope.ServiceProvider, guildId, cancellationToken)
+ .ConfigureAwait(false);
+
+ // The alarm is freshly accepted; unreachable is false (it just paired).
+ // The supervisor's prime path will re-render real state shortly.
+ var (embed, components) = renderer.RenderAlarm(added, unreachable: false, culture);
+ var newMessageId = await poster
+ .EnsureAsync(channel, pending?.MessageId, embed, components, cancellationToken)
+ .ConfigureAwait(false);
+ if (newMessageId is { } mid)
+ {
+ await store.SetMessageIdAsync(guildId, serverId, entityId, mid, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+ }
+
+ _pending.TryRemove((guildId, serverId, entityId), out _);
+ return true;
+ }
+
+ /// Drops a pending pairing; returns whether one was present.
+ /// The guild id.
+ /// The server id.
+ /// The alarm entity id.
+ /// True when a pending pairing was removed; false when none was held.
+ public bool TryDismiss(ulong guildId, Guid serverId, ulong entityId) =>
+ _pending.TryRemove((guildId, serverId, entityId), out _);
+
+ private static async Task GetCultureAsync(IServiceProvider provider, ulong guildId, CancellationToken ct)
+ {
+ var store = provider.GetRequiredService();
+ return await store.GetCultureAsync(guildId, ct).ConfigureAwait(false);
+ }
+
+ private sealed record Pending(string DefaultName, ulong? MessageId);
+}
diff --git a/src/RustPlusBot.Features.Alarms/Posting/DiscordAlarmChannelPoster.cs b/src/RustPlusBot.Features.Alarms/Posting/DiscordAlarmChannelPoster.cs
new file mode 100644
index 0000000..1ebaed4
--- /dev/null
+++ b/src/RustPlusBot.Features.Alarms/Posting/DiscordAlarmChannelPoster.cs
@@ -0,0 +1,123 @@
+using Discord.Net;
+using Discord.WebSocket;
+using Microsoft.Extensions.Logging;
+
+namespace RustPlusBot.Features.Alarms.Posting;
+
+/// Posts/edits alarm embeds in #alarms by message id. Untested integration shim.
+/// The Discord socket client.
+/// The logger.
+internal sealed partial class DiscordAlarmChannelPoster(
+ DiscordSocketClient client,
+ ILogger logger) : IAlarmChannelPoster
+{
+ ///
+ public async Task EnsureAsync(
+ ulong channelId,
+ ulong? messageId,
+ global::Discord.Embed embed,
+ global::Discord.MessageComponent components,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var options = new global::Discord.RequestOptions
+ {
+ CancelToken = cancellationToken
+ };
+ if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false)
+ is not global::Discord.ITextChannel channel)
+ {
+ return null;
+ }
+
+ if (messageId is { } id)
+ {
+ // Inner try: some Discord.Net versions THROW (HttpException 404/Unknown Message)
+ // rather than return null for a deleted message. Catch it and fall through to repost
+ // so the self-heal path always runs.
+ try
+ {
+ var existing = await channel.GetMessageAsync(id, options: options).ConfigureAwait(false);
+ if (existing is global::Discord.IUserMessage userMessage)
+ {
+ await userMessage.ModifyAsync(m =>
+ {
+ m.Embed = embed;
+ m.Components = components;
+ }, options).ConfigureAwait(false);
+ return userMessage.Id;
+ }
+
+ // Message was deleted (returned null / not a user message); fall through to repost.
+ }
+ catch (HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.NotFound)
+ {
+ // Deleted/unknown message; fall through to repost and return the new id.
+ LogMessageMissing(logger, ex, channelId, id);
+ }
+ }
+
+ var posted = await channel
+ .SendMessageAsync(embed: embed, options: options, components: components)
+ .ConfigureAwait(false);
+ return posted.Id;
+ }
+ catch (OperationCanceledException)
+ {
+ throw; // Shutdown — let the loop unwind.
+ }
+#pragma warning disable CA1031 // Broad catch: a Discord hiccup must not crash the relay; report failure as null.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogEnsureFailed(logger, ex, channelId);
+ return null;
+ }
+ }
+
+ ///
+ public async Task SendEveryonePingAsync(ulong channelId, string content, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var options = new global::Discord.RequestOptions
+ {
+ CancelToken = cancellationToken
+ };
+ if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false)
+ is not global::Discord.ITextChannel channel)
+ {
+ return;
+ }
+
+ await channel.SendMessageAsync(
+ content,
+ options: options,
+ allowedMentions: global::Discord.AllowedMentions.All)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw; // Shutdown — let the loop unwind.
+ }
+#pragma warning disable CA1031 // Broad catch: a Discord hiccup must not crash the alarm ping; swallow the failure.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogPingFailed(logger, ex, channelId);
+ }
+ }
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Posting/editing an alarm embed in channel {ChannelId} failed.")]
+ private static partial void LogEnsureFailed(ILogger logger, Exception exception, ulong channelId);
+
+ [LoggerMessage(Level = LogLevel.Debug,
+ Message = "Alarm embed {MessageId} in channel {ChannelId} was deleted; reposting.")]
+ private static partial void
+ LogMessageMissing(ILogger logger, Exception exception, ulong channelId, ulong messageId);
+
+ [LoggerMessage(Level = LogLevel.Warning,
+ Message = "Sending @everyone ping in channel {ChannelId} failed.")]
+ private static partial void LogPingFailed(ILogger logger, Exception exception, ulong channelId);
+}
diff --git a/src/RustPlusBot.Features.Alarms/Posting/IAlarmChannelPoster.cs b/src/RustPlusBot.Features.Alarms/Posting/IAlarmChannelPoster.cs
new file mode 100644
index 0000000..be01e3b
--- /dev/null
+++ b/src/RustPlusBot.Features.Alarms/Posting/IAlarmChannelPoster.cs
@@ -0,0 +1,26 @@
+namespace RustPlusBot.Features.Alarms.Posting;
+
+/// Posts/edits an alarm embed in #alarms by message id, self-healing a deleted message.
+internal interface IAlarmChannelPoster
+{
+ /// Edits the message at if present and found; otherwise posts a new one.
+ /// The #alarms channel id.
+ /// The known embed message id, or null to post fresh.
+ /// The embed to show.
+ /// The control row.
+ /// A cancellation token.
+ /// The (possibly new) message id, or null on failure.
+ Task EnsureAsync(
+ ulong channelId,
+ ulong? messageId,
+ global::Discord.Embed embed,
+ global::Discord.MessageComponent components,
+ CancellationToken cancellationToken);
+
+ /// Sends an @everyone ping message in the given channel.
+ /// The #alarms channel id.
+ /// The message content (typically includes @everyone).
+ /// A cancellation token.
+ /// A task that completes when the message has been sent (or silently swallowed on failure).
+ Task SendEveryonePingAsync(ulong channelId, string content, CancellationToken cancellationToken);
+}
diff --git a/src/RustPlusBot.Features.Alarms/Relaying/AlarmRefresher.cs b/src/RustPlusBot.Features.Alarms/Relaying/AlarmRefresher.cs
new file mode 100644
index 0000000..53badfe
--- /dev/null
+++ b/src/RustPlusBot.Features.Alarms/Relaying/AlarmRefresher.cs
@@ -0,0 +1,84 @@
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Domain.Alarms;
+using RustPlusBot.Features.Alarms.Posting;
+using RustPlusBot.Features.Alarms.Rendering;
+using RustPlusBot.Features.Workspace.Locating;
+using RustPlusBot.Persistence.Alarms;
+using RustPlusBot.Persistence.Workspace;
+
+namespace RustPlusBot.Features.Alarms.Relaying;
+
+/// Loads an alarm from the store, renders it, and posts/edits its embed in #alarms.
+/// Opens scopes for the scoped stores.
+/// Resolves the #alarms channel id.
+/// Posts/edits alarm embeds.
+/// Renders alarm embeds.
+internal sealed class AlarmRefresher(
+ IServiceScopeFactory scopeFactory,
+ IAlarmChannelLocator locator,
+ IAlarmChannelPoster poster,
+ AlarmEmbedRenderer renderer) : IAlarmRefresher
+{
+ ///
+ public async Task RefreshAsync(ulong guildId, Guid serverId, ulong entityId, bool unreachable, CancellationToken ct)
+ {
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ var alarm = await store.GetAsync(guildId, serverId, entityId, ct).ConfigureAwait(false);
+ if (alarm is null)
+ {
+ return;
+ }
+
+ await RenderAndPostAsync(scope.ServiceProvider, alarm, unreachable, ct).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ public async Task RefreshAsync(SmartAlarm alarm, bool unreachable, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(alarm);
+
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ await RenderAndPostAsync(scope.ServiceProvider, alarm, unreachable, ct).ConfigureAwait(false);
+ }
+ }
+
+ private async Task RenderAndPostAsync(
+ IServiceProvider provider,
+ SmartAlarm alarm,
+ bool unreachable,
+ CancellationToken ct)
+ {
+ var channelId = await locator.GetChannelIdAsync(alarm.GuildId, alarm.ServerId, ct).ConfigureAwait(false);
+ if (channelId is not { } channel)
+ {
+ return;
+ }
+
+ var culture = await GetCultureAsync(provider, alarm.GuildId, ct).ConfigureAwait(false);
+ var (embed, components) = renderer.RenderAlarm(alarm, unreachable, culture);
+ var newMessageId = await poster
+ .EnsureAsync(channel, alarm.MessageId, embed, components, ct)
+ .ConfigureAwait(false);
+ if (newMessageId is { } mid && mid != alarm.MessageId)
+ {
+ var store = provider.GetRequiredService();
+ await store.SetMessageIdAsync(alarm.GuildId, alarm.ServerId, alarm.EntityId, mid, ct)
+ .ConfigureAwait(false);
+ }
+ }
+
+ private static async Task GetCultureAsync(
+ IServiceProvider provider,
+ ulong guildId,
+ CancellationToken ct)
+ {
+ var store = provider.GetRequiredService();
+ return await store.GetCultureAsync(guildId, ct).ConfigureAwait(false);
+ }
+}
diff --git a/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs b/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs
new file mode 100644
index 0000000..44381b7
--- /dev/null
+++ b/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs
@@ -0,0 +1,160 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Domain.Connections;
+using RustPlusBot.Features.Alarms.Posting;
+using RustPlusBot.Features.Alarms.Rendering;
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Features.Workspace.Locating;
+using RustPlusBot.Persistence.Alarms;
+using RustPlusBot.Persistence.Connections;
+using RustPlusBot.Persistence.Workspace;
+
+namespace RustPlusBot.Features.Alarms.Relaying;
+
+///
+/// Keeps alarm embeds in sync with live socket events: updates state and re-renders on trigger; marks
+/// alarms unreachable when the server goes non-Connected.
+///
+/// Opens scopes for the scoped stores.
+/// Re-renders a single alarm embed on demand.
+/// Resolves the #alarms channel id.
+/// Posts/edits alarm embeds and sends @everyone pings.
+/// Relays messages into in-game team chat.
+/// Resolves localized alarm strings.
+/// Provides the current UTC time.
+/// The logger.
+internal sealed partial class AlarmStateRelay(
+ IServiceScopeFactory scopeFactory,
+ IAlarmRefresher refresher,
+ IAlarmChannelLocator locator,
+ IAlarmChannelPoster poster,
+ ITeamChatSender teamChatSender,
+ IAlarmLocalizer localizer,
+ IClock clock,
+ ILogger logger)
+{
+ ///
+ /// Handles a smart-device trigger: if it belongs to a managed alarm, persists the new state,
+ /// re-renders its embed, and (on the active edge only) optionally pings @everyone and/or relays
+ /// the trigger to in-game team chat.
+ ///
+ /// The device-triggered event.
+ /// A cancellation token.
+ /// A task that completes when all relay actions have finished.
+ public async Task HandleTriggeredAsync(SmartDeviceTriggeredEvent evt, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(evt);
+ bool ping, relay;
+ string name;
+
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ var alarm = await store.GetAsync(evt.GuildId, evt.ServerId, evt.EntityId, ct).ConfigureAwait(false);
+ if (alarm is null)
+ {
+ return; // not an alarm this relay manages (e.g. a switch) — ignore
+ }
+
+ await store.UpdateStateAsync(
+ evt.GuildId,
+ evt.ServerId,
+ evt.EntityId,
+ evt.IsActive,
+ evt.IsActive ? clock.UtcNow : null,
+ ct)
+ .ConfigureAwait(false);
+
+ ping = alarm.PingEveryone;
+ relay = alarm.RelayToTeamChat;
+ name = alarm.Name;
+ }
+
+ await refresher.RefreshAsync(evt.GuildId, evt.ServerId, evt.EntityId, unreachable: false, ct)
+ .ConfigureAwait(false);
+
+ if (!evt.IsActive)
+ {
+ return; // only the active edge notifies
+ }
+
+ if (ping)
+ {
+ var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false);
+ if (channelId is { } channel)
+ {
+ await poster.SendEveryonePingAsync(channel, $"@everyone {name}", ct).ConfigureAwait(false);
+ }
+ }
+
+ if (relay)
+ {
+ await RelayToTeamChatSafeAsync(evt, name, ct).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Handles a connection-status change: if the server is no longer Connected, marks every managed
+ /// alarm's embed as unreachable. Connected → no-op (the supervisor's prime republishes real state).
+ ///
+ /// The connection-status change.
+ /// A cancellation token.
+ /// A task that completes when every affected embed has been re-rendered.
+ public async Task HandleConnectionStatusAsync(ConnectionStatusChangedEvent evt, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(evt);
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var connections = scope.ServiceProvider.GetRequiredService();
+ var state = await connections.GetStateAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false);
+ if (state is { Status: ConnectionStatus.Connected })
+ {
+ // The supervisor's prime path republishes real state on connect; nothing to do here.
+ return;
+ }
+
+ var store = scope.ServiceProvider.GetRequiredService();
+ var alarms = await store.ListByServerAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false);
+ foreach (var alarm in alarms)
+ {
+ // Reuse the already-loaded alarm rather than re-fetching each by id.
+ await refresher.RefreshAsync(alarm, unreachable: true, ct).ConfigureAwait(false);
+ }
+ }
+ }
+
+ private async Task RelayToTeamChatSafeAsync(SmartDeviceTriggeredEvent evt, string name, CancellationToken ct)
+ {
+ try
+ {
+ string culture;
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var workspace = scope.ServiceProvider.GetRequiredService();
+ culture = await workspace.GetCultureAsync(evt.GuildId, ct).ConfigureAwait(false);
+ }
+
+ var line = localizer.Get("alarm.triggered.teamchat", culture, name);
+ _ = await teamChatSender.SendAsync(evt.GuildId, evt.ServerId, line, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw; // Shutdown — let the loop unwind.
+ }
+#pragma warning disable CA1031 // Broad catch: a team-chat relay failure must not block the embed/ping path.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogRelayFailed(logger, ex, evt.GuildId, evt.ServerId);
+ }
+ }
+
+ [LoggerMessage(Level = LogLevel.Warning,
+ Message = "Relaying alarm trigger to team chat for guild {GuildId} server {ServerId} failed; swallowing.")]
+ private static partial void LogRelayFailed(ILogger logger, Exception exception, ulong guildId, Guid serverId);
+}
diff --git a/src/RustPlusBot.Features.Alarms/Relaying/IAlarmRefresher.cs b/src/RustPlusBot.Features.Alarms/Relaying/IAlarmRefresher.cs
new file mode 100644
index 0000000..b50cf78
--- /dev/null
+++ b/src/RustPlusBot.Features.Alarms/Relaying/IAlarmRefresher.cs
@@ -0,0 +1,26 @@
+using RustPlusBot.Domain.Alarms;
+
+namespace RustPlusBot.Features.Alarms.Relaying;
+
+/// Re-renders a single alarm's embed on demand (prime, reconnect, or trigger).
+public interface IAlarmRefresher
+{
+ /// Loads the alarm, renders it, and posts or edits its embed.
+ /// The owning Discord guild snowflake.
+ /// The Rust server id.
+ /// The in-game smart-alarm entity id.
+ /// When true the alarm entity is currently unreachable.
+ /// A cancellation token.
+ /// A task that completes when the embed has been refreshed (or no-op if alarm/channel absent).
+ Task RefreshAsync(ulong guildId, Guid serverId, ulong entityId, bool unreachable, CancellationToken ct);
+
+ ///
+ /// Renders an already-loaded alarm and posts or edits its embed, skipping the store re-fetch. Use when the
+ /// caller already holds the alarm (e.g. a batch reconnect refresh) to avoid an extra per-alarm query.
+ ///
+ /// The alarm to render.
+ /// When true the alarm entity is currently unreachable.
+ /// A cancellation token.
+ /// A task that completes when the embed has been refreshed (or no-op if the channel is absent).
+ Task RefreshAsync(SmartAlarm alarm, bool unreachable, CancellationToken ct);
+}
diff --git a/src/RustPlusBot.Features.Alarms/Rendering/AlarmComponentIds.cs b/src/RustPlusBot.Features.Alarms/Rendering/AlarmComponentIds.cs
new file mode 100644
index 0000000..7246a64
--- /dev/null
+++ b/src/RustPlusBot.Features.Alarms/Rendering/AlarmComponentIds.cs
@@ -0,0 +1,26 @@
+namespace RustPlusBot.Features.Alarms.Rendering;
+
+/// Custom ids for alarm components. Tails encode "{serverId}:{entityId}".
+internal static class AlarmComponentIds
+{
+ /// Pairing-prompt Accept button; tail "{serverId}:{entityId}".
+ public const string AcceptPrefix = "alarm:accept:";
+
+ /// Pairing-prompt Dismiss button; tail "{serverId}:{entityId}".
+ public const string DismissPrefix = "alarm:dismiss:";
+
+ /// Ping @everyone toggle button; tail "{serverId}:{entityId}".
+ public const string PingTogglePrefix = "alarm:ping:";
+
+ /// Relay to team chat toggle button; tail "{serverId}:{entityId}".
+ public const string RelayTogglePrefix = "alarm:relay:";
+
+ /// Rename button (opens the modal); tail "{serverId}:{entityId}".
+ public const string RenamePrefix = "alarm:rename:";
+
+ /// Rename modal id; tail "{serverId}:{entityId}".
+ public const string RenameModalPrefix = "alarm:rename:modal:";
+
+ /// The rename modal's text input id.
+ public const string RenameInputId = "alarm:rename:input";
+}
diff --git a/src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs b/src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs
new file mode 100644
index 0000000..539202a
--- /dev/null
+++ b/src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs
@@ -0,0 +1,111 @@
+using System.Globalization;
+using Discord;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Domain.Alarms;
+
+namespace RustPlusBot.Features.Alarms.Rendering;
+
+/// Renders a Smart Alarm as a Discord embed + control row, and the pairing-prompt embed + row. Pure.
+/// The alarm localizer.
+/// The clock used to compute relative trigger times.
+internal sealed class AlarmEmbedRenderer(IAlarmLocalizer localizer, IClock clock)
+{
+ /// Renders the alarm embed and its control buttons.
+ /// The alarm to render.
+ /// When true the alarm entity is currently unreachable.
+ /// The guild culture (BCP-47 primary tag).
+ /// The embed and the component rows.
+ public (Embed Embed, MessageComponent Components) RenderAlarm(SmartAlarm alarm, bool unreachable, string culture)
+ {
+ ArgumentNullException.ThrowIfNull(alarm);
+
+ string statusKey;
+ if (unreachable)
+ {
+ statusKey = "alarm.status.unreachable";
+ }
+ else if (alarm.LastIsActive)
+ {
+ statusKey = "alarm.status.active";
+ }
+ else
+ {
+ statusKey = "alarm.status.armed";
+ }
+
+ var triggered = alarm.LastTriggeredUtc is { } t
+ ? localizer.Get("alarm.embed.lasttriggered", culture, CompactDuration(clock.UtcNow - t))
+ : localizer.Get("alarm.embed.nevertriggered", culture);
+
+ var embed = new EmbedBuilder()
+ .WithTitle(alarm.Name)
+ .WithDescription($"{localizer.Get(statusKey, culture)}\n{triggered}")
+ .WithFooter(localizer.Get("alarm.embed.footer", culture, alarm.EntityId))
+ .Build();
+
+ var tail = $"{alarm.ServerId}:{alarm.EntityId}";
+ var pingKey = alarm.PingEveryone ? "alarm.button.ping.on" : "alarm.button.ping.off";
+ var relayKey = alarm.RelayToTeamChat ? "alarm.button.relay.on" : "alarm.button.relay.off";
+ var components = new ComponentBuilder()
+ .WithButton(localizer.Get(pingKey, culture), AlarmComponentIds.PingTogglePrefix + tail,
+ alarm.PingEveryone ? ButtonStyle.Success : ButtonStyle.Secondary, disabled: unreachable)
+ .WithButton(localizer.Get(relayKey, culture), AlarmComponentIds.RelayTogglePrefix + tail,
+ alarm.RelayToTeamChat ? ButtonStyle.Success : ButtonStyle.Secondary, disabled: unreachable)
+ .WithButton(localizer.Get("alarm.button.rename", culture), AlarmComponentIds.RenamePrefix + tail,
+ ButtonStyle.Secondary, disabled: unreachable)
+ .Build();
+
+ return (embed, components);
+ }
+
+ /// Renders the transient "New alarm detected — Add it?" prompt.
+ /// The server id.
+ /// The entity id.
+ /// The generated default name.
+ /// The guild culture (BCP-47 primary tag).
+ /// The prompt embed and Accept/Dismiss row.
+ public (Embed Embed, MessageComponent Components) RenderPrompt(
+ Guid serverId,
+ ulong entityId,
+ string defaultName,
+ string culture)
+ {
+ var embed = new EmbedBuilder()
+ .WithTitle(localizer.Get("alarm.prompt.title", culture))
+ .WithDescription(localizer.Get("alarm.prompt.body", culture, defaultName))
+ .Build();
+
+ var tail = $"{serverId}:{entityId}";
+ var components = new ComponentBuilder()
+ .WithButton(localizer.Get("alarm.prompt.accept", culture), AlarmComponentIds.AcceptPrefix + tail,
+ ButtonStyle.Success)
+ .WithButton(localizer.Get("alarm.prompt.dismiss", culture), AlarmComponentIds.DismissPrefix + tail,
+ ButtonStyle.Secondary)
+ .Build();
+
+ return (embed, components);
+ }
+
+ /// Formats a duration compactly: "5m", "2h 10m", "3d 4h", "<1m".
+ /// The duration to format.
+ /// A compact human-readable duration string.
+ private static string CompactDuration(TimeSpan span)
+ {
+ if (span.TotalDays >= 1)
+ {
+ return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalDays}d {span.Hours}h");
+ }
+
+ if (span.TotalHours >= 1)
+ {
+ return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalHours}h {span.Minutes}m");
+ }
+
+ if (span.TotalMinutes >= 1)
+ {
+ return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalMinutes}m");
+ }
+
+ return "<1m";
+ }
+}
diff --git a/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizationCatalog.cs b/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizationCatalog.cs
new file mode 100644
index 0000000..d13abaf
--- /dev/null
+++ b/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizationCatalog.cs
@@ -0,0 +1,60 @@
+namespace RustPlusBot.Features.Alarms.Rendering;
+
+///
+/// The in-memory string catalog for Smart Alarms: culture → (key → value).
+/// English is the fallback. Intended to be passed to the per-slice
+/// constructor.
+///
+internal static class AlarmLocalizationCatalog
+{
+ ///
+ /// The built-in EN/FR catalog keyed by BCP-47 primary language tag.
+ /// Shape: culture → key → localized string.
+ ///
+ public static IReadOnlyDictionary> Default { get; } =
+ new Dictionary>(StringComparer.Ordinal)
+ {
+ ["en"] = new Dictionary(StringComparer.Ordinal)
+ {
+ ["alarm.status.armed"] = "🔔 Armed",
+ ["alarm.status.active"] = "🚨 Active",
+ ["alarm.status.unreachable"] = "⚠️ Unreachable",
+ ["alarm.button.ping.on"] = "Ping @everyone: on",
+ ["alarm.button.ping.off"] = "Ping @everyone: off",
+ ["alarm.button.relay.on"] = "Relay to team chat: on",
+ ["alarm.button.relay.off"] = "Relay to team chat: off",
+ ["alarm.button.rename"] = "Rename",
+ ["alarm.embed.footer"] = "Entity {0}",
+ ["alarm.embed.nevertriggered"] = "Never triggered",
+ ["alarm.embed.lasttriggered"] = "Last triggered {0} ago",
+ ["alarm.prompt.title"] = "New alarm detected",
+ ["alarm.prompt.body"] = "Detected a new Smart Alarm ({0}). Add it?",
+ ["alarm.prompt.accept"] = "Accept",
+ ["alarm.prompt.dismiss"] = "Dismiss",
+ ["alarm.rename.modal.title"] = "Rename alarm",
+ ["alarm.rename.input.label"] = "Alarm name",
+ ["alarm.triggered.teamchat"] = "🚨 {0} triggered",
+ },
+ ["fr"] = new Dictionary(StringComparer.Ordinal)
+ {
+ ["alarm.status.armed"] = "🔔 Armée",
+ ["alarm.status.active"] = "🚨 Active",
+ ["alarm.status.unreachable"] = "⚠️ Injoignable",
+ ["alarm.button.ping.on"] = "Ping @everyone : activé",
+ ["alarm.button.ping.off"] = "Ping @everyone : désactivé",
+ ["alarm.button.relay.on"] = "Relais tchat équipe : activé",
+ ["alarm.button.relay.off"] = "Relais tchat équipe : désactivé",
+ ["alarm.button.rename"] = "Renommer",
+ ["alarm.embed.footer"] = "Entité {0}",
+ ["alarm.embed.nevertriggered"] = "Jamais déclenchée",
+ ["alarm.embed.lasttriggered"] = "Déclenchée il y a {0}",
+ ["alarm.prompt.title"] = "Nouvelle alarme détectée",
+ ["alarm.prompt.body"] = "Nouvelle alarme connectée détectée ({0}). L'ajouter ?",
+ ["alarm.prompt.accept"] = "Accepter",
+ ["alarm.prompt.dismiss"] = "Ignorer",
+ ["alarm.rename.modal.title"] = "Renommer l'alarme",
+ ["alarm.rename.input.label"] = "Nom de l'alarme",
+ ["alarm.triggered.teamchat"] = "🚨 {0} déclenchée",
+ },
+ };
+}
diff --git a/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs b/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs
new file mode 100644
index 0000000..aa0d448
--- /dev/null
+++ b/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs
@@ -0,0 +1,62 @@
+using System.Globalization;
+
+namespace RustPlusBot.Features.Alarms.Rendering;
+
+/// Dictionary-backed localizer for Smart Alarms with English fallback and region normalization.
+/// Mirrors the SwitchLocalizer pattern; a future refactor may hoist a shared implementation.
+/// The culture → (key → value) catalog.
+internal sealed class AlarmLocalizer(IReadOnlyDictionary> catalog)
+ : IAlarmLocalizer
+{
+ private const string FallbackCulture = "en";
+
+ ///
+ public string Get(string key, string culture)
+ {
+ var normalized = Normalize(culture);
+ if (catalog.TryGetValue(normalized, out var map) && map.TryGetValue(key, out var value))
+ {
+ return value;
+ }
+
+ if (catalog.TryGetValue(FallbackCulture, out var fallback) &&
+ fallback.TryGetValue(key, out var fallbackValue))
+ {
+ return fallbackValue;
+ }
+
+ return key;
+ }
+
+ ///
+ public string Get(string key, string culture, params object[] args)
+ {
+ var format = Get(key, culture);
+ var provider = ResolveFormatProvider(Normalize(culture));
+ return string.Format(provider, format, args);
+ }
+
+ private static string Normalize(string culture)
+ {
+ if (string.IsNullOrWhiteSpace(culture))
+ {
+ return FallbackCulture;
+ }
+
+ var dash = culture.IndexOf('-', StringComparison.Ordinal);
+ var primary = dash >= 0 ? culture[..dash] : culture;
+ return primary.ToLowerInvariant();
+ }
+
+ private static CultureInfo ResolveFormatProvider(string culture)
+ {
+ try
+ {
+ return CultureInfo.GetCultureInfo(culture);
+ }
+ catch (CultureNotFoundException)
+ {
+ return CultureInfo.InvariantCulture;
+ }
+ }
+}
diff --git a/src/RustPlusBot.Features.Alarms/Rendering/IAlarmLocalizer.cs b/src/RustPlusBot.Features.Alarms/Rendering/IAlarmLocalizer.cs
new file mode 100644
index 0000000..7112668
--- /dev/null
+++ b/src/RustPlusBot.Features.Alarms/Rendering/IAlarmLocalizer.cs
@@ -0,0 +1,16 @@
+namespace RustPlusBot.Features.Alarms.Rendering;
+
+/// Resolves localized smart-alarm strings by key and culture, falling back to English.
+internal interface IAlarmLocalizer
+{
+ /// Gets the localized string for a key, or the key itself if not found.
+ /// The string key.
+ /// The BCP-47 culture tag (e.g. "en", "fr").
+ string Get(string key, string culture);
+
+ /// Gets the localized, format-applied string.
+ /// The string key.
+ /// The BCP-47 culture tag.
+ /// Format arguments.
+ string Get(string key, string culture, params object[] args);
+}
diff --git a/src/RustPlusBot.Features.Alarms/RustPlusBot.Features.Alarms.csproj b/src/RustPlusBot.Features.Alarms/RustPlusBot.Features.Alarms.csproj
new file mode 100644
index 0000000..2c5d63f
--- /dev/null
+++ b/src/RustPlusBot.Features.Alarms/RustPlusBot.Features.Alarms.csproj
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
index f85beae..e9e46be 100644
--- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
+++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
@@ -12,6 +12,7 @@
using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Persistence.Connections;
using RustPlusBot.Persistence.Servers;
+using RustPlusBot.Persistence.Alarms;
using RustPlusBot.Persistence.Switches;
namespace RustPlusBot.Features.Connections.Supervisor;
@@ -827,24 +828,57 @@ private async Task PrimeDevicesAsync(
return;
}
-#pragma warning disable S3267 // Not a projection: each iteration awaits with per-switch best-effort error handling.
- foreach (var sw in switches)
+ await PrimeEntityIdsAsync(key, connection,
+ (IReadOnlyList)switches.Select(sw => sw.EntityId).ToList()).ConfigureAwait(false);
+
+ IReadOnlyList alarms;
+ try
+ {
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ alarms = await store.ListByServerAsync(key.Guild, key.Server, ct).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+#pragma warning disable CA1031 // Broad catch: a failed alarm-list read just skips alarm priming for this connection.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogDeviceListFailed(logger, ex, key.Server);
+ return;
+ }
+
+ await PrimeEntityIdsAsync(key, connection,
+ (IReadOnlyList)alarms.Select(a => a.EntityId).ToList()).ConfigureAwait(false);
+ }
+
+ private async Task PrimeEntityIdsAsync(
+ (ulong Guild, Guid Server) key,
+ IRustServerConnection connection,
+ IReadOnlyList entityIds)
+ {
+#pragma warning disable S3267 // Not a projection: each iteration awaits with per-entity best-effort error handling.
+ foreach (var entityId in entityIds)
#pragma warning restore S3267
{
- // Best-effort per switch: one failure must not crash the connected loop or block the heartbeat.
try
{
- await PublishDevicePrimeAsync(key, connection, sw.EntityId).ConfigureAwait(false);
+ await PublishDevicePrimeAsync(key, connection, entityId).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
-#pragma warning disable CA1031 // Broad catch: a single switch's prime failure is logged and skipped.
+#pragma warning disable CA1031 // Broad catch: a single entity's prime failure is logged and skipped.
catch (Exception ex)
#pragma warning restore CA1031
{
- LogDevicePrimeFailed(logger, ex, sw.EntityId, key.Server);
+ LogDevicePrimeFailed(logger, ex, entityId, key.Server);
}
}
}
diff --git a/src/RustPlusBot.Features.Pairing/Listening/PairingNotification.cs b/src/RustPlusBot.Features.Pairing/Listening/PairingNotification.cs
index fca4029..9375854 100644
--- a/src/RustPlusBot.Features.Pairing/Listening/PairingNotification.cs
+++ b/src/RustPlusBot.Features.Pairing/Listening/PairingNotification.cs
@@ -32,6 +32,7 @@ internal enum PairingConnectOutcome
/// The Rust+ player token for this (server, player).
/// The Facepunch server GUID this pairing is associated with.
/// The in-game entity id for entity pairings; 0 for server pairings.
+/// The kind of entity being paired (SmartSwitch or SmartAlarm); defaults to SmartSwitch.
internal sealed record PairingNotification(
PairingKind Kind,
string ServerName,
@@ -40,4 +41,5 @@ internal sealed record PairingNotification(
ulong PlayerId,
string PlayerToken,
Guid FacepunchServerId = default,
- ulong EntityId = 0UL);
+ ulong EntityId = 0UL,
+ RustPlusBot.Domain.Entities.PairedEntityKind EntityKind = RustPlusBot.Domain.Entities.PairedEntityKind.SmartSwitch);
diff --git a/src/RustPlusBot.Features.Pairing/Listening/RustPlusFcmPairingSource.cs b/src/RustPlusBot.Features.Pairing/Listening/RustPlusFcmPairingSource.cs
index 39ad04c..f91f7f0 100644
--- a/src/RustPlusBot.Features.Pairing/Listening/RustPlusFcmPairingSource.cs
+++ b/src/RustPlusBot.Features.Pairing/Listening/RustPlusFcmPairingSource.cs
@@ -69,6 +69,7 @@ public RustPlusFcmListener(
_fcm = new RustPlusFcm(credentials, persistentIds: null, options: null, loggerFactory: null);
_fcm.OnServerPairing += OnServerPairing;
_fcm.OnSmartSwitchPairing += OnSmartSwitchPairing;
+ _fcm.OnSmartAlarmPairing += OnSmartAlarmPairing;
}
///
@@ -101,6 +102,7 @@ public async ValueTask DisposeAsync()
{
_fcm.OnServerPairing -= OnServerPairing;
_fcm.OnSmartSwitchPairing -= OnSmartSwitchPairing;
+ _fcm.OnSmartAlarmPairing -= OnSmartAlarmPairing;
await _fcm.DisposeAsync().ConfigureAwait(false);
}
@@ -150,6 +152,23 @@ private void OnSmartSwitchPairing(object? sender, Notification e)
Dispatch(notification);
}
+ private void OnSmartAlarmPairing(object? sender, Notification e)
+ {
+ if (e?.Data is not { } entityId)
+ {
+ return;
+ }
+
+ Dispatch(new PairingNotification(
+ Kind: PairingKind.Entity,
+ ServerName: string.Empty, Ip: string.Empty, Port: 0,
+ PlayerId: e.PlayerId,
+ PlayerToken: e.PlayerToken.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ FacepunchServerId: e.ServerId,
+ EntityId: entityId,
+ EntityKind: RustPlusBot.Domain.Entities.PairedEntityKind.SmartAlarm));
+ }
+
private void Dispatch(PairingNotification notification)
{
// Fire-and-forget bridge from synchronous event to async callback.
diff --git a/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs b/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs
index 346a145..16455ad 100644
--- a/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs
+++ b/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs
@@ -71,12 +71,29 @@ private async Task HandleEntityAsync(
return;
}
- await eventBus.PublishAsync(
- new SwitchPairedEvent(guildId, server.Id, notification.EntityId), cancellationToken)
- .ConfigureAwait(false);
+ switch (notification.EntityKind)
+ {
+ case RustPlusBot.Domain.Entities.PairedEntityKind.SmartSwitch:
+ await eventBus
+ .PublishAsync(new SwitchPairedEvent(guildId, server.Id, notification.EntityId), cancellationToken)
+ .ConfigureAwait(false);
+ break;
+ case RustPlusBot.Domain.Entities.PairedEntityKind.SmartAlarm:
+ await eventBus
+ .PublishAsync(new AlarmPairedEvent(guildId, server.Id, notification.EntityId), cancellationToken)
+ .ConfigureAwait(false);
+ break;
+ default:
+ LogUnroutedEntityKind(logger, notification.EntityKind);
+ break;
+ }
}
[LoggerMessage(Level = LogLevel.Debug,
Message = "Dropping entity pairing for unknown Facepunch server {FacepunchServerId} (no matching server).")]
private static partial void LogUnknownEntityServer(ILogger logger, Guid facepunchServerId);
+
+ [LoggerMessage(Level = LogLevel.Debug, Message = "Dropping entity pairing of unrouted kind {Kind}.")]
+ private static partial void
+ LogUnroutedEntityKind(ILogger logger, RustPlusBot.Domain.Entities.PairedEntityKind kind);
}
diff --git a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs
index 631e0f6..6dcb48f 100644
--- a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs
+++ b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs
@@ -22,6 +22,7 @@ internal sealed class LocalizationCatalog
["channel.events.name"] = "events",
["channel.map.name"] = "map",
["channel.switches.name"] = "switches",
+ ["channel.alarms.name"] = "alarms",
["information.title"] = "RustPlusBot",
["information.body"] = "Connect your Rust+ account in #setup, then pair a server in-game to begin.",
["information.servers"] = "Servers registered: {0}",
@@ -65,6 +66,7 @@ internal sealed class LocalizationCatalog
["channel.events.name"] = "evenements",
["channel.map.name"] = "carte",
["channel.switches.name"] = "interrupteurs",
+ ["channel.alarms.name"] = "alarmes",
["information.title"] = "RustPlusBot",
["information.body"] =
"Connectez votre compte Rust+ dans #configuration, puis appairez un serveur en jeu.",
diff --git a/src/RustPlusBot.Features.Workspace/Locating/AlarmChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/AlarmChannelLocator.cs
new file mode 100644
index 0000000..a2aa335
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Locating/AlarmChannelLocator.cs
@@ -0,0 +1,75 @@
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Persistence.Workspace;
+
+namespace RustPlusBot.Features.Workspace.Locating;
+
+///
+/// Caches the small set of provisioned #alarms channels (rebuilt when the cache goes stale) and resolves
+/// (guild, server) → channel id for posting/editing alarm embeds.
+///
+/// Opens scopes for the scoped workspace store.
+/// Drives the cache TTL.
+internal sealed class AlarmChannelLocator(IServiceScopeFactory scopeFactory, IClock clock)
+ : IAlarmChannelLocator, IDisposable
+{
+ private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
+ private readonly SemaphoreSlim _refreshGate = new(1, 1);
+
+ private DateTimeOffset _builtAt = DateTimeOffset.MinValue;
+
+ private Dictionary<(ulong GuildId, Guid ServerId), ulong> _byServer = new();
+
+ ///
+ public async Task GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken)
+ {
+ await EnsureFreshAsync(cancellationToken).ConfigureAwait(false);
+ return _byServer.TryGetValue((guildId, serverId), out var id) ? id : null;
+ }
+
+ ///
+ public void Dispose() => _refreshGate.Dispose();
+
+ private async Task EnsureFreshAsync(CancellationToken cancellationToken)
+ {
+ if (clock.UtcNow - _builtAt < CacheTtl)
+ {
+ return;
+ }
+
+ await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (clock.UtcNow - _builtAt < CacheTtl)
+ {
+ return;
+ }
+
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ var rows = await store.GetChannelsByKeyAsync(WorkspaceChannelKeys.ServerAlarms, cancellationToken)
+ .ConfigureAwait(false);
+
+ var byServer = new Dictionary<(ulong GuildId, Guid ServerId), ulong>();
+ foreach (var row in rows)
+ {
+ if (row.RustServerId is not { } serverId)
+ {
+ continue;
+ }
+
+ byServer[(row.GuildId, serverId)] = row.DiscordChannelId;
+ }
+
+ _byServer = byServer;
+ _builtAt = clock.UtcNow;
+ }
+ }
+ finally
+ {
+ _refreshGate.Release();
+ }
+ }
+}
diff --git a/src/RustPlusBot.Features.Workspace/Locating/IAlarmChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/IAlarmChannelLocator.cs
new file mode 100644
index 0000000..427f048
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Locating/IAlarmChannelLocator.cs
@@ -0,0 +1,12 @@
+namespace RustPlusBot.Features.Workspace.Locating;
+
+/// Resolves the per-server #alarms channel (used to post/edit alarm embeds).
+public interface IAlarmChannelLocator
+{
+ /// Gets the Discord channel id of #alarms for (, ), or null.
+ /// The guild snowflake.
+ /// The server id.
+ /// A cancellation token.
+ /// The Discord channel id, or null if not provisioned.
+ Task GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);
+}
diff --git a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs
index 723b04f..fb0f980 100644
--- a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs
+++ b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs
@@ -18,6 +18,8 @@ public IEnumerable GetChannelSpecs() =>
ChannelPermissionProfile.ReadOnly, 3),
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerSwitches, "channel.switches.name",
ChannelPermissionProfile.Interactive, 4),
+ new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerAlarms, "channel.alarms.name",
+ ChannelPermissionProfile.Interactive, 5),
];
///
diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs
index bd78212..ef75abd 100644
--- a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs
+++ b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs
@@ -26,6 +26,9 @@ internal static class WorkspaceChannelKeys
/// Key for the per-server #switches channel.
public const string ServerSwitches = "switches";
+
+ /// Key for the per-server #alarms channel.
+ public const string ServerAlarms = "alarms";
}
/// Stable message keys persisted as ProvisionedMessage.MessageKey.
diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs
index f444261..352a946 100644
--- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs
@@ -63,6 +63,7 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services)
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddHostedService();
diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs
index 63bddd9..9121ce3 100644
--- a/src/RustPlusBot.Host/Program.cs
+++ b/src/RustPlusBot.Host/Program.cs
@@ -11,6 +11,7 @@
using RustPlusBot.Features.Connections;
using RustPlusBot.Features.Events;
using RustPlusBot.Features.Map;
+using RustPlusBot.Features.Alarms;
using RustPlusBot.Features.Switches;
using RustPlusBot.Features.Players;
using RustPlusBot.Features.Pairing;
@@ -77,6 +78,7 @@
.ValidateOnStart();
builder.Services.AddMap();
builder.Services.AddSwitches();
+builder.Services.AddAlarms();
var host = builder.Build();
diff --git a/src/RustPlusBot.Host/RustPlusBot.Host.csproj b/src/RustPlusBot.Host/RustPlusBot.Host.csproj
index 14df6e2..1b9cd69 100644
--- a/src/RustPlusBot.Host/RustPlusBot.Host.csproj
+++ b/src/RustPlusBot.Host/RustPlusBot.Host.csproj
@@ -25,6 +25,7 @@
+
diff --git a/src/RustPlusBot.Persistence/Alarms/AlarmStore.cs b/src/RustPlusBot.Persistence/Alarms/AlarmStore.cs
new file mode 100644
index 0000000..ed6995d
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Alarms/AlarmStore.cs
@@ -0,0 +1,172 @@
+using Microsoft.EntityFrameworkCore;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Domain.Alarms;
+
+namespace RustPlusBot.Persistence.Alarms;
+
+/// EF-backed .
+/// The bot database context.
+/// Supplies the creation timestamp.
+public sealed class AlarmStore(BotDbContext context, IClock clock) : IAlarmStore
+{
+ ///
+ public async Task AddAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ string name,
+ ulong pairedByUserId,
+ CancellationToken ct = default)
+ {
+ var entity = new SmartAlarm
+ {
+ GuildId = guildId,
+ ServerId = serverId,
+ EntityId = entityId,
+ Name = name,
+ PairedByUserId = pairedByUserId,
+ CreatedUtc = clock.UtcNow,
+ };
+ context.SmartAlarms.Add(entity);
+ try
+ {
+ await context.SaveChangesAsync(ct).ConfigureAwait(false);
+ return entity;
+ }
+ catch (DbUpdateException)
+ {
+ // Two users accepted the same pending pairing concurrently (both saw ExistsAsync == false); the
+ // unique (GuildId, ServerId, EntityId) index rejects the second insert. Recover idempotently by
+ // detaching the failed insert and returning the row the winner persisted. If no such row exists,
+ // the failure was not the uniqueness race — let it propagate.
+ context.Entry(entity).State = EntityState.Detached;
+ var existing = await GetAsync(guildId, serverId, entityId, ct).ConfigureAwait(false);
+ if (existing is null)
+ {
+ throw;
+ }
+
+ return existing;
+ }
+ }
+
+ ///
+ public Task GetAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ CancellationToken ct = default) =>
+ context.SmartAlarms.SingleOrDefaultAsync(
+ a => a.GuildId == guildId && a.ServerId == serverId && a.EntityId == entityId, ct);
+
+ ///
+ public async Task> ListByServerAsync(
+ ulong guildId,
+ Guid serverId,
+ CancellationToken ct = default)
+ {
+ // SQLite cannot ORDER BY a DateTimeOffset column, so order oldest-first on the client side.
+ var alarms = await context.SmartAlarms
+ .Where(a => a.GuildId == guildId && a.ServerId == serverId)
+ .ToListAsync(ct)
+ .ConfigureAwait(false);
+
+ return alarms.OrderBy(a => a.CreatedUtc).ToList();
+ }
+
+ ///
+ public Task ExistsAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ CancellationToken ct = default) =>
+ context.SmartAlarms.AnyAsync(
+ a => a.GuildId == guildId && a.ServerId == serverId && a.EntityId == entityId, ct);
+
+ ///
+ public Task RenameAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ string name,
+ CancellationToken ct = default) =>
+ MutateAsync(guildId, serverId, entityId, a => a.Name = name, ct);
+
+ ///
+ public Task SetMessageIdAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ ulong messageId,
+ CancellationToken ct = default) =>
+ MutateAsync(guildId, serverId, entityId, a => a.MessageId = messageId, ct);
+
+ ///
+ public Task SetPingEveryoneAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ bool value,
+ CancellationToken ct = default) =>
+ MutateAsync(guildId, serverId, entityId, a => a.PingEveryone = value, ct);
+
+ ///
+ public Task SetRelayToTeamChatAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ bool value,
+ CancellationToken ct = default) =>
+ MutateAsync(guildId, serverId, entityId, a => a.RelayToTeamChat = value, ct);
+
+ ///
+ public Task UpdateStateAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ bool isActive,
+ DateTimeOffset? triggeredUtc,
+ CancellationToken ct = default) =>
+ MutateAsync(guildId, serverId, entityId, a =>
+ {
+ a.LastIsActive = isActive;
+ if (triggeredUtc is { } t)
+ {
+ a.LastTriggeredUtc = t;
+ }
+ }, ct);
+
+ ///
+ public async Task RemoveAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ CancellationToken ct = default)
+ {
+ var entity = await GetAsync(guildId, serverId, entityId, ct).ConfigureAwait(false);
+ if (entity is null)
+ {
+ return;
+ }
+
+ context.SmartAlarms.Remove(entity);
+ await context.SaveChangesAsync(ct).ConfigureAwait(false);
+ }
+
+ private async Task MutateAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ Action mutate,
+ CancellationToken ct)
+ {
+ var entity = await GetAsync(guildId, serverId, entityId, ct).ConfigureAwait(false);
+ if (entity is null)
+ {
+ return;
+ }
+
+ mutate(entity);
+ await context.SaveChangesAsync(ct).ConfigureAwait(false);
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Alarms/IAlarmStore.cs b/src/RustPlusBot.Persistence/Alarms/IAlarmStore.cs
new file mode 100644
index 0000000..8419d5d
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Alarms/IAlarmStore.cs
@@ -0,0 +1,141 @@
+using RustPlusBot.Domain.Alarms;
+
+namespace RustPlusBot.Persistence.Alarms;
+
+/// Persists managed Smart Alarms (accepted pairings only; pending pairings stay in-memory).
+public interface IAlarmStore
+{
+ /// Adds a managed alarm and returns the persisted row.
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// The in-game smart-alarm entity id.
+ /// The display name.
+ /// The user who accepted the pairing.
+ /// A cancellation token.
+ /// The persisted alarm.
+ Task AddAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ string name,
+ ulong pairedByUserId,
+ CancellationToken ct = default);
+
+ /// Gets an alarm by identity, or null.
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// The in-game smart-alarm entity id.
+ /// A cancellation token.
+ /// The alarm, or null.
+ Task GetAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ CancellationToken ct = default);
+
+ /// Lists every managed alarm for a server, oldest first.
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// A cancellation token.
+ /// The managed alarms for the server, ordered by creation time ascending.
+ Task> ListByServerAsync(
+ ulong guildId,
+ Guid serverId,
+ CancellationToken ct = default);
+
+ /// True when a managed alarm with this identity exists.
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// The in-game smart-alarm entity id.
+ /// A cancellation token.
+ /// True if a matching alarm exists.
+ Task ExistsAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ CancellationToken ct = default);
+
+ /// Renames an alarm (no-op if absent).
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// The in-game smart-alarm entity id.
+ /// The new display name.
+ /// A cancellation token.
+ /// A task that completes when the rename has been persisted.
+ Task RenameAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ string name,
+ CancellationToken ct = default);
+
+ /// Sets the embed message id (no-op if absent).
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// The in-game smart-alarm entity id.
+ /// The Discord embed message id.
+ /// A cancellation token.
+ /// A task that completes when the message id has been persisted.
+ Task SetMessageIdAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ ulong messageId,
+ CancellationToken ct = default);
+
+ /// Sets whether a trigger going active pings @everyone in the #alarms channel (no-op if absent).
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// The in-game smart-alarm entity id.
+ /// True to ping @everyone; false to suppress.
+ /// A cancellation token.
+ /// A task that completes when the flag has been persisted.
+ Task SetPingEveryoneAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ bool value,
+ CancellationToken ct = default);
+
+ /// Sets whether a trigger going active relays the message into in-game team chat (no-op if absent).
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// The in-game smart-alarm entity id.
+ /// True to relay; false to suppress.
+ /// A cancellation token.
+ /// A task that completes when the flag has been persisted.
+ Task SetRelayToTeamChatAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ bool value,
+ CancellationToken ct = default);
+
+ /// Updates the alarm's on/off state; stamps the last-triggered time only when going active (no-op if absent).
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// The in-game smart-alarm entity id.
+ /// The new on/off state.
+ /// When it went active (UTC); pass non-null only on the active edge — when null, the existing last-triggered time is kept.
+ /// A cancellation token.
+ /// A task that completes when the state has been persisted.
+ Task UpdateStateAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ bool isActive,
+ DateTimeOffset? triggeredUtc,
+ CancellationToken ct = default);
+
+ /// Removes an alarm (no-op if absent).
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// The in-game smart-alarm entity id.
+ /// A cancellation token.
+ /// A task that completes when the alarm has been removed.
+ Task RemoveAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ CancellationToken ct = default);
+}
diff --git a/src/RustPlusBot.Persistence/BotDbContext.cs b/src/RustPlusBot.Persistence/BotDbContext.cs
index 7bb1aa7..e9a30a8 100644
--- a/src/RustPlusBot.Persistence/BotDbContext.cs
+++ b/src/RustPlusBot.Persistence/BotDbContext.cs
@@ -7,6 +7,7 @@
using RustPlusBot.Domain.Events;
using RustPlusBot.Domain.Guilds;
using RustPlusBot.Domain.Map;
+using RustPlusBot.Domain.Alarms;
using RustPlusBot.Domain.Servers;
using RustPlusBot.Domain.Switches;
using RustPlusBot.Domain.Workspace;
@@ -48,6 +49,9 @@ public sealed class BotDbContext(DbContextOptions options) : Disco
/// Paired and managed Smart Switches.
public DbSet SmartSwitches => Set();
+ /// Paired and managed Smart Alarms.
+ public DbSet SmartAlarms => Set();
+
/// Per-guild event subscriptions.
public DbSet EventSubscriptions => Set();
@@ -76,6 +80,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.ApplyConfiguration(new GuildSettingsConfiguration())
.ApplyConfiguration(new PairedEntityConfiguration())
.ApplyConfiguration(new SmartSwitchConfiguration())
+ .ApplyConfiguration(new SmartAlarmConfiguration())
.ApplyConfiguration(new EventSubscriptionConfiguration())
.ApplyConfiguration(new ProvisionedCategoryConfiguration())
.ApplyConfiguration(new ProvisionedChannelConfiguration())
diff --git a/src/RustPlusBot.Persistence/Configurations/SmartAlarmConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/SmartAlarmConfiguration.cs
new file mode 100644
index 0000000..4a5c678
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Configurations/SmartAlarmConfiguration.cs
@@ -0,0 +1,26 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using RustPlusBot.Domain.Alarms;
+using RustPlusBot.Domain.Servers;
+
+namespace RustPlusBot.Persistence.Configurations;
+
+internal sealed class SmartAlarmConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ builder.HasKey(a => a.Id);
+ builder.Property(a => a.Name).IsRequired().HasMaxLength(128);
+ builder.HasIndex(a => new
+ {
+ a.GuildId, a.ServerId, a.EntityId
+ }).IsUnique();
+
+ // Removing a RustServer cascades to its alarms so no orphaned rows linger.
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(a => a.ServerId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Migrations/20260622210359_SmartAlarms.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260622210359_SmartAlarms.Designer.cs
new file mode 100644
index 0000000..482e489
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Migrations/20260622210359_SmartAlarms.Designer.cs
@@ -0,0 +1,660 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using RustPlusBot.Persistence;
+
+#nullable disable
+
+namespace RustPlusBot.Persistence.Migrations
+{
+ [DbContext(typeof(BotDbContext))]
+ [Migration("20260622210359_SmartAlarms")]
+ partial class SmartAlarms
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
+
+ modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ParentId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId");
+
+ b.HasIndex("ParentId");
+
+ b.ToTable("Channels");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.GuildEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("OwnerId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("Guilds");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.MemberEntity", b =>
+ {
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("JoinedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Nickname")
+ .HasColumnType("TEXT");
+
+ b.HasKey("GuildId", "UserId");
+
+ b.ToTable("Members");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.RoleEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("Color")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Permissions")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId");
+
+ b.ToTable("Roles");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.UserEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("GlobalName")
+ .HasColumnType("TEXT");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Alarms.SmartAlarm", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("EntityId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastIsActive")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastTriggeredUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("MessageId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("PairedByUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PingEveryone")
+ .HasColumnType("INTEGER");
+
+ b.Property("RelayToTeamChat")
+ .HasColumnType("INTEGER");
+
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ServerId");
+
+ b.HasIndex("GuildId", "ServerId", "EntityId")
+ .IsUnique();
+
+ b.ToTable("SmartAlarms");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b =>
+ {
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Muted")
+ .HasColumnType("INTEGER");
+
+ b.Property("Prefix")
+ .IsRequired()
+ .HasMaxLength(8)
+ .HasColumnType("TEXT");
+
+ b.HasKey("ServerId");
+
+ b.ToTable("ServerCommandSettings");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b =>
+ {
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("ActiveCredentialId")
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PlayerCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("RustServerId");
+
+ b.ToTable("ConnectionStates");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Credentials.FcmRegistration", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("OwnerUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ProtectedFcmCredentials")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId", "OwnerUserId")
+ .IsUnique();
+
+ b.ToTable("FcmRegistrations");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("OwnerUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ProtectedPlayerToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("SteamId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId", "OwnerUserId")
+ .IsUnique();
+
+ b.ToTable("PlayerCredentials");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Entities.PairedEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("EntityId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId", "RustServerId");
+
+ b.ToTable("PairedEntities");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Events.EventSubscription", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("EventKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId", "RustServerId");
+
+ b.ToTable("EventSubscriptions");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Guilds.GuildSettings", b =>
+ {
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Culture")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.HasKey("GuildId");
+
+ b.ToTable("GuildSettings");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Map.ServerMapSettings", b =>
+ {
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowGrid")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowMarkers")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowMonuments")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowPlayers")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowRigs")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowVendor")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ServerId");
+
+ b.ToTable("ServerMapSettings");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Servers.RustServer", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedByUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("FacepunchServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Ip")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("Port")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FacepunchServerId");
+
+ b.HasIndex("GuildId");
+
+ b.HasIndex("GuildId", "Ip", "Port")
+ .IsUnique();
+
+ b.ToTable("RustServers");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("EntityId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastIsActive")
+ .HasColumnType("INTEGER");
+
+ b.Property("MessageId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("PairedByUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ServerId");
+
+ b.HasIndex("GuildId", "ServerId", "EntityId")
+ .IsUnique();
+
+ b.ToTable("SmartSwitches");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DiscordCategoryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId")
+ .IsUnique();
+
+ b.ToTable("ProvisionedCategories");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ChannelKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DiscordChannelId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId", "ChannelKey")
+ .IsUnique();
+
+ b.ToTable("ProvisionedChannels");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DiscordChannelId")
+ .HasColumnType("INTEGER");
+
+ b.Property("DiscordMessageId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("MessageKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId", "MessageKey")
+ .IsUnique();
+
+ b.ToTable("ProvisionedMessages");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b =>
+ {
+ b.HasOne("Persistord.Core.Entities.ChannelEntity", null)
+ .WithMany()
+ .HasForeignKey("ParentId")
+ .OnDelete(DeleteBehavior.Restrict);
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Alarms.SmartAlarm", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithOne()
+ .HasForeignKey("RustPlusBot.Domain.Commands.ServerCommandSettings", "ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithOne()
+ .HasForeignKey("RustPlusBot.Domain.Connections.ConnectionState", "RustServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Map.ServerMapSettings", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithOne()
+ .HasForeignKey("RustPlusBot.Domain.Map.ServerMapSettings", "ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Migrations/20260622210359_SmartAlarms.cs b/src/RustPlusBot.Persistence/Migrations/20260622210359_SmartAlarms.cs
new file mode 100644
index 0000000..fe88975
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Migrations/20260622210359_SmartAlarms.cs
@@ -0,0 +1,61 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace RustPlusBot.Persistence.Migrations
+{
+ ///
+ public partial class SmartAlarms : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "SmartAlarms",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ GuildId = table.Column(type: "INTEGER", nullable: false),
+ ServerId = table.Column(type: "TEXT", nullable: false),
+ EntityId = table.Column(type: "INTEGER", nullable: false),
+ Name = table.Column(type: "TEXT", maxLength: 128, nullable: false),
+ MessageId = table.Column(type: "INTEGER", nullable: true),
+ PairedByUserId = table.Column(type: "INTEGER", nullable: false),
+ CreatedUtc = table.Column(type: "TEXT", nullable: false),
+ PingEveryone = table.Column(type: "INTEGER", nullable: false),
+ RelayToTeamChat = table.Column(type: "INTEGER", nullable: false),
+ LastIsActive = table.Column(type: "INTEGER", nullable: false),
+ LastTriggeredUtc = table.Column(type: "TEXT", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_SmartAlarms", x => x.Id);
+ table.ForeignKey(
+ name: "FK_SmartAlarms_RustServers_ServerId",
+ column: x => x.ServerId,
+ principalTable: "RustServers",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_SmartAlarms_GuildId_ServerId_EntityId",
+ table: "SmartAlarms",
+ columns: new[] { "GuildId", "ServerId", "EntityId" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_SmartAlarms_ServerId",
+ table: "SmartAlarms",
+ column: "ServerId");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "SmartAlarms");
+ }
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs
index 510beff..1afec44 100644
--- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs
+++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs
@@ -122,6 +122,57 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("Users");
});
+ modelBuilder.Entity("RustPlusBot.Domain.Alarms.SmartAlarm", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("EntityId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastIsActive")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastTriggeredUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("MessageId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("PairedByUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PingEveryone")
+ .HasColumnType("INTEGER");
+
+ b.Property("RelayToTeamChat")
+ .HasColumnType("INTEGER");
+
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ServerId");
+
+ b.HasIndex("GuildId", "ServerId", "EntityId")
+ .IsUnique();
+
+ b.ToTable("SmartAlarms");
+ });
+
modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b =>
{
b.Property("ServerId")
@@ -523,6 +574,15 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.OnDelete(DeleteBehavior.Restrict);
});
+ modelBuilder.Entity("RustPlusBot.Domain.Alarms.SmartAlarm", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b =>
{
b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
diff --git a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs
index 98ff335..d8f42c3 100644
--- a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using RustPlusBot.Abstractions.Credentials;
+using RustPlusBot.Persistence.Alarms;
using RustPlusBot.Persistence.Commands;
using RustPlusBot.Persistence.Connections;
using RustPlusBot.Persistence.Credentials;
@@ -37,6 +38,7 @@ public static IServiceCollection AddBotPersistence(this IServiceCollection servi
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/tests/RustPlusBot.Abstractions.Tests/AlarmPairedEventTests.cs b/tests/RustPlusBot.Abstractions.Tests/AlarmPairedEventTests.cs
new file mode 100644
index 0000000..0babe66
--- /dev/null
+++ b/tests/RustPlusBot.Abstractions.Tests/AlarmPairedEventTests.cs
@@ -0,0 +1,14 @@
+using RustPlusBot.Abstractions.Events;
+
+namespace RustPlusBot.Abstractions.Tests;
+
+public sealed class AlarmPairedEventTests
+{
+ [Fact]
+ public void CarriesIdentity()
+ {
+ var e = new AlarmPairedEvent(10UL, Guid.Empty, 42UL);
+ Assert.Equal(10UL, e.GuildId);
+ Assert.Equal(42UL, e.EntityId);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs
new file mode 100644
index 0000000..4fad778
--- /dev/null
+++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs
@@ -0,0 +1,279 @@
+using Discord;
+using NSubstitute;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Domain.Alarms;
+using RustPlusBot.Features.Alarms.Rendering;
+
+namespace RustPlusBot.Features.Alarms.Tests;
+
+public sealed class AlarmEmbedRendererTests
+{
+ private static readonly DateTimeOffset _fixedNow = new(2025, 6, 1, 12, 0, 0, TimeSpan.Zero);
+
+ private static AlarmEmbedRenderer Create(DateTimeOffset? now = null)
+ {
+ var clock = Substitute.For();
+ clock.UtcNow.Returns(now ?? _fixedNow);
+ var localizer = new AlarmLocalizer(AlarmLocalizationCatalog.Default);
+ return new AlarmEmbedRenderer(localizer, clock);
+ }
+
+ private static SmartAlarm Sample(
+ string name = "Fire Alarm",
+ bool pingEveryone = false,
+ bool relayToTeamChat = false,
+ bool lastIsActive = false,
+ DateTimeOffset? lastTriggeredUtc = null) => new()
+ {
+ GuildId = 10UL,
+ ServerId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
+ EntityId = 42UL,
+ Name = name,
+ PingEveryone = pingEveryone,
+ RelayToTeamChat = relayToTeamChat,
+ LastIsActive = lastIsActive,
+ LastTriggeredUtc = lastTriggeredUtc,
+ };
+
+ private static List Buttons(MessageComponent components) =>
+ components.Components.OfType()
+ .SelectMany(r => r.Components)
+ .OfType()
+ .ToList();
+
+ // ── Status ────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void RenderAlarm_lastIsActive_false_shows_armed_status()
+ {
+ var (embed, _) = Create().RenderAlarm(Sample(lastIsActive: false), unreachable: false, "en");
+
+ Assert.Contains("Armed", embed.Description ?? string.Empty, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void RenderAlarm_lastIsActive_true_shows_active_status()
+ {
+ var (embed, _) = Create().RenderAlarm(Sample(lastIsActive: true), unreachable: false, "en");
+
+ Assert.Contains("Active", embed.Description ?? string.Empty, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void RenderAlarm_unreachable_shows_unreachable_status()
+ {
+ var (embed, _) = Create().RenderAlarm(Sample(), unreachable: true, "en");
+
+ Assert.Contains("Unreachable", embed.Description ?? string.Empty, StringComparison.Ordinal);
+ }
+
+ // ── Description / triggered text ──────────────────────────────────────────
+
+ [Fact]
+ public void RenderAlarm_never_triggered_shows_never_triggered_text()
+ {
+ var (embed, _) = Create().RenderAlarm(Sample(lastTriggeredUtc: null), unreachable: false, "en");
+
+ Assert.Contains("Never triggered", embed.Description ?? string.Empty, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void RenderAlarm_triggered_recently_shows_last_triggered_ago()
+ {
+ var triggered = _fixedNow.AddMinutes(-5);
+ var (embed, _) = Create().RenderAlarm(Sample(lastTriggeredUtc: triggered), unreachable: false, "en");
+
+ Assert.Contains("Last triggered", embed.Description ?? string.Empty, StringComparison.Ordinal);
+ Assert.Contains("ago", embed.Description ?? string.Empty, StringComparison.Ordinal);
+ Assert.Contains("5m", embed.Description ?? string.Empty, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void RenderAlarm_triggered_hours_ago_shows_hours_format()
+ {
+ var triggered = _fixedNow.AddHours(-2).AddMinutes(-10);
+ var (embed, _) = Create().RenderAlarm(Sample(lastTriggeredUtc: triggered), unreachable: false, "en");
+
+ Assert.Contains("2h", embed.Description ?? string.Empty, StringComparison.Ordinal);
+ Assert.Contains("10m", embed.Description ?? string.Empty, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void RenderAlarm_triggered_days_ago_shows_days_format()
+ {
+ var triggered = _fixedNow.AddDays(-3);
+ var (embed, _) = Create().RenderAlarm(Sample(lastTriggeredUtc: triggered), unreachable: false, "en");
+
+ Assert.Contains("3d", embed.Description ?? string.Empty, StringComparison.Ordinal);
+ }
+
+ // ── Buttons — ping ────────────────────────────────────────────────────────
+
+ [Fact]
+ public void RenderAlarm_ping_off_button_is_secondary_style()
+ {
+ var (_, components) = Create().RenderAlarm(Sample(pingEveryone: false), unreachable: false, "en");
+ var btn = Buttons(components).Single(b =>
+ b.CustomId!.StartsWith(AlarmComponentIds.PingTogglePrefix, StringComparison.Ordinal));
+
+ Assert.Equal(ButtonStyle.Secondary, btn.Style);
+ Assert.False(btn.IsDisabled);
+ }
+
+ [Fact]
+ public void RenderAlarm_ping_on_button_is_success_style()
+ {
+ var (_, components) = Create().RenderAlarm(Sample(pingEveryone: true), unreachable: false, "en");
+ var btn = Buttons(components).Single(b =>
+ b.CustomId!.StartsWith(AlarmComponentIds.PingTogglePrefix, StringComparison.Ordinal));
+
+ Assert.Equal(ButtonStyle.Success, btn.Style);
+ Assert.False(btn.IsDisabled);
+ }
+
+ // ── Buttons — relay ───────────────────────────────────────────────────────
+
+ [Fact]
+ public void RenderAlarm_relay_off_button_is_secondary_style()
+ {
+ var (_, components) = Create().RenderAlarm(Sample(relayToTeamChat: false), unreachable: false, "en");
+ var btn = Buttons(components).Single(b =>
+ b.CustomId!.StartsWith(AlarmComponentIds.RelayTogglePrefix, StringComparison.Ordinal));
+
+ Assert.Equal(ButtonStyle.Secondary, btn.Style);
+ Assert.False(btn.IsDisabled);
+ }
+
+ [Fact]
+ public void RenderAlarm_relay_on_button_is_success_style()
+ {
+ var (_, components) = Create().RenderAlarm(Sample(relayToTeamChat: true), unreachable: false, "en");
+ var btn = Buttons(components).Single(b =>
+ b.CustomId!.StartsWith(AlarmComponentIds.RelayTogglePrefix, StringComparison.Ordinal));
+
+ Assert.Equal(ButtonStyle.Success, btn.Style);
+ Assert.False(btn.IsDisabled);
+ }
+
+ // ── Buttons — unreachable disables all ───────────────────────────────────
+
+ [Fact]
+ public void RenderAlarm_unreachable_disables_all_buttons()
+ {
+ var (_, components) = Create().RenderAlarm(Sample(), unreachable: true, "en");
+ var buttons = Buttons(components);
+
+ Assert.NotEmpty(buttons);
+ Assert.All(buttons, b => Assert.True(b.IsDisabled));
+ }
+
+ // ── Button labels ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public void RenderAlarm_ping_on_label_contains_on()
+ {
+ var (_, components) = Create().RenderAlarm(Sample(pingEveryone: true), unreachable: false, "en");
+ var btn = Buttons(components).Single(b =>
+ b.CustomId!.StartsWith(AlarmComponentIds.PingTogglePrefix, StringComparison.Ordinal));
+
+ Assert.Contains("on", btn.Label, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void RenderAlarm_ping_off_label_contains_off()
+ {
+ var (_, components) = Create().RenderAlarm(Sample(pingEveryone: false), unreachable: false, "en");
+ var btn = Buttons(components).Single(b =>
+ b.CustomId!.StartsWith(AlarmComponentIds.PingTogglePrefix, StringComparison.Ordinal));
+
+ Assert.Contains("off", btn.Label, StringComparison.OrdinalIgnoreCase);
+ }
+
+ // ── Custom-id tails ───────────────────────────────────────────────────────
+
+ [Fact]
+ public void RenderAlarm_buttons_have_correct_serverid_entityid_tail()
+ {
+ var alarm = Sample();
+ var tail = $"{alarm.ServerId}:{alarm.EntityId}";
+ var (_, components) = Create().RenderAlarm(alarm, unreachable: false, "en");
+ var buttons = Buttons(components);
+
+ Assert.All(buttons, b => Assert.EndsWith(tail, b.CustomId, StringComparison.Ordinal));
+ }
+
+ // ── Culture / FR ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public void RenderAlarm_french_armed_uses_french_status()
+ {
+ var (embed, _) = Create().RenderAlarm(Sample(lastIsActive: false), unreachable: false, "fr");
+
+ Assert.Contains("Armée", embed.Description ?? string.Empty, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void RenderAlarm_french_never_triggered_uses_french_text()
+ {
+ var (embed, _) = Create().RenderAlarm(Sample(lastTriggeredUtc: null), unreachable: false, "fr");
+
+ Assert.Contains("Jamais", embed.Description ?? string.Empty, StringComparison.Ordinal);
+ }
+
+ // ── Footer ────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void RenderAlarm_footer_contains_entity_id()
+ {
+ var (embed, _) = Create().RenderAlarm(Sample(), unreachable: false, "en");
+
+ Assert.NotNull(embed.Footer);
+ Assert.Contains("42", embed.Footer.Value.Text, StringComparison.Ordinal);
+ }
+
+ // ── RenderPrompt ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public void RenderPrompt_carries_accept_and_dismiss_with_identity_tail()
+ {
+ var serverId = Guid.Parse("22222222-2222-2222-2222-222222222222");
+ var (_, components) = Create().RenderPrompt(serverId, 99UL, "Alarm 99", "en");
+ var buttons = Buttons(components);
+
+ Assert.Contains(buttons, b =>
+ b.CustomId == AlarmComponentIds.AcceptPrefix + $"{serverId}:99");
+ Assert.Contains(buttons, b =>
+ b.CustomId == AlarmComponentIds.DismissPrefix + $"{serverId}:99");
+ }
+
+ [Fact]
+ public void RenderPrompt_embed_title_is_new_alarm_detected()
+ {
+ var (embed, _) = Create().RenderPrompt(Guid.NewGuid(), 1UL, "Alarm 1", "en");
+
+ Assert.Contains("alarm", embed.Title ?? string.Empty, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void RenderPrompt_embed_body_contains_default_name()
+ {
+ var (embed, _) = Create().RenderPrompt(Guid.NewGuid(), 1UL, "My Custom Alarm", "en");
+
+ Assert.Contains("My Custom Alarm", embed.Description ?? string.Empty, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void RenderPrompt_french_uses_french_strings()
+ {
+ var (embed, _) = Create().RenderPrompt(Guid.NewGuid(), 1UL, "Alarme Test", "fr");
+
+ Assert.Contains("détectée", embed.Title ?? string.Empty, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void RenderAlarm_null_alarm_throws_argument_null()
+ {
+ Assert.Throws(() =>
+ Create().RenderAlarm(null!, unreachable: false, "en"));
+ }
+}
diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmLocalizationCatalogTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmLocalizationCatalogTests.cs
new file mode 100644
index 0000000..2736d0c
--- /dev/null
+++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmLocalizationCatalogTests.cs
@@ -0,0 +1,48 @@
+using RustPlusBot.Features.Alarms.Rendering;
+
+namespace RustPlusBot.Features.Alarms.Tests;
+
+public sealed class AlarmLocalizationCatalogTests
+{
+ private static readonly string[] RequiredKeys =
+ [
+ "alarm.status.armed",
+ "alarm.status.active",
+ "alarm.status.unreachable",
+ "alarm.button.ping.on",
+ "alarm.button.ping.off",
+ "alarm.button.relay.on",
+ "alarm.button.relay.off",
+ "alarm.button.rename",
+ "alarm.embed.footer",
+ "alarm.embed.nevertriggered",
+ "alarm.embed.lasttriggered",
+ "alarm.prompt.title",
+ "alarm.prompt.body",
+ "alarm.prompt.accept",
+ "alarm.prompt.dismiss",
+ "alarm.rename.modal.title",
+ "alarm.rename.input.label",
+ "alarm.triggered.teamchat",
+ ];
+
+ [Theory]
+ [InlineData("en")]
+ [InlineData("fr")]
+ public void Catalog_contains_all_required_keys(string culture)
+ {
+ var map = AlarmLocalizationCatalog.Default[culture];
+ foreach (var key in RequiredKeys)
+ {
+ Assert.True(map.ContainsKey(key), $"Missing key '{key}' for culture '{culture}'.");
+ }
+ }
+
+ [Fact]
+ public void En_and_fr_have_identical_key_sets()
+ {
+ var enKeys = new SortedSet(AlarmLocalizationCatalog.Default["en"].Keys);
+ var frKeys = new SortedSet(AlarmLocalizationCatalog.Default["fr"].Keys);
+ Assert.Equal(enKeys, frKeys);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmPairingCoordinatorTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmPairingCoordinatorTests.cs
new file mode 100644
index 0000000..2353312
--- /dev/null
+++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmPairingCoordinatorTests.cs
@@ -0,0 +1,146 @@
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Domain.Alarms;
+using RustPlusBot.Features.Alarms.Pairing;
+using RustPlusBot.Features.Alarms.Posting;
+using RustPlusBot.Features.Alarms.Rendering;
+using RustPlusBot.Features.Workspace.Locating;
+using RustPlusBot.Persistence.Alarms;
+using RustPlusBot.Persistence.Workspace;
+
+namespace RustPlusBot.Features.Alarms.Tests;
+
+public sealed class AlarmPairingCoordinatorTests
+{
+ private static Harness Create()
+ {
+ var store = Substitute.For();
+ var workspace = Substitute.For();
+ workspace.GetCultureAsync(Arg.Any(), Arg.Any()).Returns("en");
+
+ var services = new ServiceCollection();
+ services.AddScoped(_ => store);
+ services.AddScoped(_ => workspace);
+ var provider = services.BuildServiceProvider();
+ var scopeFactory = provider.GetRequiredService();
+
+ var locator = Substitute.For();
+ locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(777UL);
+
+ var poster = Substitute.For();
+ poster.EnsureAsync(Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any())
+ .Returns(900UL);
+
+ var clock = Substitute.For();
+ clock.UtcNow.Returns(new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero));
+ var localizer = new AlarmLocalizer(AlarmLocalizationCatalog.Default);
+ var renderer = new AlarmEmbedRenderer(localizer, clock);
+
+ var coordinator = new AlarmPairingCoordinator(scopeFactory, locator, poster, renderer);
+ return new Harness(coordinator, store, poster, locator);
+ }
+
+ [Fact]
+ public async Task Paired_new_alarm_posts_prompt_with_default_name()
+ {
+ var h = Create();
+ var serverId = Guid.NewGuid();
+ h.Store.ExistsAsync(10UL, serverId, 42UL, Arg.Any()).Returns(false);
+
+ await h.Coordinator.HandlePairedAsync(new AlarmPairedEvent(10UL, serverId, 42UL), CancellationToken.None);
+
+ await h.Poster.Received(1).EnsureAsync(777UL, null, Arg.Any(),
+ Arg.Any(), Arg.Any());
+ Assert.Equal("Alarm 42", h.Coordinator.PendingName(10UL, serverId, 42UL));
+ }
+
+ [Fact]
+ public async Task Paired_already_managed_alarm_is_ignored()
+ {
+ var h = Create();
+ var serverId = Guid.NewGuid();
+ h.Store.ExistsAsync(10UL, serverId, 42UL, Arg.Any()).Returns(true);
+
+ await h.Coordinator.HandlePairedAsync(new AlarmPairedEvent(10UL, serverId, 42UL), CancellationToken.None);
+
+ await h.Poster.DidNotReceive().EnsureAsync(Arg.Any(), Arg.Any(),
+ Arg.Any(),
+ Arg.Any(), Arg.Any());
+ Assert.Null(h.Coordinator.PendingName(10UL, serverId, 42UL));
+ }
+
+ [Fact]
+ public async Task Accept_persists_alarm_and_replaces_prompt()
+ {
+ var h = Create();
+ var serverId = Guid.NewGuid();
+ h.Store.ExistsAsync(10UL, serverId, 42UL, Arg.Any()).Returns(false);
+ await h.Coordinator.HandlePairedAsync(new AlarmPairedEvent(10UL, serverId, 42UL), CancellationToken.None);
+ h.Store.AddAsync(10UL, serverId, 42UL, "Alarm 42", 5UL, Arg.Any())
+ .Returns(new SmartAlarm
+ {
+ GuildId = 10UL, ServerId = serverId, EntityId = 42UL, Name = "Alarm 42",
+ });
+
+ var ok = await h.Coordinator.TryAcceptAsync(10UL, serverId, 42UL, acceptingUserId: 5UL, CancellationToken.None);
+
+ Assert.True(ok);
+ await h.Store.Received(1).AddAsync(10UL, serverId, 42UL, "Alarm 42", 5UL, Arg.Any());
+ Assert.Null(h.Coordinator.PendingName(10UL, serverId, 42UL)); // pending cleared
+ // The prompt (posted by HandlePairedAsync) must be replaced by the alarm embed (posted by TryAcceptAsync).
+ // EnsureAsync is called twice: once for the prompt (messageId=null), once for the embed (messageId=900 from prompt).
+ await h.Poster.Received(2).EnsureAsync(777UL, Arg.Any(), Arg.Any(),
+ Arg.Any