Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
ulong entityId,
CancellationToken cancellationToken);

/// <summary>Reads a smart alarm's live state and reachability (kind-aware Rust+ read).</summary>
/// <param name="guildId">The owning guild snowflake.</param>
/// <param name="serverId">The target server id.</param>
/// <param name="entityId">The in-game smart-alarm entity id.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The reading; <see cref="DeviceReachability.NoResponse"/> with a null state when there is no live socket.</returns>
Task<DeviceReading> GetSmartAlarmReadingAsync(ulong guildId,
Guid serverId,
ulong entityId,
CancellationToken cancellationToken);

/// <summary>Reads a storage monitor's contents for a (guild, server), or null when there is no live socket.</summary>
/// <param name="guildId">The guild snowflake.</param>
/// <param name="serverId">The server id.</param>
Expand Down
14 changes: 14 additions & 0 deletions src/RustPlusBot.Abstractions/Connections/SmartDeviceKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace RustPlusBot.Abstractions.Connections;

/// <summary>
/// The kind of binary-state smart device behind an entity id. The Rust+ API validates the entity type
/// on reads, so callers must ask for the kind they actually paired (a switch read against an alarm fails).
/// </summary>
public enum SmartDeviceKind
{
/// <summary>A smart switch.</summary>
Switch = 0,

/// <summary>A smart alarm.</summary>
Alarm = 1,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace RustPlusBot.Abstractions.Events;

/// <summary>
/// A managed smart device's on/off state as <em>observed</em> by a read (connect prime, periodic sweep,
/// or manual refresh) — as opposed to <see cref="SmartDeviceTriggeredEvent"/>, which reports an in-game
/// broadcast. Consumers correct drifted state silently: an observation must never ping or relay.
/// </summary>
/// <param name="GuildId">The owning Discord guild snowflake.</param>
/// <param name="ServerId">The local Rust server id.</param>
/// <param name="EntityId">The in-game entity id (the discriminant — features filter to the ids they manage).</param>
/// <param name="IsActive">The observed on/off state.</param>
public sealed record SmartDeviceStateObservedEvent(ulong GuildId, Guid ServerId, ulong EntityId, bool IsActive);
33 changes: 30 additions & 3 deletions src/RustPlusBot.Features.Alarms/Hosting/AlarmsHostedService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@

namespace RustPlusBot.Features.Alarms.Hosting;

/// <summary>Runs the alarm-pairing loop, the alarm-triggered relay loop, the connection-status relay loop, and the per-device reachability loop.</summary>
/// <summary>Runs the alarm-pairing loop, the alarm-triggered relay loop, the connection-status relay loop, the per-device reachability loop, and the observed-state sync loop.</summary>
/// <param name="eventBus">The in-process event bus.</param>
/// <param name="coordinator">Handles paired alarms.</param>
/// <param name="relay">Re-renders alarms on trigger/connection/reachability changes.</param>
/// <param name="relay">Re-renders alarms on trigger/connection/reachability/observed-state changes.</param>
/// <param name="logger">The logger.</param>
internal sealed partial class AlarmsHostedService(
IEventBus eventBus,
Expand All @@ -18,6 +18,7 @@ internal sealed partial class AlarmsHostedService(
ILogger<AlarmsHostedService> logger) : IHostedService, IDisposable
{
private readonly CancellationTokenSource _cts = new();
private Task? _observedLoop;
private Task? _pairedLoop;
private Task? _reachabilityLoop;
private Task? _statusLoop;
Expand All @@ -33,6 +34,7 @@ public Task StartAsync(CancellationToken cancellationToken)
_triggeredLoop = Task.Run(() => ConsumeTriggeredAsync(_cts.Token), CancellationToken.None);
_statusLoop = Task.Run(() => ConsumeStatusAsync(_cts.Token), CancellationToken.None);
_reachabilityLoop = Task.Run(() => ConsumeReachabilityChangedAsync(_cts.Token), CancellationToken.None);
_observedLoop = Task.Run(() => ConsumeStateObservedAsync(_cts.Token), CancellationToken.None);
return Task.CompletedTask;
}

Expand All @@ -42,7 +44,7 @@ public async Task StopAsync(CancellationToken cancellationToken)
await _cts.CancelAsync().ConfigureAwait(false);
foreach (var loop in new[]
{
_pairedLoop, _triggeredLoop, _statusLoop, _reachabilityLoop
_pairedLoop, _triggeredLoop, _statusLoop, _reachabilityLoop, _observedLoop
}.Where(t => t is not null))
{
try
Expand Down Expand Up @@ -146,6 +148,28 @@ private async Task ConsumeReachabilityChangedAsync(CancellationToken cancellatio
}
}

private async Task ConsumeStateObservedAsync(CancellationToken cancellationToken)
{
try
{
await foreach (var evt in eventBus.SubscribeAsync<SmartDeviceStateObservedEvent>(cancellationToken)
.ConfigureAwait(false))
{
await relay.HandleStateObservedAsync(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
{
LogObservedLoopFaulted(logger, ex);
}
}

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

Expand All @@ -157,4 +181,7 @@ private async Task ConsumeReachabilityChangedAsync(CancellationToken cancellatio

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

[LoggerMessage(Level = LogLevel.Error, Message = "Alarm observed-state sync loop faulted.")]
private static partial void LogObservedLoopFaulted(ILogger logger, Exception exception);
}
41 changes: 40 additions & 1 deletion src/RustPlusBot.Features.Alarms/Modules/AlarmComponentModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using Discord;
using Discord.Interactions;
using Microsoft.Extensions.DependencyInjection;
using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Features.Alarms.Pairing;
using RustPlusBot.Features.Alarms.Relaying;
using RustPlusBot.Features.Alarms.Rendering;
Expand All @@ -12,9 +14,13 @@ namespace RustPlusBot.Features.Alarms.Modules;
/// <summary>Thin handler for the #alarms pairing prompt + control buttons + rename modal. Any guild member.</summary>
/// <param name="scopeFactory">Creates a short-lived DI scope per interaction.</param>
/// <param name="refresher">Re-renders the alarm embed on demand.</param>
/// <param name="query">Live socket read (for Refresh).</param>
/// <param name="eventBus">Publishes reachability/observed-state events to drive an embed refresh.</param>
public sealed class AlarmComponentModule(
IServiceScopeFactory scopeFactory,
IAlarmRefresher refresher) : InteractionModuleBase<SocketInteractionContext>
IAlarmRefresher refresher,
IRustServerQuery query,
IEventBus eventBus) : InteractionModuleBase<SocketInteractionContext>
{
private const string InvalidControlMessage = "That control wasn't valid.";

Expand Down Expand Up @@ -136,6 +142,39 @@ await refresher.RefreshAsync(Context.Guild.Id, serverId, entityId, unreachable:
await FollowupAsync("Updated.", ephemeral: true).ConfigureAwait(false);
}

/// <summary>Re-reads the alarm's live state and republishes it so the embed refreshes.</summary>
/// <param name="tail">The "{serverId}:{entityId}" custom-id tail.</param>
[ComponentInteraction(AlarmComponentIds.RefreshPrefix + "*")]
public async Task RefreshAsync(string tail)
{
if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null)
{
await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
return;
}

await DeferAsync(ephemeral: true).ConfigureAwait(false);
var reading = await query
.GetSmartAlarmReadingAsync(Context.Guild.Id, serverId, entityId, CancellationToken.None)
.ConfigureAwait(false);

// Persist/render stays in the relay pipelines — this handler only reads and publishes.
await eventBus
.PublishAsync(new DeviceReachabilityChangedEvent(Context.Guild.Id, serverId, entityId,
reading.Reachability))
.ConfigureAwait(false);
if (reading is { Reachability: DeviceReachability.Reachable, IsActive: { } isActive })
{
await eventBus
.PublishAsync(new SmartDeviceStateObservedEvent(Context.Guild.Id, serverId, entityId, isActive))
.ConfigureAwait(false);
await FollowupAsync("Refreshed.", ephemeral: true).ConfigureAwait(false);
return;
}

await FollowupAsync("Alarm is unreachable right now.", ephemeral: true).ConfigureAwait(false);
}

/// <summary>Opens the rename modal, carrying the target tail in the modal custom id.</summary>
/// <param name="tail">The "{serverId}:{entityId}" custom-id tail.</param>
[ComponentInteraction(AlarmComponentIds.RenamePrefix + "*")]
Expand Down
33 changes: 33 additions & 0 deletions src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,39 @@ public async Task HandleConnectionStatusAsync(ConnectionStatusChangedEvent evt,
}
}

/// <summary>
/// Handles an observed (prime/sweep/refresh) state reading: if it drifted from the persisted state,
/// persists it and re-renders the embed. Never pings or relays — only real triggers notify. The
/// last-triggered timestamp is left untouched (an observation cannot tell when the change happened).
/// </summary>
/// <param name="evt">The observed-state event.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A task that completes when any drift has been persisted and re-rendered.</returns>
public async Task HandleStateObservedAsync(
SmartDeviceStateObservedEvent evt,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(evt);
var scope = scopeFactory.CreateAsyncScope();
await using (scope.ConfigureAwait(false))
{
var store = scope.ServiceProvider.GetRequiredService<IAlarmStore>();
var alarm = await store.GetAsync(evt.GuildId, evt.ServerId, evt.EntityId, cancellationToken)
.ConfigureAwait(false);
if (alarm is null || alarm.LastIsActive == evt.IsActive)
{
return; // foreign entity, or no drift — nothing to do (no Discord edit in steady state).
}

await store.UpdateStateAsync(evt.GuildId, evt.ServerId, evt.EntityId, evt.IsActive,
triggeredUtc: null, cancellationToken)
.ConfigureAwait(false);
}

await refresher.RefreshAsync(evt.GuildId, evt.ServerId, evt.EntityId, unreachable: false, cancellationToken)
.ConfigureAwait(false);
}

/// <summary>
/// Handles a per-device reachability change: if the entity belongs to a managed alarm, persists the new
/// reachability and triggers a refresh. Foreign entities are silently ignored.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ internal static class AlarmComponentIds
/// <summary>Relay to team chat toggle button; tail "{serverId}:{entityId}".</summary>
public const string RelayTogglePrefix = "alarm:relay:";

/// <summary>Refresh button (re-reads live state); tail "{serverId}:{entityId}".</summary>
public const string RefreshPrefix = "alarm:refresh:";

/// <summary>Rename button (opens the modal); tail "{serverId}:{entityId}".</summary>
public const string RenamePrefix = "alarm:rename:";

Expand Down
34 changes: 7 additions & 27 deletions src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
using System.Globalization;
using Discord;
using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Domain.Alarms;
using RustPlusBot.Localization;

namespace RustPlusBot.Features.Alarms.Rendering;

/// <summary>Renders a Smart Alarm as a Discord embed + control row, and the pairing-prompt embed + row. Pure.</summary>
/// <param name="localizer">The alarm localizer.</param>
/// <param name="clock">The clock used to compute relative trigger times.</param>
internal sealed class AlarmEmbedRenderer(ILocalizer localizer, IClock clock)
internal sealed class AlarmEmbedRenderer(ILocalizer localizer)
{
/// <summary>Renders the alarm embed and its control buttons.</summary>
/// <param name="alarm">The alarm to render.</param>
Expand Down Expand Up @@ -54,8 +52,11 @@ internal sealed class AlarmEmbedRenderer(ILocalizer localizer, IClock clock)
var blocked = alarm.Reachability is DeviceReachability.Removed or DeviceReachability.NoPrivilege;
var disableButtons = unreachable || blocked;

// <t:unix:R> is Discord's native relative timestamp — clients render "x minutes ago" and keep
// it updating live, so the embed never shows a stale duration between re-renders.
var triggered = alarm.LastTriggeredUtc is { } t
? localizer.Get("alarm.embed.lasttriggered", culture, CompactDuration(clock.UtcNow - t))
? localizer.Get("alarm.embed.lasttriggered", culture,
string.Create(CultureInfo.InvariantCulture, $"<t:{t.ToUnixTimeSeconds()}:R>"))
: localizer.Get("alarm.embed.nevertriggered", culture);

var embed = new EmbedBuilder()
Expand All @@ -72,6 +73,8 @@ internal sealed class AlarmEmbedRenderer(ILocalizer localizer, IClock clock)
alarm.PingEveryone ? ButtonStyle.Success : ButtonStyle.Secondary, disabled: disableButtons)
.WithButton(localizer.Get(relayKey, culture), AlarmComponentIds.RelayTogglePrefix + tail,
alarm.RelayToTeamChat ? ButtonStyle.Success : ButtonStyle.Secondary, disabled: disableButtons)
.WithButton(localizer.Get("alarm.button.refresh", culture), AlarmComponentIds.RefreshPrefix + tail,
ButtonStyle.Primary, disabled: disableButtons)
.WithButton(localizer.Get("alarm.button.rename", culture), AlarmComponentIds.RenamePrefix + tail,
ButtonStyle.Secondary, disabled: disableButtons)
.Build();
Expand Down Expand Up @@ -106,27 +109,4 @@ internal sealed class AlarmEmbedRenderer(ILocalizer localizer, IClock clock)

return (embed, components);
}

/// <summary>Formats a duration compactly: "5m", "2h 10m", "3d 4h", "&lt;1m".</summary>
/// <param name="span">The duration to format.</param>
/// <returns>A compact human-readable duration string.</returns>
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";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,14 @@ internal interface IRustServerConnection : IAsyncDisposable

/// <summary>Reads a smart device's on/off state and reachability. Also primes the socket's interest in the entity (so triggers fire for it thereafter).</summary>
/// <param name="entityId">The in-game entity id (switch or alarm).</param>
/// <param name="kind">The paired device kind; the Rust+ API validates the entity type on reads, so an alarm must be read as an alarm.</param>
/// <param name="timeout">How long to wait for the response.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A <see cref="DeviceReading"/> with the active state (non-null only when <see cref="DeviceReachability.Reachable"/>) and the reachability outcome.</returns>
Task<DeviceReading> GetSmartDeviceInfoAsync(ulong entityId, TimeSpan timeout, CancellationToken cancellationToken);
Task<DeviceReading> GetSmartDeviceInfoAsync(ulong entityId,
SmartDeviceKind kind,
TimeSpan timeout,
CancellationToken cancellationToken);

/// <summary>Reads a storage monitor's contents and reachability. Also primes the socket's interest so triggers fire for it thereafter.</summary>
/// <param name="entityId">The in-game storage-monitor entity id.</param>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public Task<bool> PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, Cancella
Task.FromResult(false);

public Task<DeviceReading> GetSmartDeviceInfoAsync(ulong entityId,
SmartDeviceKind kind,
TimeSpan timeout,
CancellationToken cancellationToken) =>
Task.FromResult(new DeviceReading(null, DeviceReachability.NoResponse));
Expand Down Expand Up @@ -364,20 +365,27 @@ public async Task<bool> PromoteToLeaderAsync(ulong steamId,
public event EventHandler<StorageMonitorTrigger>? StorageMonitorTriggered;

public async Task<DeviceReading> GetSmartDeviceInfoAsync(ulong entityId,
SmartDeviceKind kind,
TimeSpan timeout,
CancellationToken cancellationToken)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
try
{
// CONFIRMED (2.0.0-beta.3): GetSmartSwitchInfoAsync(ulong, CancellationToken) returns
// Task of Response of SmartDeviceInfo; Response.IsSuccess and Response.Data are the accessors and
// SmartDeviceInfo.IsActive is a bool. The call also primes the socket's interest in this
// CONFIRMED (2.0.0-beta.3): GetSmartSwitchInfoAsync/GetAlarmInfoAsync(ulong, CancellationToken)
// return Task of Response of SmartDeviceInfo; Response.IsSuccess and Response.Data are the accessors
// and SmartDeviceInfo.IsActive is a bool. The call also primes the socket's interest in this
// entity, so OnSmartDeviceTriggered fires for it thereafter.
// CONFIRMED: both mappers throw InvalidOperationException when the server-reported entity type
// does not match the method (AppEntityInfoToModel type checks), so the paired kind must pick
// the matching read — a switch read against an alarm surfaces as NoResponse otherwise.
// CONFIRMED: Response.Error is ErrorMessage? (null on success), so response.Error?.Code is correct.
var response = await _rustPlus.GetSmartSwitchInfoAsync(entityId, timeoutCts.Token)
.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
var response = kind == SmartDeviceKind.Alarm
? await _rustPlus.GetAlarmInfoAsync(entityId, timeoutCts.Token)
.WaitAsync(timeoutCts.Token).ConfigureAwait(false)
: await _rustPlus.GetSmartSwitchInfoAsync(entityId, timeoutCts.Token)
.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
var reachability = ReachabilityMapping.FromResponse(response.IsSuccess, response.Error?.Code);
var isActive = response is { IsSuccess: true, Data: { } info } ? info.IsActive : (bool?)null;
return new DeviceReading(isActive, reachability);
Expand Down
Loading
Loading