From e094017a6d75ea707fe503368ceb783571db1487 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 22 Jun 2026 17:44:27 +0200 Subject: [PATCH 01/17] feat(alarms): add SmartAlarm entity, EF config, and SmartAlarms migration --- src/RustPlusBot.Domain/Alarms/SmartAlarm.cs | 44 ++ src/RustPlusBot.Persistence/BotDbContext.cs | 5 + .../Configurations/SmartAlarmConfiguration.cs | 26 + .../20260622154342_SmartAlarms.Designer.cs | 663 ++++++++++++++++++ .../Migrations/20260622154342_SmartAlarms.cs | 62 ++ .../Migrations/BotDbContextModelSnapshot.cs | 63 ++ .../Alarms/SmartAlarmSchemaTests.cs | 49 ++ 7 files changed, 912 insertions(+) create mode 100644 src/RustPlusBot.Domain/Alarms/SmartAlarm.cs create mode 100644 src/RustPlusBot.Persistence/Configurations/SmartAlarmConfiguration.cs create mode 100644 src/RustPlusBot.Persistence/Migrations/20260622154342_SmartAlarms.Designer.cs create mode 100644 src/RustPlusBot.Persistence/Migrations/20260622154342_SmartAlarms.cs create mode 100644 tests/RustPlusBot.Persistence.Tests/Alarms/SmartAlarmSchemaTests.cs diff --git a/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs b/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs new file mode 100644 index 0000000..89b4c97 --- /dev/null +++ b/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs @@ -0,0 +1,44 @@ +namespace RustPlusBot.Domain.Alarms; + +/// A paired Smart Alarm the bot manages, surviving restarts. Guild- and server-scoped. Notify-only (FCM push). +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>" (the FCM event carries no name). + 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 fire pings @everyone in #alarms. + public bool PingEveryone { get; set; } + + /// When true, a fire relays the message into in-game team chat. + public bool RelayToTeamChat { get; set; } + + /// The title from the most recent fire, or null if never fired. + public string? LastTitle { get; set; } + + /// The message from the most recent fire, or null if never fired. + public string? LastMessage { get; set; } + + /// When the alarm most recently fired (UTC), or null if never. + public DateTimeOffset? LastFiredUtc { get; set; } +} 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/20260622154342_SmartAlarms.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260622154342_SmartAlarms.Designer.cs new file mode 100644 index 0000000..5b8bbc1 --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260622154342_SmartAlarms.Designer.cs @@ -0,0 +1,663 @@ +// +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("20260622154342_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("LastFiredUtc") + .HasColumnType("TEXT"); + + b.Property("LastMessage") + .HasColumnType("TEXT"); + + b.Property("LastTitle") + .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/20260622154342_SmartAlarms.cs b/src/RustPlusBot.Persistence/Migrations/20260622154342_SmartAlarms.cs new file mode 100644 index 0000000..a379947 --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260622154342_SmartAlarms.cs @@ -0,0 +1,62 @@ +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), + LastTitle = table.Column(type: "TEXT", nullable: true), + LastMessage = table.Column(type: "TEXT", nullable: true), + LastFiredUtc = 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..1d658a4 100644 --- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs +++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs @@ -122,6 +122,60 @@ 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("LastFiredUtc") + .HasColumnType("TEXT"); + + b.Property("LastMessage") + .HasColumnType("TEXT"); + + b.Property("LastTitle") + .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 +577,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/tests/RustPlusBot.Persistence.Tests/Alarms/SmartAlarmSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Alarms/SmartAlarmSchemaTests.cs new file mode 100644 index 0000000..3919d62 --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/Alarms/SmartAlarmSchemaTests.cs @@ -0,0 +1,49 @@ +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()); + } +} From 9092d013c0c3591bf5fdfed5e9b39786a35b172c Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 22 Jun 2026 17:50:35 +0200 Subject: [PATCH 02/17] feat(alarms): add IAlarmStore + AlarmStore with toggle + record-fired --- .../Alarms/AlarmStore.cs | 171 ++++++++++++ .../Alarms/IAlarmStore.cs | 143 ++++++++++ .../Alarms/AlarmStoreTests.cs | 259 ++++++++++++++++++ 3 files changed, 573 insertions(+) create mode 100644 src/RustPlusBot.Persistence/Alarms/AlarmStore.cs create mode 100644 src/RustPlusBot.Persistence/Alarms/IAlarmStore.cs create mode 100644 tests/RustPlusBot.Persistence.Tests/Alarms/AlarmStoreTests.cs diff --git a/src/RustPlusBot.Persistence/Alarms/AlarmStore.cs b/src/RustPlusBot.Persistence/Alarms/AlarmStore.cs new file mode 100644 index 0000000..79d8c51 --- /dev/null +++ b/src/RustPlusBot.Persistence/Alarms/AlarmStore.cs @@ -0,0 +1,171 @@ +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 RecordFiredAsync( + ulong guildId, + Guid serverId, + ulong entityId, + string? title, + string? message, + DateTimeOffset firedUtc, + CancellationToken ct = default) => + MutateAsync(guildId, serverId, entityId, a => + { + a.LastTitle = title; + a.LastMessage = message; + a.LastFiredUtc = firedUtc; + }, 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..c79834c --- /dev/null +++ b/src/RustPlusBot.Persistence/Alarms/IAlarmStore.cs @@ -0,0 +1,143 @@ +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 fire 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 fire 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); + + /// Records that the alarm fired, storing its title, message, and timestamp (no-op if absent). + /// Owning Discord guild snowflake. + /// The Rust server id. + /// The in-game smart-alarm entity id. + /// The FCM notification title, or null. + /// The FCM notification message body, or null. + /// When the alarm fired (UTC). + /// A cancellation token. + /// A task that completes when the fire record has been persisted. + Task RecordFiredAsync( + ulong guildId, + Guid serverId, + ulong entityId, + string? title, + string? message, + DateTimeOffset firedUtc, + 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/tests/RustPlusBot.Persistence.Tests/Alarms/AlarmStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Alarms/AlarmStoreTests.cs new file mode 100644 index 0000000..b8b4d35 --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/Alarms/AlarmStoreTests.cs @@ -0,0 +1,259 @@ +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.Null(loaded.LastTitle); + Assert.Null(loaded.LastMessage); + Assert.Null(loaded.LastFiredUtc); + } + + [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 RecordFiredAsync_StoresTitleMessageAndTimestamp() + { + 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 fired = DateTimeOffset.Parse("2026-06-22T12:00:00Z", CultureInfo.InvariantCulture); + await store.RecordFiredAsync(10UL, serverId, 42UL, "Raid!", "Base under attack", fired); + + var a = await store.GetAsync(10UL, serverId, 42UL); + Assert.Equal("Raid!", a!.LastTitle); + Assert.Equal("Base under attack", a.LastMessage); + Assert.Equal(fired, a.LastFiredUtc); + } + + [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.RecordFiredAsync(10UL, serverId, 42UL, "t", "m", DateTimeOffset.UnixEpoch); + await store.RemoveAsync(10UL, serverId, 42UL); + + Assert.Null(await store.GetAsync(10UL, serverId, 42UL)); + } +} From f5930b7396277a809fea88b5a370d6218ab9dc43 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 22 Jun 2026 17:55:21 +0200 Subject: [PATCH 03/17] fix(alarms): register IAlarmStore in DI --- .../PersistenceServiceCollectionExtensions.cs | 2 ++ 1 file changed, 2 insertions(+) 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(); From 14c0432ea7735c9f5f11055e95d457507134d455 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 22 Jun 2026 23:04:42 +0200 Subject: [PATCH 04/17] refactor(alarms): revise SmartAlarm to socket-state model (LastIsActive/LastTriggeredUtc; UpdateStateAsync) --- src/RustPlusBot.Domain/Alarms/SmartAlarm.cs | 19 +++++----- .../Alarms/AlarmStore.cs | 15 ++++---- .../Alarms/IAlarmStore.cs | 20 +++++------ ...=> 20260622210359_SmartAlarms.Designer.cs} | 11 +++--- ...larms.cs => 20260622210359_SmartAlarms.cs} | 5 ++- .../Migrations/BotDbContextModelSnapshot.cs | 9 ++--- .../Alarms/AlarmStoreTests.cs | 36 +++++++++++++------ 7 files changed, 60 insertions(+), 55 deletions(-) rename src/RustPlusBot.Persistence/Migrations/{20260622154342_SmartAlarms.Designer.cs => 20260622210359_SmartAlarms.Designer.cs} (98%) rename src/RustPlusBot.Persistence/Migrations/{20260622154342_SmartAlarms.cs => 20260622210359_SmartAlarms.cs} (90%) diff --git a/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs b/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs index 89b4c97..4033a9a 100644 --- a/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs +++ b/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs @@ -1,6 +1,6 @@ namespace RustPlusBot.Domain.Alarms; -/// A paired Smart Alarm the bot manages, surviving restarts. Guild- and server-scoped. Notify-only (FCM push). +/// 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. @@ -15,7 +15,7 @@ public sealed class SmartAlarm /// The in-game smart-alarm entity id. public ulong EntityId { get; set; } - /// User-facing label; defaults to a generated "Alarm <EntityId>" (the FCM event carries no name). + /// 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. @@ -27,18 +27,15 @@ public sealed class SmartAlarm /// When the alarm was accepted (UTC). public DateTimeOffset CreatedUtc { get; set; } - /// When true, a fire pings @everyone in #alarms. + /// When true, a trigger going active pings @everyone in #alarms. public bool PingEveryone { get; set; } - /// When true, a fire relays the message into in-game team chat. + /// When true, a trigger going active relays the message into in-game team chat. public bool RelayToTeamChat { get; set; } - /// The title from the most recent fire, or null if never fired. - public string? LastTitle { get; set; } + /// The last observed on/off state from the in-game socket broadcast. + public bool LastIsActive { get; set; } - /// The message from the most recent fire, or null if never fired. - public string? LastMessage { get; set; } - - /// When the alarm most recently fired (UTC), or null if never. - public DateTimeOffset? LastFiredUtc { 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.Persistence/Alarms/AlarmStore.cs b/src/RustPlusBot.Persistence/Alarms/AlarmStore.cs index 79d8c51..ed6995d 100644 --- a/src/RustPlusBot.Persistence/Alarms/AlarmStore.cs +++ b/src/RustPlusBot.Persistence/Alarms/AlarmStore.cs @@ -120,19 +120,20 @@ public Task SetRelayToTeamChatAsync( MutateAsync(guildId, serverId, entityId, a => a.RelayToTeamChat = value, ct); /// - public Task RecordFiredAsync( + public Task UpdateStateAsync( ulong guildId, Guid serverId, ulong entityId, - string? title, - string? message, - DateTimeOffset firedUtc, + bool isActive, + DateTimeOffset? triggeredUtc, CancellationToken ct = default) => MutateAsync(guildId, serverId, entityId, a => { - a.LastTitle = title; - a.LastMessage = message; - a.LastFiredUtc = firedUtc; + a.LastIsActive = isActive; + if (triggeredUtc is { } t) + { + a.LastTriggeredUtc = t; + } }, ct); /// diff --git a/src/RustPlusBot.Persistence/Alarms/IAlarmStore.cs b/src/RustPlusBot.Persistence/Alarms/IAlarmStore.cs index c79834c..8419d5d 100644 --- a/src/RustPlusBot.Persistence/Alarms/IAlarmStore.cs +++ b/src/RustPlusBot.Persistence/Alarms/IAlarmStore.cs @@ -83,7 +83,7 @@ Task SetMessageIdAsync( ulong messageId, CancellationToken ct = default); - /// Sets whether a fire pings @everyone in the #alarms channel (no-op if absent). + /// 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. @@ -97,7 +97,7 @@ Task SetPingEveryoneAsync( bool value, CancellationToken ct = default); - /// Sets whether a fire relays the message into in-game team chat (no-op if absent). + /// 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. @@ -111,22 +111,20 @@ Task SetRelayToTeamChatAsync( bool value, CancellationToken ct = default); - /// Records that the alarm fired, storing its title, message, and timestamp (no-op if absent). + /// 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 FCM notification title, or null. - /// The FCM notification message body, or null. - /// When the alarm fired (UTC). + /// 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 fire record has been persisted. - Task RecordFiredAsync( + /// A task that completes when the state has been persisted. + Task UpdateStateAsync( ulong guildId, Guid serverId, ulong entityId, - string? title, - string? message, - DateTimeOffset firedUtc, + bool isActive, + DateTimeOffset? triggeredUtc, CancellationToken ct = default); /// Removes an alarm (no-op if absent). diff --git a/src/RustPlusBot.Persistence/Migrations/20260622154342_SmartAlarms.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260622210359_SmartAlarms.Designer.cs similarity index 98% rename from src/RustPlusBot.Persistence/Migrations/20260622154342_SmartAlarms.Designer.cs rename to src/RustPlusBot.Persistence/Migrations/20260622210359_SmartAlarms.Designer.cs index 5b8bbc1..482e489 100644 --- a/src/RustPlusBot.Persistence/Migrations/20260622154342_SmartAlarms.Designer.cs +++ b/src/RustPlusBot.Persistence/Migrations/20260622210359_SmartAlarms.Designer.cs @@ -11,7 +11,7 @@ namespace RustPlusBot.Persistence.Migrations { [DbContext(typeof(BotDbContext))] - [Migration("20260622154342_SmartAlarms")] + [Migration("20260622210359_SmartAlarms")] partial class SmartAlarms { /// @@ -140,13 +140,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("GuildId") .HasColumnType("INTEGER"); - b.Property("LastFiredUtc") - .HasColumnType("TEXT"); - - b.Property("LastMessage") - .HasColumnType("TEXT"); + b.Property("LastIsActive") + .HasColumnType("INTEGER"); - b.Property("LastTitle") + b.Property("LastTriggeredUtc") .HasColumnType("TEXT"); b.Property("MessageId") diff --git a/src/RustPlusBot.Persistence/Migrations/20260622154342_SmartAlarms.cs b/src/RustPlusBot.Persistence/Migrations/20260622210359_SmartAlarms.cs similarity index 90% rename from src/RustPlusBot.Persistence/Migrations/20260622154342_SmartAlarms.cs rename to src/RustPlusBot.Persistence/Migrations/20260622210359_SmartAlarms.cs index a379947..fe88975 100644 --- a/src/RustPlusBot.Persistence/Migrations/20260622154342_SmartAlarms.cs +++ b/src/RustPlusBot.Persistence/Migrations/20260622210359_SmartAlarms.cs @@ -25,9 +25,8 @@ protected override void Up(MigrationBuilder migrationBuilder) CreatedUtc = table.Column(type: "TEXT", nullable: false), PingEveryone = table.Column(type: "INTEGER", nullable: false), RelayToTeamChat = table.Column(type: "INTEGER", nullable: false), - LastTitle = table.Column(type: "TEXT", nullable: true), - LastMessage = table.Column(type: "TEXT", nullable: true), - LastFiredUtc = table.Column(type: "TEXT", nullable: true) + LastIsActive = table.Column(type: "INTEGER", nullable: false), + LastTriggeredUtc = table.Column(type: "TEXT", nullable: true) }, constraints: table => { diff --git a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs index 1d658a4..1afec44 100644 --- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs +++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs @@ -137,13 +137,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("GuildId") .HasColumnType("INTEGER"); - b.Property("LastFiredUtc") - .HasColumnType("TEXT"); - - b.Property("LastMessage") - .HasColumnType("TEXT"); + b.Property("LastIsActive") + .HasColumnType("INTEGER"); - b.Property("LastTitle") + b.Property("LastTriggeredUtc") .HasColumnType("TEXT"); b.Property("MessageId") diff --git a/tests/RustPlusBot.Persistence.Tests/Alarms/AlarmStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Alarms/AlarmStoreTests.cs index b8b4d35..06c45bd 100644 --- a/tests/RustPlusBot.Persistence.Tests/Alarms/AlarmStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Alarms/AlarmStoreTests.cs @@ -47,9 +47,8 @@ public async Task Add_then_Get_round_trips_fields() Assert.Equal(DateTimeOffset.UnixEpoch, loaded.CreatedUtc); Assert.False(loaded.PingEveryone); Assert.False(loaded.RelayToTeamChat); - Assert.Null(loaded.LastTitle); - Assert.Null(loaded.LastMessage); - Assert.Null(loaded.LastFiredUtc); + Assert.False(loaded.LastIsActive); + Assert.Null(loaded.LastTriggeredUtc); } [Fact] @@ -207,7 +206,7 @@ public async Task SetRelayToTeamChat_toggles_the_flag() } [Fact] - public async Task RecordFiredAsync_StoresTitleMessageAndTimestamp() + public async Task UpdateStateAsync_active_sets_state_and_triggered_time() { var (store, context, conn) = Create(); await using var _ = conn; @@ -215,13 +214,30 @@ public async Task RecordFiredAsync_StoresTitleMessageAndTimestamp() var serverId = await SeedServerAsync(context); await store.AddAsync(10UL, serverId, 42UL, "Alarm 42", 1UL); - var fired = DateTimeOffset.Parse("2026-06-22T12:00:00Z", CultureInfo.InvariantCulture); - await store.RecordFiredAsync(10UL, serverId, 42UL, "Raid!", "Base under attack", fired); + 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.Equal("Raid!", a!.LastTitle); - Assert.Equal("Base under attack", a.LastMessage); - Assert.Equal(fired, a.LastFiredUtc); + 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] @@ -251,7 +267,7 @@ public async Task Mutators_are_noops_when_alarm_absent() await store.SetMessageIdAsync(10UL, serverId, 42UL, 1UL); await store.SetPingEveryoneAsync(10UL, serverId, 42UL, true); await store.SetRelayToTeamChatAsync(10UL, serverId, 42UL, true); - await store.RecordFiredAsync(10UL, serverId, 42UL, "t", "m", DateTimeOffset.UnixEpoch); + 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)); From 1ffe82641b82aeffff2ed42259219ee5a8c97eb9 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 22 Jun 2026 23:08:33 +0200 Subject: [PATCH 05/17] feat(alarms): add AlarmPairedEvent --- .../Events/AlarmPairedEvent.cs | 7 +++++++ .../AlarmPairedEventTests.cs | 14 ++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 src/RustPlusBot.Abstractions/Events/AlarmPairedEvent.cs create mode 100644 tests/RustPlusBot.Abstractions.Tests/AlarmPairedEventTests.cs 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/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); + } +} From c9aa0b160182c53a79ce48d32fc770e218d98374 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 22 Jun 2026 23:15:20 +0200 Subject: [PATCH 06/17] feat(alarms): route alarm pairings to AlarmPairedEvent + prime alarms on connect --- .../Supervisor/ConnectionSupervisor.cs | 46 +++++- .../Listening/PairingNotification.cs | 4 +- .../Listening/RustPlusFcmPairingSource.cs | 19 +++ .../Pairing/PairingHandler.cs | 18 ++- .../AlarmPrimingTests.cs | 132 ++++++++++++++++++ .../PairingHandlerTests.cs | 23 +++ 6 files changed, 232 insertions(+), 10 deletions(-) create mode 100644 tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs 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..9bc166a 100644 --- a/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs +++ b/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs @@ -71,12 +71,24 @@ 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/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..7781915 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,22 @@ 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()); + } } From 13294156129c4b5f14d11ccd3738ce3b8a635a92 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 22 Jun 2026 23:26:36 +0200 Subject: [PATCH 07/17] feat(alarms): provision #alarms channel + AlarmChannelLocator Co-Authored-By: Claude Sonnet 4.6 --- .../Localization/LocalizationCatalog.cs | 2 + .../Locating/AlarmChannelLocator.cs | 75 ++++++++++ .../Locating/IAlarmChannelLocator.cs | 12 ++ .../Specs/ServerWorkspaceSpecProvider.cs | 2 + .../WorkspaceKeys.cs | 3 + .../WorkspaceServiceCollectionExtensions.cs | 1 + .../Locating/AlarmChannelLocatorTests.cs | 136 ++++++++++++++++++ 7 files changed, 231 insertions(+) create mode 100644 src/RustPlusBot.Features.Workspace/Locating/AlarmChannelLocator.cs create mode 100644 src/RustPlusBot.Features.Workspace/Locating/IAlarmChannelLocator.cs create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Locating/AlarmChannelLocatorTests.cs 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..a367ced --- /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 void Dispose() => _refreshGate.Dispose(); + + /// + 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; + } + + 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/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); + } +} From 1788dc4bd8071ba5e68a8aeea2e0457bf4620625 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 22 Jun 2026 23:35:16 +0200 Subject: [PATCH 08/17] feat(alarms): scaffold Features.Alarms (component ids + localization catalog) Co-Authored-By: Claude Sonnet 4.6 --- RustPlusBot.slnx | 2 + .../Rendering/AlarmComponentIds.cs | 26 ++++++++ .../Rendering/AlarmLocalizationCatalog.cs | 60 +++++++++++++++++++ .../RustPlusBot.Features.Alarms.csproj | 22 +++++++ .../AlarmLocalizationCatalogTests.cs | 48 +++++++++++++++ .../RustPlusBot.Features.Alarms.Tests.csproj | 20 +++++++ 6 files changed, 178 insertions(+) create mode 100644 src/RustPlusBot.Features.Alarms/Rendering/AlarmComponentIds.cs create mode 100644 src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizationCatalog.cs create mode 100644 src/RustPlusBot.Features.Alarms/RustPlusBot.Features.Alarms.csproj create mode 100644 tests/RustPlusBot.Features.Alarms.Tests/AlarmLocalizationCatalogTests.cs create mode 100644 tests/RustPlusBot.Features.Alarms.Tests/RustPlusBot.Features.Alarms.Tests.csproj 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.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/AlarmLocalizationCatalog.cs b/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizationCatalog.cs new file mode 100644 index 0000000..c68b7ed --- /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 directly to the shared +/// RustPlusBot.Discord.Localization.ILocalizer 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/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/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/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 @@ + + + + + + + + + + + + + + + + + + + + From 4891830f6f325863a7dd4046ba70e0efa5caafef Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 22 Jun 2026 23:41:10 +0200 Subject: [PATCH 09/17] feat(alarms): add AlarmEmbedRenderer Co-Authored-By: Claude Sonnet 4.6 --- .../Rendering/AlarmEmbedRenderer.cs | 111 +++++++ .../Rendering/AlarmLocalizer.cs | 66 +++++ .../AlarmEmbedRendererTests.cs | 273 ++++++++++++++++++ 3 files changed, 450 insertions(+) create mode 100644 src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs create mode 100644 src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs create mode 100644 tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs diff --git a/src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs b/src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs new file mode 100644 index 0000000..2b49527 --- /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(AlarmLocalizer 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/AlarmLocalizer.cs b/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs new file mode 100644 index 0000000..f569e70 --- /dev/null +++ b/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs @@ -0,0 +1,66 @@ +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) +{ + private const string FallbackCulture = "en"; + + /// Gets the localized string for a key, or the key itself if not found. + /// The string key to resolve. + /// The BCP-47 culture tag (e.g. "en", "fr"). + 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; + } + + /// Gets the localized, -applied string. + /// The string key to resolve. + /// The BCP-47 culture tag (e.g. "en", "fr"). + /// Format arguments. + 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/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs new file mode 100644 index 0000000..e7c89f5 --- /dev/null +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs @@ -0,0 +1,273 @@ +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")); + } +} From 032d5403185bf51100e2d42758111628c4feae8c Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 22 Jun 2026 23:48:53 +0200 Subject: [PATCH 10/17] refactor(alarms): extract IAlarmLocalizer + fix stale catalog doc Fix 1: Update stale XML comment on AlarmLocalizationCatalog.Default to reference the actual per-slice AlarmLocalizer constructor instead of non-existent shared ILocalizer type. Fix 2: Extract IAlarmLocalizer interface (matching ISwitchLocalizer pattern) with Get(key, culture) and Get(key, culture, params args) methods. Make AlarmLocalizer implement it with on public methods. Update AlarmEmbedRenderer ctor to accept IAlarmLocalizer instead of concrete AlarmLocalizer. Co-Authored-By: Claude Opus 4.8 --- .../Rendering/AlarmEmbedRenderer.cs | 2 +- .../Rendering/AlarmLocalizationCatalog.cs | 4 ++-- .../Rendering/AlarmLocalizer.cs | 11 +++-------- .../Rendering/IAlarmLocalizer.cs | 16 ++++++++++++++++ 4 files changed, 22 insertions(+), 11 deletions(-) create mode 100644 src/RustPlusBot.Features.Alarms/Rendering/IAlarmLocalizer.cs diff --git a/src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs b/src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs index 2b49527..539202a 100644 --- a/src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs +++ b/src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs @@ -8,7 +8,7 @@ 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(AlarmLocalizer localizer, IClock clock) +internal sealed class AlarmEmbedRenderer(IAlarmLocalizer localizer, IClock clock) { /// Renders the alarm embed and its control buttons. /// The alarm to render. diff --git a/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizationCatalog.cs b/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizationCatalog.cs index c68b7ed..d13abaf 100644 --- a/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizationCatalog.cs +++ b/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizationCatalog.cs @@ -2,8 +2,8 @@ namespace RustPlusBot.Features.Alarms.Rendering; /// /// The in-memory string catalog for Smart Alarms: culture → (key → value). -/// English is the fallback. Intended to be passed directly to the shared -/// RustPlusBot.Discord.Localization.ILocalizer constructor. +/// English is the fallback. Intended to be passed to the per-slice +/// constructor. /// internal static class AlarmLocalizationCatalog { diff --git a/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs b/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs index f569e70..8f14712 100644 --- a/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs +++ b/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs @@ -5,13 +5,11 @@ 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) +internal sealed class AlarmLocalizer(IReadOnlyDictionary> catalog) : IAlarmLocalizer { private const string FallbackCulture = "en"; - /// Gets the localized string for a key, or the key itself if not found. - /// The string key to resolve. - /// The BCP-47 culture tag (e.g. "en", "fr"). + /// public string Get(string key, string culture) { var normalized = Normalize(culture); @@ -29,10 +27,7 @@ public string Get(string key, string culture) return key; } - /// Gets the localized, -applied string. - /// The string key to resolve. - /// The BCP-47 culture tag (e.g. "en", "fr"). - /// Format arguments. + /// public string Get(string key, string culture, params object[] args) { var format = Get(key, culture); 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); +} From b82c320e6e370a4517e8183773c2b563c96d2af9 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 22 Jun 2026 23:54:22 +0200 Subject: [PATCH 11/17] feat(alarms): add AlarmChannelPoster + AlarmRefresher + AlarmPairingCoordinator Co-Authored-By: Claude Opus 4.8 --- .../Pairing/AlarmPairingCoordinator.cs | 142 ++++++++++++++++++ .../Posting/DiscordAlarmChannelPoster.cs | 123 +++++++++++++++ .../Posting/IAlarmChannelPoster.cs | 26 ++++ .../Relaying/AlarmRefresher.cs | 60 ++++++++ .../Relaying/IAlarmRefresher.cs | 14 ++ .../AlarmPairingCoordinatorTests.cs | 140 +++++++++++++++++ .../AlarmRefresherTests.cs | 136 +++++++++++++++++ 7 files changed, 641 insertions(+) create mode 100644 src/RustPlusBot.Features.Alarms/Pairing/AlarmPairingCoordinator.cs create mode 100644 src/RustPlusBot.Features.Alarms/Posting/DiscordAlarmChannelPoster.cs create mode 100644 src/RustPlusBot.Features.Alarms/Posting/IAlarmChannelPoster.cs create mode 100644 src/RustPlusBot.Features.Alarms/Relaying/AlarmRefresher.cs create mode 100644 src/RustPlusBot.Features.Alarms/Relaying/IAlarmRefresher.cs create mode 100644 tests/RustPlusBot.Features.Alarms.Tests/AlarmPairingCoordinatorTests.cs create mode 100644 tests/RustPlusBot.Features.Alarms.Tests/AlarmRefresherTests.cs diff --git a/src/RustPlusBot.Features.Alarms/Pairing/AlarmPairingCoordinator.cs b/src/RustPlusBot.Features.Alarms/Pairing/AlarmPairingCoordinator.cs new file mode 100644 index 0000000..2b57191 --- /dev/null +++ b/src/RustPlusBot.Features.Alarms/Pairing/AlarmPairingCoordinator.cs @@ -0,0 +1,142 @@ +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); + if (await 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(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) + { + if (await ExistsAsync(guildId, serverId, entityId, cancellationToken).ConfigureAwait(false)) + { + _pending.TryRemove((guildId, serverId, entityId), out _); + return false; + } + + _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(); + 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(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 async Task ExistsAsync(ulong guildId, Guid serverId, ulong entityId, CancellationToken ct) + { + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + return await store.ExistsAsync(guildId, serverId, entityId, ct).ConfigureAwait(false); + } + } + + private async Task GetCultureAsync(ulong guildId, CancellationToken ct) + { + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.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..717f6e9 --- /dev/null +++ b/src/RustPlusBot.Features.Alarms/Relaying/AlarmRefresher.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.DependencyInjection; +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; + } + + var channelId = await locator.GetChannelIdAsync(guildId, serverId, ct).ConfigureAwait(false); + if (channelId is not { } channel) + { + return; + } + + var culture = await GetCultureAsync(scope.ServiceProvider, 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) + { + await store.SetMessageIdAsync(guildId, serverId, 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/IAlarmRefresher.cs b/src/RustPlusBot.Features.Alarms/Relaying/IAlarmRefresher.cs new file mode 100644 index 0000000..5b4caa6 --- /dev/null +++ b/src/RustPlusBot.Features.Alarms/Relaying/IAlarmRefresher.cs @@ -0,0 +1,14 @@ +namespace RustPlusBot.Features.Alarms.Relaying; + +/// Re-renders a single alarm's embed on demand (prime, reconnect, or trigger). +internal 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); +} diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmPairingCoordinatorTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmPairingCoordinatorTests.cs new file mode 100644 index 0000000..3fc28ec --- /dev/null +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmPairingCoordinatorTests.cs @@ -0,0 +1,140 @@ +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 + } + + [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..a8b5b74 --- /dev/null +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmRefresherTests.cs @@ -0,0 +1,136 @@ +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()); + } + + private sealed record Harness( + AlarmRefresher Refresher, + IAlarmStore Store, + IAlarmChannelPoster Poster, + IAlarmChannelLocator Locator); +} From ff4b7fce1c96a03de708c868c034ea57296151b7 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 22 Jun 2026 23:59:02 +0200 Subject: [PATCH 12/17] refactor(alarms): reuse open scope for coordinator culture read + assert prompt replacement Co-Authored-By: Claude Sonnet 4.6 --- .../Pairing/AlarmPairingCoordinator.cs | 70 ++++++++----------- .../AlarmPairingCoordinatorTests.cs | 6 ++ 2 files changed, 37 insertions(+), 39 deletions(-) diff --git a/src/RustPlusBot.Features.Alarms/Pairing/AlarmPairingCoordinator.cs b/src/RustPlusBot.Features.Alarms/Pairing/AlarmPairingCoordinator.cs index 2b57191..b9861ed 100644 --- a/src/RustPlusBot.Features.Alarms/Pairing/AlarmPairingCoordinator.cs +++ b/src/RustPlusBot.Features.Alarms/Pairing/AlarmPairingCoordinator.cs @@ -37,24 +37,30 @@ internal sealed class AlarmPairingCoordinator( public async Task HandlePairedAsync(AlarmPairedEvent evt, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(evt); - if (await 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) + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) { - return; - } + var store = scope.ServiceProvider.GetRequiredService(); + if (await store.ExistsAsync(evt.GuildId, evt.ServerId, evt.EntityId, cancellationToken).ConfigureAwait(false)) + { + return; + } - var culture = await GetCultureAsync(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); + 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. @@ -71,12 +77,6 @@ public async Task TryAcceptAsync( ulong acceptingUserId, CancellationToken cancellationToken) { - if (await ExistsAsync(guildId, serverId, entityId, cancellationToken).ConfigureAwait(false)) - { - _pending.TryRemove((guildId, serverId, entityId), out _); - return false; - } - _pending.TryGetValue((guildId, serverId, entityId), out var pending); var name = pending?.DefaultName ?? $"Alarm {entityId}"; @@ -84,13 +84,19 @@ public async Task TryAcceptAsync( 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(guildId, cancellationToken).ConfigureAwait(false); + 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. @@ -118,24 +124,10 @@ await store.SetMessageIdAsync(guildId, serverId, entityId, mid, cancellationToke public bool TryDismiss(ulong guildId, Guid serverId, ulong entityId) => _pending.TryRemove((guildId, serverId, entityId), out _); - private async Task ExistsAsync(ulong guildId, Guid serverId, ulong entityId, CancellationToken ct) - { - var scope = scopeFactory.CreateAsyncScope(); - await using (scope.ConfigureAwait(false)) - { - var store = scope.ServiceProvider.GetRequiredService(); - return await store.ExistsAsync(guildId, serverId, entityId, ct).ConfigureAwait(false); - } - } - - private async Task GetCultureAsync(ulong guildId, CancellationToken ct) + private static async Task GetCultureAsync(IServiceProvider provider, ulong guildId, CancellationToken ct) { - var scope = scopeFactory.CreateAsyncScope(); - await using (scope.ConfigureAwait(false)) - { - var store = scope.ServiceProvider.GetRequiredService(); - return await store.GetCultureAsync(guildId, ct).ConfigureAwait(false); - } + var store = provider.GetRequiredService(); + return await store.GetCultureAsync(guildId, ct).ConfigureAwait(false); } private sealed record Pending(string DefaultName, ulong? MessageId); diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmPairingCoordinatorTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmPairingCoordinatorTests.cs index 3fc28ec..2353312 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmPairingCoordinatorTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmPairingCoordinatorTests.cs @@ -91,6 +91,12 @@ public async Task Accept_persists_alarm_and_replaces_prompt() 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] From ab6962783dce9606f9d446fa26d4812c8d2a8b7b Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 23 Jun 2026 00:05:23 +0200 Subject: [PATCH 13/17] feat(alarms): add AlarmStateRelay (state update, active-edge ping/relay, unreachable) Co-Authored-By: Claude Sonnet 4.6 --- .../Relaying/AlarmStateRelay.cs | 160 ++++++++ .../AlarmStateRelayTests.cs | 341 ++++++++++++++++++ 2 files changed, 501 insertions(+) create mode 100644 src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs create mode 100644 tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs diff --git a/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs b/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs new file mode 100644 index 0000000..c9a02c8 --- /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) + { + await refresher.RefreshAsync(evt.GuildId, evt.ServerId, alarm.EntityId, 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/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs new file mode 100644 index 0000000..bb6652d --- /dev/null +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs @@ -0,0 +1,341 @@ +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(10UL, serverId, 42UL, unreachable: true, Arg.Any()); + await h.Refresher.Received(1).RefreshAsync(10UL, serverId, 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()); + } + + private sealed record Harness( + AlarmStateRelay Relay, + IAlarmStore Store, + IAlarmRefresher Refresher, + IAlarmChannelPoster Poster, + ITeamChatSender TeamChatSender, + IConnectionStore Connections); +} From aa5d9ae34b479124f9b08f9214e9c2f14e38255b Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 23 Jun 2026 00:11:40 +0200 Subject: [PATCH 14/17] feat(alarms): add AlarmComponentModule + rename modal Co-Authored-By: Claude Sonnet 4.6 --- .../Modules/AlarmComponentModule.cs | 216 ++++++++++++++++++ .../Modules/AlarmRenameModal.cs | 17 ++ .../Relaying/IAlarmRefresher.cs | 2 +- 3 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 src/RustPlusBot.Features.Alarms/Modules/AlarmComponentModule.cs create mode 100644 src/RustPlusBot.Features.Alarms/Modules/AlarmRenameModal.cs diff --git a/src/RustPlusBot.Features.Alarms/Modules/AlarmComponentModule.cs b/src/RustPlusBot.Features.Alarms/Modules/AlarmComponentModule.cs new file mode 100644 index 0000000..74bbc2e --- /dev/null +++ b/src/RustPlusBot.Features.Alarms/Modules/AlarmComponentModule.cs @@ -0,0 +1,216 @@ +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/Relaying/IAlarmRefresher.cs b/src/RustPlusBot.Features.Alarms/Relaying/IAlarmRefresher.cs index 5b4caa6..fc0a27f 100644 --- a/src/RustPlusBot.Features.Alarms/Relaying/IAlarmRefresher.cs +++ b/src/RustPlusBot.Features.Alarms/Relaying/IAlarmRefresher.cs @@ -1,7 +1,7 @@ namespace RustPlusBot.Features.Alarms.Relaying; /// Re-renders a single alarm's embed on demand (prime, reconnect, or trigger). -internal interface IAlarmRefresher +public interface IAlarmRefresher { /// Loads the alarm, renders it, and posts or edits its embed. /// The owning Discord guild snowflake. From 9401d6ea9b9a82aa634fa6f4ab5c6196928b0d37 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 23 Jun 2026 00:22:37 +0200 Subject: [PATCH 15/17] feat(alarms): add AlarmsHostedService, AddAlarms DI, and Host wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the smart-alarms slice live: AlarmsHostedService drives three consume loops (AlarmPairedEvent → coordinator, SmartDeviceTriggeredEvent → relay, ConnectionStatusChangedEvent → relay); AddAlarms registers all slice singletons and the hosted service; Host references the Alarms project and calls AddAlarms() after AddSwitches(). AlarmRegistrationTests covers both descriptor presence and captive-dependency-free resolution. Co-Authored-By: Claude Sonnet 4.6 --- .../AlarmServiceCollectionExtensions.cs | 34 +++++ .../Hosting/AlarmsHostedService.cs | 133 ++++++++++++++++++ src/RustPlusBot.Host/Program.cs | 2 + src/RustPlusBot.Host/RustPlusBot.Host.csproj | 1 + .../AlarmRegistrationTests.cs | 83 +++++++++++ 5 files changed, 253 insertions(+) create mode 100644 src/RustPlusBot.Features.Alarms/AlarmServiceCollectionExtensions.cs create mode 100644 src/RustPlusBot.Features.Alarms/Hosting/AlarmsHostedService.cs create mode 100644 tests/RustPlusBot.Features.Alarms.Tests/AlarmRegistrationTests.cs 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.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/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); + } +} From 78477d07a2ab00814350c3c76de992cdfccf10f0 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 23 Jun 2026 00:22:53 +0200 Subject: [PATCH 16/17] style: apply jb cleanupcode ReformatAndReorder to alarm-slice files Whitespace-only line-wrap reformats produced by dotnet jb cleanupcode --profile=ReformatAndReorder. No semantic changes. Co-Authored-By: Claude Sonnet 4.6 --- .../Modules/AlarmComponentModule.cs | 3 +- .../Pairing/AlarmPairingCoordinator.cs | 9 +++-- .../Rendering/AlarmLocalizer.cs | 3 +- .../Pairing/PairingHandler.cs | 11 ++++-- .../Locating/AlarmChannelLocator.cs | 6 +-- .../AlarmEmbedRendererTests.cs | 18 ++++++--- .../AlarmRefresherTests.cs | 5 +-- .../AlarmStateRelayTests.cs | 38 ++++++++++--------- .../PairingHandlerTests.cs | 3 +- .../Alarms/SmartAlarmSchemaTests.cs | 34 ++++++++++++++--- 10 files changed, 86 insertions(+), 44 deletions(-) diff --git a/src/RustPlusBot.Features.Alarms/Modules/AlarmComponentModule.cs b/src/RustPlusBot.Features.Alarms/Modules/AlarmComponentModule.cs index 74bbc2e..6342659 100644 --- a/src/RustPlusBot.Features.Alarms/Modules/AlarmComponentModule.cs +++ b/src/RustPlusBot.Features.Alarms/Modules/AlarmComponentModule.cs @@ -89,7 +89,8 @@ public async Task PingToggleAsync(string tail) } await store - .SetPingEveryoneAsync(Context.Guild.Id, serverId, entityId, !current.PingEveryone, CancellationToken.None) + .SetPingEveryoneAsync(Context.Guild.Id, serverId, entityId, !current.PingEveryone, + CancellationToken.None) .ConfigureAwait(false); } diff --git a/src/RustPlusBot.Features.Alarms/Pairing/AlarmPairingCoordinator.cs b/src/RustPlusBot.Features.Alarms/Pairing/AlarmPairingCoordinator.cs index b9861ed..f40b9e4 100644 --- a/src/RustPlusBot.Features.Alarms/Pairing/AlarmPairingCoordinator.cs +++ b/src/RustPlusBot.Features.Alarms/Pairing/AlarmPairingCoordinator.cs @@ -42,7 +42,8 @@ public async Task HandlePairedAsync(AlarmPairedEvent evt, CancellationToken canc await using (scope.ConfigureAwait(false)) { var store = scope.ServiceProvider.GetRequiredService(); - if (await store.ExistsAsync(evt.GuildId, evt.ServerId, evt.EntityId, cancellationToken).ConfigureAwait(false)) + if (await store.ExistsAsync(evt.GuildId, evt.ServerId, evt.EntityId, cancellationToken) + .ConfigureAwait(false)) { return; } @@ -54,7 +55,8 @@ public async Task HandlePairedAsync(AlarmPairedEvent evt, CancellationToken canc return; } - var culture = await GetCultureAsync(scope.ServiceProvider, evt.GuildId, cancellationToken).ConfigureAwait(false); + 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) @@ -96,7 +98,8 @@ public async Task TryAcceptAsync( var channelId = await locator.GetChannelIdAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); if (channelId is { } channel) { - var culture = await GetCultureAsync(scope.ServiceProvider, guildId, cancellationToken).ConfigureAwait(false); + 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. diff --git a/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs b/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs index 8f14712..aa0d448 100644 --- a/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs +++ b/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs @@ -5,7 +5,8 @@ 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 +internal sealed class AlarmLocalizer(IReadOnlyDictionary> catalog) + : IAlarmLocalizer { private const string FallbackCulture = "en"; diff --git a/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs b/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs index 9bc166a..16455ad 100644 --- a/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs +++ b/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs @@ -74,10 +74,14 @@ private async Task HandleEntityAsync( switch (notification.EntityKind) { case RustPlusBot.Domain.Entities.PairedEntityKind.SmartSwitch: - await eventBus.PublishAsync(new SwitchPairedEvent(guildId, server.Id, notification.EntityId), cancellationToken).ConfigureAwait(false); + 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); + await eventBus + .PublishAsync(new AlarmPairedEvent(guildId, server.Id, notification.EntityId), cancellationToken) + .ConfigureAwait(false); break; default: LogUnroutedEntityKind(logger, notification.EntityKind); @@ -90,5 +94,6 @@ private async Task HandleEntityAsync( 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); + private static partial void + LogUnroutedEntityKind(ILogger logger, RustPlusBot.Domain.Entities.PairedEntityKind kind); } diff --git a/src/RustPlusBot.Features.Workspace/Locating/AlarmChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/AlarmChannelLocator.cs index a367ced..a2aa335 100644 --- a/src/RustPlusBot.Features.Workspace/Locating/AlarmChannelLocator.cs +++ b/src/RustPlusBot.Features.Workspace/Locating/AlarmChannelLocator.cs @@ -20,9 +20,6 @@ internal sealed class AlarmChannelLocator(IServiceScopeFactory scopeFactory, ICl private Dictionary<(ulong GuildId, Guid ServerId), ulong> _byServer = new(); - /// - public void Dispose() => _refreshGate.Dispose(); - /// public async Task GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) { @@ -30,6 +27,9 @@ internal sealed class AlarmChannelLocator(IServiceScopeFactory scopeFactory, ICl 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) diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs index e7c89f5..4fad778 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs @@ -113,7 +113,8 @@ public void RenderAlarm_triggered_days_ago_shows_days_format() 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)); + var btn = Buttons(components).Single(b => + b.CustomId!.StartsWith(AlarmComponentIds.PingTogglePrefix, StringComparison.Ordinal)); Assert.Equal(ButtonStyle.Secondary, btn.Style); Assert.False(btn.IsDisabled); @@ -123,7 +124,8 @@ public void RenderAlarm_ping_off_button_is_secondary_style() 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)); + var btn = Buttons(components).Single(b => + b.CustomId!.StartsWith(AlarmComponentIds.PingTogglePrefix, StringComparison.Ordinal)); Assert.Equal(ButtonStyle.Success, btn.Style); Assert.False(btn.IsDisabled); @@ -135,7 +137,8 @@ public void RenderAlarm_ping_on_button_is_success_style() 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)); + var btn = Buttons(components).Single(b => + b.CustomId!.StartsWith(AlarmComponentIds.RelayTogglePrefix, StringComparison.Ordinal)); Assert.Equal(ButtonStyle.Secondary, btn.Style); Assert.False(btn.IsDisabled); @@ -145,7 +148,8 @@ public void RenderAlarm_relay_off_button_is_secondary_style() 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)); + var btn = Buttons(components).Single(b => + b.CustomId!.StartsWith(AlarmComponentIds.RelayTogglePrefix, StringComparison.Ordinal)); Assert.Equal(ButtonStyle.Success, btn.Style); Assert.False(btn.IsDisabled); @@ -169,7 +173,8 @@ public void RenderAlarm_unreachable_disables_all_buttons() 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)); + var btn = Buttons(components).Single(b => + b.CustomId!.StartsWith(AlarmComponentIds.PingTogglePrefix, StringComparison.Ordinal)); Assert.Contains("on", btn.Label, StringComparison.OrdinalIgnoreCase); } @@ -178,7 +183,8 @@ public void RenderAlarm_ping_on_label_contains_on() 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)); + var btn = Buttons(components).Single(b => + b.CustomId!.StartsWith(AlarmComponentIds.PingTogglePrefix, StringComparison.Ordinal)); Assert.Contains("off", btn.Label, StringComparison.OrdinalIgnoreCase); } diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmRefresherTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmRefresherTests.cs index a8b5b74..ae8d61f 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmRefresherTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmRefresherTests.cs @@ -114,10 +114,7 @@ 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", + GuildId = 10UL, ServerId = serverId, EntityId = 42UL, Name = "Fire Alarm", }; var h = Create(alarm: alarm, channelId: null); diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs index bb6652d..f9a7770 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs @@ -81,10 +81,7 @@ 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", + GuildId = 10UL, ServerId = serverId, EntityId = 42UL, Name = "Perimeter", }; var h = Create(alarm: alarm); @@ -93,7 +90,8 @@ await h.Relay.HandleTriggeredAsync( 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.Refresher.Received(1) + .RefreshAsync(10UL, serverId, 42UL, unreachable: false, Arg.Any()); } /// Active trigger with PingEveryone sends the @everyone message. @@ -209,7 +207,8 @@ await h.Relay.HandleTriggeredAsync( 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.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() @@ -272,7 +271,8 @@ await h.Relay.HandleTriggeredAsync( 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.Refresher.Received(1) + .RefreshAsync(10UL, serverId, 42UL, unreachable: false, Arg.Any()); await h.Poster.Received(1).SendEveryonePingAsync(777UL, "@everyone Fire", Arg.Any()); } @@ -290,23 +290,29 @@ public async Task ConnectionStatus_not_connected_refreshes_all_alarms_unreachabl h.Connections.GetStateAsync(10UL, serverId, Arg.Any()) .Returns(new ConnectionState { - GuildId = 10UL, - RustServerId = serverId, - Status = ConnectionStatus.Unreachable, + 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" }, + 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(10UL, serverId, 42UL, unreachable: true, Arg.Any()); - await h.Refresher.Received(1).RefreshAsync(10UL, serverId, 43UL, unreachable: true, Arg.Any()); + await h.Refresher.Received(1) + .RefreshAsync(10UL, serverId, 42UL, unreachable: true, Arg.Any()); + await h.Refresher.Received(1) + .RefreshAsync(10UL, serverId, 43UL, unreachable: true, Arg.Any()); } /// Connected server → no-op (supervisor's prime path handles it). @@ -319,9 +325,7 @@ public async Task ConnectionStatus_connected_does_nothing() h.Connections.GetStateAsync(10UL, serverId, Arg.Any()) .Returns(new ConnectionState { - GuildId = 10UL, - RustServerId = serverId, - Status = ConnectionStatus.Connected, + GuildId = 10UL, RustServerId = serverId, Status = ConnectionStatus.Connected, }); await h.Relay.HandleConnectionStatusAsync( diff --git a/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs index 7781915..9ab4821 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs @@ -133,7 +133,8 @@ public async Task EntityPairing_UnknownServer_DropsAndCreatesNothing() public async Task EntityPairing_Alarm_PublishesAlarmPairedEvent_NotSwitch() { var (context, connection) = TestDb.Create(); - await using var _ = context; await using var __ = connection; + 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); diff --git a/tests/RustPlusBot.Persistence.Tests/Alarms/SmartAlarmSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Alarms/SmartAlarmSchemaTests.cs index 3919d62..3f08067 100644 --- a/tests/RustPlusBot.Persistence.Tests/Alarms/SmartAlarmSchemaTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Alarms/SmartAlarmSchemaTests.cs @@ -13,13 +13,20 @@ public async Task RemovingServer_CascadeDeletesAlarms() await using var _ = context; await using var __ = connection; - var server = new RustServer { GuildId = 10UL, Name = "S", Ip = "1.2.3.4", Port = 28015 }; + 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, + GuildId = 10UL, + ServerId = server.Id, + EntityId = 42UL, + Name = "Alarm 42", + CreatedUtc = DateTimeOffset.UtcNow, }); await context.SaveChangesAsync(); @@ -36,13 +43,30 @@ public async Task DuplicateEntityForSameServer_IsRejected() await using var _ = context; await using var __ = connection; - var server = new RustServer { GuildId = 10UL, Name = "S", Ip = "1.2.3.4", Port = 28015 }; + 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 }); + 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 }); + context.SmartAlarms.Add(new SmartAlarm + { + GuildId = 10UL, + ServerId = server.Id, + EntityId = 7UL, + Name = "b", + CreatedUtc = DateTimeOffset.UtcNow + }); await Assert.ThrowsAsync(() => context.SaveChangesAsync()); } From c4d545e25398b6de02592f81ff5fbf60a3bc07fe Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 23 Jun 2026 01:01:42 +0200 Subject: [PATCH 17/17] perf(alarms): reuse loaded alarms on reconnect refresh to avoid N+1 re-fetch Add an IAlarmRefresher.RefreshAsync(SmartAlarm, ...) overload that renders an already-loaded alarm, skipping the per-alarm store.GetAsync. The id-based method now fetches-then-delegates via a shared RenderAndPostAsync helper. HandleConnectionStatusAsync passes the alarms it already listed instead of re-querying each by id. Co-Authored-By: Claude Opus 4.8 --- .../Relaying/AlarmRefresher.cs | 50 ++++++++++++++----- .../Relaying/AlarmStateRelay.cs | 4 +- .../Relaying/IAlarmRefresher.cs | 12 +++++ .../AlarmRefresherTests.cs | 23 +++++++++ .../AlarmStateRelayTests.cs | 10 ++-- 5 files changed, 80 insertions(+), 19 deletions(-) diff --git a/src/RustPlusBot.Features.Alarms/Relaying/AlarmRefresher.cs b/src/RustPlusBot.Features.Alarms/Relaying/AlarmRefresher.cs index 717f6e9..53badfe 100644 --- a/src/RustPlusBot.Features.Alarms/Relaying/AlarmRefresher.cs +++ b/src/RustPlusBot.Features.Alarms/Relaying/AlarmRefresher.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Domain.Alarms; using RustPlusBot.Features.Alarms.Posting; using RustPlusBot.Features.Alarms.Rendering; using RustPlusBot.Features.Workspace.Locating; @@ -31,21 +32,44 @@ public async Task RefreshAsync(ulong guildId, Guid serverId, ulong entityId, boo return; } - var channelId = await locator.GetChannelIdAsync(guildId, serverId, ct).ConfigureAwait(false); - if (channelId is not { } channel) - { - return; - } + await RenderAndPostAsync(scope.ServiceProvider, alarm, unreachable, ct).ConfigureAwait(false); + } + } + + /// + public async Task RefreshAsync(SmartAlarm alarm, bool unreachable, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(alarm); - var culture = await GetCultureAsync(scope.ServiceProvider, guildId, ct).ConfigureAwait(false); - var (embed, components) = renderer.RenderAlarm(alarm, unreachable, culture); - var newMessageId = await poster - .EnsureAsync(channel, alarm.MessageId, embed, components, ct) + 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); - if (newMessageId is { } mid && mid != alarm.MessageId) - { - await store.SetMessageIdAsync(guildId, serverId, entityId, mid, ct).ConfigureAwait(false); - } } } diff --git a/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs b/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs index c9a02c8..44381b7 100644 --- a/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs +++ b/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs @@ -121,8 +121,8 @@ public async Task HandleConnectionStatusAsync(ConnectionStatusChangedEvent evt, var alarms = await store.ListByServerAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false); foreach (var alarm in alarms) { - await refresher.RefreshAsync(evt.GuildId, evt.ServerId, alarm.EntityId, unreachable: true, ct) - .ConfigureAwait(false); + // Reuse the already-loaded alarm rather than re-fetching each by id. + await refresher.RefreshAsync(alarm, unreachable: true, ct).ConfigureAwait(false); } } } diff --git a/src/RustPlusBot.Features.Alarms/Relaying/IAlarmRefresher.cs b/src/RustPlusBot.Features.Alarms/Relaying/IAlarmRefresher.cs index fc0a27f..b50cf78 100644 --- a/src/RustPlusBot.Features.Alarms/Relaying/IAlarmRefresher.cs +++ b/src/RustPlusBot.Features.Alarms/Relaying/IAlarmRefresher.cs @@ -1,3 +1,5 @@ +using RustPlusBot.Domain.Alarms; + namespace RustPlusBot.Features.Alarms.Relaying; /// Re-renders a single alarm's embed on demand (prime, reconnect, or trigger). @@ -11,4 +13,14 @@ public interface IAlarmRefresher /// 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/tests/RustPlusBot.Features.Alarms.Tests/AlarmRefresherTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmRefresherTests.cs index ae8d61f..7220cda 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmRefresherTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmRefresherTests.cs @@ -125,6 +125,29 @@ await h.Poster.DidNotReceive().EnsureAsync(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, diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs index f9a7770..858bfcd 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs @@ -309,10 +309,10 @@ public async Task ConnectionStatus_not_connected_refreshes_all_alarms_unreachabl await h.Relay.HandleConnectionStatusAsync( new ConnectionStatusChangedEvent(10UL, serverId), CancellationToken.None); - await h.Refresher.Received(1) - .RefreshAsync(10UL, serverId, 42UL, unreachable: true, Arg.Any()); - await h.Refresher.Received(1) - .RefreshAsync(10UL, serverId, 43UL, unreachable: true, Arg.Any()); + 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). @@ -333,6 +333,8 @@ await h.Relay.HandleConnectionStatusAsync( 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(