Skip to content

Commit ab69627

Browse files
HandyS11claude
andcommitted
feat(alarms): add AlarmStateRelay (state update, active-edge ping/relay, unreachable)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ff4b7fc commit ab69627

2 files changed

Lines changed: 501 additions & 0 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using Microsoft.Extensions.Logging;
3+
using RustPlusBot.Abstractions.Events;
4+
using RustPlusBot.Abstractions.Time;
5+
using RustPlusBot.Domain.Connections;
6+
using RustPlusBot.Features.Alarms.Posting;
7+
using RustPlusBot.Features.Alarms.Rendering;
8+
using RustPlusBot.Features.Connections.Listening;
9+
using RustPlusBot.Features.Workspace.Locating;
10+
using RustPlusBot.Persistence.Alarms;
11+
using RustPlusBot.Persistence.Connections;
12+
using RustPlusBot.Persistence.Workspace;
13+
14+
namespace RustPlusBot.Features.Alarms.Relaying;
15+
16+
/// <summary>
17+
/// Keeps alarm embeds in sync with live socket events: updates state and re-renders on trigger; marks
18+
/// alarms unreachable when the server goes non-Connected.
19+
/// </summary>
20+
/// <param name="scopeFactory">Opens scopes for the scoped stores.</param>
21+
/// <param name="refresher">Re-renders a single alarm embed on demand.</param>
22+
/// <param name="locator">Resolves the #alarms channel id.</param>
23+
/// <param name="poster">Posts/edits alarm embeds and sends @everyone pings.</param>
24+
/// <param name="teamChatSender">Relays messages into in-game team chat.</param>
25+
/// <param name="localizer">Resolves localized alarm strings.</param>
26+
/// <param name="clock">Provides the current UTC time.</param>
27+
/// <param name="logger">The logger.</param>
28+
internal sealed partial class AlarmStateRelay(
29+
IServiceScopeFactory scopeFactory,
30+
IAlarmRefresher refresher,
31+
IAlarmChannelLocator locator,
32+
IAlarmChannelPoster poster,
33+
ITeamChatSender teamChatSender,
34+
IAlarmLocalizer localizer,
35+
IClock clock,
36+
ILogger<AlarmStateRelay> logger)
37+
{
38+
/// <summary>
39+
/// Handles a smart-device trigger: if it belongs to a managed alarm, persists the new state,
40+
/// re-renders its embed, and (on the active edge only) optionally pings @everyone and/or relays
41+
/// the trigger to in-game team chat.
42+
/// </summary>
43+
/// <param name="evt">The device-triggered event.</param>
44+
/// <param name="ct">A cancellation token.</param>
45+
/// <returns>A task that completes when all relay actions have finished.</returns>
46+
public async Task HandleTriggeredAsync(SmartDeviceTriggeredEvent evt, CancellationToken ct)
47+
{
48+
ArgumentNullException.ThrowIfNull(evt);
49+
bool ping, relay;
50+
string name;
51+
52+
var scope = scopeFactory.CreateAsyncScope();
53+
await using (scope.ConfigureAwait(false))
54+
{
55+
var store = scope.ServiceProvider.GetRequiredService<IAlarmStore>();
56+
var alarm = await store.GetAsync(evt.GuildId, evt.ServerId, evt.EntityId, ct).ConfigureAwait(false);
57+
if (alarm is null)
58+
{
59+
return; // not an alarm this relay manages (e.g. a switch) — ignore
60+
}
61+
62+
await store.UpdateStateAsync(
63+
evt.GuildId,
64+
evt.ServerId,
65+
evt.EntityId,
66+
evt.IsActive,
67+
evt.IsActive ? clock.UtcNow : null,
68+
ct)
69+
.ConfigureAwait(false);
70+
71+
ping = alarm.PingEveryone;
72+
relay = alarm.RelayToTeamChat;
73+
name = alarm.Name;
74+
}
75+
76+
await refresher.RefreshAsync(evt.GuildId, evt.ServerId, evt.EntityId, unreachable: false, ct)
77+
.ConfigureAwait(false);
78+
79+
if (!evt.IsActive)
80+
{
81+
return; // only the active edge notifies
82+
}
83+
84+
if (ping)
85+
{
86+
var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false);
87+
if (channelId is { } channel)
88+
{
89+
await poster.SendEveryonePingAsync(channel, $"@everyone {name}", ct).ConfigureAwait(false);
90+
}
91+
}
92+
93+
if (relay)
94+
{
95+
await RelayToTeamChatSafeAsync(evt, name, ct).ConfigureAwait(false);
96+
}
97+
}
98+
99+
/// <summary>
100+
/// Handles a connection-status change: if the server is no longer Connected, marks every managed
101+
/// alarm's embed as unreachable. Connected → no-op (the supervisor's prime republishes real state).
102+
/// </summary>
103+
/// <param name="evt">The connection-status change.</param>
104+
/// <param name="ct">A cancellation token.</param>
105+
/// <returns>A task that completes when every affected embed has been re-rendered.</returns>
106+
public async Task HandleConnectionStatusAsync(ConnectionStatusChangedEvent evt, CancellationToken ct)
107+
{
108+
ArgumentNullException.ThrowIfNull(evt);
109+
var scope = scopeFactory.CreateAsyncScope();
110+
await using (scope.ConfigureAwait(false))
111+
{
112+
var connections = scope.ServiceProvider.GetRequiredService<IConnectionStore>();
113+
var state = await connections.GetStateAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false);
114+
if (state is { Status: ConnectionStatus.Connected })
115+
{
116+
// The supervisor's prime path republishes real state on connect; nothing to do here.
117+
return;
118+
}
119+
120+
var store = scope.ServiceProvider.GetRequiredService<IAlarmStore>();
121+
var alarms = await store.ListByServerAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false);
122+
foreach (var alarm in alarms)
123+
{
124+
await refresher.RefreshAsync(evt.GuildId, evt.ServerId, alarm.EntityId, unreachable: true, ct)
125+
.ConfigureAwait(false);
126+
}
127+
}
128+
}
129+
130+
private async Task RelayToTeamChatSafeAsync(SmartDeviceTriggeredEvent evt, string name, CancellationToken ct)
131+
{
132+
try
133+
{
134+
string culture;
135+
var scope = scopeFactory.CreateAsyncScope();
136+
await using (scope.ConfigureAwait(false))
137+
{
138+
var workspace = scope.ServiceProvider.GetRequiredService<IWorkspaceStore>();
139+
culture = await workspace.GetCultureAsync(evt.GuildId, ct).ConfigureAwait(false);
140+
}
141+
142+
var line = localizer.Get("alarm.triggered.teamchat", culture, name);
143+
_ = await teamChatSender.SendAsync(evt.GuildId, evt.ServerId, line, ct).ConfigureAwait(false);
144+
}
145+
catch (OperationCanceledException)
146+
{
147+
throw; // Shutdown — let the loop unwind.
148+
}
149+
#pragma warning disable CA1031 // Broad catch: a team-chat relay failure must not block the embed/ping path.
150+
catch (Exception ex)
151+
#pragma warning restore CA1031
152+
{
153+
LogRelayFailed(logger, ex, evt.GuildId, evt.ServerId);
154+
}
155+
}
156+
157+
[LoggerMessage(Level = LogLevel.Warning,
158+
Message = "Relaying alarm trigger to team chat for guild {GuildId} server {ServerId} failed; swallowing.")]
159+
private static partial void LogRelayFailed(ILogger logger, Exception exception, ulong guildId, Guid serverId);
160+
}

0 commit comments

Comments
 (0)