diff --git a/.github/workflows/Sonar.yml b/.github/workflows/Sonar.yml
index 85639eb..15c453d 100644
--- a/.github/workflows/Sonar.yml
+++ b/.github/workflows/Sonar.yml
@@ -60,7 +60,7 @@ jobs:
- name: Build and analyze
run: |
- ./.sonar/scanner/dotnet-sonarscanner begin /k:"${{ secrets.SONAR_PROJECT_KEY }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="${{ secrets.SONAR_HOST_URL }}" /d:sonar.coverage.exclusions="**/tests/**" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml"
+ ./.sonar/scanner/dotnet-sonarscanner begin /k:"${{ secrets.SONAR_PROJECT_KEY }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="${{ secrets.SONAR_HOST_URL }}" /d:sonar.coverage.exclusions="**/tests/**" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.cpd.exclusions="**/Migrations/*.cs" /d:sonar.exclusions="**/Migrations/*.cs,**/obj/**,**/bin/**"
dotnet build --no-restore --configuration Release
dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage;Format=opencover" --blame-hang-timeout 60s
./.sonar/scanner/dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 03d14d0..7643b91 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -10,6 +10,7 @@
+
diff --git a/RustPlusBot.slnx b/RustPlusBot.slnx
index 1c9ca94..ea26c15 100644
--- a/RustPlusBot.slnx
+++ b/RustPlusBot.slnx
@@ -14,6 +14,7 @@
+
@@ -28,6 +29,7 @@
+
diff --git a/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs b/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs
index 3c7576b..e948f30 100644
--- a/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs
+++ b/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs
@@ -1,4 +1,4 @@
-namespace RustPlusBot.Features.Connections.Listening;
+namespace RustPlusBot.Abstractions.Connections;
/// Reads live data from a connected server's socket (implemented by the connection supervisor).
public interface IRustServerQuery
diff --git a/src/RustPlusBot.Abstractions/Connections/MapDimensions.cs b/src/RustPlusBot.Abstractions/Connections/MapDimensions.cs
index ada92dd..1cc4e4d 100644
--- a/src/RustPlusBot.Abstractions/Connections/MapDimensions.cs
+++ b/src/RustPlusBot.Abstractions/Connections/MapDimensions.cs
@@ -1,4 +1,4 @@
-namespace RustPlusBot.Features.Connections.Listening;
+namespace RustPlusBot.Abstractions.Connections;
/// The static-per-wipe map size needed to convert world coordinates to a grid reference.
/// Map image width.
diff --git a/src/RustPlusBot.Abstractions/Connections/MapMarkerSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/MapMarkerSnapshot.cs
index 3e4f66d..5c6cd5c 100644
--- a/src/RustPlusBot.Abstractions/Connections/MapMarkerSnapshot.cs
+++ b/src/RustPlusBot.Abstractions/Connections/MapMarkerSnapshot.cs
@@ -1,4 +1,4 @@
-namespace RustPlusBot.Features.Connections.Listening;
+namespace RustPlusBot.Abstractions.Connections;
/// One map marker observed in a GetMapMarkers poll.
/// The stable marker id (used to diff polls).
diff --git a/src/RustPlusBot.Abstractions/Connections/MarkerKind.cs b/src/RustPlusBot.Abstractions/Connections/MarkerKind.cs
index fa337fc..be7398a 100644
--- a/src/RustPlusBot.Abstractions/Connections/MarkerKind.cs
+++ b/src/RustPlusBot.Abstractions/Connections/MarkerKind.cs
@@ -1,4 +1,4 @@
-namespace RustPlusBot.Features.Connections.Listening;
+namespace RustPlusBot.Abstractions.Connections;
/// The subset of Rust map-marker types this bot reasons about; everything else is .
public enum MarkerKind
diff --git a/src/RustPlusBot.Abstractions/Connections/MonumentSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/MonumentSnapshot.cs
index 107bb7a..c01fabd 100644
--- a/src/RustPlusBot.Abstractions/Connections/MonumentSnapshot.cs
+++ b/src/RustPlusBot.Abstractions/Connections/MonumentSnapshot.cs
@@ -1,4 +1,4 @@
-namespace RustPlusBot.Features.Connections.Listening;
+namespace RustPlusBot.Abstractions.Connections;
/// One named monument observed in a GetMap response.
/// The monument token (e.g. oilrig_1, large_oil_rig).
diff --git a/src/RustPlusBot.Abstractions/Connections/ServerInfoSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/ServerInfoSnapshot.cs
index bb52bdf..ab22c21 100644
--- a/src/RustPlusBot.Abstractions/Connections/ServerInfoSnapshot.cs
+++ b/src/RustPlusBot.Abstractions/Connections/ServerInfoSnapshot.cs
@@ -1,4 +1,4 @@
-namespace RustPlusBot.Features.Connections.Listening;
+namespace RustPlusBot.Abstractions.Connections;
/// A point-in-time view of a server's population and wipe, decoupled from RustPlusApi types.
/// Current player count.
diff --git a/src/RustPlusBot.Abstractions/Connections/ServerTimeSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/ServerTimeSnapshot.cs
index 56a96b5..c6256f3 100644
--- a/src/RustPlusBot.Abstractions/Connections/ServerTimeSnapshot.cs
+++ b/src/RustPlusBot.Abstractions/Connections/ServerTimeSnapshot.cs
@@ -1,4 +1,4 @@
-namespace RustPlusBot.Features.Connections.Listening;
+namespace RustPlusBot.Abstractions.Connections;
/// A point-in-time view of in-game time, decoupled from RustPlusApi types.
/// Current in-game time of day (RustPlusApi TimeInfo.Time).
diff --git a/src/RustPlusBot.Abstractions/Connections/TeamInfoSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/TeamInfoSnapshot.cs
index 9f2ca8d..e91e5f6 100644
--- a/src/RustPlusBot.Abstractions/Connections/TeamInfoSnapshot.cs
+++ b/src/RustPlusBot.Abstractions/Connections/TeamInfoSnapshot.cs
@@ -1,4 +1,4 @@
-namespace RustPlusBot.Features.Connections.Listening;
+namespace RustPlusBot.Abstractions.Connections;
/// A point-in-time view of a team, decoupled from RustPlusApi types.
/// Steam64 id of the current team leader.
diff --git a/src/RustPlusBot.Abstractions/Events/MapMarkersChangedEvent.cs b/src/RustPlusBot.Abstractions/Events/MapMarkersChangedEvent.cs
index 1d0a239..c71da8e 100644
--- a/src/RustPlusBot.Abstractions/Events/MapMarkersChangedEvent.cs
+++ b/src/RustPlusBot.Abstractions/Events/MapMarkersChangedEvent.cs
@@ -1,4 +1,4 @@
-using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Abstractions.Connections;
namespace RustPlusBot.Abstractions.Events;
diff --git a/src/RustPlusBot.Abstractions/Events/PlayerStateChangedEvent.cs b/src/RustPlusBot.Abstractions/Events/PlayerStateChangedEvent.cs
index 7e0b750..574407e 100644
--- a/src/RustPlusBot.Abstractions/Events/PlayerStateChangedEvent.cs
+++ b/src/RustPlusBot.Abstractions/Events/PlayerStateChangedEvent.cs
@@ -1,4 +1,4 @@
-using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Abstractions.Connections;
namespace RustPlusBot.Abstractions.Events;
diff --git a/src/RustPlusBot.Abstractions/Events/RigStateChangedEvent.cs b/src/RustPlusBot.Abstractions/Events/RigStateChangedEvent.cs
index fb7e9ab..39d4050 100644
--- a/src/RustPlusBot.Abstractions/Events/RigStateChangedEvent.cs
+++ b/src/RustPlusBot.Abstractions/Events/RigStateChangedEvent.cs
@@ -1,4 +1,4 @@
-using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Abstractions.Connections;
namespace RustPlusBot.Abstractions.Events;
diff --git a/src/RustPlusBot.Discord/Posting/DiscordChannelMessenger.cs b/src/RustPlusBot.Discord/Posting/DiscordChannelMessenger.cs
new file mode 100644
index 0000000..0f0dca1
--- /dev/null
+++ b/src/RustPlusBot.Discord/Posting/DiscordChannelMessenger.cs
@@ -0,0 +1,132 @@
+using Discord;
+using Discord.Net;
+using Discord.WebSocket;
+using Microsoft.Extensions.Logging;
+
+namespace RustPlusBot.Discord.Posting;
+
+/// Shared Discord channel post/edit boilerplate: fetch, options, self-heal, broad-catch.
+public static class DiscordChannelMessenger
+{
+ ///
+ /// Edits the message by id (self-healing on 404 by reposting) or posts a new one.
+ /// Returns the message id, or null on failure.
+ ///
+ /// The Discord socket client.
+ /// The target channel id.
+ /// The existing message id to edit, or null to post a new one.
+ /// The embed to post or update.
+ /// The message components to post or update.
+ /// The caller's logger.
+ /// The cancellation token.
+ public static async Task EnsureAsync(
+ DiscordSocketClient client,
+ ulong channelId,
+ ulong? messageId,
+ Embed embed,
+ MessageComponent components,
+ ILogger logger,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var options = new RequestOptions
+ {
+ CancelToken = cancellationToken
+ };
+ if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false)
+ is not 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 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.
+#pragma warning disable CA1848, CA1873 // Use LoggerMessage delegates / avoid expensive log-arg evaluation — plain logger.Log is fine for a shared helper (no source-gen partial context); ulong boxing is negligible vs. the caught exception.
+ logger.LogDebug(ex, "Embed {MessageId} in channel {ChannelId} was deleted; reposting.", id,
+ channelId);
+#pragma warning restore CA1848, CA1873
+ }
+ }
+
+ 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
+ {
+#pragma warning disable CA1848, CA1873 // Use LoggerMessage delegates / avoid expensive log-arg evaluation — plain logger.Log is fine for a shared helper (no source-gen partial context); ulong boxing is negligible vs. the caught exception.
+ logger.LogWarning(ex, "Posting/editing an embed in channel {ChannelId} failed.", channelId);
+#pragma warning restore CA1848, CA1873
+ return null;
+ }
+ }
+
+ /// Posts an embed fire-and-forget; Discord hiccups are logged and swallowed.
+ /// The Discord socket client.
+ /// The target channel id.
+ /// The embed to post.
+ /// The caller's logger.
+ /// The cancellation token.
+ public static async Task PostAsync(
+ DiscordSocketClient client,
+ ulong channelId,
+ Embed embed,
+ ILogger logger,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var options = new RequestOptions
+ {
+ CancelToken = cancellationToken
+ };
+ if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) is not ITextChannel channel)
+ {
+ return;
+ }
+
+ await channel.SendMessageAsync(embed: embed, options: options).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw; // Cancellation (shutdown) is not a post failure; let the relay loop unwind cleanly.
+ }
+#pragma warning disable CA1031 // Broad catch: a Discord hiccup must not crash the relay.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+#pragma warning disable CA1848, CA1873 // Use LoggerMessage delegates / avoid expensive log-arg evaluation — plain logger.Log is fine for a shared helper (no source-gen partial context); ulong boxing is negligible vs. the caught exception.
+ logger.LogWarning(ex, "Posting an embed to channel {ChannelId} failed.", channelId);
+#pragma warning restore CA1848, CA1873
+ }
+ }
+}
diff --git a/src/RustPlusBot.Discord/RustPlusBot.Discord.csproj b/src/RustPlusBot.Discord/RustPlusBot.Discord.csproj
index 583c73c..9f33403 100644
--- a/src/RustPlusBot.Discord/RustPlusBot.Discord.csproj
+++ b/src/RustPlusBot.Discord/RustPlusBot.Discord.csproj
@@ -9,6 +9,7 @@
+
diff --git a/src/RustPlusBot.Features.Alarms/AlarmServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Alarms/AlarmServiceCollectionExtensions.cs
index dff1355..cf1afe2 100644
--- a/src/RustPlusBot.Features.Alarms/AlarmServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Features.Alarms/AlarmServiceCollectionExtensions.cs
@@ -23,6 +23,7 @@ public static IServiceCollection AddAlarms(this IServiceCollection services)
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddHostedService();
diff --git a/src/RustPlusBot.Features.Alarms/Modules/AlarmComponentModule.cs b/src/RustPlusBot.Features.Alarms/Modules/AlarmComponentModule.cs
index 6342659..ccfa43d 100644
--- a/src/RustPlusBot.Features.Alarms/Modules/AlarmComponentModule.cs
+++ b/src/RustPlusBot.Features.Alarms/Modules/AlarmComponentModule.cs
@@ -16,6 +16,8 @@ public sealed class AlarmComponentModule(
IServiceScopeFactory scopeFactory,
IAlarmRefresher refresher) : InteractionModuleBase
{
+ private const string InvalidControlMessage = "That control wasn't valid.";
+
/// Accepts a pending pairing prompt and starts managing the alarm.
/// The "{serverId}:{entityId}" custom-id tail.
[ComponentInteraction(AlarmComponentIds.AcceptPrefix + "*")]
@@ -23,7 +25,7 @@ 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);
+ await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
return;
}
@@ -47,7 +49,7 @@ 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);
+ await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
return;
}
@@ -71,7 +73,7 @@ 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);
+ await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
return;
}
@@ -106,7 +108,7 @@ 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);
+ await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
return;
}
@@ -141,7 +143,7 @@ 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);
+ await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
return;
}
@@ -159,7 +161,7 @@ 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);
+ await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
return;
}
diff --git a/src/RustPlusBot.Features.Alarms/Posting/DiscordAlarmChannelPoster.cs b/src/RustPlusBot.Features.Alarms/Posting/DiscordAlarmChannelPoster.cs
index 1ebaed4..b68cc00 100644
--- a/src/RustPlusBot.Features.Alarms/Posting/DiscordAlarmChannelPoster.cs
+++ b/src/RustPlusBot.Features.Alarms/Posting/DiscordAlarmChannelPoster.cs
@@ -1,6 +1,7 @@
-using Discord.Net;
+using Discord;
using Discord.WebSocket;
using Microsoft.Extensions.Logging;
+using RustPlusBot.Discord.Posting;
namespace RustPlusBot.Features.Alarms.Posting;
@@ -12,81 +13,26 @@ internal sealed partial class DiscordAlarmChannelPoster(
ILogger logger) : IAlarmChannelPoster
{
///
- public async Task EnsureAsync(
+ public Task EnsureAsync(
ulong channelId,
ulong? messageId,
- global::Discord.Embed embed,
- global::Discord.MessageComponent components,
+ Embed embed,
+ 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;
- }
- }
+ => DiscordChannelMessenger.EnsureAsync(client, channelId, messageId, embed, components, logger,
+ cancellationToken);
///
public async Task SendEveryonePingAsync(ulong channelId, string content, CancellationToken cancellationToken)
{
try
{
- var options = new global::Discord.RequestOptions
+ var options = new RequestOptions
{
CancelToken = cancellationToken
};
if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false)
- is not global::Discord.ITextChannel channel)
+ is not ITextChannel channel)
{
return;
}
@@ -94,7 +40,7 @@ public async Task SendEveryonePingAsync(ulong channelId, string content, Cancell
await channel.SendMessageAsync(
content,
options: options,
- allowedMentions: global::Discord.AllowedMentions.All)
+ allowedMentions: AllowedMentions.All)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
@@ -109,14 +55,6 @@ await channel.SendMessageAsync(
}
}
- [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/Relaying/AlarmStateRelay.cs b/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs
index 44381b7..1581cb9 100644
--- a/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs
+++ b/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs
@@ -13,24 +13,29 @@
namespace RustPlusBot.Features.Alarms.Relaying;
+/// Bundles the channel/chat collaborators injected into .
+/// Resolves the #alarms channel id.
+/// Posts/edits alarm embeds and sends @everyone pings.
+/// Relays messages into in-game team chat.
+internal sealed record AlarmRelayChannels(
+ IAlarmChannelLocator Locator,
+ IAlarmChannelPoster Poster,
+ ITeamChatSender TeamChatSender);
+
///
/// 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.
+/// Bundles the channel/chat collaborators.
/// 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,
+ AlarmRelayChannels channels,
IAlarmLocalizer localizer,
IClock clock,
ILogger logger)
@@ -83,10 +88,11 @@ await refresher.RefreshAsync(evt.GuildId, evt.ServerId, evt.EntityId, unreachabl
if (ping)
{
- var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false);
+ var channelId = await channels.Locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, ct)
+ .ConfigureAwait(false);
if (channelId is { } channel)
{
- await poster.SendEveryonePingAsync(channel, $"@everyone {name}", ct).ConfigureAwait(false);
+ await channels.Poster.SendEveryonePingAsync(channel, $"@everyone {name}", ct).ConfigureAwait(false);
}
}
@@ -140,7 +146,7 @@ private async Task RelayToTeamChatSafeAsync(SmartDeviceTriggeredEvent evt, strin
}
var line = localizer.Get("alarm.triggered.teamchat", culture, name);
- _ = await teamChatSender.SendAsync(evt.GuildId, evt.ServerId, line, ct).ConfigureAwait(false);
+ _ = await channels.TeamChatSender.SendAsync(evt.GuildId, evt.ServerId, line, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
diff --git a/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs b/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs
index aa0d448..3840c58 100644
--- a/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs
+++ b/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs
@@ -1,62 +1,8 @@
-using System.Globalization;
+using RustPlusBot.Localization;
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.
+/// Smart-alarm localizer backed by the shared .
/// The culture → (key → value) catalog.
internal sealed class AlarmLocalizer(IReadOnlyDictionary> catalog)
- : IAlarmLocalizer
-{
- private const string FallbackCulture = "en";
-
- ///
- public string Get(string key, string culture)
- {
- var normalized = Normalize(culture);
- if (catalog.TryGetValue(normalized, out var map) && map.TryGetValue(key, out var value))
- {
- return value;
- }
-
- if (catalog.TryGetValue(FallbackCulture, out var fallback) &&
- fallback.TryGetValue(key, out var fallbackValue))
- {
- return fallbackValue;
- }
-
- return key;
- }
-
- ///
- public string Get(string key, string culture, params object[] args)
- {
- var format = Get(key, culture);
- var provider = ResolveFormatProvider(Normalize(culture));
- return string.Format(provider, format, args);
- }
-
- private static string Normalize(string culture)
- {
- if (string.IsNullOrWhiteSpace(culture))
- {
- return FallbackCulture;
- }
-
- var dash = culture.IndexOf('-', StringComparison.Ordinal);
- var primary = dash >= 0 ? culture[..dash] : culture;
- return primary.ToLowerInvariant();
- }
-
- private static CultureInfo ResolveFormatProvider(string culture)
- {
- try
- {
- return CultureInfo.GetCultureInfo(culture);
- }
- catch (CultureNotFoundException)
- {
- return CultureInfo.InvariantCulture;
- }
- }
-}
+ : DictionaryLocalizer(catalog), IAlarmLocalizer;
diff --git a/src/RustPlusBot.Features.Alarms/Rendering/IAlarmLocalizer.cs b/src/RustPlusBot.Features.Alarms/Rendering/IAlarmLocalizer.cs
index 7112668..e7b41ec 100644
--- a/src/RustPlusBot.Features.Alarms/Rendering/IAlarmLocalizer.cs
+++ b/src/RustPlusBot.Features.Alarms/Rendering/IAlarmLocalizer.cs
@@ -1,16 +1,6 @@
+using RustPlusBot.Localization;
+
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);
-}
+internal interface IAlarmLocalizer : ILocalizer;
diff --git a/src/RustPlusBot.Features.Alarms/RustPlusBot.Features.Alarms.csproj b/src/RustPlusBot.Features.Alarms/RustPlusBot.Features.Alarms.csproj
index 2c5d63f..f20da96 100644
--- a/src/RustPlusBot.Features.Alarms/RustPlusBot.Features.Alarms.csproj
+++ b/src/RustPlusBot.Features.Alarms/RustPlusBot.Features.Alarms.csproj
@@ -7,6 +7,7 @@
+
diff --git a/src/RustPlusBot.Features.Commands/Formatting/TeamMemberFilter.cs b/src/RustPlusBot.Features.Commands/Formatting/TeamMemberFilter.cs
index ee6f805..f3ab3c0 100644
--- a/src/RustPlusBot.Features.Commands/Formatting/TeamMemberFilter.cs
+++ b/src/RustPlusBot.Features.Commands/Formatting/TeamMemberFilter.cs
@@ -1,4 +1,4 @@
-using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Abstractions.Connections;
namespace RustPlusBot.Features.Commands.Formatting;
@@ -20,8 +20,6 @@ public static IReadOnlyList ByName(
return members;
}
- return members
- .Where(m => m.Name.Contains(nameArg, StringComparison.OrdinalIgnoreCase))
- .ToList();
+ return [.. members.Where(m => m.Name.Contains(nameArg, StringComparison.OrdinalIgnoreCase))];
}
}
diff --git a/src/RustPlusBot.Features.Commands/Handlers/AliveCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/AliveCommandHandler.cs
index 5c6c9ea..bc3324a 100644
--- a/src/RustPlusBot.Features.Commands/Handlers/AliveCommandHandler.cs
+++ b/src/RustPlusBot.Features.Commands/Handlers/AliveCommandHandler.cs
@@ -1,8 +1,8 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Formatting;
using RustPlusBot.Features.Commands.Localization;
-using RustPlusBot.Features.Connections.Listening;
namespace RustPlusBot.Features.Commands.Handlers;
diff --git a/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs
index 5ee0e7c..a0770f6 100644
--- a/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs
+++ b/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs
@@ -1,8 +1,8 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Formatting;
using RustPlusBot.Features.Commands.Localization;
-using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Events.Formatting;
using RustPlusBot.Features.Events.State;
diff --git a/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs
index 8a7150a..b08af67 100644
--- a/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs
+++ b/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs
@@ -1,8 +1,8 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Formatting;
using RustPlusBot.Features.Commands.Localization;
-using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Events.Formatting;
using RustPlusBot.Features.Events.State;
diff --git a/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs
index ecc2c26..1f5e61d 100644
--- a/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs
+++ b/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs
@@ -1,8 +1,8 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Formatting;
using RustPlusBot.Features.Commands.Localization;
-using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Events.Formatting;
using RustPlusBot.Features.Events.State;
diff --git a/src/RustPlusBot.Features.Commands/Handlers/OfflineCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/OfflineCommandHandler.cs
index 49fc613..7d360b6 100644
--- a/src/RustPlusBot.Features.Commands/Handlers/OfflineCommandHandler.cs
+++ b/src/RustPlusBot.Features.Commands/Handlers/OfflineCommandHandler.cs
@@ -1,6 +1,6 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Localization;
-using RustPlusBot.Features.Connections.Listening;
namespace RustPlusBot.Features.Commands.Handlers;
diff --git a/src/RustPlusBot.Features.Commands/Handlers/OnlineCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/OnlineCommandHandler.cs
index ee63010..9dc61ad 100644
--- a/src/RustPlusBot.Features.Commands/Handlers/OnlineCommandHandler.cs
+++ b/src/RustPlusBot.Features.Commands/Handlers/OnlineCommandHandler.cs
@@ -1,6 +1,6 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Localization;
-using RustPlusBot.Features.Connections.Listening;
namespace RustPlusBot.Features.Commands.Handlers;
diff --git a/src/RustPlusBot.Features.Commands/Handlers/PopCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/PopCommandHandler.cs
index 0353d34..eda2e4b 100644
--- a/src/RustPlusBot.Features.Commands/Handlers/PopCommandHandler.cs
+++ b/src/RustPlusBot.Features.Commands/Handlers/PopCommandHandler.cs
@@ -1,6 +1,6 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Localization;
-using RustPlusBot.Features.Connections.Listening;
namespace RustPlusBot.Features.Commands.Handlers;
diff --git a/src/RustPlusBot.Features.Commands/Handlers/ProxCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/ProxCommandHandler.cs
index c442e54..f7ce298 100644
--- a/src/RustPlusBot.Features.Commands/Handlers/ProxCommandHandler.cs
+++ b/src/RustPlusBot.Features.Commands/Handlers/ProxCommandHandler.cs
@@ -1,7 +1,7 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Formatting;
using RustPlusBot.Features.Commands.Localization;
-using RustPlusBot.Features.Connections.Listening;
namespace RustPlusBot.Features.Commands.Handlers;
diff --git a/src/RustPlusBot.Features.Commands/Handlers/SteamIdCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/SteamIdCommandHandler.cs
index dacdc3d..1c7a009 100644
--- a/src/RustPlusBot.Features.Commands/Handlers/SteamIdCommandHandler.cs
+++ b/src/RustPlusBot.Features.Commands/Handlers/SteamIdCommandHandler.cs
@@ -1,8 +1,8 @@
using System.Globalization;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Formatting;
using RustPlusBot.Features.Commands.Localization;
-using RustPlusBot.Features.Connections.Listening;
namespace RustPlusBot.Features.Commands.Handlers;
diff --git a/src/RustPlusBot.Features.Commands/Handlers/TeamCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/TeamCommandHandler.cs
index da2bc4a..c9fa0f5 100644
--- a/src/RustPlusBot.Features.Commands/Handlers/TeamCommandHandler.cs
+++ b/src/RustPlusBot.Features.Commands/Handlers/TeamCommandHandler.cs
@@ -1,6 +1,6 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Localization;
-using RustPlusBot.Features.Connections.Listening;
namespace RustPlusBot.Features.Commands.Handlers;
diff --git a/src/RustPlusBot.Features.Commands/Handlers/TimeCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/TimeCommandHandler.cs
index 722e843..62ba061 100644
--- a/src/RustPlusBot.Features.Commands/Handlers/TimeCommandHandler.cs
+++ b/src/RustPlusBot.Features.Commands/Handlers/TimeCommandHandler.cs
@@ -1,7 +1,7 @@
using System.Globalization;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Localization;
-using RustPlusBot.Features.Connections.Listening;
namespace RustPlusBot.Features.Commands.Handlers;
diff --git a/src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs
index b823260..d956ed8 100644
--- a/src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs
+++ b/src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs
@@ -1,8 +1,8 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Formatting;
using RustPlusBot.Features.Commands.Localization;
-using RustPlusBot.Features.Connections.Listening;
namespace RustPlusBot.Features.Commands.Handlers;
diff --git a/src/RustPlusBot.Features.Commands/Leader/LeaderService.cs b/src/RustPlusBot.Features.Commands/Leader/LeaderService.cs
index a32d25d..41063d7 100644
--- a/src/RustPlusBot.Features.Commands/Leader/LeaderService.cs
+++ b/src/RustPlusBot.Features.Commands/Leader/LeaderService.cs
@@ -1,5 +1,5 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Features.Commands.Localization;
-using RustPlusBot.Features.Connections.Listening;
namespace RustPlusBot.Features.Commands.Leader;
diff --git a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizer.cs b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizer.cs
index 936a94d..8acc4cf 100644
--- a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizer.cs
+++ b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizer.cs
@@ -1,61 +1,8 @@
-using System.Globalization;
+using RustPlusBot.Localization;
namespace RustPlusBot.Features.Commands.Localization;
-/// Dictionary-backed with English fallback and region normalization.
-/// Duplicated from Workspace.Localizer; consolidate into a shared project in a future refactor.
+/// In-game reply localizer backed by the shared .
/// The string catalog.
-internal sealed class CommandLocalizer(CommandLocalizationCatalog catalog) : ICommandLocalizer
-{
- private const string FallbackCulture = "en";
-
- ///
- public string Get(string key, string culture)
- {
- var normalized = Normalize(culture);
- if (catalog.Strings.TryGetValue(normalized, out var map) && map.TryGetValue(key, out var value))
- {
- return value;
- }
-
- if (catalog.Strings.TryGetValue(FallbackCulture, out var fallback) &&
- fallback.TryGetValue(key, out var fallbackValue))
- {
- return fallbackValue;
- }
-
- return key;
- }
-
- ///
- public string Get(string key, string culture, params object[] args)
- {
- var format = Get(key, culture);
- var provider = ResolveFormatProvider(Normalize(culture));
- return string.Format(provider, format, args);
- }
-
- private static string Normalize(string culture)
- {
- if (string.IsNullOrWhiteSpace(culture))
- {
- return FallbackCulture;
- }
-
- var dash = culture.IndexOf('-', StringComparison.Ordinal);
- var primary = dash >= 0 ? culture[..dash] : culture;
- return primary.ToLowerInvariant();
- }
-
- private static CultureInfo ResolveFormatProvider(string culture)
- {
- try
- {
- return CultureInfo.GetCultureInfo(culture);
- }
- catch (CultureNotFoundException)
- {
- return CultureInfo.InvariantCulture;
- }
- }
-}
+internal sealed class CommandLocalizer(CommandLocalizationCatalog catalog)
+ : DictionaryLocalizer(catalog.Strings), ICommandLocalizer;
diff --git a/src/RustPlusBot.Features.Commands/Localization/ICommandLocalizer.cs b/src/RustPlusBot.Features.Commands/Localization/ICommandLocalizer.cs
index 7c2b805..0bc28d1 100644
--- a/src/RustPlusBot.Features.Commands/Localization/ICommandLocalizer.cs
+++ b/src/RustPlusBot.Features.Commands/Localization/ICommandLocalizer.cs
@@ -1,16 +1,6 @@
+using RustPlusBot.Localization;
+
namespace RustPlusBot.Features.Commands.Localization;
/// Resolves localized in-game reply strings by key and culture, falling back to English.
-internal interface ICommandLocalizer
-{
- /// 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);
-}
+internal interface ICommandLocalizer : ILocalizer;
diff --git a/src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs b/src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs
index 99369fd..b314e64 100644
--- a/src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs
+++ b/src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs
@@ -46,7 +46,7 @@ public async Task HelpAsync()
var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false);
var known = await servers.ListAsync(Context.Guild.Id).ConfigureAwait(false);
- string prefix = "!";
+ var prefix = "!";
var showNote = false;
if (known.Count == 1)
{
diff --git a/src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj b/src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj
index 915dea8..28eb310 100644
--- a/src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj
+++ b/src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj
@@ -7,6 +7,7 @@
+
diff --git a/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs
index 6dc974b..f96eb32 100644
--- a/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Discord;
using RustPlusBot.Features.Connections.Hosting;
using RustPlusBot.Features.Connections.Listening;
@@ -18,6 +19,7 @@ public static IServiceCollection AddConnections(this IServiceCollection services
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton(sp => sp.GetRequiredService());
diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs
index dd7b65f..0ddc88b 100644
--- a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs
+++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs
@@ -1,3 +1,5 @@
+using RustPlusBot.Abstractions.Connections;
+
namespace RustPlusBot.Features.Connections.Listening;
/// One live Rust+ socket to a single server, driven by one player credential.
diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
index fea185f..8184a07 100644
--- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
+++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
@@ -1,6 +1,7 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
using RustPlusApi;
+using RustPlusBot.Abstractions.Connections;
namespace RustPlusBot.Features.Connections.Listening;
diff --git a/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs b/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs
index 2930d25..0907ba3 100644
--- a/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs
+++ b/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs
@@ -1,13 +1,21 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
namespace RustPlusBot.Features.Connections.Listening;
+/// Bundles the polling parameters passed to the UpdateAfk private method in .
+/// The current wall-clock time.
+/// How long a member must be still before being flagged AFK.
+/// Movement tolerance (world units) below which a member is considered still.
+/// Whether the member died during this poll.
+internal sealed record AfkPollContext(DateTimeOffset Clock, TimeSpan Threshold, float Epsilon, bool DiedThisPoll);
+
/// Diffs successive team snapshots into presence transitions. One instance per connected window.
internal sealed class TeamStateTracker
{
- private readonly HashSet _afk = new();
+ private readonly HashSet _afk = [];
private readonly object _gate = new();
- private readonly Dictionary _stillSince = new();
+ private readonly Dictionary _stillSince = [];
private Dictionary? _baseline;
/// Diffs against the previous one. First non-null call primes silently.
@@ -53,7 +61,8 @@ public IReadOnlyList Diff(
AddPresenceTransitions(transitions, id, was, nowMember, snapshot);
var diedThisPoll = nowMember.LastDeathTimeUtc > was.LastDeathTimeUtc;
- UpdateAfk(transitions, id, was, nowMember, now, afkThreshold, afkEpsilon, diedThisPoll);
+ UpdateAfk(transitions, id, was, nowMember,
+ new AfkPollContext(now, afkThreshold, afkEpsilon, diedThisPoll));
}
PruneDepartedMembers(current);
@@ -95,19 +104,16 @@ private void UpdateAfk(
ulong id,
TeamMemberSnapshot was,
TeamMemberSnapshot now,
- DateTimeOffset clock,
- TimeSpan threshold,
- float epsilon,
- bool diedThisPoll)
+ AfkPollContext context)
{
// A member who goes offline, is dead, or died this poll (even if a slow poll already shows them
// respawned) can no longer be AFK. Clear the AFK latch SILENTLY — the disconnect/death transition
// already speaks for them, and a "back" line alongside "disconnected"/"died" would contradict it —
// and reset the stillness clock so AFK must be re-earned after the state change.
- if (!now.IsOnline || !now.IsAlive || diedThisPoll)
+ if (!now.IsOnline || !now.IsAlive || context.DiedThisPoll)
{
_afk.Remove(id);
- _stillSince[id] = clock;
+ _stillSince[id] = context.Clock;
return;
}
@@ -115,10 +121,10 @@ private void UpdateAfk(
// movement of dx=dy=0.8, epsilon=1 — distance ≈ 1.13 — as still).
var dx = now.X - was.X;
var dy = now.Y - was.Y;
- var moved = (dx * dx) + (dy * dy) > epsilon * epsilon;
+ var moved = (dx * dx) + (dy * dy) > context.Epsilon * context.Epsilon;
if (moved)
{
- _stillSince[id] = clock;
+ _stillSince[id] = context.Clock;
if (_afk.Remove(id))
{
transitions.Add(new PlayerTransition(PlayerTransitionKind.ReturnedFromAfk, id, now.Name, null));
@@ -127,9 +133,9 @@ private void UpdateAfk(
return;
}
- var since = _stillSince.TryGetValue(id, out var s) ? s : clock;
+ var since = _stillSince.TryGetValue(id, out var s) ? s : context.Clock;
_stillSince.TryAdd(id, since);
- if (clock - since >= threshold && _afk.Add(id))
+ if (context.Clock - since >= context.Threshold && _afk.Add(id))
{
transitions.Add(new PlayerTransition(PlayerTransitionKind.BecameAfk, id, now.Name, (now.X, now.Y)));
}
diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
index e9e46be..0604975 100644
--- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
+++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
@@ -3,6 +3,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Credentials;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Abstractions.Time;
@@ -10,18 +11,22 @@
using RustPlusBot.Domain.Connections;
using RustPlusBot.Domain.Credentials;
using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Persistence.Alarms;
using RustPlusBot.Persistence.Connections;
using RustPlusBot.Persistence.Servers;
-using RustPlusBot.Persistence.Alarms;
using RustPlusBot.Persistence.Switches;
namespace RustPlusBot.Features.Connections.Supervisor;
+/// Bundles the security/notification collaborators injected into .
+/// DMs an owner when their credential is rejected.
+/// Unprotects stored tokens before connecting.
+internal sealed record ConnectionSecurity(IUserDmSender DmSender, ICredentialProtector Protector);
+
/// Default : one connect->heartbeat->failover loop per (guild, server).
/// Creates sockets (RustPlusApi in production, a fake in tests).
/// Opens scopes for the scoped stores.
-/// DMs an owner when their credential is rejected.
-/// Unprotects stored tokens before connecting.
+/// Bundles the security/notification collaborators.
/// Publishes ConnectionStatusChangedEvent on state changes.
/// Wall-clock source used for AFK hysteresis timestamps.
/// Timeouts/backoff/heartbeat settings.
@@ -29,8 +34,7 @@ namespace RustPlusBot.Features.Connections.Supervisor;
internal sealed partial class ConnectionSupervisor(
IRustSocketSource source,
IServiceScopeFactory scopeFactory,
- IUserDmSender dmSender,
- ICredentialProtector protector,
+ ConnectionSecurity security,
IEventBus eventBus,
IClock clock,
IOptions options,
@@ -487,24 +491,7 @@ void OnSmartDevice(object? sender, SmartDeviceTrigger trigger)
CancellationToken.None);
try
{
- while (!ct.IsCancellationRequested)
- {
- await Task.Delay(_options.HeartbeatInterval, ct).ConfigureAwait(false);
- var beat = await connection.GetInfoAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false);
- switch (beat.Kind)
- {
- case HeartbeatKind.Ok:
- await PublishStatusAsync(key, ConnectionStatus.Connected, beat.PlayerCount, credentialId, ct)
- .ConfigureAwait(false);
- break;
- case HeartbeatKind.AuthRejected:
- return ReconnectReason.AuthRejected;
- default:
- return ReconnectReason.Unreachable;
- }
- }
-
- return ReconnectReason.Stopped;
+ return await RunHeartbeatLoopAsync(key, connection, credentialId, ct).ConfigureAwait(false);
}
finally
{
@@ -526,6 +513,32 @@ await PublishStatusAsync(key, ConnectionStatus.Connected, beat.PlayerCount, cred
}
}
+ private async Task RunHeartbeatLoopAsync(
+ (ulong Guild, Guid Server) key,
+ IRustServerConnection connection,
+ Guid credentialId,
+ CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ await Task.Delay(_options.HeartbeatInterval, ct).ConfigureAwait(false);
+ var beat = await connection.GetInfoAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false);
+ switch (beat.Kind)
+ {
+ case HeartbeatKind.Ok:
+ await PublishStatusAsync(key, ConnectionStatus.Connected, beat.PlayerCount, credentialId, ct)
+ .ConfigureAwait(false);
+ break;
+ case HeartbeatKind.AuthRejected:
+ return ReconnectReason.AuthRejected;
+ default:
+ return ReconnectReason.Unreachable;
+ }
+ }
+
+ return ReconnectReason.Stopped;
+ }
+
private async Task PollMarkersAsync(
(ulong Guild, Guid Server) key,
IRustServerConnection connection,
@@ -705,13 +718,13 @@ private async Task> GetRigPositionsAsync(
string token;
try
{
- token = protector.Unprotect(active.ProtectedPlayerToken);
+ token = security.Protector.Unprotect(active.ProtectedPlayerToken);
}
catch (CryptographicException ex)
{
LogUnreadableToken(logger, ex, active.Id);
await store.MarkInvalidAsync(active.Id, ct).ConfigureAwait(false);
- await dmSender.SendAsync(
+ await security.DmSender.SendAsync(
active.OwnerUserId,
$"Your Rust+ credential for **{server.Name}** could not be read — reconnect in #setup.",
ct)
@@ -736,7 +749,7 @@ private async Task FailoverAsync(Guid credentialId, ulong ownerUserId, string se
await store.MarkInvalidAsync(credentialId, ct).ConfigureAwait(false);
}
- await dmSender.SendAsync(
+ await security.DmSender.SendAsync(
ownerUserId,
$"Your Rust+ credential for **{serverName}** was rejected — reconnect in #setup to keep it in the pool.",
ct)
@@ -829,7 +842,7 @@ private async Task PrimeDevicesAsync(
}
await PrimeEntityIdsAsync(key, connection,
- (IReadOnlyList)switches.Select(sw => sw.EntityId).ToList()).ConfigureAwait(false);
+ (IReadOnlyList)[.. switches.Select(sw => sw.EntityId)]).ConfigureAwait(false);
IReadOnlyList alarms;
try
@@ -854,7 +867,7 @@ await PrimeEntityIdsAsync(key, connection,
}
await PrimeEntityIdsAsync(key, connection,
- (IReadOnlyList)alarms.Select(a => a.EntityId).ToList()).ConfigureAwait(false);
+ (IReadOnlyList)[.. alarms.Select(a => a.EntityId)]).ConfigureAwait(false);
}
private async Task PrimeEntityIdsAsync(
diff --git a/src/RustPlusBot.Features.Events/Classifying/MarkerEventClassifier.cs b/src/RustPlusBot.Features.Events/Classifying/MarkerEventClassifier.cs
index 05fd53d..85161b2 100644
--- a/src/RustPlusBot.Features.Events/Classifying/MarkerEventClassifier.cs
+++ b/src/RustPlusBot.Features.Events/Classifying/MarkerEventClassifier.cs
@@ -1,6 +1,6 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Abstractions.Time;
-using RustPlusBot.Features.Connections.Listening;
namespace RustPlusBot.Features.Events.Classifying;
diff --git a/src/RustPlusBot.Features.Events/Classifying/RustMapEvent.cs b/src/RustPlusBot.Features.Events/Classifying/RustMapEvent.cs
index a28796e..3aa1dc3 100644
--- a/src/RustPlusBot.Features.Events/Classifying/RustMapEvent.cs
+++ b/src/RustPlusBot.Features.Events/Classifying/RustMapEvent.cs
@@ -1,4 +1,4 @@
-using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Abstractions.Connections;
namespace RustPlusBot.Features.Events.Classifying;
diff --git a/src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs
index 78b7fb9..8f26890 100644
--- a/src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs
@@ -27,7 +27,9 @@ public static IServiceCollection AddEvents(this IServiceCollection services)
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddHostedService();
return services;
diff --git a/src/RustPlusBot.Features.Events/Formatting/GridReference.cs b/src/RustPlusBot.Features.Events/Formatting/GridReference.cs
index 45dc933..59eeca5 100644
--- a/src/RustPlusBot.Features.Events/Formatting/GridReference.cs
+++ b/src/RustPlusBot.Features.Events/Formatting/GridReference.cs
@@ -1,6 +1,6 @@
using System.Globalization;
using System.Text;
-using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Abstractions.Connections;
namespace RustPlusBot.Features.Events.Formatting;
diff --git a/src/RustPlusBot.Features.Events/Hosting/EventsHostedService.cs b/src/RustPlusBot.Features.Events/Hosting/EventsHostedService.cs
index 5d8966b..06c919a 100644
--- a/src/RustPlusBot.Features.Events/Hosting/EventsHostedService.cs
+++ b/src/RustPlusBot.Features.Events/Hosting/EventsHostedService.cs
@@ -12,11 +12,15 @@
namespace RustPlusBot.Features.Events.Hosting;
+/// Bundles the state-store collaborators injected into .
+/// The marker state store, cleared on disconnect.
+/// The rig state store, advanced by the tick and cleared on disconnect.
+internal sealed record EventStores(EventStateStore Store, RigStateStore RigStore);
+
/// Runs the marker relay loop, the rig-event relay loop, the rig-timer tick, and the disconnect-clear loop.
/// The in-process event bus.
/// Relays marker + rig events into Discord #events and in-game chat.
-/// The marker state store, cleared on disconnect.
-/// The rig state store, advanced by the tick and cleared on disconnect.
+/// Bundles the marker and rig state stores.
/// Supplies the current time for the tick.
/// Supplies the rig-tick interval.
/// Opens scopes to read connection state.
@@ -24,8 +28,7 @@ namespace RustPlusBot.Features.Events.Hosting;
internal sealed partial class EventsHostedService(
IEventBus eventBus,
EventRelay relay,
- EventStateStore store,
- RigStateStore rigStore,
+ EventStores stores,
IClock clock,
IOptions options,
IServiceScopeFactory scopeFactory,
@@ -77,7 +80,7 @@ public async Task StopAsync(CancellationToken cancellationToken)
/// A task that completes when the tick has published all crossings.
internal async Task TickOnceAsync(CancellationToken cancellationToken)
{
- foreach (var c in rigStore.Advance(clock.UtcNow))
+ foreach (var c in stores.RigStore.Advance(clock.UtcNow))
{
await eventBus.PublishAsync(
new RigStateChangedEvent(c.GuildId, c.ServerId, c.Rig, c.Kind, c.X, c.Y, c.Dimensions),
@@ -184,8 +187,8 @@ private async Task ClearIfDisconnectedAsync(ConnectionStatusChangedEvent evt, Ca
.ConfigureAwait(false);
if (state is null || state.Status != ConnectionStatus.Connected)
{
- store.Clear(evt.GuildId, evt.ServerId);
- rigStore.Clear(evt.GuildId, evt.ServerId);
+ stores.Store.Clear(evt.GuildId, evt.ServerId);
+ stores.RigStore.Clear(evt.GuildId, evt.ServerId);
}
}
}
diff --git a/src/RustPlusBot.Features.Events/Posting/DiscordEventChannelPoster.cs b/src/RustPlusBot.Features.Events/Posting/DiscordEventChannelPoster.cs
index d32e93c..4686121 100644
--- a/src/RustPlusBot.Features.Events/Posting/DiscordEventChannelPoster.cs
+++ b/src/RustPlusBot.Features.Events/Posting/DiscordEventChannelPoster.cs
@@ -1,45 +1,19 @@
using Discord;
using Discord.WebSocket;
using Microsoft.Extensions.Logging;
+using RustPlusBot.Discord.Posting;
namespace RustPlusBot.Features.Events.Posting;
/// Posts event embeds to Discord text channels via the gateway client.
/// The Discord socket client.
/// The logger.
-internal sealed partial class DiscordEventChannelPoster(
+internal sealed class DiscordEventChannelPoster(
DiscordSocketClient client,
ILogger logger)
: IEventChannelPoster
{
///
- public async Task PostAsync(ulong channelId, Embed embed, CancellationToken cancellationToken)
- {
- try
- {
- var options = new RequestOptions
- {
- CancelToken = cancellationToken
- };
- if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) is not ITextChannel channel)
- {
- return;
- }
-
- await channel.SendMessageAsync(embed: embed, options: options).ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- throw; // Cancellation (shutdown) is not a post failure; let the relay loop unwind cleanly.
- }
-#pragma warning disable CA1031 // Broad catch: a Discord hiccup must not crash the relay.
- catch (Exception ex)
-#pragma warning restore CA1031
- {
- LogPostFailed(logger, ex, channelId);
- }
- }
-
- [LoggerMessage(Level = LogLevel.Warning, Message = "Posting an event embed to channel {ChannelId} failed.")]
- private static partial void LogPostFailed(ILogger logger, Exception exception, ulong channelId);
+ public Task PostAsync(ulong channelId, Embed embed, CancellationToken cancellationToken)
+ => DiscordChannelMessenger.PostAsync(client, channelId, embed, logger, cancellationToken);
}
diff --git a/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs b/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs
index f034dba..45ec85f 100644
--- a/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs
+++ b/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs
@@ -10,22 +10,27 @@
namespace RustPlusBot.Features.Events.Relaying;
+/// Bundles the channel/chat collaborators injected into .
+/// Resolves the #events Discord channel id.
+/// Posts embeds to the Discord channel.
+/// Broadcasts the in-game team-chat line.
+internal sealed record EventRelayChannels(
+ IEventChannelLocator Locator,
+ IEventChannelPoster Poster,
+ ITeamChatSender TeamChatSender);
+
/// Posts every live event to #events AND in-game team chat; tracks rig state.
/// Classifies raw marker deltas into domain events.
/// Tracks active markers and recent events per server.
/// Renders events as embeds and in-game lines.
-/// Resolves the #events Discord channel id.
-/// Posts embeds to the Discord channel.
-/// Broadcasts the in-game team-chat line.
+/// Bundles the channel/chat collaborators.
/// Tracks oil-rig state (Apply on Activated).
/// Opens scopes to read guild culture.
internal sealed class EventRelay(
MarkerEventClassifier classifier,
EventStateStore state,
EventEmbedRenderer renderer,
- IEventChannelLocator locator,
- IEventChannelPoster poster,
- ITeamChatSender teamChatSender,
+ EventRelayChannels channels,
RigStateStore rigStore,
IServiceScopeFactory scopeFactory)
{
@@ -44,17 +49,18 @@ public async Task RelayAsync(MapMarkersChangedEvent evt, CancellationToken cance
}
var culture = await GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false);
- var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken)
+ var channelId = await channels.Locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken)
.ConfigureAwait(false);
foreach (var e in events)
{
- await teamChatSender
+ await channels.TeamChatSender
.SendAsync(evt.GuildId, evt.ServerId, renderer.RenderLine(e, culture), cancellationToken)
.ConfigureAwait(false);
if (channelId is { } id)
{
- await poster.PostAsync(id, renderer.Render(e, culture), cancellationToken).ConfigureAwait(false);
+ await channels.Poster.PostAsync(id, renderer.Render(e, culture), cancellationToken)
+ .ConfigureAwait(false);
}
}
}
@@ -72,15 +78,16 @@ public async Task RelayRigAsync(RigStateChangedEvent evt, CancellationToken canc
}
var culture = await GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false);
- await teamChatSender
+ await channels.TeamChatSender
.SendAsync(evt.GuildId, evt.ServerId, renderer.RenderRigLine(evt, culture), cancellationToken)
.ConfigureAwait(false);
- var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken)
+ var channelId = await channels.Locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken)
.ConfigureAwait(false);
if (channelId is { } id)
{
- await poster.PostAsync(id, renderer.RenderRig(evt, culture), cancellationToken).ConfigureAwait(false);
+ await channels.Poster.PostAsync(id, renderer.RenderRig(evt, culture), cancellationToken)
+ .ConfigureAwait(false);
}
}
diff --git a/src/RustPlusBot.Features.Events/Rendering/EventLocalizer.cs b/src/RustPlusBot.Features.Events/Rendering/EventLocalizer.cs
index 6027a4a..9335d43 100644
--- a/src/RustPlusBot.Features.Events/Rendering/EventLocalizer.cs
+++ b/src/RustPlusBot.Features.Events/Rendering/EventLocalizer.cs
@@ -1,61 +1,8 @@
-using System.Globalization;
+using RustPlusBot.Localization;
namespace RustPlusBot.Features.Events.Rendering;
-/// Dictionary-backed with English fallback and region normalization.
-/// Duplicated from the command/workspace localizers; consolidate into a shared project in a future refactor.
+/// Live-event localizer backed by the shared .
/// The string catalog.
-internal sealed class EventLocalizer(EventLocalizationCatalog catalog) : IEventLocalizer
-{
- private const string FallbackCulture = "en";
-
- ///
- public string Get(string key, string culture)
- {
- var normalized = Normalize(culture);
- if (catalog.Strings.TryGetValue(normalized, out var map) && map.TryGetValue(key, out var value))
- {
- return value;
- }
-
- if (catalog.Strings.TryGetValue(FallbackCulture, out var fallback) &&
- fallback.TryGetValue(key, out var fallbackValue))
- {
- return fallbackValue;
- }
-
- return key;
- }
-
- ///
- public string Get(string key, string culture, params object[] args)
- {
- var format = Get(key, culture);
- var provider = ResolveFormatProvider(Normalize(culture));
- return string.Format(provider, format, args);
- }
-
- private static string Normalize(string culture)
- {
- if (string.IsNullOrWhiteSpace(culture))
- {
- return FallbackCulture;
- }
-
- var dash = culture.IndexOf('-', StringComparison.Ordinal);
- var primary = dash >= 0 ? culture[..dash] : culture;
- return primary.ToLowerInvariant();
- }
-
- private static CultureInfo ResolveFormatProvider(string culture)
- {
- try
- {
- return CultureInfo.GetCultureInfo(culture);
- }
- catch (CultureNotFoundException)
- {
- return CultureInfo.InvariantCulture;
- }
- }
-}
+internal sealed class EventLocalizer(EventLocalizationCatalog catalog)
+ : DictionaryLocalizer(catalog.Strings), IEventLocalizer;
diff --git a/src/RustPlusBot.Features.Events/Rendering/IEventLocalizer.cs b/src/RustPlusBot.Features.Events/Rendering/IEventLocalizer.cs
index efdd621..c87ddb5 100644
--- a/src/RustPlusBot.Features.Events/Rendering/IEventLocalizer.cs
+++ b/src/RustPlusBot.Features.Events/Rendering/IEventLocalizer.cs
@@ -1,16 +1,6 @@
+using RustPlusBot.Localization;
+
namespace RustPlusBot.Features.Events.Rendering;
/// Resolves localized live-event strings by key and culture, falling back to English.
-internal interface IEventLocalizer
-{
- /// 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);
-}
+internal interface IEventLocalizer : ILocalizer;
diff --git a/src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj b/src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj
index 4f55cb2..9d0a33c 100644
--- a/src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj
+++ b/src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj
@@ -7,6 +7,7 @@
+
diff --git a/src/RustPlusBot.Features.Events/State/ActiveMarker.cs b/src/RustPlusBot.Features.Events/State/ActiveMarker.cs
index c3782bd..6e97f7f 100644
--- a/src/RustPlusBot.Features.Events/State/ActiveMarker.cs
+++ b/src/RustPlusBot.Features.Events/State/ActiveMarker.cs
@@ -1,4 +1,4 @@
-using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Abstractions.Connections;
namespace RustPlusBot.Features.Events.State;
diff --git a/src/RustPlusBot.Features.Events/State/EventStateStore.cs b/src/RustPlusBot.Features.Events/State/EventStateStore.cs
index ddfa0cb..162d5f3 100644
--- a/src/RustPlusBot.Features.Events/State/EventStateStore.cs
+++ b/src/RustPlusBot.Features.Events/State/EventStateStore.cs
@@ -1,7 +1,7 @@
using System.Collections.Concurrent;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Abstractions.Time;
-using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Events.Classifying;
namespace RustPlusBot.Features.Events.State;
@@ -23,10 +23,12 @@ public IReadOnlyList GetActiveMarkers(ulong guildId, Guid serverId
lock (state.Gate)
{
- return state.Active.Values
- .Where(m => m.Kind == kind)
- .OrderByDescending(m => m.SeenAtUtc)
- .ToList();
+ return
+ [
+ .. state.Active.Values
+ .Where(m => m.Kind == kind)
+ .OrderByDescending(m => m.SeenAtUtc)
+ ];
}
}
@@ -40,7 +42,7 @@ public IReadOnlyList GetRecentEvents(ulong guildId, Guid serverId)
lock (state.Gate)
{
- return state.Recent.ToList(); // already newest-first
+ return [.. state.Recent]; // already newest-first
}
}
diff --git a/src/RustPlusBot.Features.Events/State/IEventState.cs b/src/RustPlusBot.Features.Events/State/IEventState.cs
index 69881dd..6d9acdd 100644
--- a/src/RustPlusBot.Features.Events/State/IEventState.cs
+++ b/src/RustPlusBot.Features.Events/State/IEventState.cs
@@ -1,4 +1,4 @@
-using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Features.Events.Classifying;
namespace RustPlusBot.Features.Events.State;
diff --git a/src/RustPlusBot.Features.Events/State/RigStateStore.cs b/src/RustPlusBot.Features.Events/State/RigStateStore.cs
index 80a80bd..1dd04fa 100644
--- a/src/RustPlusBot.Features.Events/State/RigStateStore.cs
+++ b/src/RustPlusBot.Features.Events/State/RigStateStore.cs
@@ -1,9 +1,9 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Features.Connections;
-using RustPlusBot.Features.Connections.Listening;
namespace RustPlusBot.Features.Events.State;
diff --git a/src/RustPlusBot.Features.Map/Assets/MapIcons.cs b/src/RustPlusBot.Features.Map/Assets/MapIcons.cs
index fa790f7..0f51e0e 100644
--- a/src/RustPlusBot.Features.Map/Assets/MapIcons.cs
+++ b/src/RustPlusBot.Features.Map/Assets/MapIcons.cs
@@ -1,6 +1,6 @@
using System.Collections.Concurrent;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
-using RustPlusBot.Features.Connections.Listening;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
diff --git a/src/RustPlusBot.Features.Map/Composing/BaseMapCache.cs b/src/RustPlusBot.Features.Map/Composing/BaseMapCache.cs
index ee0295e..967ef90 100644
--- a/src/RustPlusBot.Features.Map/Composing/BaseMapCache.cs
+++ b/src/RustPlusBot.Features.Map/Composing/BaseMapCache.cs
@@ -1,5 +1,5 @@
using System.Collections.Concurrent;
-using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Abstractions.Connections;
namespace RustPlusBot.Features.Map.Composing;
diff --git a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs
index 621b9f3..a69ec25 100644
--- a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs
+++ b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs
@@ -1,6 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
-using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Events.State;
using RustPlusBot.Features.Map.Rendering;
using RustPlusBot.Persistence.Map;
diff --git a/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs b/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs
index dab0d77..c779dad 100644
--- a/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs
+++ b/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs
@@ -13,35 +13,45 @@
namespace RustPlusBot.Features.Map.Hosting;
+/// Bundles the rendering-pipeline collaborators injected into .
+/// Renders the map PNG from the cached base + live markers.
+/// The base-map cache, cleared on disconnect.
+/// Resolves the #map Discord channel for a server.
+/// Posts the rendered PNG to Discord.
+internal sealed record MapPipeline(
+ MapComposer Composer,
+ BaseMapCache Cache,
+ IMapChannelLocator Locator,
+ IMapChannelPoster Poster);
+
///
/// Keeps the #map image current: re-renders on marker changes, on a steady interval (so moving
/// markers track even though their ids are stable), and on connect; clears the base-map cache on
/// disconnect. All refreshes pass through a per-server throttle so the surfaces never double-post.
///
/// The in-process event bus.
-/// Renders the map PNG from the cached base + live markers.
-/// The base-map cache, cleared on disconnect.
-/// Resolves the #map Discord channel for a server.
-/// Posts the rendered PNG to Discord.
+/// Bundles the rendering-pipeline collaborators.
/// Supplies the current time for the throttle.
/// Supplies the refresh interval.
/// Opens scopes to read connection state.
/// The logger.
internal sealed partial class MapHostedService(
IEventBus eventBus,
- MapComposer composer,
- BaseMapCache cache,
- IMapChannelLocator locator,
- IMapChannelPoster poster,
+ MapPipeline pipeline,
IClock clock,
IOptions options,
IServiceScopeFactory scopeFactory,
ILogger logger) : IHostedService, IDisposable
{
+ private readonly BaseMapCache _cache = pipeline.Cache;
+ private readonly MapComposer _composer = pipeline.Composer;
+
/// A value-less concurrent set of currently-connected servers the periodic loop repaints.
private readonly ConcurrentDictionary<(ulong Guild, Guid Server), byte> _connected = new();
private readonly CancellationTokenSource _cts = new();
+ private readonly IMapChannelLocator _locator = pipeline.Locator;
+ private readonly IMapChannelPoster _poster = pipeline.Poster;
private readonly MapRefreshThrottle _throttle = new(clock);
private Task? _markerLoop;
private Task? _settingsLoop;
@@ -174,19 +184,19 @@ private async Task RefreshAsync(ulong guildId, Guid serverId, CancellationToken
return;
}
- var channelId = await locator.GetChannelIdAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
+ var channelId = await _locator.GetChannelIdAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
if (channelId is not { } id)
{
return;
}
- var png = await composer.ComposeAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
+ var png = await _composer.ComposeAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
if (png is null)
{
return;
}
- await poster.PostAsync(id, png, cancellationToken).ConfigureAwait(false);
+ await _poster.PostAsync(id, png, cancellationToken).ConfigureAwait(false);
}
private async Task ConsumeConnectionStatusEventsAsync(CancellationToken cancellationToken)
@@ -223,7 +233,7 @@ private async Task OnConnectionStatusAsync(ConnectionStatusChangedEvent evt, Can
if (state is null || state.Status != ConnectionStatus.Connected)
{
_connected.TryRemove(key, out _);
- cache.Clear(evt.GuildId, evt.ServerId);
+ _cache.Clear(evt.GuildId, evt.ServerId);
return;
}
diff --git a/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs
index 7fa62f0..ff7429b 100644
--- a/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs
@@ -9,7 +9,7 @@ namespace RustPlusBot.Features.Map;
/// DI registration for the map-render feature.
public static class MapServiceCollectionExtensions
{
- /// Registers the renderer, base-map cache, composer, poster, and hosted service.
+ /// Registers the renderer, base-map cache, composer, poster, pipeline bundle, and hosted service.
/// The service collection to add to.
/// The same service collection, for chaining.
public static IServiceCollection AddMap(this IServiceCollection services)
@@ -20,6 +20,7 @@ public static IServiceCollection AddMap(this IServiceCollection services)
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddHostedService();
return services;
diff --git a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs
index b3f83ec..1dd7d5e 100644
--- a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs
+++ b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs
@@ -1,5 +1,5 @@
using System.Globalization;
-using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Features.Map.Assets;
using SixLabors.Fonts;
using SixLabors.ImageSharp;
@@ -189,34 +189,42 @@ private static void DrawPlayers(Image image, IReadOnlyList? icon)
+ {
+ if (icon is not null)
+ {
+ ctx.DrawImage(icon, CenterAt(player.PixelX, player.PixelY, icon), 1f);
+ return;
+ }
+
+ var isActive = player is { IsAlive: true, IsOnline: true };
+ var dotColor = isActive ? Color.LimeGreen : Color.Gray;
+ var dot = new EllipsePolygon(player.PixelX, player.PixelY, PlayerRadius);
+ ctx.Fill(dotColor, dot);
+ ctx.Draw(Color.Black, OutlinePenWidth, dot);
+ }
+
+ private static void DrawPlayerLabel(IImageProcessingContext ctx, PlayerPlacement player)
+ {
+ var isActive = player is { IsAlive: true, IsOnline: true };
+ var suffix = player.IsAlive ? " (offline)" : " (dead)";
+ var label = isActive ? player.Name : player.Name + suffix;
+ var labelColor = isActive ? Color.White : Color.Gray;
+ var textOptions = new RichTextOptions(Font)
+ {
+ Origin = new PointF(player.PixelX, player.PixelY + PlayerLabelOffset),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Top,
+ };
+ ctx.DrawText(textOptions, label, labelColor);
+ }
+
private static Point CenterAt(float x, float y, Image icon) =>
new((int)(x - (icon.Width / 2f)), (int)(y - (icon.Height / 2f)));
}
diff --git a/src/RustPlusBot.Features.Map/Rendering/MarkerPlacement.cs b/src/RustPlusBot.Features.Map/Rendering/MarkerPlacement.cs
index 4e6c318..f986f02 100644
--- a/src/RustPlusBot.Features.Map/Rendering/MarkerPlacement.cs
+++ b/src/RustPlusBot.Features.Map/Rendering/MarkerPlacement.cs
@@ -1,4 +1,4 @@
-using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Abstractions.Connections;
namespace RustPlusBot.Features.Map.Rendering;
diff --git a/src/RustPlusBot.Features.Map/Rendering/WorldToPixel.cs b/src/RustPlusBot.Features.Map/Rendering/WorldToPixel.cs
index 844bbdc..bcf38e9 100644
--- a/src/RustPlusBot.Features.Map/Rendering/WorldToPixel.cs
+++ b/src/RustPlusBot.Features.Map/Rendering/WorldToPixel.cs
@@ -1,4 +1,4 @@
-using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Abstractions.Connections;
namespace RustPlusBot.Features.Map.Rendering;
diff --git a/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs b/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs
index 9033c62..7deea32 100644
--- a/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs
+++ b/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs
@@ -15,6 +15,7 @@ namespace RustPlusBot.Features.Pairing.Modules;
public sealed class CredentialModule(IServiceScopeFactory scopeFactory)
: InteractionModuleBase
{
+ private const string ServerOnlyMessage = "This control must be used in a server.";
private const string DisconnectConfirmId = "pairing:account:disconnect:confirm";
private const string DisconnectCancelId = "pairing:account:disconnect:cancel";
@@ -24,7 +25,7 @@ public async Task OpenAsync()
{
if (Context.Guild is null)
{
- await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false);
+ await RespondAsync(ServerOnlyMessage, ephemeral: true).ConfigureAwait(false);
return;
}
@@ -39,7 +40,7 @@ public async Task SubmitAsync(ConnectModal modal)
ArgumentNullException.ThrowIfNull(modal);
if (Context.Guild is null)
{
- await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false);
+ await RespondAsync(ServerOnlyMessage, ephemeral: true).ConfigureAwait(false);
return;
}
@@ -80,7 +81,7 @@ public async Task DisconnectPromptAsync()
{
if (Context.Guild is null)
{
- await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false);
+ await RespondAsync(ServerOnlyMessage, ephemeral: true).ConfigureAwait(false);
return;
}
@@ -117,7 +118,7 @@ public async Task DisconnectConfirmAsync()
{
if (Context.Guild is null)
{
- await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false);
+ await RespondAsync(ServerOnlyMessage, ephemeral: true).ConfigureAwait(false);
return;
}
diff --git a/src/RustPlusBot.Features.Players/Posting/DiscordPlayerChannelPoster.cs b/src/RustPlusBot.Features.Players/Posting/DiscordPlayerChannelPoster.cs
index c6bbbb3..a27619c 100644
--- a/src/RustPlusBot.Features.Players/Posting/DiscordPlayerChannelPoster.cs
+++ b/src/RustPlusBot.Features.Players/Posting/DiscordPlayerChannelPoster.cs
@@ -1,45 +1,19 @@
using Discord;
using Discord.WebSocket;
using Microsoft.Extensions.Logging;
+using RustPlusBot.Discord.Posting;
namespace RustPlusBot.Features.Players.Posting;
/// Posts player-event embeds to Discord text channels via the gateway client.
/// The Discord socket client.
/// The logger.
-internal sealed partial class DiscordPlayerChannelPoster(
+internal sealed class DiscordPlayerChannelPoster(
DiscordSocketClient client,
ILogger logger)
: IPlayerChannelPoster
{
///
- public async Task PostAsync(ulong channelId, Embed embed, CancellationToken cancellationToken)
- {
- try
- {
- var options = new RequestOptions
- {
- CancelToken = cancellationToken
- };
- if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) is not ITextChannel channel)
- {
- return;
- }
-
- await channel.SendMessageAsync(embed: embed, options: options).ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- throw; // Cancellation (shutdown) is not a post failure; let the relay loop unwind cleanly.
- }
-#pragma warning disable CA1031 // Broad catch: a Discord hiccup must not crash the relay.
- catch (Exception ex)
-#pragma warning restore CA1031
- {
- LogPostFailed(logger, ex, channelId);
- }
- }
-
- [LoggerMessage(Level = LogLevel.Warning, Message = "Posting a player-event embed to channel {ChannelId} failed.")]
- private static partial void LogPostFailed(ILogger logger, Exception exception, ulong channelId);
+ public Task PostAsync(ulong channelId, Embed embed, CancellationToken cancellationToken)
+ => DiscordChannelMessenger.PostAsync(client, channelId, embed, logger, cancellationToken);
}
diff --git a/src/RustPlusBot.Features.Players/Rendering/IPlayerLocalizer.cs b/src/RustPlusBot.Features.Players/Rendering/IPlayerLocalizer.cs
index db06fb3..818dfdc 100644
--- a/src/RustPlusBot.Features.Players/Rendering/IPlayerLocalizer.cs
+++ b/src/RustPlusBot.Features.Players/Rendering/IPlayerLocalizer.cs
@@ -1,16 +1,6 @@
+using RustPlusBot.Localization;
+
namespace RustPlusBot.Features.Players.Rendering;
/// Resolves localized player-event strings by key and culture, falling back to English.
-internal interface IPlayerLocalizer
-{
- /// 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);
-}
+internal interface IPlayerLocalizer : ILocalizer;
diff --git a/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs b/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs
index ae86a78..c2babbc 100644
--- a/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs
+++ b/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs
@@ -1,6 +1,6 @@
using Discord;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
-using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Events.Formatting;
namespace RustPlusBot.Features.Players.Rendering;
@@ -35,25 +35,18 @@ public string RenderLine(PlayerTransition transition, MapDimensions? dims, strin
private string Describe(PlayerTransition t, MapDimensions? dims, string culture, string suffix)
{
- switch (t.Kind)
+ return t.Kind switch
{
- case PlayerTransitionKind.Connect:
- return localizer.Get("player.connect" + suffix, culture, t.Name);
- case PlayerTransitionKind.Disconnect:
- return localizer.Get("player.disconnect" + suffix, culture, t.Name);
- case PlayerTransitionKind.Respawn:
- return localizer.Get("player.respawn" + suffix, culture, t.Name, Grid(t, dims));
- case PlayerTransitionKind.ReturnedFromAfk:
- return localizer.Get("player.afk.back" + suffix, culture, t.Name);
- case PlayerTransitionKind.BecameAfk:
- return localizer.Get("player.afk" + suffix, culture, t.Name, Grid(t, dims));
- case PlayerTransitionKind.Death:
- return t.Location is null
- ? localizer.Get("player.death.unknown" + suffix, culture, t.Name)
- : localizer.Get("player.death" + suffix, culture, t.Name, Grid(t, dims));
- default:
- throw new ArgumentOutOfRangeException(nameof(t), t.Kind, "Unsupported transition kind.");
- }
+ PlayerTransitionKind.Connect => localizer.Get("player.connect" + suffix, culture, t.Name),
+ PlayerTransitionKind.Disconnect => localizer.Get("player.disconnect" + suffix, culture, t.Name),
+ PlayerTransitionKind.Respawn => localizer.Get("player.respawn" + suffix, culture, t.Name, Grid(t, dims)),
+ PlayerTransitionKind.ReturnedFromAfk => localizer.Get("player.afk.back" + suffix, culture, t.Name),
+ PlayerTransitionKind.BecameAfk => localizer.Get("player.afk" + suffix, culture, t.Name, Grid(t, dims)),
+ PlayerTransitionKind.Death => t.Location is null
+ ? localizer.Get("player.death.unknown" + suffix, culture, t.Name)
+ : localizer.Get("player.death" + suffix, culture, t.Name, Grid(t, dims)),
+ _ => throw new ArgumentOutOfRangeException(nameof(t), t.Kind, "Unsupported transition kind."),
+ };
}
private static string Grid(PlayerTransition t, MapDimensions? dims)
diff --git a/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizer.cs b/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizer.cs
index ddafa26..a841eac 100644
--- a/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizer.cs
+++ b/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizer.cs
@@ -1,61 +1,8 @@
-using System.Globalization;
+using RustPlusBot.Localization;
namespace RustPlusBot.Features.Players.Rendering;
-/// Dictionary-backed with English fallback and region normalization.
-/// Duplicated from the command/workspace localizers; consolidate into a shared project in a future refactor.
+/// Player-event localizer backed by the shared .
/// The string catalog.
-internal sealed class PlayerLocalizer(PlayerLocalizationCatalog catalog) : IPlayerLocalizer
-{
- private const string FallbackCulture = "en";
-
- ///
- public string Get(string key, string culture)
- {
- var normalized = Normalize(culture);
- if (catalog.Strings.TryGetValue(normalized, out var map) && map.TryGetValue(key, out var value))
- {
- return value;
- }
-
- if (catalog.Strings.TryGetValue(FallbackCulture, out var fallback) &&
- fallback.TryGetValue(key, out var fallbackValue))
- {
- return fallbackValue;
- }
-
- return key;
- }
-
- ///
- public string Get(string key, string culture, params object[] args)
- {
- var format = Get(key, culture);
- var provider = ResolveFormatProvider(Normalize(culture));
- return string.Format(provider, format, args);
- }
-
- private static string Normalize(string culture)
- {
- if (string.IsNullOrWhiteSpace(culture))
- {
- return FallbackCulture;
- }
-
- var dash = culture.IndexOf('-', StringComparison.Ordinal);
- var primary = dash >= 0 ? culture[..dash] : culture;
- return primary.ToLowerInvariant();
- }
-
- private static CultureInfo ResolveFormatProvider(string culture)
- {
- try
- {
- return CultureInfo.GetCultureInfo(culture);
- }
- catch (CultureNotFoundException)
- {
- return CultureInfo.InvariantCulture;
- }
- }
-}
+internal sealed class PlayerLocalizer(PlayerLocalizationCatalog catalog)
+ : DictionaryLocalizer(catalog.Strings), IPlayerLocalizer;
diff --git a/src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj b/src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj
index 41aa50f..47e2e87 100644
--- a/src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj
+++ b/src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj
@@ -7,6 +7,7 @@
+
diff --git a/src/RustPlusBot.Features.Switches/Modules/SwitchComponentModule.cs b/src/RustPlusBot.Features.Switches/Modules/SwitchComponentModule.cs
index ccc6f51..f9eff93 100644
--- a/src/RustPlusBot.Features.Switches/Modules/SwitchComponentModule.cs
+++ b/src/RustPlusBot.Features.Switches/Modules/SwitchComponentModule.cs
@@ -2,8 +2,8 @@
using Discord;
using Discord.Interactions;
using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
-using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Switches.Pairing;
using RustPlusBot.Features.Switches.Rendering;
using RustPlusBot.Persistence.Switches;
@@ -19,6 +19,8 @@ public sealed class SwitchComponentModule(
IRustServerQuery query,
IEventBus eventBus) : InteractionModuleBase
{
+ private const string InvalidControlMessage = "That control wasn't valid.";
+
/// Accepts a pending pairing prompt and starts managing the switch.
/// The "{serverId}:{entityId}" custom-id tail.
[ComponentInteraction(SwitchComponentIds.AcceptPrefix + "*")]
@@ -26,7 +28,7 @@ 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);
+ await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
return;
}
@@ -50,7 +52,7 @@ 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);
+ await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
return;
}
@@ -84,7 +86,7 @@ public async Task StrobeAsync(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);
+ await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
return;
}
@@ -114,7 +116,7 @@ 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);
+ await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
return;
}
@@ -132,7 +134,7 @@ public async Task RenameSubmitAsync(string tail, SwitchRenameModal 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);
+ await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
return;
}
@@ -163,7 +165,7 @@ private async Task SetAsync(string tail, bool value)
{
if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null)
{
- await RespondAsync("That control wasn't valid.", ephemeral: true).ConfigureAwait(false);
+ await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
return;
}
diff --git a/src/RustPlusBot.Features.Switches/Posting/DiscordSwitchChannelPoster.cs b/src/RustPlusBot.Features.Switches/Posting/DiscordSwitchChannelPoster.cs
index 539b32e..a689f2d 100644
--- a/src/RustPlusBot.Features.Switches/Posting/DiscordSwitchChannelPoster.cs
+++ b/src/RustPlusBot.Features.Switches/Posting/DiscordSwitchChannelPoster.cs
@@ -1,86 +1,24 @@
-using Discord.Net;
+using Discord;
using Discord.WebSocket;
using Microsoft.Extensions.Logging;
+using RustPlusBot.Discord.Posting;
namespace RustPlusBot.Features.Switches.Posting;
/// Posts/edits switch embeds in #switches by message id. Untested integration shim.
/// The Discord socket client.
/// The logger.
-internal sealed partial class DiscordSwitchChannelPoster(
+internal sealed class DiscordSwitchChannelPoster(
DiscordSocketClient client,
ILogger logger) : ISwitchChannelPoster
{
///
- public async Task EnsureAsync(
+ public Task EnsureAsync(
ulong channelId,
ulong? messageId,
- global::Discord.Embed embed,
- global::Discord.MessageComponent components,
+ Embed embed,
+ 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;
- }
- }
-
- [LoggerMessage(Level = LogLevel.Warning, Message = "Posting/editing a switch embed in channel {ChannelId} failed.")]
- private static partial void LogEnsureFailed(ILogger logger, Exception exception, ulong channelId);
-
- [LoggerMessage(Level = LogLevel.Debug,
- Message = "Switch embed {MessageId} in channel {ChannelId} was deleted; reposting.")]
- private static partial void
- LogMessageMissing(ILogger logger, Exception exception, ulong channelId, ulong messageId);
+ => DiscordChannelMessenger.EnsureAsync(client, channelId, messageId, embed, components, logger,
+ cancellationToken);
}
diff --git a/src/RustPlusBot.Features.Switches/Rendering/ISwitchLocalizer.cs b/src/RustPlusBot.Features.Switches/Rendering/ISwitchLocalizer.cs
index c274dab..39b3454 100644
--- a/src/RustPlusBot.Features.Switches/Rendering/ISwitchLocalizer.cs
+++ b/src/RustPlusBot.Features.Switches/Rendering/ISwitchLocalizer.cs
@@ -1,16 +1,6 @@
+using RustPlusBot.Localization;
+
namespace RustPlusBot.Features.Switches.Rendering;
/// Resolves localized smart-switch strings by key and culture, falling back to English.
-internal interface ISwitchLocalizer
-{
- /// 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);
-}
+internal interface ISwitchLocalizer : ILocalizer;
diff --git a/src/RustPlusBot.Features.Switches/Rendering/SwitchLocalizer.cs b/src/RustPlusBot.Features.Switches/Rendering/SwitchLocalizer.cs
index 95bd080..f9d545f 100644
--- a/src/RustPlusBot.Features.Switches/Rendering/SwitchLocalizer.cs
+++ b/src/RustPlusBot.Features.Switches/Rendering/SwitchLocalizer.cs
@@ -1,61 +1,8 @@
-using System.Globalization;
+using RustPlusBot.Localization;
namespace RustPlusBot.Features.Switches.Rendering;
-/// Dictionary-backed with English fallback and region normalization.
-/// Duplicated from the command/workspace localizers; consolidate into a shared project in a future refactor.
+/// Smart-switch localizer backed by the shared .
/// The string catalog.
-internal sealed class SwitchLocalizer(SwitchLocalizationCatalog catalog) : ISwitchLocalizer
-{
- private const string FallbackCulture = "en";
-
- ///
- public string Get(string key, string culture)
- {
- var normalized = Normalize(culture);
- if (catalog.Strings.TryGetValue(normalized, out var map) && map.TryGetValue(key, out var value))
- {
- return value;
- }
-
- if (catalog.Strings.TryGetValue(FallbackCulture, out var fallback) &&
- fallback.TryGetValue(key, out var fallbackValue))
- {
- return fallbackValue;
- }
-
- return key;
- }
-
- ///
- public string Get(string key, string culture, params object[] args)
- {
- var format = Get(key, culture);
- var provider = ResolveFormatProvider(Normalize(culture));
- return string.Format(provider, format, args);
- }
-
- private static string Normalize(string culture)
- {
- if (string.IsNullOrWhiteSpace(culture))
- {
- return FallbackCulture;
- }
-
- var dash = culture.IndexOf('-', StringComparison.Ordinal);
- var primary = dash >= 0 ? culture[..dash] : culture;
- return primary.ToLowerInvariant();
- }
-
- private static CultureInfo ResolveFormatProvider(string culture)
- {
- try
- {
- return CultureInfo.GetCultureInfo(culture);
- }
- catch (CultureNotFoundException)
- {
- return CultureInfo.InvariantCulture;
- }
- }
-}
+internal sealed class SwitchLocalizer(SwitchLocalizationCatalog catalog)
+ : DictionaryLocalizer(catalog.Strings), ISwitchLocalizer;
diff --git a/src/RustPlusBot.Features.Switches/RustPlusBot.Features.Switches.csproj b/src/RustPlusBot.Features.Switches/RustPlusBot.Features.Switches.csproj
index 7ac83d0..d0d9a88 100644
--- a/src/RustPlusBot.Features.Switches/RustPlusBot.Features.Switches.csproj
+++ b/src/RustPlusBot.Features.Switches/RustPlusBot.Features.Switches.csproj
@@ -7,6 +7,7 @@
+
diff --git a/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs
index ebbdb8e..b9efd24 100644
--- a/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs
+++ b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs
@@ -169,17 +169,35 @@ public IReadOnlyList GetMissingBotPermissions(ulong guildId)
var permissions = guild.CurrentUser.GuildPermissions;
var missing = new List();
- if (!permissions.ManageChannels) { missing.Add("Manage Channels"); }
+ if (!permissions.ManageChannels)
+ {
+ missing.Add("Manage Channels");
+ }
- if (!permissions.ManageRoles) { missing.Add("Manage Roles"); }
+ if (!permissions.ManageRoles)
+ {
+ missing.Add("Manage Roles");
+ }
- if (!permissions.SendMessages) { missing.Add("Send Messages"); }
+ if (!permissions.SendMessages)
+ {
+ missing.Add("Send Messages");
+ }
- if (!permissions.EmbedLinks) { missing.Add("Embed Links"); }
+ if (!permissions.EmbedLinks)
+ {
+ missing.Add("Embed Links");
+ }
- if (!permissions.ManageMessages) { missing.Add("Manage Messages"); }
+ if (!permissions.ManageMessages)
+ {
+ missing.Add("Manage Messages");
+ }
- if (!permissions.ViewChannel) { missing.Add("View Channels"); }
+ if (!permissions.ViewChannel)
+ {
+ missing.Add("View Channels");
+ }
return missing;
}
diff --git a/src/RustPlusBot.Features.Workspace/Localization/ILocalizer.cs b/src/RustPlusBot.Features.Workspace/Localization/ILocalizer.cs
index 379ed67..17ff656 100644
--- a/src/RustPlusBot.Features.Workspace/Localization/ILocalizer.cs
+++ b/src/RustPlusBot.Features.Workspace/Localization/ILocalizer.cs
@@ -1,16 +1,4 @@
namespace RustPlusBot.Features.Workspace.Localization;
/// Resolves localized strings by key and BCP-47 culture, falling back to English.
-internal interface ILocalizer
-{
- /// 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").
- string Get(string key, string culture);
-
- /// Gets the localized, -applied string.
- /// The string key to resolve.
- /// The BCP-47 culture tag (e.g. "en", "fr").
- /// Format arguments.
- string Get(string key, string culture, params object[] args);
-}
+internal interface ILocalizer : RustPlusBot.Localization.ILocalizer;
diff --git a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs
index 6dcb48f..de595b3 100644
--- a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs
+++ b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs
@@ -3,6 +3,8 @@ namespace RustPlusBot.Features.Workspace.Localization;
/// The in-memory string catalog: culture -> (key -> value). English is the fallback.
internal sealed class LocalizationCatalog
{
+ private const string ProductName = "RustPlusBot";
+
/// culture -> key -> value.
public required IReadOnlyDictionary> Strings { get; init; }
@@ -13,7 +15,7 @@ internal sealed class LocalizationCatalog
{
["en"] = new Dictionary(StringComparer.Ordinal)
{
- ["category.global.name"] = "RustPlusBot",
+ ["category.global.name"] = ProductName,
["channel.information.name"] = "information",
["channel.setup.name"] = "setup",
["channel.settings.name"] = "settings",
@@ -23,7 +25,7 @@ internal sealed class LocalizationCatalog
["channel.map.name"] = "map",
["channel.switches.name"] = "switches",
["channel.alarms.name"] = "alarms",
- ["information.title"] = "RustPlusBot",
+ ["information.title"] = ProductName,
["information.body"] = "Connect your Rust+ account in #setup, then pair a server in-game to begin.",
["information.servers"] = "Servers registered: {0}",
["setup.title"] = "Connect your Rust+ account",
@@ -57,7 +59,7 @@ internal sealed class LocalizationCatalog
},
["fr"] = new Dictionary(StringComparer.Ordinal)
{
- ["category.global.name"] = "RustPlusBot",
+ ["category.global.name"] = ProductName,
["channel.information.name"] = "informations",
["channel.setup.name"] = "configuration",
["channel.settings.name"] = "parametres",
@@ -67,7 +69,7 @@ internal sealed class LocalizationCatalog
["channel.map.name"] = "carte",
["channel.switches.name"] = "interrupteurs",
["channel.alarms.name"] = "alarmes",
- ["information.title"] = "RustPlusBot",
+ ["information.title"] = ProductName,
["information.body"] =
"Connectez votre compte Rust+ dans #configuration, puis appairez un serveur en jeu.",
["information.servers"] = "Serveurs enregistres : {0}",
diff --git a/src/RustPlusBot.Features.Workspace/Localization/Localizer.cs b/src/RustPlusBot.Features.Workspace/Localization/Localizer.cs
index 4ca7f3d..805b4cf 100644
--- a/src/RustPlusBot.Features.Workspace/Localization/Localizer.cs
+++ b/src/RustPlusBot.Features.Workspace/Localization/Localizer.cs
@@ -1,60 +1,6 @@
-using System.Globalization;
-
namespace RustPlusBot.Features.Workspace.Localization;
-/// Dictionary-backed with English fallback and region normalization.
+/// Workspace localizer backed by the shared .
/// The string catalog.
-internal sealed class Localizer(LocalizationCatalog catalog) : ILocalizer
-{
- private const string FallbackCulture = "en";
-
- ///
- public string Get(string key, string culture)
- {
- var normalized = Normalize(culture);
- if (catalog.Strings.TryGetValue(normalized, out var map) && map.TryGetValue(key, out var value))
- {
- return value;
- }
-
- if (catalog.Strings.TryGetValue(FallbackCulture, out var fallback) &&
- fallback.TryGetValue(key, out var fallbackValue))
- {
- return fallbackValue;
- }
-
- return key;
- }
-
- ///
- public string Get(string key, string culture, params object[] args)
- {
- var format = Get(key, culture);
- var provider = ResolveFormatProvider(Normalize(culture));
- return string.Format(provider, format, args);
- }
-
- private static string Normalize(string culture)
- {
- if (string.IsNullOrWhiteSpace(culture))
- {
- return FallbackCulture;
- }
-
- var dash = culture.IndexOf('-', StringComparison.Ordinal);
- var primary = dash >= 0 ? culture[..dash] : culture;
- return primary.ToLowerInvariant();
- }
-
- private static CultureInfo ResolveFormatProvider(string culture)
- {
- try
- {
- return CultureInfo.GetCultureInfo(culture);
- }
- catch (CultureNotFoundException)
- {
- return CultureInfo.InvariantCulture;
- }
- }
-}
+internal sealed class Localizer(LocalizationCatalog catalog)
+ : RustPlusBot.Localization.DictionaryLocalizer(catalog.Strings), ILocalizer;
diff --git a/src/RustPlusBot.Features.Workspace/Locating/AlarmChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/AlarmChannelLocator.cs
index a2aa335..d4120fd 100644
--- a/src/RustPlusBot.Features.Workspace/Locating/AlarmChannelLocator.cs
+++ b/src/RustPlusBot.Features.Workspace/Locating/AlarmChannelLocator.cs
@@ -1,75 +1,10 @@
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.
-///
+/// Resolves the #alarms channel id for a (guild, server).
/// Opens scopes for the scoped workspace store.
/// Drives the cache TTL.
internal sealed class AlarmChannelLocator(IServiceScopeFactory scopeFactory, IClock clock)
- : IAlarmChannelLocator, IDisposable
-{
- private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
- private readonly SemaphoreSlim _refreshGate = new(1, 1);
-
- private DateTimeOffset _builtAt = DateTimeOffset.MinValue;
-
- private Dictionary<(ulong GuildId, Guid ServerId), ulong> _byServer = new();
-
- ///
- public async Task GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken)
- {
- await EnsureFreshAsync(cancellationToken).ConfigureAwait(false);
- return _byServer.TryGetValue((guildId, serverId), out var id) ? id : null;
- }
-
- ///
- public void Dispose() => _refreshGate.Dispose();
-
- private async Task EnsureFreshAsync(CancellationToken cancellationToken)
- {
- if (clock.UtcNow - _builtAt < CacheTtl)
- {
- return;
- }
-
- await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
- try
- {
- if (clock.UtcNow - _builtAt < CacheTtl)
- {
- return;
- }
-
- var scope = scopeFactory.CreateAsyncScope();
- await using (scope.ConfigureAwait(false))
- {
- var store = scope.ServiceProvider.GetRequiredService();
- var rows = await store.GetChannelsByKeyAsync(WorkspaceChannelKeys.ServerAlarms, cancellationToken)
- .ConfigureAwait(false);
-
- var byServer = new Dictionary<(ulong GuildId, Guid ServerId), ulong>();
- foreach (var row in rows)
- {
- if (row.RustServerId is not { } serverId)
- {
- continue;
- }
-
- byServer[(row.GuildId, serverId)] = row.DiscordChannelId;
- }
-
- _byServer = byServer;
- _builtAt = clock.UtcNow;
- }
- }
- finally
- {
- _refreshGate.Release();
- }
- }
-}
+ : CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerAlarms), IAlarmChannelLocator;
diff --git a/src/RustPlusBot.Features.Workspace/Locating/CachingChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/CachingChannelLocator.cs
new file mode 100644
index 0000000..3ef41a0
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Locating/CachingChannelLocator.cs
@@ -0,0 +1,96 @@
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Persistence.Workspace;
+
+namespace RustPlusBot.Features.Workspace.Locating;
+
+/// Caches a provisioned channel set per key and resolves (guild, server) → channel id.
+/// Opens scopes for the scoped workspace store.
+/// Drives the cache TTL.
+/// The workspace channel key to load from the store.
+internal abstract class CachingChannelLocator(IServiceScopeFactory scopeFactory, IClock clock, string channelKey)
+ : 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();
+
+ /// A read-only view of the cached (guild, server) → channel-id map, available after ensure-fresh.
+ protected IReadOnlyDictionary<(ulong GuildId, Guid ServerId), ulong> Entries => _byServer;
+
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ /// Releases managed resources.
+ /// when called from .
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _refreshGate.Dispose();
+ }
+ }
+
+ ///
+ /// Gets the Discord channel id for (, ), or
+ /// if no channel is provisioned.
+ ///
+ /// The guild snowflake.
+ /// The server id.
+ /// A cancellation token.
+ /// The Discord channel id, or if not provisioned.
+ 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;
+ }
+
+ /// Ensures the cache is current; refreshes from the store when the TTL has elapsed.
+ /// A cancellation token.
+ protected 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(channelKey, 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/EventChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/EventChannelLocator.cs
index f2f0435..9ed68e4 100644
--- a/src/RustPlusBot.Features.Workspace/Locating/EventChannelLocator.cs
+++ b/src/RustPlusBot.Features.Workspace/Locating/EventChannelLocator.cs
@@ -1,75 +1,10 @@
using Microsoft.Extensions.DependencyInjection;
using RustPlusBot.Abstractions.Time;
-using RustPlusBot.Persistence.Workspace;
namespace RustPlusBot.Features.Workspace.Locating;
-///
-/// Caches the small set of provisioned #events channels (rebuilt when the cache goes stale) and resolves
-/// the game-to-Discord direction only (there is no Discord→game path for events).
-///
+/// Resolves the #events channel id for a (guild, server).
/// Opens scopes for the scoped workspace store.
/// Drives the cache TTL.
internal sealed class EventChannelLocator(IServiceScopeFactory scopeFactory, IClock clock)
- : IEventChannelLocator, 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.ServerEvents, 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();
- }
- }
-}
+ : CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerEvents), IEventChannelLocator;
diff --git a/src/RustPlusBot.Features.Workspace/Locating/MapChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/MapChannelLocator.cs
index 9bc1857..31a2276 100644
--- a/src/RustPlusBot.Features.Workspace/Locating/MapChannelLocator.cs
+++ b/src/RustPlusBot.Features.Workspace/Locating/MapChannelLocator.cs
@@ -1,75 +1,10 @@
using Microsoft.Extensions.DependencyInjection;
using RustPlusBot.Abstractions.Time;
-using RustPlusBot.Persistence.Workspace;
namespace RustPlusBot.Features.Workspace.Locating;
-///
-/// Caches the small set of provisioned #map channels (rebuilt when the cache goes stale) and resolves
-/// the game-to-Discord direction only (there is no Discord→game path for the map).
-///
+/// Resolves the #map channel id for a (guild, server).
/// Opens scopes for the scoped workspace store.
/// Drives the cache TTL.
internal sealed class MapChannelLocator(IServiceScopeFactory scopeFactory, IClock clock)
- : IMapChannelLocator, 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.ServerMap, 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();
- }
- }
-}
+ : CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerMap), IMapChannelLocator;
diff --git a/src/RustPlusBot.Features.Workspace/Locating/SwitchChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/SwitchChannelLocator.cs
index 3bcb8c1..e45063a 100644
--- a/src/RustPlusBot.Features.Workspace/Locating/SwitchChannelLocator.cs
+++ b/src/RustPlusBot.Features.Workspace/Locating/SwitchChannelLocator.cs
@@ -1,75 +1,10 @@
using Microsoft.Extensions.DependencyInjection;
using RustPlusBot.Abstractions.Time;
-using RustPlusBot.Persistence.Workspace;
namespace RustPlusBot.Features.Workspace.Locating;
-///
-/// Caches the small set of provisioned #switches channels (rebuilt when the cache goes stale) and resolves
-/// (guild, server) → channel id for posting/editing switch embeds.
-///
+/// Resolves the #switches channel id for a (guild, server).
/// Opens scopes for the scoped workspace store.
/// Drives the cache TTL.
internal sealed class SwitchChannelLocator(IServiceScopeFactory scopeFactory, IClock clock)
- : ISwitchChannelLocator, 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.ServerSwitches, 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();
- }
- }
-}
+ : CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerSwitches), ISwitchChannelLocator;
diff --git a/src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs
index b12bb8b..8f873ea 100644
--- a/src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs
+++ b/src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs
@@ -1,89 +1,30 @@
using Microsoft.Extensions.DependencyInjection;
using RustPlusBot.Abstractions.Time;
-using RustPlusBot.Persistence.Workspace;
namespace RustPlusBot.Features.Workspace.Locating;
///
-/// Caches the small set of provisioned #teamchat channels (rebuilt when the cache goes stale) and resolves
-/// both directions. The Discord MessageReceived handler calls for every guild
-/// message, so a per-call DB hit is avoided by serving hits AND misses from the cached snapshot.
+/// Resolves the #teamchat channel id for a (guild, server) and supports the reverse direction
+/// (channel id → guild + server) used by the Discord message handler.
///
/// Opens scopes for the scoped workspace store.
/// Drives the cache TTL.
internal sealed class TeamChatChannelLocator(IServiceScopeFactory scopeFactory, IClock clock)
- : ITeamChatChannelLocator, IDisposable
+ : CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerTeamChat), ITeamChatChannelLocator
{
- private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
- private readonly SemaphoreSlim _refreshGate = new(1, 1);
-
- private DateTimeOffset _builtAt = DateTimeOffset.MinValue;
-
- private Dictionary _byChannelId = new();
-
- 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;
- }
-
///
public async Task<(ulong GuildId, Guid ServerId)?> ResolveAsync(ulong channelId,
CancellationToken cancellationToken)
{
await EnsureFreshAsync(cancellationToken).ConfigureAwait(false);
- return _byChannelId.TryGetValue(channelId, out var pair) ? pair : null;
- }
-
- private async Task EnsureFreshAsync(CancellationToken cancellationToken)
- {
- if (clock.UtcNow - _builtAt < CacheTtl)
- {
- return;
- }
-
- await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
- try
+ foreach (var (key, value) in Entries)
{
- if (clock.UtcNow - _builtAt < CacheTtl)
+ if (value == channelId)
{
- return;
- }
-
- var scope = scopeFactory.CreateAsyncScope();
- await using (scope.ConfigureAwait(false))
- {
- var store = scope.ServiceProvider.GetRequiredService();
- var rows = await store.GetChannelsByKeyAsync(WorkspaceChannelKeys.ServerTeamChat, cancellationToken)
- .ConfigureAwait(false);
-
- var byChannel = new Dictionary();
- var byServer = new Dictionary<(ulong GuildId, Guid ServerId), ulong>();
- foreach (var row in rows)
- {
- if (row.RustServerId is not { } serverId)
- {
- continue;
- }
-
- byChannel[row.DiscordChannelId] = (row.GuildId, serverId);
- byServer[(row.GuildId, serverId)] = row.DiscordChannelId;
- }
-
- _byChannelId = byChannel;
- _byServer = byServer;
- _builtAt = clock.UtcNow;
+ return key;
}
}
- finally
- {
- _refreshGate.Release();
- }
+
+ return null;
}
}
diff --git a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs
index 67828c7..0e69122 100644
--- a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs
+++ b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs
@@ -1,8 +1,8 @@
using System.Globalization;
using Discord;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Domain.Connections;
using RustPlusBot.Domain.Credentials;
-using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Workspace.Gateway;
using RustPlusBot.Features.Workspace.Localization;
using RustPlusBot.Features.Workspace.Registry;
@@ -66,22 +66,7 @@ public async ValueTask RenderAsync(MessageRenderContext context,
if (status == ConnectionStatus.Connected)
{
- var team = await query.GetTeamInfoAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false);
- if (team is not null)
- {
- var online = team.Members.Count(m => m.IsOnline);
- var leaderEntry = team.Members.FirstOrDefault(m => m.SteamId == team.LeaderSteamId);
- // The API can report a member with no display name, so treat an empty name as missing.
- var leaderName = string.IsNullOrWhiteSpace(leaderEntry?.Name)
- ? team.LeaderSteamId.ToString(CultureInfo.InvariantCulture)
- : leaderEntry.Name;
- embed.AddField(
- localizer.Get("server.info.team.label", context.Culture),
- localizer.Get("server.info.team.value", context.Culture,
- online.ToString(CultureInfo.InvariantCulture),
- team.Members.Count.ToString(CultureInfo.InvariantCulture),
- leaderName));
- }
+ await AddTeamFieldAsync(embed, context, serverId, cancellationToken).ConfigureAwait(false);
}
var eligible = pool
@@ -113,6 +98,32 @@ public async ValueTask RenderAsync(MessageRenderContext context,
return new MessagePayload(null, embed.Build(), builder.Build());
}
+ private async ValueTask AddTeamFieldAsync(
+ EmbedBuilder embed,
+ MessageRenderContext context,
+ Guid serverId,
+ CancellationToken cancellationToken)
+ {
+ var team = await query.GetTeamInfoAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false);
+ if (team is null)
+ {
+ return;
+ }
+
+ var online = team.Members.Count(m => m.IsOnline);
+ var leaderEntry = team.Members.FirstOrDefault(m => m.SteamId == team.LeaderSteamId);
+ // The API can report a member with no display name, so treat an empty name as missing.
+ var leaderName = string.IsNullOrWhiteSpace(leaderEntry?.Name)
+ ? team.LeaderSteamId.ToString(CultureInfo.InvariantCulture)
+ : leaderEntry.Name;
+ embed.AddField(
+ localizer.Get("server.info.team.label", context.Culture),
+ localizer.Get("server.info.team.value", context.Culture,
+ online.ToString(CultureInfo.InvariantCulture),
+ team.Members.Count.ToString(CultureInfo.InvariantCulture),
+ leaderName));
+ }
+
private static string Glyph(ConnectionStatus status) => status switch
{
ConnectionStatus.Connected => "🟢",
diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs
index c5f105d..d274939 100644
--- a/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs
+++ b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs
@@ -8,19 +8,24 @@
namespace RustPlusBot.Features.Workspace.Reconciler;
+/// Bundles the storage-backend collaborators injected into .
+/// The workspace channel/message spec registry.
+/// The Discord gateway adapter.
+/// The provisioning persistence store.
+internal sealed record WorkspaceBackends(
+ IWorkspaceRegistry Registry,
+ IWorkspaceGateway Gateway,
+ IWorkspaceStore Store);
+
/// Desired-state reconciler. resolve -> adopt -> create, serialized per guild.
-/// The workspace channel/message spec registry.
-/// The Discord gateway adapter.
-/// The provisioning persistence store.
+/// Bundles the storage-backend collaborators.
/// All registered message renderers.
/// The server persistence service.
/// The localization service.
/// The per-guild provisioning lock.
/// The logger.
internal sealed class WorkspaceReconciler(
- IWorkspaceRegistry registry,
- IWorkspaceGateway gateway,
- IWorkspaceStore store,
+ WorkspaceBackends backends,
IEnumerable renderers,
IServerService servers,
ILocalizer localizer,
@@ -35,7 +40,7 @@ public async Task ReconcileGlobalAsync(ulong guildId,
CancellationToken cancellationToken = default)
{
using var handle = await provisioningLock.AcquireAsync(guildId, cancellationToken).ConfigureAwait(false);
- var missing = gateway.GetMissingBotPermissions(guildId);
+ var missing = backends.Gateway.GetMissingBotPermissions(guildId);
if (missing.Count > 0)
{
return ReconcileResult.Missing(missing);
@@ -51,7 +56,7 @@ public async Task ReconcileServerAsync(ulong guildId,
CancellationToken cancellationToken = default)
{
using var handle = await provisioningLock.AcquireAsync(guildId, cancellationToken).ConfigureAwait(false);
- var missing = gateway.GetMissingBotPermissions(guildId);
+ var missing = backends.Gateway.GetMissingBotPermissions(guildId);
if (missing.Count > 0)
{
return ReconcileResult.Missing(missing);
@@ -68,13 +73,13 @@ public async Task HealGuildAsync(ulong guildId, CancellationToken cancellationTo
// Heal only the scopes that are actually provisioned; never resurrect a workspace cleared by
// reset (no categories) and never create a scope the guild never asked for.
- var categories = await store.GetAllCategoriesAsync(guildId, cancellationToken).ConfigureAwait(false);
+ var categories = await backends.Store.GetAllCategoriesAsync(guildId, cancellationToken).ConfigureAwait(false);
if (categories.Count == 0)
{
return;
}
- if (gateway.GetMissingBotPermissions(guildId).Count > 0)
+ if (backends.Gateway.GetMissingBotPermissions(guildId).Count > 0)
{
return;
}
@@ -93,7 +98,7 @@ await ReconcileServerCoreAsync(guildId, category.RustServerId!.Value, cancellati
private async Task ReconcileGlobalCoreAsync(ulong guildId, CancellationToken cancellationToken)
{
- var culture = await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false);
+ var culture = await backends.Store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false);
var categoryName = localizer.Get("category.global.name", culture);
await ReconcileScopeAsync(guildId, null, categoryName, culture, WorkspaceScope.Global, cancellationToken)
.ConfigureAwait(false);
@@ -109,7 +114,7 @@ private async Task ReconcileServerCoreAsync(ulong guildId, Guid serverId,
return false;
}
- var culture = await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false);
+ var culture = await backends.Store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false);
await ReconcileScopeAsync(guildId, serverId, server.Name, culture, WorkspaceScope.PerServer, cancellationToken)
.ConfigureAwait(false);
return true;
@@ -135,16 +140,17 @@ private async Task EnsureCategoryAsync(ulong guildId,
string name,
CancellationToken cancellationToken)
{
- var record = await store.GetCategoryAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
- if (record is not null && gateway.CategoryExists(guildId, record.DiscordCategoryId))
+ var record = await backends.Store.GetCategoryAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
+ if (record is not null && backends.Gateway.CategoryExists(guildId, record.DiscordCategoryId))
{
return record.DiscordCategoryId;
}
- var adopted = await gateway.FindCategoryAsync(guildId, name, cancellationToken).ConfigureAwait(false);
+ var adopted = await backends.Gateway.FindCategoryAsync(guildId, name, cancellationToken).ConfigureAwait(false);
var categoryId = adopted ??
- await gateway.CreateCategoryAsync(guildId, name, cancellationToken).ConfigureAwait(false);
- await store.SaveCategoryAsync(
+ await backends.Gateway.CreateCategoryAsync(guildId, name, cancellationToken)
+ .ConfigureAwait(false);
+ await backends.Store.SaveCategoryAsync(
new ProvisionedCategory
{
GuildId = guildId, RustServerId = serverId, DiscordCategoryId = categoryId
@@ -160,42 +166,44 @@ private async Task> EnsureChannelsAsync(ulong guildId,
WorkspaceScope scope,
CancellationToken cancellationToken)
{
- var existing = (await store.GetChannelsAsync(guildId, serverId, cancellationToken).ConfigureAwait(false))
+ var existing =
+ (await backends.Store.GetChannelsAsync(guildId, serverId, cancellationToken).ConfigureAwait(false))
.ToDictionary(c => c.ChannelKey, StringComparer.Ordinal);
var result = new Dictionary(StringComparer.Ordinal);
- var specs = registry.GetChannelSpecs(scope);
+ var specs = backends.Registry.GetChannelSpecs(scope);
foreach (var spec in specs)
{
var name = localizer.Get(spec.NameKey, culture);
ulong channelId;
- if (existing.TryGetValue(spec.Key, out var rec) && gateway.ChannelExists(guildId, rec.DiscordChannelId))
+ if (existing.TryGetValue(spec.Key, out var rec) &&
+ backends.Gateway.ChannelExists(guildId, rec.DiscordChannelId))
{
channelId = rec.DiscordChannelId;
- await gateway
+ await backends.Gateway
.ApplyChannelSettingsAsync(guildId, channelId, categoryId, name, spec.Permissions,
cancellationToken).ConfigureAwait(false);
}
else
{
- var adopted = await gateway.FindChannelAsync(guildId, categoryId, name, cancellationToken)
+ var adopted = await backends.Gateway.FindChannelAsync(guildId, categoryId, name, cancellationToken)
.ConfigureAwait(false);
if (adopted is ulong adoptedId)
{
channelId = adoptedId;
- await gateway
+ await backends.Gateway
.ApplyChannelSettingsAsync(guildId, channelId, categoryId, name, spec.Permissions,
cancellationToken).ConfigureAwait(false);
}
else
{
- channelId = await gateway
+ channelId = await backends.Gateway
.CreateChannelAsync(guildId, categoryId, name, spec.Permissions, cancellationToken)
.ConfigureAwait(false);
}
- await store.SaveChannelAsync(
+ await backends.Store.SaveChannelAsync(
new ProvisionedChannel
{
GuildId = guildId, RustServerId = serverId, ChannelKey = spec.Key, DiscordChannelId = channelId
@@ -227,7 +235,7 @@ private async Task EnsureMessagesAsync(ulong guildId,
WorkspaceScope scope,
CancellationToken cancellationToken)
{
- foreach (var spec in registry.GetMessageSpecs(scope))
+ foreach (var spec in backends.Registry.GetMessageSpecs(scope))
{
if (!channelIds.TryGetValue(spec.ChannelKey, out var channelId) ||
!_renderers.TryGetValue(spec.Key, out var renderer))
@@ -246,20 +254,21 @@ private async Task EnsureMessagesAsync(ulong guildId,
continue;
}
- var record = await store.GetMessageAsync(guildId, serverId, spec.Key, cancellationToken)
+ var record = await backends.Store.GetMessageAsync(guildId, serverId, spec.Key, cancellationToken)
.ConfigureAwait(false);
var canEditInPlace = record is not null
&& record.DiscordChannelId == channelId
- && await gateway
+ && await backends.Gateway
.MessageExistsAsync(guildId, channelId, record.DiscordMessageId, cancellationToken)
.ConfigureAwait(false);
if (canEditInPlace)
{
- await gateway.EditMessageAsync(guildId, channelId, record!.DiscordMessageId, payload, cancellationToken)
+ await backends.Gateway.EditMessageAsync(guildId, channelId, record!.DiscordMessageId, payload,
+ cancellationToken)
.ConfigureAwait(false);
- await store.SaveMessageAsync(
+ await backends.Store.SaveMessageAsync(
new ProvisionedMessage
{
GuildId = guildId,
@@ -272,9 +281,9 @@ await store.SaveMessageAsync(
}
else
{
- var messageId = await gateway.PostMessageAsync(guildId, channelId, payload, cancellationToken)
+ var messageId = await backends.Gateway.PostMessageAsync(guildId, channelId, payload, cancellationToken)
.ConfigureAwait(false);
- await store.SaveMessageAsync(
+ await backends.Store.SaveMessageAsync(
new ProvisionedMessage
{
GuildId = guildId,
diff --git a/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs b/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs
index 9733af5..dd3d873 100644
--- a/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs
+++ b/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs
@@ -7,15 +7,14 @@ internal sealed class WorkspaceRegistry(
IEnumerable channelProviders,
IEnumerable messageProviders) : IWorkspaceRegistry
{
- private readonly List _channels = channelProviders.SelectMany(p => p.GetChannelSpecs()).ToList();
- private readonly List _messages = messageProviders.SelectMany(p => p.GetMessageSpecs()).ToList();
+ private readonly List _channels = [.. channelProviders.SelectMany(p => p.GetChannelSpecs())];
+ private readonly List _messages = [.. messageProviders.SelectMany(p => p.GetMessageSpecs())];
///
public IReadOnlyList GetChannelSpecs(WorkspaceScope scope) =>
- _channels.Where(s => s.Scope == scope).OrderBy(s => s.Order).ThenBy(s => s.Key, StringComparer.Ordinal)
- .ToList();
+ [.. _channels.Where(s => s.Scope == scope).OrderBy(s => s.Order).ThenBy(s => s.Key, StringComparer.Ordinal)];
///
public IReadOnlyList GetMessageSpecs(WorkspaceScope scope) =>
- _messages.Where(s => s.Scope == scope).ToList();
+ [.. _messages.Where(s => s.Scope == scope)];
}
diff --git a/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj b/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj
index 136df24..5fff1dc 100644
--- a/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj
+++ b/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj
@@ -10,6 +10,7 @@
+
diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs
index 352a946..1b85963 100644
--- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs
@@ -47,6 +47,7 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services)
// Reconciler + teardown (scoped). Register the teardown service once and expose both interfaces
// off the same scoped instance, so resolving either does not create a second instance.
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped(sp => sp.GetRequiredService());
diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs
index 9121ce3..fcdf266 100644
--- a/src/RustPlusBot.Host/Program.cs
+++ b/src/RustPlusBot.Host/Program.cs
@@ -6,15 +6,15 @@
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Discord;
+using RustPlusBot.Features.Alarms;
using RustPlusBot.Features.Chat;
using RustPlusBot.Features.Commands;
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;
+using RustPlusBot.Features.Players;
+using RustPlusBot.Features.Switches;
using RustPlusBot.Features.Workspace;
using RustPlusBot.Host.Credentials;
using RustPlusBot.Persistence;
diff --git a/src/RustPlusBot.Localization/DictionaryLocalizer.cs b/src/RustPlusBot.Localization/DictionaryLocalizer.cs
new file mode 100644
index 0000000..f724562
--- /dev/null
+++ b/src/RustPlusBot.Localization/DictionaryLocalizer.cs
@@ -0,0 +1,61 @@
+using System.Globalization;
+
+namespace RustPlusBot.Localization;
+
+/// Dictionary-backed with English fallback and region normalization.
+/// Culture → (key → value) string table.
+public class DictionaryLocalizer(
+ IReadOnlyDictionary> strings) : ILocalizer
+{
+ private const string FallbackCulture = "en";
+
+ ///
+ public string Get(string key, string culture)
+ {
+ var normalized = Normalize(culture);
+ if (strings.TryGetValue(normalized, out var map) && map.TryGetValue(key, out var value))
+ {
+ return value;
+ }
+
+ if (strings.TryGetValue(FallbackCulture, out var fallback) &&
+ fallback.TryGetValue(key, out var fallbackValue))
+ {
+ return fallbackValue;
+ }
+
+ return key;
+ }
+
+ ///
+ public string Get(string key, string culture, params object[] args)
+ {
+ var format = Get(key, culture);
+ var provider = ResolveFormatProvider(Normalize(culture));
+ return string.Format(provider, format, args);
+ }
+
+ private static string Normalize(string culture)
+ {
+ if (string.IsNullOrWhiteSpace(culture))
+ {
+ return FallbackCulture;
+ }
+
+ var dash = culture.IndexOf('-', StringComparison.Ordinal);
+ var primary = dash >= 0 ? culture[..dash] : culture;
+ return primary.ToLowerInvariant();
+ }
+
+ private static CultureInfo ResolveFormatProvider(string culture)
+ {
+ try
+ {
+ return CultureInfo.GetCultureInfo(culture);
+ }
+ catch (CultureNotFoundException)
+ {
+ return CultureInfo.InvariantCulture;
+ }
+ }
+}
diff --git a/src/RustPlusBot.Localization/ILocalizer.cs b/src/RustPlusBot.Localization/ILocalizer.cs
new file mode 100644
index 0000000..c46c7a3
--- /dev/null
+++ b/src/RustPlusBot.Localization/ILocalizer.cs
@@ -0,0 +1,16 @@
+namespace RustPlusBot.Localization;
+
+/// Resolves localized strings by key and BCP-47 culture, falling back to English.
+public interface ILocalizer
+{
+ /// 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").
+ string Get(string key, string culture);
+
+ /// Gets the localized, -applied string.
+ /// The string key to resolve.
+ /// The BCP-47 culture tag (e.g. "en", "fr").
+ /// Format arguments.
+ string Get(string key, string culture, params object[] args);
+}
diff --git a/src/RustPlusBot.Localization/RustPlusBot.Localization.csproj b/src/RustPlusBot.Localization/RustPlusBot.Localization.csproj
new file mode 100644
index 0000000..c632161
--- /dev/null
+++ b/src/RustPlusBot.Localization/RustPlusBot.Localization.csproj
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/RustPlusBot.Persistence/Alarms/AlarmStore.cs b/src/RustPlusBot.Persistence/Alarms/AlarmStore.cs
index ed6995d..a15efd1 100644
--- a/src/RustPlusBot.Persistence/Alarms/AlarmStore.cs
+++ b/src/RustPlusBot.Persistence/Alarms/AlarmStore.cs
@@ -71,7 +71,7 @@ public async Task> ListByServerAsync(
.ToListAsync(ct)
.ConfigureAwait(false);
- return alarms.OrderBy(a => a.CreatedUtc).ToList();
+ return [.. alarms.OrderBy(a => a.CreatedUtc)];
}
///
diff --git a/src/RustPlusBot.Persistence/BotDbContext.cs b/src/RustPlusBot.Persistence/BotDbContext.cs
index e9a30a8..f6e0f96 100644
--- a/src/RustPlusBot.Persistence/BotDbContext.cs
+++ b/src/RustPlusBot.Persistence/BotDbContext.cs
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Persistord.Core;
+using RustPlusBot.Domain.Alarms;
using RustPlusBot.Domain.Commands;
using RustPlusBot.Domain.Connections;
using RustPlusBot.Domain.Credentials;
@@ -7,7 +8,6 @@
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;
diff --git a/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs b/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs
index ca76668..ef09cc6 100644
--- a/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs
+++ b/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs
@@ -38,13 +38,26 @@ public async Task SetLayerAsync(ulong guildId,
};
switch (layer)
{
- case MapLayer.Grid: row.ShowGrid = enabled; break;
- case MapLayer.Markers: row.ShowMarkers = enabled; break;
- case MapLayer.Monuments: row.ShowMonuments = enabled; break;
- case MapLayer.Vendor: row.ShowVendor = enabled; break;
- case MapLayer.Players: row.ShowPlayers = enabled; break;
- case MapLayer.Rigs: row.ShowRigs = enabled; break;
- default: throw new ArgumentOutOfRangeException(nameof(layer), layer, "Unknown map layer.");
+ case MapLayer.Grid:
+ row.ShowGrid = enabled;
+ break;
+ case MapLayer.Markers:
+ row.ShowMarkers = enabled;
+ break;
+ case MapLayer.Monuments:
+ row.ShowMonuments = enabled;
+ break;
+ case MapLayer.Vendor:
+ row.ShowVendor = enabled;
+ break;
+ case MapLayer.Players:
+ row.ShowPlayers = enabled;
+ break;
+ case MapLayer.Rigs:
+ row.ShowRigs = enabled;
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(layer), layer, "Unknown map layer.");
}
if (existing is null)
diff --git a/src/RustPlusBot.Persistence/Switches/SwitchStore.cs b/src/RustPlusBot.Persistence/Switches/SwitchStore.cs
index a63183d..bf3cf7d 100644
--- a/src/RustPlusBot.Persistence/Switches/SwitchStore.cs
+++ b/src/RustPlusBot.Persistence/Switches/SwitchStore.cs
@@ -72,7 +72,7 @@ public async Task> ListByServerAsync(
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
- return switches.OrderBy(s => s.CreatedUtc).ToList();
+ return [.. switches.OrderBy(s => s.CreatedUtc)];
}
///
diff --git a/tests/RustPlusBot.Abstractions.Tests/Connections/MapMarkerSnapshotTests.cs b/tests/RustPlusBot.Abstractions.Tests/Connections/MapMarkerSnapshotTests.cs
index e9fd66b..7b280b2 100644
--- a/tests/RustPlusBot.Abstractions.Tests/Connections/MapMarkerSnapshotTests.cs
+++ b/tests/RustPlusBot.Abstractions.Tests/Connections/MapMarkerSnapshotTests.cs
@@ -1,4 +1,4 @@
-using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Abstractions.Connections;
namespace RustPlusBot.Abstractions.Tests.Connections;
diff --git a/tests/RustPlusBot.Abstractions.Tests/Events/RigStateChangedEventTests.cs b/tests/RustPlusBot.Abstractions.Tests/Events/RigStateChangedEventTests.cs
index eb461db..2a82881 100644
--- a/tests/RustPlusBot.Abstractions.Tests/Events/RigStateChangedEventTests.cs
+++ b/tests/RustPlusBot.Abstractions.Tests/Events/RigStateChangedEventTests.cs
@@ -1,5 +1,5 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
-using RustPlusBot.Features.Connections.Listening;
namespace RustPlusBot.Abstractions.Tests.Events;
diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs
index 4fad778..a1a666a 100644
--- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs
+++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs
@@ -36,10 +36,11 @@ private static SmartAlarm Sample(
};
private static List Buttons(MessageComponent components) =>
- components.Components.OfType()
+ [
+ .. components.Components.OfType()
.SelectMany(r => r.Components)
.OfType()
- .ToList();
+ ];
// ── Status ────────────────────────────────────────────────────────────────
diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs
index 858bfcd..cb6ec22 100644
--- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs
+++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs
@@ -60,9 +60,7 @@ private static Harness Create(SmartAlarm? alarm = null, ulong? channelId = 777UL
var relay = new AlarmStateRelay(
scopeFactory,
refresher,
- locator,
- poster,
- teamChatSender,
+ new AlarmRelayChannels(locator, poster, teamChatSender),
alarmLocalizer,
clock,
NullLogger.Instance);
@@ -294,8 +292,8 @@ public async Task ConnectionStatus_not_connected_refreshes_all_alarms_unreachabl
});
h.Store.ListByServerAsync(10UL, serverId, Arg.Any())
- .Returns(new[]
- {
+ .Returns(
+ [
new SmartAlarm
{
GuildId = 10UL, ServerId = serverId, EntityId = 42UL, Name = "A"
@@ -304,7 +302,7 @@ public async Task ConnectionStatus_not_connected_refreshes_all_alarms_unreachabl
{
GuildId = 10UL, ServerId = serverId, EntityId = 43UL, Name = "B"
},
- });
+ ]);
await h.Relay.HandleConnectionStatusAsync(
new ConnectionStatusChangedEvent(10UL, serverId), CancellationToken.None);
diff --git a/tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs b/tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs
index ab81386..e70c041 100644
--- a/tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs
+++ b/tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs
@@ -38,10 +38,10 @@ public async Task Reports_none_when_empty()
public async Task Lists_afk_members_with_durations()
{
_afk.GetAfkMembersAsync(Arg.Any(), Arg.Any(), Arg.Any())
- .Returns(new List
- {
+ .Returns(
+ [
new(1, "Bob", TimeSpan.FromMinutes(6))
- });
+ ]);
var reply = await new AfkCommandHandler(_afk, _localizer).ExecuteAsync(Ctx(), CancellationToken.None);
Assert.Contains("Bob", reply, StringComparison.Ordinal);
}
diff --git a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs
index 00474a6..c2dc2b8 100644
--- a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs
+++ b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs
@@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using NSubstitute;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Discord;
diff --git a/tests/RustPlusBot.Features.Commands.Tests/Formatting/TeamMemberFilterTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Formatting/TeamMemberFilterTests.cs
index 8fd122a..d37e357 100644
--- a/tests/RustPlusBot.Features.Commands.Tests/Formatting/TeamMemberFilterTests.cs
+++ b/tests/RustPlusBot.Features.Commands.Tests/Formatting/TeamMemberFilterTests.cs
@@ -1,5 +1,5 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Features.Commands.Formatting;
-using RustPlusBot.Features.Connections.Listening;
namespace RustPlusBot.Features.Commands.Tests.Formatting;
diff --git a/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs
index 44cbd93..89756bb 100644
--- a/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs
+++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs
@@ -1,9 +1,9 @@
using NSubstitute;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Handlers;
using RustPlusBot.Features.Commands.Localization;
-using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Events.Classifying;
using RustPlusBot.Features.Events.State;
diff --git a/tests/RustPlusBot.Features.Commands.Tests/Handlers/QueryHandlersTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/QueryHandlersTests.cs
index 0f362a0..d6b9ff1 100644
--- a/tests/RustPlusBot.Features.Commands.Tests/Handlers/QueryHandlersTests.cs
+++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/QueryHandlersTests.cs
@@ -1,10 +1,10 @@
using NSubstitute;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Handlers;
using RustPlusBot.Features.Commands.Hosting;
using RustPlusBot.Features.Commands.Localization;
-using RustPlusBot.Features.Connections.Listening;
namespace RustPlusBot.Features.Commands.Tests.Handlers;
diff --git a/tests/RustPlusBot.Features.Commands.Tests/Handlers/TeamIntelHandlersTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/TeamIntelHandlersTests.cs
index 1d533e8..84afbd0 100644
--- a/tests/RustPlusBot.Features.Commands.Tests/Handlers/TeamIntelHandlersTests.cs
+++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/TeamIntelHandlersTests.cs
@@ -1,9 +1,9 @@
using NSubstitute;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Handlers;
using RustPlusBot.Features.Commands.Localization;
-using RustPlusBot.Features.Connections.Listening;
namespace RustPlusBot.Features.Commands.Tests.Handlers;
diff --git a/tests/RustPlusBot.Features.Commands.Tests/Leader/LeaderServiceTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Leader/LeaderServiceTests.cs
index 77d2c47..e85a381 100644
--- a/tests/RustPlusBot.Features.Commands.Tests/Leader/LeaderServiceTests.cs
+++ b/tests/RustPlusBot.Features.Commands.Tests/Leader/LeaderServiceTests.cs
@@ -1,7 +1,7 @@
using NSubstitute;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Features.Commands.Leader;
using RustPlusBot.Features.Commands.Localization;
-using RustPlusBot.Features.Connections.Listening;
namespace RustPlusBot.Features.Commands.Tests.Leader;
diff --git a/tests/RustPlusBot.Features.Connections.Tests/AfkStateTests.cs b/tests/RustPlusBot.Features.Connections.Tests/AfkStateTests.cs
index 6545985..ea2f4d1 100644
--- a/tests/RustPlusBot.Features.Connections.Tests/AfkStateTests.cs
+++ b/tests/RustPlusBot.Features.Connections.Tests/AfkStateTests.cs
@@ -1,3 +1,4 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Features.Connections.Listening;
namespace RustPlusBot.Features.Connections.Tests;
diff --git a/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs b/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs
index 6518bc4..b5e7870 100644
--- a/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs
+++ b/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs
@@ -62,6 +62,7 @@ private static (ServiceProvider Provider, ConnectionSupervisor Supervisor, InMem
HeartbeatInterval = TimeSpan.FromMilliseconds(20),
HeartbeatTimeout = TimeSpan.FromMilliseconds(200),
}));
+ services.AddSingleton();
services.AddSingleton();
var provider = services.BuildServiceProvider();
diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs
index 392d053..cb210fc 100644
--- a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs
+++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs
@@ -3,6 +3,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NSubstitute;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Credentials;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Abstractions.Time;
@@ -67,6 +68,7 @@ private static Harness CreateHarness(FakeRustSocketSource source)
MarkerPollInterval = TimeSpan.FromMilliseconds(20),
MarkerPollFastInterval = TimeSpan.FromMilliseconds(20),
}));
+ services.AddSingleton();
services.AddSingleton();
var provider = services.BuildServiceProvider();
@@ -299,7 +301,10 @@ public async Task First_marker_poll_is_a_silent_baseline()
await h.Supervisor.StopAllAsync();
await cts.CancelAsync();
- try { await subTask; }
+ try
+ {
+ await subTask;
+ }
catch (OperationCanceledException)
{
/* expected */
@@ -354,7 +359,10 @@ public async Task Marker_added_on_a_later_poll_publishes_changed_event()
await h.Supervisor.StopAllAsync();
await cts.CancelAsync();
- try { await subTask; }
+ try
+ {
+ await subTask;
+ }
catch (OperationCanceledException)
{
/* expected */
@@ -412,7 +420,10 @@ public async Task Failed_marker_poll_retains_previous_snapshot()
await h.Supervisor.StopAllAsync();
await cts.CancelAsync();
- try { await subTask; }
+ try
+ {
+ await subTask;
+ }
catch (OperationCanceledException)
{
/* expected */
@@ -465,7 +476,10 @@ public async Task Ch47_entering_rig_radius_publishes_activated_once_per_visit()
await h.Supervisor.StopAllAsync();
await cts.CancelAsync();
- try { await subTask; }
+ try
+ {
+ await subTask;
+ }
catch (OperationCanceledException)
{
/* expected */
@@ -519,13 +533,19 @@ await WaitUntilAsync(
await h.Supervisor.StopAllAsync();
await cts.CancelAsync();
- try { await markerSub; }
+ try
+ {
+ await markerSub;
+ }
catch (OperationCanceledException)
{
/* expected */
}
- try { await rigSub; }
+ try
+ {
+ await rigSub;
+ }
catch (OperationCanceledException)
{
/* expected */
diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs
index 0055341..34d5560 100644
--- a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs
+++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Features.Connections.Listening;
namespace RustPlusBot.Features.Connections.Tests.Fakes;
@@ -118,7 +119,7 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke
public ulong LastPromotedSteamId { get; private set; }
/// The state returned by per entity id; absent → null.
- public Dictionary SwitchStates { get; } = new();
+ public Dictionary SwitchStates { get; } = [];
/// The result returned by . Defaults to true.
public bool SetSwitchResult { get; set; } = true;
diff --git a/tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs b/tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs
index 97d33ec..e265194 100644
--- a/tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs
+++ b/tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs
@@ -58,6 +58,7 @@ private static (ServiceProvider Provider, ConnectionSupervisor Supervisor) Creat
HeartbeatInterval = TimeSpan.FromMilliseconds(20),
HeartbeatTimeout = TimeSpan.FromMilliseconds(200),
}));
+ services.AddSingleton();
services.AddSingleton();
var provider = services.BuildServiceProvider();
@@ -98,10 +99,10 @@ public async Task GetMapImage_ReturnsBytes_WhenConnected()
await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token);
await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token);
- source.LastConnection!.MapImageResult = new byte[]
- {
+ source.LastConnection!.MapImageResult =
+ [
1, 2, 3
- };
+ ];
var image = await supervisor.GetMapImageAsync(10UL, serverId, cts.Token);
Assert.Equal(new byte[]
diff --git a/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs
index bbaf641..c8e0352 100644
--- a/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs
+++ b/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs
@@ -3,6 +3,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NSubstitute;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Credentials;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Abstractions.Time;
@@ -58,6 +59,7 @@ private static (ServiceProvider Provider, ConnectionSupervisor Supervisor) Creat
HeartbeatInterval = TimeSpan.FromMilliseconds(20),
HeartbeatTimeout = TimeSpan.FromMilliseconds(200),
}));
+ services.AddSingleton();
services.AddSingleton();
var provider = services.BuildServiceProvider();
diff --git a/tests/RustPlusBot.Features.Connections.Tests/SwitchQueryTests.cs b/tests/RustPlusBot.Features.Connections.Tests/SwitchQueryTests.cs
index 882e26c..3f125de 100644
--- a/tests/RustPlusBot.Features.Connections.Tests/SwitchQueryTests.cs
+++ b/tests/RustPlusBot.Features.Connections.Tests/SwitchQueryTests.cs
@@ -60,6 +60,7 @@ private static (ServiceProvider Provider, ConnectionSupervisor Supervisor, InMem
HeartbeatInterval = TimeSpan.FromMilliseconds(20),
HeartbeatTimeout = TimeSpan.FromMilliseconds(200),
}));
+ services.AddSingleton();
services.AddSingleton();
var provider = services.BuildServiceProvider();
diff --git a/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs b/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs
index b31f0d8..ae82525 100644
--- a/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs
+++ b/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs
@@ -58,6 +58,7 @@ private static (ServiceProvider Provider, ConnectionSupervisor Supervisor, IEven
HeartbeatInterval = TimeSpan.FromMilliseconds(20),
HeartbeatTimeout = TimeSpan.FromMilliseconds(200),
}));
+ services.AddSingleton();
services.AddSingleton();
var provider = services.BuildServiceProvider();
diff --git a/tests/RustPlusBot.Features.Connections.Tests/TeamInfoSnapshotTests.cs b/tests/RustPlusBot.Features.Connections.Tests/TeamInfoSnapshotTests.cs
index b96fa7c..ca4fc87 100644
--- a/tests/RustPlusBot.Features.Connections.Tests/TeamInfoSnapshotTests.cs
+++ b/tests/RustPlusBot.Features.Connections.Tests/TeamInfoSnapshotTests.cs
@@ -1,4 +1,4 @@
-using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Abstractions.Connections;
namespace RustPlusBot.Features.Connections.Tests;
diff --git a/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerAfkTests.cs b/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerAfkTests.cs
index 5bc8381..28f9680 100644
--- a/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerAfkTests.cs
+++ b/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerAfkTests.cs
@@ -1,3 +1,4 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Features.Connections.Listening;
diff --git a/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerTests.cs b/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerTests.cs
index 377515c..4960747 100644
--- a/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerTests.cs
+++ b/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerTests.cs
@@ -1,3 +1,4 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Features.Connections.Listening;
diff --git a/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs b/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs
index df33392..d7fdb74 100644
--- a/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs
+++ b/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs
@@ -1,7 +1,7 @@
using NSubstitute;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Abstractions.Time;
-using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Events.Classifying;
namespace RustPlusBot.Features.Events.Tests.Classifying;
diff --git a/tests/RustPlusBot.Features.Events.Tests/Formatting/GridReferenceTests.cs b/tests/RustPlusBot.Features.Events.Tests/Formatting/GridReferenceTests.cs
index 1b01b5d..0b2bbd1 100644
--- a/tests/RustPlusBot.Features.Events.Tests/Formatting/GridReferenceTests.cs
+++ b/tests/RustPlusBot.Features.Events.Tests/Formatting/GridReferenceTests.cs
@@ -1,4 +1,4 @@
-using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Features.Events.Formatting;
namespace RustPlusBot.Features.Events.Tests.Formatting;
diff --git a/tests/RustPlusBot.Features.Events.Tests/Hosting/EventsHostedServiceTickTests.cs b/tests/RustPlusBot.Features.Events.Tests/Hosting/EventsHostedServiceTickTests.cs
index 827ecd7..e7d6e3d 100644
--- a/tests/RustPlusBot.Features.Events.Tests/Hosting/EventsHostedServiceTickTests.cs
+++ b/tests/RustPlusBot.Features.Events.Tests/Hosting/EventsHostedServiceTickTests.cs
@@ -61,8 +61,7 @@ internal static EventsHostedService Create(
return new EventsHostedService(
eventBus,
relay: null!,
- eventStateStore,
- rigStore,
+ new EventStores(eventStateStore, rigStore),
clock,
options,
scopeFactory,
diff --git a/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs b/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs
index 29ec392..c2b39ba 100644
--- a/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs
+++ b/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs
@@ -2,6 +2,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NSubstitute;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Features.Connections;
@@ -49,9 +50,7 @@ private static (EventRelay Relay, EventStateStore Store, IEventChannelPoster Pos
new MarkerEventClassifier(clock),
store,
renderer,
- locator,
- poster,
- sender,
+ new EventRelayChannels(locator, poster, sender),
rigStore,
provider.GetRequiredService());
diff --git a/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs b/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs
index 9b5d12d..ce3e982 100644
--- a/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs
+++ b/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs
@@ -1,4 +1,4 @@
-using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Features.Events.Classifying;
using RustPlusBot.Features.Events.Rendering;
diff --git a/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs b/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs
index f57c07f..f12bc4b 100644
--- a/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs
+++ b/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs
@@ -1,7 +1,7 @@
using NSubstitute;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Abstractions.Time;
-using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Events.Classifying;
using RustPlusBot.Features.Events.State;
diff --git a/tests/RustPlusBot.Features.Map.Tests/BaseMapCacheTests.cs b/tests/RustPlusBot.Features.Map.Tests/BaseMapCacheTests.cs
index 1e24cb4..936cf67 100644
--- a/tests/RustPlusBot.Features.Map.Tests/BaseMapCacheTests.cs
+++ b/tests/RustPlusBot.Features.Map.Tests/BaseMapCacheTests.cs
@@ -1,5 +1,5 @@
using NSubstitute;
-using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Features.Map.Composing;
using Xunit;
@@ -15,23 +15,17 @@ public async Task GetAsync_fetches_once_then_serves_from_cache()
{
var query = Substitute.For();
query.GetMapImageAsync(Guild, Server, Arg.Any())
- .Returns(new byte[]
- {
+ .Returns(
+ [
9
- });
+ ]);
var cache = new BaseMapCache(query);
var first = await cache.GetAsync(Guild, Server, CancellationToken.None);
var second = await cache.GetAsync(Guild, Server, CancellationToken.None);
- Assert.Equal(new byte[]
- {
- 9
- }, first);
- Assert.Equal(new byte[]
- {
- 9
- }, second);
+ Assert.Equal("\t"u8.ToArray(), first);
+ Assert.Equal("\t"u8.ToArray(), second);
await query.Received(1).GetMapImageAsync(Guild, Server, Arg.Any());
}
@@ -40,10 +34,10 @@ public async Task GetAsync_does_not_cache_null_and_retries()
{
var query = Substitute.For();
query.GetMapImageAsync(Guild, Server, Arg.Any())
- .Returns((byte[]?)null, new byte[]
- {
+ .Returns((byte[]?)null,
+ [
7
- });
+ ]);
var cache = new BaseMapCache(query);
var first = await cache.GetAsync(Guild, Server, CancellationToken.None);
@@ -61,10 +55,10 @@ public async Task GetAsync_does_not_cache_null_and_retries()
public async Task Clear_evicts_so_next_get_refetches()
{
var query = Substitute.For();
- query.GetMapImageAsync(Guild, Server, Arg.Any()).Returns(new byte[]
- {
+ query.GetMapImageAsync(Guild, Server, Arg.Any()).Returns(
+ [
1
- });
+ ]);
var cache = new BaseMapCache(query);
await cache.GetAsync(Guild, Server, CancellationToken.None);
diff --git a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs
index f454840..0bcf9ba 100644
--- a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs
+++ b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs
@@ -1,7 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
-using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Events.State;
using RustPlusBot.Features.Map.Composing;
using RustPlusBot.Features.Map.Rendering;
@@ -52,7 +52,7 @@ private static IEventState NewEvents(params ActiveMarker[] markers)
{
var events = Substitute.For();
events.GetActiveMarkers(Guild, Server, Arg.Any())
- .Returns(ci => markers.Where(m => m.Kind == (MarkerKind)ci[2]!).ToList());
+ .Returns(ci => [.. markers.Where(m => m.Kind == (MarkerKind)ci[2]!)]);
return events;
}
diff --git a/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs
index 893674e..9b7a567 100644
--- a/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs
+++ b/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs
@@ -1,5 +1,5 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
-using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Map.Assets;
using Xunit;
diff --git a/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs
index 004f5da..e272320 100644
--- a/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs
+++ b/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs
@@ -1,6 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
-using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Features.Events.State;
using RustPlusBot.Features.Map.Composing;
using RustPlusBot.Features.Map.Rendering;
diff --git a/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs
index c38f75f..c3d5c20 100644
--- a/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs
+++ b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs
@@ -1,5 +1,5 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
-using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Map.Rendering;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
diff --git a/tests/RustPlusBot.Features.Map.Tests/WorldToPixelTests.cs b/tests/RustPlusBot.Features.Map.Tests/WorldToPixelTests.cs
index 44a88d1..7f585c7 100644
--- a/tests/RustPlusBot.Features.Map.Tests/WorldToPixelTests.cs
+++ b/tests/RustPlusBot.Features.Map.Tests/WorldToPixelTests.cs
@@ -1,4 +1,4 @@
-using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Features.Map.Rendering;
using Xunit;
diff --git a/tests/RustPlusBot.Features.Pairing.Tests/AccountDisconnectServiceTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/AccountDisconnectServiceTests.cs
index 5e9de86..096633e 100644
--- a/tests/RustPlusBot.Features.Pairing.Tests/AccountDisconnectServiceTests.cs
+++ b/tests/RustPlusBot.Features.Pairing.Tests/AccountDisconnectServiceTests.cs
@@ -38,10 +38,10 @@ public async Task Disconnect_StopsListener_DisablesRegistration_RemovesCreds_Pub
Id = regId, GuildId = 10UL, OwnerUserId = 99UL
});
creds.RemoveForOwnerAsync(10UL, 99UL, Arg.Any())
- .Returns(new List
- {
+ .Returns(
+ [
s1, s2
- });
+ ]);
var count = await sut.DisconnectAsync(10UL, 99UL);
@@ -60,7 +60,7 @@ public async Task Disconnect_WhenNothingStored_StopsListener_NoEvents_ReturnsZer
{
var (sut, sup, regs, creds, _, bus) = Create();
regs.GetAsync(10UL, 99UL, Arg.Any()).Returns((FcmRegistration?)null);
- creds.RemoveForOwnerAsync(10UL, 99UL, Arg.Any()).Returns(new List());
+ creds.RemoveForOwnerAsync(10UL, 99UL, Arg.Any()).Returns([]);
var count = await sut.DisconnectAsync(10UL, 99UL);
@@ -82,10 +82,10 @@ public async Task Preview_ReportsConnectedAndServerNames()
Id = Guid.NewGuid(), GuildId = 10UL, OwnerUserId = 99UL
});
creds.ListServerIdsForOwnerAsync(10UL, 99UL, Arg.Any())
- .Returns(new List
- {
+ .Returns(
+ [
s1
- });
+ ]);
servers.GetAsync(10UL, s1, Arg.Any())
.Returns(new RustServer
{
@@ -107,7 +107,7 @@ public async Task Preview_WhenNothing_ReportsNotConnected()
{
var (sut, _, regs, creds, _, _) = Create();
regs.GetAsync(10UL, 99UL, Arg.Any()).Returns((FcmRegistration?)null);
- creds.ListServerIdsForOwnerAsync(10UL, 99UL, Arg.Any()).Returns(new List());
+ creds.ListServerIdsForOwnerAsync(10UL, 99UL, Arg.Any()).Returns([]);
var preview = await sut.PreviewAsync(10UL, 99UL);
diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs
index 1afdaa3..3069465 100644
--- a/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs
+++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs
@@ -1,5 +1,5 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
-using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Players.Rendering;
namespace RustPlusBot.Features.Players.Tests;
@@ -11,7 +11,7 @@ public void All_transition_kinds_render_without_throwing()
{
var renderer = new PlayerEventRenderer(new PlayerLocalizer(PlayerLocalizationCatalog.Default));
var dims = new MapDimensions(3000, 3000, 0);
- foreach (PlayerTransitionKind kind in Enum.GetValues())
+ foreach (var kind in Enum.GetValues())
{
var loc = kind is PlayerTransitionKind.Death or PlayerTransitionKind.Respawn
or PlayerTransitionKind.BecameAfk
diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs
index 3ce6295..ec9ddb2 100644
--- a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs
+++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs
@@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Players.Posting;
diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs
index 547772f..71266a0 100644
--- a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs
+++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs
@@ -1,5 +1,5 @@
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
-using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Players.Rendering;
namespace RustPlusBot.Features.Players.Tests;
diff --git a/tests/RustPlusBot.Features.Switches.Tests/SwitchStateRelayTests.cs b/tests/RustPlusBot.Features.Switches.Tests/SwitchStateRelayTests.cs
index 242dec6..9f649df 100644
--- a/tests/RustPlusBot.Features.Switches.Tests/SwitchStateRelayTests.cs
+++ b/tests/RustPlusBot.Features.Switches.Tests/SwitchStateRelayTests.cs
@@ -74,8 +74,8 @@ public async Task ConnectionStatus_not_connected_marks_switches_unreachable()
GuildId = 10UL, RustServerId = serverId, Status = ConnectionStatus.Unreachable
});
h.Store.ListByServerAsync(10UL, serverId, Arg.Any())
- .Returns(new[]
- {
+ .Returns(
+ [
new SmartSwitch
{
GuildId = 10UL,
@@ -84,7 +84,7 @@ public async Task ConnectionStatus_not_connected_marks_switches_unreachable()
Name = "G",
MessageId = 900UL
}
- });
+ ]);
await h.Relay.HandleConnectionStatusAsync(
new ConnectionStatusChangedEvent(10UL, serverId), CancellationToken.None);
diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs
index 53eb11a..5156cb1 100644
--- a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs
+++ b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs
@@ -17,8 +17,8 @@ internal sealed class FakeWorkspaceGateway : IWorkspaceGateway
public int CreatedChannels { get; private set; }
public int PostedMessages { get; private set; }
public int EditedMessages { get; private set; }
- public IReadOnlyCollection ChannelIds => _channels.Keys.ToList();
- public IReadOnlyCollection CategoryIds => _categories.Keys.ToList();
+ public IReadOnlyCollection ChannelIds => [.. _channels.Keys];
+ public IReadOnlyCollection CategoryIds => [.. _categories.Keys];
public bool CategoryExists(ulong guildId, ulong categoryId) => _categories.ContainsKey(categoryId);
diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs
index 1e7c24d..c34eb10 100644
--- a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs
+++ b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs
@@ -33,9 +33,12 @@ public Task> GetChannelsAsync(ulong guildId,
CancellationToken cancellationToken = default)
{
var prefix = Scope(guildId, serverId) + "|";
- IReadOnlyList list = _channels
- .Where(kv => kv.Key.StartsWith(prefix, StringComparison.Ordinal))
- .Select(kv => kv.Value).ToList();
+ IReadOnlyList list =
+ [
+ .. _channels
+ .Where(kv => kv.Key.StartsWith(prefix, StringComparison.Ordinal))
+ .Select(kv => kv.Value)
+ ];
return Task.FromResult(list);
}
@@ -44,7 +47,7 @@ public Task> GetChannelsByKeyAsync(
CancellationToken cancellationToken = default)
{
IReadOnlyList result =
- _channels.Values.Where(c => c.ChannelKey == channelKey).ToList();
+ [.. _channels.Values.Where(c => c.ChannelKey == channelKey)];
return Task.FromResult(result);
}
@@ -95,13 +98,13 @@ public Task DeleteScopeAsync(ulong guildId, Guid? serverId, CancellationToken ca
public Task> GetAllCategoriesAsync(ulong guildId,
CancellationToken cancellationToken = default)
{
- IReadOnlyList list = _categories.Values.Where(c => c.GuildId == guildId).ToList();
+ IReadOnlyList list = [.. _categories.Values.Where(c => c.GuildId == guildId)];
return Task.FromResult(list);
}
public Task> GetProvisionedGuildIdsAsync(CancellationToken cancellationToken = default)
{
- IReadOnlyList list = _categories.Values.Select(c => c.GuildId).Distinct().ToList();
+ IReadOnlyList list = [.. _categories.Values.Select(c => c.GuildId).Distinct()];
return Task.FromResult(list);
}
diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/CachingChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/CachingChannelLocatorTests.cs
new file mode 100644
index 0000000..47175d7
--- /dev/null
+++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/CachingChannelLocatorTests.cs
@@ -0,0 +1,159 @@
+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;
+
+///
+/// Minimal concrete subclass used only in this test assembly to exercise the shared base behaviour.
+/// Uses the ServerEvents key as a representative channel key.
+///
+/// Opens scopes for the scoped workspace store.
+/// Drives the cache TTL.
+internal sealed class TestChannelLocator(IServiceScopeFactory scopeFactory, IClock clock)
+ : CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerEvents);
+
+public sealed class CachingChannelLocatorTests
+{
+ private static (TestChannelLocator Locator, ServiceProvider Provider, string ConnectionString, IClock Clock)
+ CreateLocator()
+ {
+ var clock = Substitute.For();
+ clock.UtcNow.Returns(DateTimeOffset.UnixEpoch);
+
+ var cs = $"DataSource=caching-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 TestChannelLocator(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.ServerEvents,
+ DiscordChannelId = 555UL,
+ CreatedAt = DateTimeOffset.UnixEpoch,
+ });
+ await context.SaveChangesAsync();
+
+ return server.Id;
+ }
+
+ [Fact]
+ public async Task GetChannelIdAsync_returns_seeded_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(555UL, channelId);
+ }
+
+ [Fact]
+ public async Task GetChannelIdAsync_returns_null_for_unknown_server()
+ {
+ var (locator, provider, _, _) = CreateLocator();
+ await using var _p = provider;
+
+ Assert.Null(await locator.GetChannelIdAsync(10UL, Guid.NewGuid(), CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task Cache_hit_skips_db_reload_within_ttl()
+ {
+ 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.ServerEvents,
+ 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);
+ }
+
+ [Fact]
+ public async Task Rows_with_null_server_id_are_skipped()
+ {
+ var (locator, provider, cs, _) = CreateLocator();
+ await using var _p = provider;
+
+ await using var context =
+ new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options);
+
+ // Insert a channel row with no RustServerId (global scope) — must be ignored by the locator.
+ context.ProvisionedChannels.Add(new ProvisionedChannel
+ {
+ GuildId = 10UL,
+ RustServerId = null,
+ ChannelKey = WorkspaceChannelKeys.ServerEvents,
+ DiscordChannelId = 444UL,
+ CreatedAt = DateTimeOffset.UnixEpoch,
+ });
+ await context.SaveChangesAsync();
+
+ // The locator should return null because the only row has a null server id.
+ Assert.Null(await locator.GetChannelIdAsync(10UL, Guid.NewGuid(), CancellationToken.None));
+ }
+}
diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs
index 01a1445..0f3af71 100644
--- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs
+++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs
@@ -1,9 +1,9 @@
using Discord;
using NSubstitute;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Domain.Connections;
using RustPlusBot.Domain.Credentials;
using RustPlusBot.Domain.Servers;
-using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Workspace.Localization;
using RustPlusBot.Features.Workspace.Messages;
using RustPlusBot.Features.Workspace.Registry;
@@ -23,8 +23,8 @@ public async Task Information_ShowsServerCount()
{
var servers = Substitute.For();
servers.ListAsync(1, Arg.Any())
- .Returns(new List
- {
+ .Returns(
+ [
new()
{
Name = "A"
@@ -37,7 +37,7 @@ public async Task Information_ShowsServerCount()
{
Name = "C"
}
- });
+ ]);
var renderer = new InformationMessageRenderer(servers, Loc);
var payload = await renderer.RenderAsync(Global, default);
@@ -86,8 +86,8 @@ public async Task ServerInfo_Connected_ShowsStatusActivePlayerAndCount_AndSwapSe
PlayerCount = 12,
});
connections.ListPoolAsync(1, serverId, Arg.Any())
- .Returns(new List
- {
+ .Returns(
+ [
new()
{
Id = credId,
@@ -97,7 +97,7 @@ public async Task ServerInfo_Connected_ShowsStatusActivePlayerAndCount_AndSwapSe
SteamId = 76561198000000000UL,
Status = CredentialStatus.Active
},
- });
+ ]);
var query = Substitute.For();
query.GetTeamInfoAsync(Arg.Any(), Arg.Any(), Arg.Any())
.Returns((TeamInfoSnapshot?)null);
@@ -136,7 +136,7 @@ public async Task ServerInfo_NoCredentials_ShowsNoCredentialsAndNoCount()
RustServerId = serverId, GuildId = 1, Status = ConnectionStatus.NoCredentials
});
connections.ListPoolAsync(1, serverId, Arg.Any())
- .Returns(new List());
+ .Returns([]);
var query = Substitute.For();
query.GetTeamInfoAsync(Arg.Any(), Arg.Any(), Arg.Any())
.Returns((TeamInfoSnapshot?)null);
@@ -167,7 +167,7 @@ public async Task ServerInfo_NullState_DefaultsToNoCredentials()
connections.GetStateAsync(1, serverId, Arg.Any())
.Returns((DomainConnectionState?)null);
connections.ListPoolAsync(1, serverId, Arg.Any())
- .Returns(new List());
+ .Returns([]);
var query = Substitute.For();
query.GetTeamInfoAsync(Arg.Any(), Arg.Any(), Arg.Any())
.Returns((TeamInfoSnapshot?)null);
@@ -226,7 +226,7 @@ public async Task ServerInfo_HasRemoveServerButton()
RustServerId = serverId, GuildId = 1, Status = ConnectionStatus.NoCredentials
});
connections.ListPoolAsync(1, serverId, Arg.Any())
- .Returns(new List());
+ .Returns([]);
var query = Substitute.For();
query.GetTeamInfoAsync(Arg.Any(), Arg.Any(), Arg.Any())
.Returns((TeamInfoSnapshot?)null);
@@ -265,8 +265,8 @@ public async Task ServerInfo_SwapSelectAndRemoveButton_AreInSeparateActionRows()
PlayerCount = 1,
});
connections.ListPoolAsync(1, serverId, Arg.Any())
- .Returns(new List
- {
+ .Returns(
+ [
new()
{
Id = credId,
@@ -276,7 +276,7 @@ public async Task ServerInfo_SwapSelectAndRemoveButton_AreInSeparateActionRows()
SteamId = 5UL,
Status = CredentialStatus.Active
},
- });
+ ]);
var query = Substitute.For();
query.GetTeamInfoAsync(Arg.Any(), Arg.Any(), Arg.Any())
.Returns((TeamInfoSnapshot?)null);
@@ -318,8 +318,8 @@ public async Task ServerInfo_Connected_WithTeam_ShowsTeamSummary()
PlayerCount = 5,
});
connections.ListPoolAsync(1, serverId, Arg.Any())
- .Returns(new List
- {
+ .Returns(
+ [
new()
{
Id = credId,
@@ -329,7 +329,7 @@ public async Task ServerInfo_Connected_WithTeam_ShowsTeamSummary()
SteamId = 5UL,
Status = CredentialStatus.Active
},
- });
+ ]);
var query = Substitute.For();
query.GetTeamInfoAsync(1, serverId, Arg.Any())
.Returns(new TeamInfoSnapshot(
@@ -375,8 +375,8 @@ public async Task ServerInfo_Connected_LeaderHasEmptyName_FallsBackToSteamId()
PlayerCount = 1,
});
connections.ListPoolAsync(1, serverId, Arg.Any())
- .Returns(new List
- {
+ .Returns(
+ [
new()
{
Id = credId,
@@ -386,7 +386,7 @@ public async Task ServerInfo_Connected_LeaderHasEmptyName_FallsBackToSteamId()
SteamId = 5UL,
Status = CredentialStatus.Active
},
- });
+ ]);
var query = Substitute.For();
query.GetTeamInfoAsync(1, serverId, Arg.Any())
.Returns(new TeamInfoSnapshot(
@@ -430,8 +430,8 @@ public async Task ServerInfo_Connected_NullTeam_OmitsTeamSummary()
PlayerCount = 5,
});
connections.ListPoolAsync(1, serverId, Arg.Any())
- .Returns(new List
- {
+ .Returns(
+ [
new()
{
Id = credId,
@@ -441,7 +441,7 @@ public async Task ServerInfo_Connected_NullTeam_OmitsTeamSummary()
SteamId = 5UL,
Status = CredentialStatus.Active
},
- });
+ ]);
var query = Substitute.For();
query.GetTeamInfoAsync(1, serverId, Arg.Any())
.Returns((TeamInfoSnapshot?)null);
diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs
index 5f2578d..ae76bbe 100644
--- a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs
+++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs
@@ -39,7 +39,7 @@ public WorkspaceReconciler Build()
{
var registry = new WorkspaceRegistry(_channelProviders, _messageProviders);
return new WorkspaceReconciler(
- registry, Gateway, Store, _renderers, Servers,
+ new WorkspaceBackends(registry, Gateway, Store), _renderers, Servers,
new Localizer(LocalizationCatalog.Default), new ProvisioningLock(),
NullLogger.Instance);
}
@@ -79,8 +79,9 @@ public ReconcilerBuilderReusing WithChannel(WorkspaceScope scope, string key, st
}
public WorkspaceReconciler Build() => new(
- new WorkspaceRegistry(_channelProviders, _messageProviders),
- source.Gateway, source.Store, _renderers, source.Servers,
+ new WorkspaceBackends(new WorkspaceRegistry(_channelProviders, _messageProviders), source.Gateway,
+ source.Store),
+ _renderers, source.Servers,
new Localizer(LocalizationCatalog.Default), new ProvisioningLock(),
NullLogger.Instance);
diff --git a/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs
index e1fcaeb..8aca4dc 100644
--- a/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs
+++ b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs
@@ -1,9 +1,9 @@
using Discord.WebSocket;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
+using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Abstractions.Time;
-using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Workspace;
using RustPlusBot.Features.Workspace.Reconciler;
using RustPlusBot.Features.Workspace.Teardown;
diff --git a/tests/RustPlusBot.Localization.Tests/DictionaryLocalizerTests.cs b/tests/RustPlusBot.Localization.Tests/DictionaryLocalizerTests.cs
new file mode 100644
index 0000000..61ae923
--- /dev/null
+++ b/tests/RustPlusBot.Localization.Tests/DictionaryLocalizerTests.cs
@@ -0,0 +1,37 @@
+using RustPlusBot.Localization;
+
+namespace RustPlusBot.Localization.Tests;
+
+public sealed class DictionaryLocalizerTests
+{
+ private static DictionaryLocalizer Build() => new(
+ new Dictionary>(StringComparer.Ordinal)
+ {
+ ["en"] = new Dictionary(StringComparer.Ordinal)
+ {
+ ["greet"] = "Hello {0}", ["bye"] = "Bye",
+ },
+ ["fr"] = new Dictionary(StringComparer.Ordinal)
+ {
+ ["greet"] = "Bonjour {0}"
+ },
+ });
+
+ [Fact]
+ public void Get_ReturnsCultureValue() => Assert.Equal("Bonjour {0}", Build().Get("greet", "fr"));
+
+ [Fact]
+ public void Get_NormalizesRegion() => Assert.Equal("Bonjour {0}", Build().Get("greet", "fr-FR"));
+
+ [Fact]
+ public void Get_FallsBackToEnglish() => Assert.Equal("Bye", Build().Get("bye", "fr"));
+
+ [Fact]
+ public void Get_ReturnsKeyWhenMissing() => Assert.Equal("nope", Build().Get("nope", "en"));
+
+ [Fact]
+ public void Get_FormatsArgs() => Assert.Equal("Hello world", Build().Get("greet", "en", "world"));
+
+ [Fact]
+ public void Get_BlankCulture_UsesEnglish() => Assert.Equal("Bye", Build().Get("bye", ""));
+}
diff --git a/tests/RustPlusBot.Localization.Tests/RustPlusBot.Localization.Tests.csproj b/tests/RustPlusBot.Localization.Tests/RustPlusBot.Localization.Tests.csproj
new file mode 100644
index 0000000..3ae341d
--- /dev/null
+++ b/tests/RustPlusBot.Localization.Tests/RustPlusBot.Localization.Tests.csproj
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/RustPlusBot.Persistence.Tests/Servers/ServerServiceTests.cs b/tests/RustPlusBot.Persistence.Tests/Servers/ServerServiceTests.cs
index b688df4..eb89023 100644
--- a/tests/RustPlusBot.Persistence.Tests/Servers/ServerServiceTests.cs
+++ b/tests/RustPlusBot.Persistence.Tests/Servers/ServerServiceTests.cs
@@ -75,12 +75,12 @@ public async Task ResolveOrCreateByEndpoint_ReturnsExistingForSameEndpoint()
await using var __ = connection;
var service = new ServerService(context);
- var first = await service.ResolveOrCreateByEndpointAsync(10UL, 1UL, "Main", "1.2.3.4", 28015);
+ var (Server, Created) = await service.ResolveOrCreateByEndpointAsync(10UL, 1UL, "Main", "1.2.3.4", 28015);
var second = await service.ResolveOrCreateByEndpointAsync(10UL, 2UL, "Main again", "1.2.3.4", 28015);
- Assert.True(first.Created);
+ Assert.True(Created);
Assert.False(second.Created);
- Assert.Equal(first.Server.Id, second.Server.Id);
+ Assert.Equal(Server.Id, second.Server.Id);
Assert.Single(await service.ListAsync(10UL));
}