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)); }