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(), Arg.Any()); + await h.Poster.Received(1).EnsureAsync(777UL, 900UL, Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Accept_is_noop_when_already_persisted_by_race() + { + var h = Create(); + var serverId = Guid.NewGuid(); + h.Store.ExistsAsync(10UL, serverId, 42UL, Arg.Any()).Returns(true); + + var ok = await h.Coordinator.TryAcceptAsync(10UL, serverId, 42UL, 5UL, CancellationToken.None); + + Assert.False(ok); + await h.Store.DidNotReceive().AddAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task TryDismiss_removes_pending_and_returns_true() + { + 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); + + var dismissed = h.Coordinator.TryDismiss(10UL, serverId, 42UL); + + Assert.True(dismissed); + Assert.Null(h.Coordinator.PendingName(10UL, serverId, 42UL)); + } + + [Fact] + public void TryDismiss_no_pending_returns_false() + { + var h = Create(); + var serverId = Guid.NewGuid(); + + var dismissed = h.Coordinator.TryDismiss(10UL, serverId, 42UL); + + Assert.False(dismissed); + } + + private sealed record Harness( + AlarmPairingCoordinator Coordinator, + IAlarmStore Store, + IAlarmChannelPoster Poster, + IAlarmChannelLocator Locator); +} diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmRefresherTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmRefresherTests.cs new file mode 100644 index 0000000..7220cda --- /dev/null +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmRefresherTests.cs @@ -0,0 +1,156 @@ +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Domain.Alarms; +using RustPlusBot.Features.Alarms.Posting; +using RustPlusBot.Features.Alarms.Relaying; +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 AlarmRefresherTests +{ + private static readonly DateTimeOffset _fixedNow = new(2025, 6, 1, 12, 0, 0, TimeSpan.Zero); + + private static Harness Create(SmartAlarm? alarm = null, ulong? channelId = 777UL) + { + 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(channelId); + + 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(_fixedNow); + var localizer = new AlarmLocalizer(AlarmLocalizationCatalog.Default); + var renderer = new AlarmEmbedRenderer(localizer, clock); + + if (alarm is not null) + { + store.GetAsync(alarm.GuildId, alarm.ServerId, alarm.EntityId, Arg.Any()) + .Returns(alarm); + } + + var refresher = new AlarmRefresher(scopeFactory, locator, poster, renderer); + return new Harness(refresher, store, poster, locator); + } + + [Fact] + public async Task RefreshAsync_alarm_present_and_channel_resolved_calls_render_and_ensure() + { + var serverId = Guid.NewGuid(); + var alarm = new SmartAlarm + { + GuildId = 10UL, + ServerId = serverId, + EntityId = 42UL, + Name = "Fire Alarm", + MessageId = 800UL, + }; + var h = Create(alarm: alarm, channelId: 777UL); + + await h.Refresher.RefreshAsync(10UL, serverId, 42UL, unreachable: false, CancellationToken.None); + + await h.Poster.Received(1).EnsureAsync(777UL, 800UL, Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task RefreshAsync_alarm_present_new_message_id_persisted() + { + var serverId = Guid.NewGuid(); + var alarm = new SmartAlarm + { + GuildId = 10UL, + ServerId = serverId, + EntityId = 42UL, + Name = "Fire Alarm", + MessageId = 800UL, + }; + var h = Create(alarm: alarm, channelId: 777UL); + // EnsureAsync returns 900 (different from MessageId 800) — SetMessageIdAsync must be called + h.Poster.EnsureAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(900UL); + + await h.Refresher.RefreshAsync(10UL, serverId, 42UL, unreachable: false, CancellationToken.None); + + await h.Store.Received(1).SetMessageIdAsync(10UL, serverId, 42UL, 900UL, Arg.Any()); + } + + [Fact] + public async Task RefreshAsync_alarm_absent_does_not_post() + { + var serverId = Guid.NewGuid(); + // store returns null by default (not configured for this identity) + var h = Create(alarm: null, channelId: 777UL); + + await h.Refresher.RefreshAsync(10UL, serverId, 42UL, unreachable: false, CancellationToken.None); + + await h.Poster.DidNotReceive().EnsureAsync(Arg.Any(), Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task RefreshAsync_channel_unresolved_does_not_post() + { + var serverId = Guid.NewGuid(); + var alarm = new SmartAlarm + { + GuildId = 10UL, ServerId = serverId, EntityId = 42UL, Name = "Fire Alarm", + }; + var h = Create(alarm: alarm, channelId: null); + + await h.Refresher.RefreshAsync(10UL, serverId, 42UL, unreachable: false, CancellationToken.None); + + await h.Poster.DidNotReceive().EnsureAsync(Arg.Any(), Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task RefreshAsync_loaded_alarm_posts_without_refetching() + { + var serverId = Guid.NewGuid(); + var alarm = new SmartAlarm + { + GuildId = 10UL, + ServerId = serverId, + EntityId = 42UL, + Name = "Fire Alarm", + MessageId = 800UL, + }; + // Note: store.GetAsync is intentionally NOT configured — the overload must not call it. + var h = Create(alarm: null, channelId: 777UL); + + await h.Refresher.RefreshAsync(alarm, unreachable: true, CancellationToken.None); + + await h.Store.DidNotReceive().GetAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + await h.Poster.Received(1).EnsureAsync(777UL, 800UL, Arg.Any(), + Arg.Any(), Arg.Any()); + } + + private sealed record Harness( + AlarmRefresher Refresher, + IAlarmStore Store, + IAlarmChannelPoster Poster, + IAlarmChannelLocator Locator); +} diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmRegistrationTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmRegistrationTests.cs new file mode 100644 index 0000000..4bd0b39 --- /dev/null +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmRegistrationTests.cs @@ -0,0 +1,83 @@ +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NSubstitute; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Alarms.Pairing; +using RustPlusBot.Features.Alarms.Posting; +using RustPlusBot.Features.Alarms.Relaying; +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.Tests; + +/// Validates that registers all required services. +public sealed class AlarmRegistrationTests +{ + /// Verifies core service descriptors are present after calling . + [Fact] + public void AddAlarms_registers_core_services() + { + var services = new ServiceCollection(); + services.AddAlarms(); + + Assert.Contains(services, d => d.ServiceType == typeof(IAlarmLocalizer)); + Assert.Contains(services, d => d.ServiceType == typeof(AlarmEmbedRenderer)); + Assert.Contains(services, d => d.ServiceType == typeof(IAlarmChannelPoster)); + Assert.Contains(services, d => d.ServiceType == typeof(IAlarmRefresher)); + Assert.Contains(services, d => d.ServiceType == typeof(AlarmPairingCoordinator)); + Assert.Contains(services, d => d.ServiceType == typeof(AlarmStateRelay)); + Assert.Contains(services, d => d.ServiceType == typeof(IHostedService)); + } + + /// Verifies the container resolves key types without captive-dependency errors when all cross-layer deps are provided. + [Fact] + public void AddAlarms_resolves_without_captive_dependency_errors() + { + var services = new ServiceCollection(); + + // Logging (required by concrete types such as DiscordAlarmChannelPoster and AlarmsHostedService) + services.AddLogging(); + + // Cross-layer singletons from other slices + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + + // Discord + var discordConfig = new DiscordSocketConfig(); + services.AddSingleton(new DiscordSocketClient(discordConfig)); + + // Scoped stores from Persistence + services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); + + services.AddAlarms(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + + var localizer = provider.GetRequiredService(); + var renderer = provider.GetRequiredService(); + var poster = provider.GetRequiredService(); + var refresher = provider.GetRequiredService(); + var coordinator = provider.GetRequiredService(); + var relay = provider.GetRequiredService(); + var hostedService = provider.GetRequiredService(); + + Assert.NotNull(localizer); + Assert.NotNull(renderer); + Assert.NotNull(poster); + Assert.NotNull(refresher); + Assert.NotNull(coordinator); + Assert.NotNull(relay); + Assert.NotNull(hostedService); + } +} diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs new file mode 100644 index 0000000..858bfcd --- /dev/null +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs @@ -0,0 +1,347 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Domain.Alarms; +using RustPlusBot.Domain.Connections; +using RustPlusBot.Features.Alarms.Posting; +using RustPlusBot.Features.Alarms.Relaying; +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.Tests; + +/// Unit tests for . +public sealed class AlarmStateRelayTests +{ + private static readonly DateTimeOffset _fixedNow = new(2025, 6, 1, 12, 0, 0, TimeSpan.Zero); + + private static Harness Create(SmartAlarm? alarm = null, ulong? channelId = 777UL) + { + var store = Substitute.For(); + var connections = Substitute.For(); + var workspace = Substitute.For(); + workspace.GetCultureAsync(Arg.Any(), Arg.Any()).Returns("en"); + + var services = new ServiceCollection(); + services.AddScoped(_ => store); + services.AddScoped(_ => connections); + services.AddScoped(_ => workspace); + var provider = services.BuildServiceProvider(); + var scopeFactory = provider.GetRequiredService(); + + var refresher = Substitute.For(); + var locator = Substitute.For(); + locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(channelId); + + var poster = Substitute.For(); + var teamChatSender = Substitute.For(); + teamChatSender + .SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(TeamChatSendResult.Sent); + + var alarmLocalizer = new AlarmLocalizer(AlarmLocalizationCatalog.Default); + var clock = Substitute.For(); + clock.UtcNow.Returns(_fixedNow); + + if (alarm is not null) + { + store.GetAsync(alarm.GuildId, alarm.ServerId, alarm.EntityId, Arg.Any()) + .Returns(alarm); + } + + var relay = new AlarmStateRelay( + scopeFactory, + refresher, + locator, + poster, + teamChatSender, + alarmLocalizer, + clock, + NullLogger.Instance); + + return new Harness(relay, store, refresher, poster, teamChatSender, connections); + } + + // ────────────────────────────────────────────────────────────────────────── + // Triggered → active + // ────────────────────────────────────────────────────────────────────────── + + /// Active trigger on a managed alarm persists state (with timestamp) and refreshes the embed. + [Fact] + public async Task Triggered_active_managed_alarm_updates_state_and_refreshes() + { + var serverId = Guid.NewGuid(); + var alarm = new SmartAlarm + { + GuildId = 10UL, ServerId = serverId, EntityId = 42UL, Name = "Perimeter", + }; + var h = Create(alarm: alarm); + + await h.Relay.HandleTriggeredAsync( + new SmartDeviceTriggeredEvent(10UL, serverId, 42UL, IsActive: true), CancellationToken.None); + + await h.Store.Received(1).UpdateStateAsync( + 10UL, serverId, 42UL, true, _fixedNow, Arg.Any()); + await h.Refresher.Received(1) + .RefreshAsync(10UL, serverId, 42UL, unreachable: false, Arg.Any()); + } + + /// Active trigger with PingEveryone sends the @everyone message. + [Fact] + public async Task Triggered_active_ping_everyone_set_sends_ping() + { + var serverId = Guid.NewGuid(); + var alarm = new SmartAlarm + { + GuildId = 10UL, + ServerId = serverId, + EntityId = 42UL, + Name = "Fire", + PingEveryone = true, + }; + var h = Create(alarm: alarm, channelId: 777UL); + + await h.Relay.HandleTriggeredAsync( + new SmartDeviceTriggeredEvent(10UL, serverId, 42UL, IsActive: true), CancellationToken.None); + + await h.Poster.Received(1).SendEveryonePingAsync(777UL, "@everyone Fire", Arg.Any()); + } + + /// Active trigger without PingEveryone does NOT send the @everyone message. + [Fact] + public async Task Triggered_active_ping_everyone_unset_does_not_ping() + { + var serverId = Guid.NewGuid(); + var alarm = new SmartAlarm + { + GuildId = 10UL, + ServerId = serverId, + EntityId = 42UL, + Name = "Fire", + PingEveryone = false, + }; + var h = Create(alarm: alarm); + + await h.Relay.HandleTriggeredAsync( + new SmartDeviceTriggeredEvent(10UL, serverId, 42UL, IsActive: true), CancellationToken.None); + + await h.Poster.DidNotReceive() + .SendEveryonePingAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + /// Active trigger with RelayToTeamChat sends the localized line to team chat. + [Fact] + public async Task Triggered_active_relay_to_team_chat_set_sends_team_chat_line() + { + var serverId = Guid.NewGuid(); + var alarm = new SmartAlarm + { + GuildId = 10UL, + ServerId = serverId, + EntityId = 42UL, + Name = "Perimeter", + RelayToTeamChat = true, + }; + var h = Create(alarm: alarm); + + await h.Relay.HandleTriggeredAsync( + new SmartDeviceTriggeredEvent(10UL, serverId, 42UL, IsActive: true), CancellationToken.None); + + // "alarm.triggered.teamchat" in EN = "🚨 {0} triggered" + await h.TeamChatSender.Received(1).SendAsync( + 10UL, serverId, "🚨 Perimeter triggered", Arg.Any()); + } + + /// Active trigger without RelayToTeamChat does NOT send to team chat. + [Fact] + public async Task Triggered_active_relay_to_team_chat_unset_does_not_send_team_chat() + { + var serverId = Guid.NewGuid(); + var alarm = new SmartAlarm + { + GuildId = 10UL, + ServerId = serverId, + EntityId = 42UL, + Name = "Perimeter", + RelayToTeamChat = false, + }; + var h = Create(alarm: alarm); + + await h.Relay.HandleTriggeredAsync( + new SmartDeviceTriggeredEvent(10UL, serverId, 42UL, IsActive: true), CancellationToken.None); + + await h.TeamChatSender.DidNotReceive() + .SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + // ────────────────────────────────────────────────────────────────────────── + // Triggered → inactive + // ────────────────────────────────────────────────────────────────────────── + + /// Inactive trigger persists state (null timestamp), refreshes embed, no ping/relay. + [Fact] + public async Task Triggered_inactive_updates_state_and_refreshes_no_ping_no_relay() + { + var serverId = Guid.NewGuid(); + var alarm = new SmartAlarm + { + GuildId = 10UL, + ServerId = serverId, + EntityId = 42UL, + Name = "Perimeter", + PingEveryone = true, + RelayToTeamChat = true, + }; + var h = Create(alarm: alarm); + + await h.Relay.HandleTriggeredAsync( + new SmartDeviceTriggeredEvent(10UL, serverId, 42UL, IsActive: false), CancellationToken.None); + + await h.Store.Received(1).UpdateStateAsync( + 10UL, serverId, 42UL, false, null, Arg.Any()); + await h.Refresher.Received(1) + .RefreshAsync(10UL, serverId, 42UL, unreachable: false, Arg.Any()); + await h.Poster.DidNotReceive() + .SendEveryonePingAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await h.TeamChatSender.DidNotReceive() + .SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + // ────────────────────────────────────────────────────────────────────────── + // Triggered → non-alarm id + // ────────────────────────────────────────────────────────────────────────── + + /// Entity id not in alarm store is ignored — no update, no refresh, no ping, no relay. + [Fact] + public async Task Triggered_non_alarm_id_is_ignored() + { + var serverId = Guid.NewGuid(); + // store returns null by default (not configured for this identity) + var h = Create(alarm: null); + + await h.Relay.HandleTriggeredAsync( + new SmartDeviceTriggeredEvent(10UL, serverId, 99UL, IsActive: true), CancellationToken.None); + + await h.Store.DidNotReceive().UpdateStateAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); + await h.Refresher.DidNotReceive().RefreshAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + await h.Poster.DidNotReceive() + .SendEveryonePingAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await h.TeamChatSender.DidNotReceive() + .SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + // ────────────────────────────────────────────────────────────────────────── + // Relay SendAsync throws → swallowed + // ────────────────────────────────────────────────────────────────────────── + + /// If team-chat SendAsync throws, the exception is swallowed and the update+refresh+ping still complete. + [Fact] + public async Task Triggered_relay_send_throws_is_swallowed_and_other_actions_complete() + { + var serverId = Guid.NewGuid(); + var alarm = new SmartAlarm + { + GuildId = 10UL, + ServerId = serverId, + EntityId = 42UL, + Name = "Fire", + PingEveryone = true, + RelayToTeamChat = true, + }; + var h = Create(alarm: alarm, channelId: 777UL); + + h.TeamChatSender + .SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("team chat down")); + + // Must not throw + await h.Relay.HandleTriggeredAsync( + new SmartDeviceTriggeredEvent(10UL, serverId, 42UL, IsActive: true), CancellationToken.None); + + await h.Store.Received(1).UpdateStateAsync( + 10UL, serverId, 42UL, true, _fixedNow, Arg.Any()); + await h.Refresher.Received(1) + .RefreshAsync(10UL, serverId, 42UL, unreachable: false, Arg.Any()); + await h.Poster.Received(1).SendEveryonePingAsync(777UL, "@everyone Fire", Arg.Any()); + } + + // ────────────────────────────────────────────────────────────────────────── + // Connection status + // ────────────────────────────────────────────────────────────────────────── + + /// Non-Connected server marks each alarm's embed as unreachable. + [Fact] + public async Task ConnectionStatus_not_connected_refreshes_all_alarms_unreachable() + { + var serverId = Guid.NewGuid(); + var h = Create(); + + h.Connections.GetStateAsync(10UL, serverId, Arg.Any()) + .Returns(new ConnectionState + { + GuildId = 10UL, RustServerId = serverId, Status = ConnectionStatus.Unreachable, + }); + + h.Store.ListByServerAsync(10UL, serverId, Arg.Any()) + .Returns(new[] + { + new SmartAlarm + { + GuildId = 10UL, ServerId = serverId, EntityId = 42UL, Name = "A" + }, + new SmartAlarm + { + GuildId = 10UL, ServerId = serverId, EntityId = 43UL, Name = "B" + }, + }); + + await h.Relay.HandleConnectionStatusAsync( + new ConnectionStatusChangedEvent(10UL, serverId), CancellationToken.None); + + await h.Refresher.Received(1).RefreshAsync( + Arg.Is(a => a.EntityId == 42UL), unreachable: true, Arg.Any()); + await h.Refresher.Received(1).RefreshAsync( + Arg.Is(a => a.EntityId == 43UL), unreachable: true, Arg.Any()); + } + + /// Connected server → no-op (supervisor's prime path handles it). + [Fact] + public async Task ConnectionStatus_connected_does_nothing() + { + var serverId = Guid.NewGuid(); + var h = Create(); + + h.Connections.GetStateAsync(10UL, serverId, Arg.Any()) + .Returns(new ConnectionState + { + GuildId = 10UL, RustServerId = serverId, Status = ConnectionStatus.Connected, + }); + + await h.Relay.HandleConnectionStatusAsync( + new ConnectionStatusChangedEvent(10UL, serverId), CancellationToken.None); + + await h.Refresher.DidNotReceive().RefreshAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + await h.Refresher.DidNotReceive() + .RefreshAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + private sealed record Harness( + AlarmStateRelay Relay, + IAlarmStore Store, + IAlarmRefresher Refresher, + IAlarmChannelPoster Poster, + ITeamChatSender TeamChatSender, + IConnectionStore Connections); +} diff --git a/tests/RustPlusBot.Features.Alarms.Tests/RustPlusBot.Features.Alarms.Tests.csproj b/tests/RustPlusBot.Features.Alarms.Tests/RustPlusBot.Features.Alarms.Tests.csproj new file mode 100644 index 0000000..d5551cd --- /dev/null +++ b/tests/RustPlusBot.Features.Alarms.Tests/RustPlusBot.Features.Alarms.Tests.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs b/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs new file mode 100644 index 0000000..6518bc4 --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs @@ -0,0 +1,132 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NSubstitute; +using RustPlusBot.Abstractions.Credentials; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Discord.Notifications; +using RustPlusBot.Domain.Credentials; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Connections.Supervisor; +using RustPlusBot.Features.Connections.Tests.Fakes; +using RustPlusBot.Persistence; +using RustPlusBot.Persistence.Alarms; +using RustPlusBot.Persistence.Connections; +using RustPlusBot.Persistence.Servers; +using RustPlusBot.Persistence.Switches; + +namespace RustPlusBot.Features.Connections.Tests; + +public sealed class AlarmPrimingTests +{ + private static (ServiceProvider Provider, ConnectionSupervisor Supervisor, InMemoryEventBus Bus) CreateHarness( + FakeRustSocketSource source) + { + var protector = Substitute.For(); + protector.Unprotect(Arg.Any()).Returns(c => c.Arg()); + var dm = Substitute.For(); + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + var bus = new InMemoryEventBus(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(clock); + services.AddSingleton(protector); + services.AddSingleton(dm); + services.AddSingleton(bus); + + var cs = $"DataSource=alarmpriming-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; + var keepAlive = new SqliteConnection(cs); + keepAlive.Open(); + using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) + { + seed.Database.Migrate(); + } + + services.AddSingleton(keepAlive); + services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(source); + services.AddSingleton(Options.Create(new ConnectionOptions + { + ConnectTimeout = TimeSpan.FromSeconds(1), + InitialRetryDelay = TimeSpan.FromMilliseconds(5), + MaxRetryDelay = TimeSpan.FromMilliseconds(20), + HeartbeatInterval = TimeSpan.FromMilliseconds(20), + HeartbeatTimeout = TimeSpan.FromMilliseconds(200), + })); + services.AddSingleton(); + + var provider = services.BuildServiceProvider(); + return (provider, provider.GetRequiredService(), bus); + } + + private static async Task SeedServerWithActiveAndAlarmAsync(ServiceProvider provider, ulong entityId) + { + using var scope = provider.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + ctx.RustServers.Add(server); + ctx.PlayerCredentials.Add(new PlayerCredential + { + GuildId = 10UL, + RustServerId = server.Id, + OwnerUserId = 1UL, + SteamId = 555UL, + ProtectedPlayerToken = "123", + Status = CredentialStatus.Active, + }); + await ctx.SaveChangesAsync(); + var store = scope.ServiceProvider.GetRequiredService(); + await store.AddAsync(10UL, server.Id, entityId, $"Alarm {entityId}", 1UL); + return server.Id; + } + + [Fact] + public async Task Priming_publishes_state_for_persisted_alarm_on_connect() + { + var source = new FakeRustSocketSource(); + var (provider, supervisor, bus) = CreateHarness(source); + await using var disposeProvider = provider; + var serverId = await SeedServerWithActiveAndAlarmAsync(provider, entityId: 77UL); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + // Subscribe BEFORE connecting: SubscribeAsync registers the channel eagerly on THIS thread, so the primed + // publish (which fires during EnsureConnectionAsync) is observed. Only the enumeration runs in Task.Run. + var stream = bus.SubscribeAsync(cts.Token); + var received = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _ = Task.Run( + async () => + { + await foreach (var evt in stream) + { + if (evt.EntityId == 77UL) + { + received.TrySetResult(evt); + break; + } + } + }, + cts.Token); + + // The fake defaults entity 77 absent → GetSmartDeviceInfoAsync returns null → priming publishes off. + await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + + var evt = await received.Task.WaitAsync(cts.Token); + Assert.Equal(77UL, evt.EntityId); + Assert.False(evt.IsActive); // absent in SwitchStates → null → defaulted off + await supervisor.StopAllAsync(); + } +} diff --git a/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs index e8d3aa3..9ab4821 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs @@ -3,6 +3,7 @@ using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; using RustPlusBot.Domain.Credentials; +using RustPlusBot.Domain.Entities; using RustPlusBot.Features.Pairing.Listening; using RustPlusBot.Features.Pairing.Pairing; using RustPlusBot.Persistence; @@ -33,6 +34,10 @@ private static PairingNotification EntityPairing(Guid fpServer, ulong entityId = new(PairingKind.Entity, string.Empty, string.Empty, 0, 1UL, "t", FacepunchServerId: fpServer, EntityId: entityId); + private static PairingNotification AlarmPairing(Guid fpServer, ulong entityId = 55UL) => + new(PairingKind.Entity, string.Empty, string.Empty, 0, 1UL, "t", + FacepunchServerId: fpServer, EntityId: entityId, EntityKind: PairedEntityKind.SmartAlarm); + [Fact] public async Task ServerPairing_CreatesServerCredentialAndFiresEventOnce() { @@ -123,4 +128,23 @@ public async Task EntityPairing_UnknownServer_DropsAndCreatesNothing() Assert.Empty(await context.RustServers.ToListAsync()); await bus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); } + + [Fact] + public async Task EntityPairing_Alarm_PublishesAlarmPairedEvent_NotSwitch() + { + var (context, connection) = TestDb.Create(); + await using var _ = context; + await using var __ = connection; + var bus = Substitute.For(); + var handler = CreateHandler(context, bus); + await handler.HandleAsync(10UL, 99UL, ServerPairing(), CancellationToken.None); + var server = await context.RustServers.SingleAsync(); + bus.ClearReceivedCalls(); + + await handler.HandleAsync(10UL, 1UL, AlarmPairing(FpServer, 55UL), CancellationToken.None); + + await bus.Received(1).PublishAsync( + Arg.Is(e => e.ServerId == server.Id && e.EntityId == 55UL), Arg.Any()); + await bus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + } } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/AlarmChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/AlarmChannelLocatorTests.cs new file mode 100644 index 0000000..f626be3 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/AlarmChannelLocatorTests.cs @@ -0,0 +1,136 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Domain.Workspace; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Workspace.Tests.Locating; + +public sealed class AlarmChannelLocatorTests +{ + private static (AlarmChannelLocator Locator, ServiceProvider Provider, string ConnectionString, IClock Clock) + CreateLocator() + { + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + + var cs = $"DataSource=alarm-locator-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; + var keepAlive = new SqliteConnection(cs); + keepAlive.Open(); + using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) + { + seed.Database.Migrate(); + } + + var services = new ServiceCollection(); + services.AddSingleton(keepAlive); + services.AddSingleton(clock); + services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); + services.AddScoped(); + var provider = services.BuildServiceProvider(); + + var locator = new AlarmChannelLocator(provider.GetRequiredService(), clock); + return (locator, provider, cs, clock); + } + + private static async Task SeedAsync(string connectionString) + { + await using var context = + new BotDbContext(new DbContextOptionsBuilder().UseSqlite(connectionString).Options); + + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + + context.ProvisionedChannels.Add(new ProvisionedChannel + { + GuildId = 10UL, + RustServerId = server.Id, + ChannelKey = WorkspaceChannelKeys.ServerAlarms, + DiscordChannelId = 888UL, + CreatedAt = DateTimeOffset.UnixEpoch, + }); + + // A row with a null RustServerId (global scope) must be skipped by the locator. + context.ProvisionedChannels.Add(new ProvisionedChannel + { + GuildId = 10UL, + RustServerId = null, + ChannelKey = WorkspaceChannelKeys.ServerAlarms, + DiscordChannelId = 777UL, + CreatedAt = DateTimeOffset.UnixEpoch, + }); + await context.SaveChangesAsync(); + + return server.Id; + } + + [Fact] + public async Task GetChannelIdAsync_returns_provisioned_alarms_channel() + { + var (locator, provider, cs, _) = CreateLocator(); + await using var _p = provider; + var serverId = await SeedAsync(cs); + + var channelId = await locator.GetChannelIdAsync(10UL, serverId, CancellationToken.None); + + Assert.Equal(888UL, channelId); + } + + [Fact] + public async Task GetChannelIdAsync_returns_null_when_not_provisioned() + { + var (locator, provider, _, _) = CreateLocator(); + await using var _p = provider; + + Assert.Null(await locator.GetChannelIdAsync(10UL, Guid.NewGuid(), CancellationToken.None)); + } + + [Fact] + public async Task Cache_refreshes_after_ttl_expires() + { + var (locator, provider, cs, clock) = CreateLocator(); + await using var _p = provider; + + // Cold load with empty DB — cache built at UnixEpoch, no rows. + var firstResult = await locator.GetChannelIdAsync(20UL, Guid.NewGuid(), CancellationToken.None); + Assert.Null(firstResult); + + // Insert a server + channel into the DB after the first load. + await using var insertCtx = + new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options); + var server = new RustServer + { + GuildId = 20UL, Name = "T", Ip = "2.2.2.2", Port = 28015 + }; + insertCtx.RustServers.Add(server); + await insertCtx.SaveChangesAsync(); + insertCtx.ProvisionedChannels.Add(new ProvisionedChannel + { + GuildId = 20UL, + RustServerId = server.Id, + ChannelKey = WorkspaceChannelKeys.ServerAlarms, + DiscordChannelId = 999UL, + CreatedAt = DateTimeOffset.UnixEpoch, + }); + await insertCtx.SaveChangesAsync(); + + // Clock still at UnixEpoch — within the 30 s TTL, cache must NOT be reloaded. + var withinTtlResult = await locator.GetChannelIdAsync(20UL, server.Id, CancellationToken.None); + Assert.Null(withinTtlResult); + + // Advance the clock past the 30 s TTL — next call must rebuild the cache. + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch + TimeSpan.FromSeconds(31)); + + var afterTtlResult = await locator.GetChannelIdAsync(20UL, server.Id, CancellationToken.None); + Assert.Equal(999UL, afterTtlResult); + } +} diff --git a/tests/RustPlusBot.Persistence.Tests/Alarms/AlarmStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Alarms/AlarmStoreTests.cs new file mode 100644 index 0000000..06c45bd --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/Alarms/AlarmStoreTests.cs @@ -0,0 +1,275 @@ +using System.Globalization; +using Microsoft.Data.Sqlite; +using NSubstitute; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Persistence.Alarms; + +namespace RustPlusBot.Persistence.Tests.Alarms; + +public sealed class AlarmStoreTests +{ + private static (AlarmStore Store, BotDbContext Context, SqliteConnection Conn) Create( + DateTimeOffset? now = null) + { + var (context, connection) = SqliteContextFixture.Create(); + var clock = Substitute.For(); + clock.UtcNow.Returns(now ?? DateTimeOffset.UnixEpoch); + return (new AlarmStore(context, clock), context, connection); + } + + private static async Task SeedServerAsync(BotDbContext context, string ip = "1.1.1.1", string name = "S") + { + var server = new RustServer + { + GuildId = 10UL, Name = name, Ip = ip, Port = 28015 + }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + return server.Id; + } + + [Fact] + public async Task Add_then_Get_round_trips_fields() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + + var added = await store.AddAsync(10UL, serverId, 42UL, "Alarm 42", pairedByUserId: 7UL); + var loaded = await store.GetAsync(10UL, serverId, 42UL); + + Assert.NotNull(loaded); + Assert.Equal(added.Id, loaded.Id); + Assert.Equal("Alarm 42", loaded.Name); + Assert.Equal(7UL, loaded.PairedByUserId); + Assert.Equal(DateTimeOffset.UnixEpoch, loaded.CreatedUtc); + Assert.False(loaded.PingEveryone); + Assert.False(loaded.RelayToTeamChat); + Assert.False(loaded.LastIsActive); + Assert.Null(loaded.LastTriggeredUtc); + } + + [Fact] + public async Task Add_is_idempotent_when_alarm_already_exists() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + + var first = await store.AddAsync(10UL, serverId, 42UL, "Alarm 42", pairedByUserId: 7UL); + // Simulates the concurrent double-accept race: second AddAsync for the same identity must not throw, + // and must return the row the first accept persisted. + var second = await store.AddAsync(10UL, serverId, 42UL, "Alarm 42 (dup)", pairedByUserId: 9UL); + + Assert.Equal(first.Id, second.Id); + Assert.Equal("Alarm 42", second.Name); + Assert.Equal(7UL, second.PairedByUserId); + Assert.Single(await store.ListByServerAsync(10UL, serverId)); + } + + [Fact] + public async Task Exists_reflects_presence() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + + Assert.False(await store.ExistsAsync(10UL, serverId, 42UL)); + await store.AddAsync(10UL, serverId, 42UL, "Alarm 42", 7UL); + Assert.True(await store.ExistsAsync(10UL, serverId, 42UL)); + } + + [Fact] + public async Task ListByServer_returns_oldest_first() + { + var t0 = DateTimeOffset.UnixEpoch; + var t1 = t0.AddMinutes(1); + var t2 = t0.AddMinutes(2); + + var (context0, conn0) = SqliteContextFixture.Create(); + await using var _conn = conn0; + await using var _ctx = context0; + var serverId = await SeedServerAsync(context0); + + var clock0 = Substitute.For(); + clock0.UtcNow.Returns(t2); + var store0 = new AlarmStore(context0, clock0); + await store0.AddAsync(10UL, serverId, 3UL, "C", 7UL); + + clock0.UtcNow.Returns(t0); + var store1 = new AlarmStore(context0, clock0); + await store1.AddAsync(10UL, serverId, 1UL, "A", 7UL); + + clock0.UtcNow.Returns(t1); + var store2 = new AlarmStore(context0, clock0); + await store2.AddAsync(10UL, serverId, 2UL, "B", 7UL); + + var list = await store0.ListByServerAsync(10UL, serverId); + Assert.Equal(3, list.Count); + Assert.Equal(t0, list[0].CreatedUtc); + Assert.Equal(t1, list[1].CreatedUtc); + Assert.Equal(t2, list[2].CreatedUtc); + } + + [Fact] + public async Task ListByServer_returns_only_that_server() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverA = await SeedServerAsync(context); + var serverB = await SeedServerAsync(context, ip: "2.2.2.2", name: "T"); + + await store.AddAsync(10UL, serverA, 1UL, "A1", 7UL); + await store.AddAsync(10UL, serverA, 2UL, "A2", 7UL); + await store.AddAsync(10UL, serverB, 3UL, "B1", 7UL); + + var listA = await store.ListByServerAsync(10UL, serverA); + Assert.Equal(2, listA.Count); + Assert.All(listA, a => Assert.Equal(serverA, a.ServerId)); + } + + [Fact] + public async Task Rename_mutates_the_row() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + await store.AddAsync(10UL, serverId, 42UL, "Alarm 42", 7UL); + + await store.RenameAsync(10UL, serverId, 42UL, "Front base sensor"); + + var loaded = await store.GetAsync(10UL, serverId, 42UL); + Assert.NotNull(loaded); + Assert.Equal("Front base sensor", loaded.Name); + } + + [Fact] + public async Task SetMessageId_mutates_the_row() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + await store.AddAsync(10UL, serverId, 42UL, "Alarm 42", 7UL); + + await store.SetMessageIdAsync(10UL, serverId, 42UL, 999UL); + + var loaded = await store.GetAsync(10UL, serverId, 42UL); + Assert.NotNull(loaded); + Assert.Equal(999UL, loaded.MessageId); + } + + [Fact] + public async Task SetPingEveryone_toggles_the_flag() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + await store.AddAsync(10UL, serverId, 42UL, "Alarm 42", 7UL); + + await store.SetPingEveryoneAsync(10UL, serverId, 42UL, true); + var loaded = await store.GetAsync(10UL, serverId, 42UL); + Assert.NotNull(loaded); + Assert.True(loaded.PingEveryone); + + await store.SetPingEveryoneAsync(10UL, serverId, 42UL, false); + loaded = await store.GetAsync(10UL, serverId, 42UL); + Assert.NotNull(loaded); + Assert.False(loaded.PingEveryone); + } + + [Fact] + public async Task SetRelayToTeamChat_toggles_the_flag() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + await store.AddAsync(10UL, serverId, 42UL, "Alarm 42", 7UL); + + await store.SetRelayToTeamChatAsync(10UL, serverId, 42UL, true); + var loaded = await store.GetAsync(10UL, serverId, 42UL); + Assert.NotNull(loaded); + Assert.True(loaded.RelayToTeamChat); + + await store.SetRelayToTeamChatAsync(10UL, serverId, 42UL, false); + loaded = await store.GetAsync(10UL, serverId, 42UL); + Assert.NotNull(loaded); + Assert.False(loaded.RelayToTeamChat); + } + + [Fact] + public async Task UpdateStateAsync_active_sets_state_and_triggered_time() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + await store.AddAsync(10UL, serverId, 42UL, "Alarm 42", 1UL); + + var t = DateTimeOffset.Parse("2026-06-22T12:00:00Z", CultureInfo.InvariantCulture); + await store.UpdateStateAsync(10UL, serverId, 42UL, isActive: true, triggeredUtc: t); + + var a = await store.GetAsync(10UL, serverId, 42UL); + Assert.True(a!.LastIsActive); + Assert.Equal(t, a.LastTriggeredUtc); + } + + [Fact] + public async Task UpdateStateAsync_inactive_keeps_triggered_time() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + await store.AddAsync(10UL, serverId, 42UL, "Alarm 42", 1UL); + var t = DateTimeOffset.Parse("2026-06-22T12:00:00Z", CultureInfo.InvariantCulture); + await store.UpdateStateAsync(10UL, serverId, 42UL, isActive: true, triggeredUtc: t); + + await store.UpdateStateAsync(10UL, serverId, 42UL, isActive: false, triggeredUtc: null); + + var a = await store.GetAsync(10UL, serverId, 42UL); + Assert.False(a!.LastIsActive); + Assert.Equal(t, a.LastTriggeredUtc); // unchanged — only the active edge stamps it + } + + [Fact] + public async Task Remove_deletes_the_row() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + await store.AddAsync(10UL, serverId, 42UL, "Alarm 42", 7UL); + + await store.RemoveAsync(10UL, serverId, 42UL); + + Assert.Null(await store.GetAsync(10UL, serverId, 42UL)); + } + + [Fact] + public async Task Mutators_are_noops_when_alarm_absent() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + + // Should not throw. + await store.RenameAsync(10UL, serverId, 42UL, "x"); + await store.SetMessageIdAsync(10UL, serverId, 42UL, 1UL); + await store.SetPingEveryoneAsync(10UL, serverId, 42UL, true); + await store.SetRelayToTeamChatAsync(10UL, serverId, 42UL, true); + await store.UpdateStateAsync(10UL, serverId, 42UL, isActive: true, triggeredUtc: DateTimeOffset.UnixEpoch); + await store.RemoveAsync(10UL, serverId, 42UL); + + Assert.Null(await store.GetAsync(10UL, serverId, 42UL)); + } +} diff --git a/tests/RustPlusBot.Persistence.Tests/Alarms/SmartAlarmSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Alarms/SmartAlarmSchemaTests.cs new file mode 100644 index 0000000..3f08067 --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/Alarms/SmartAlarmSchemaTests.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore; +using RustPlusBot.Domain.Alarms; +using RustPlusBot.Domain.Servers; + +namespace RustPlusBot.Persistence.Tests.Alarms; + +public sealed class SmartAlarmSchemaTests +{ + [Fact] + public async Task RemovingServer_CascadeDeletesAlarms() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.2.3.4", Port = 28015 + }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + + context.SmartAlarms.Add(new SmartAlarm + { + GuildId = 10UL, + ServerId = server.Id, + EntityId = 42UL, + Name = "Alarm 42", + CreatedUtc = DateTimeOffset.UtcNow, + }); + await context.SaveChangesAsync(); + + context.RustServers.Remove(server); + await context.SaveChangesAsync(); + + Assert.Empty(await context.SmartAlarms.ToListAsync()); + } + + [Fact] + public async Task DuplicateEntityForSameServer_IsRejected() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.2.3.4", Port = 28015 + }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + + context.SmartAlarms.Add(new SmartAlarm + { + GuildId = 10UL, + ServerId = server.Id, + EntityId = 7UL, + Name = "a", + CreatedUtc = DateTimeOffset.UtcNow + }); + await context.SaveChangesAsync(); + context.SmartAlarms.Add(new SmartAlarm + { + GuildId = 10UL, + ServerId = server.Id, + EntityId = 7UL, + Name = "b", + CreatedUtc = DateTimeOffset.UtcNow + }); + + await Assert.ThrowsAsync(() => context.SaveChangesAsync()); + } +}