diff --git a/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs b/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs index b406f446..5056005c 100644 --- a/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs +++ b/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs @@ -22,7 +22,7 @@ namespace RustPlusBot.Features.Alarms.Relaying; internal sealed record AlarmRelayChannels( IAlarmChannelLocator Locator, IAlarmChannelPoster Poster, - ITeamChatSender TeamChatSender); + IBotTeamChatSender TeamChatSender); /// /// Keeps alarm embeds in sync with live socket events: updates state and re-renders on trigger; marks diff --git a/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs b/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs index 441ad69f..a640afb8 100644 --- a/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs +++ b/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs @@ -1,17 +1,25 @@ +using Microsoft.Extensions.DependencyInjection; using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Chat.Webhooks; +using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Commands; namespace RustPlusBot.Features.Chat.Relaying; -/// Relays one received in-game team message into its Discord #teamchat channel, dropping our own echoes. +/// +/// Relays one received in-game team message into its Discord #teamchat channel, dropping our own +/// echoes and command invocations. +/// /// Resolves the target #teamchat channel. /// Posts the line via webhook. /// Tracks lines the bridge relayed into the game so their echoes can be dropped. +/// Opens a scope to read the scoped command prefix. internal sealed class TeamChatRelay( ITeamChatChannelLocator locator, ITeamChatWebhookPoster poster, - RelayDedupBuffer dedup) + RelayDedupBuffer dedup, + IServiceScopeFactory scopeFactory) { /// Handles one . /// The received team message. @@ -19,11 +27,18 @@ internal sealed class TeamChatRelay( /// A task that completes when the line has been relayed or dropped. public async Task RelayAsync(TeamMessageReceivedEvent evt, CancellationToken cancellationToken) { + if (evt.FromActivePlayer && evt.Message.StartsWith(BotTeamChat.Prefix, StringComparison.Ordinal)) + { + return; // Bot-originated line echoing back; #teamchat carries only human discussion. + } + if (evt.FromActivePlayer && dedup.TryConsume((evt.GuildId, evt.ServerId), evt.Message)) { return; // Our own relayed line echoing back; do not re-post. } + // The locator is an in-memory cache, so resolve the channel first: unmapped servers exit + // before the per-message prefix query below. var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) .ConfigureAwait(false); if (channelId is null) @@ -31,6 +46,25 @@ public async Task RelayAsync(TeamMessageReceivedEvent evt, CancellationToken can return; } + // A command invocation (e.g. "!pop") gets its reply in game; the bare trigger line is noise in Discord. + var prefix = await GetCommandPrefixAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(prefix) && + evt.Message.TrimStart().StartsWith(prefix, StringComparison.Ordinal)) + { + return; + } + await poster.PostAsync(channelId.Value, evt.SenderName, evt.Message, cancellationToken).ConfigureAwait(false); } + + private async Task GetCommandPrefixAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + { + // IMuteStore is scoped, so resolve it from a fresh scope rather than capturing it on this singleton. + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var muteStore = scope.ServiceProvider.GetRequiredService(); + return await muteStore.GetPrefixAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + } + } } diff --git a/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs b/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs index 61c6d2b4..e1fbaf97 100644 --- a/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs +++ b/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs @@ -19,7 +19,7 @@ internal sealed partial class CommandDispatcher private readonly Dictionary _handlers; private readonly ILogger _logger; private readonly IMuteStore _muteStore; - private readonly ITeamChatSender _sender; + private readonly IBotTeamChatSender _sender; private readonly IWorkspaceStore _workspace; /// Initializes the dispatcher. @@ -34,7 +34,7 @@ public CommandDispatcher( CommandCooldown cooldown, IMuteStore muteStore, IWorkspaceStore workspace, - ITeamChatSender sender, + IBotTeamChatSender sender, ILogger logger) { ArgumentNullException.ThrowIfNull(handlers); @@ -54,7 +54,9 @@ public async Task DispatchAsync(TeamMessageReceivedEvent evt, CancellationToken { ArgumentNullException.ThrowIfNull(evt); - if (evt.FromActivePlayer) + // Bot-originated lines echo back from the active player carrying the bot prefix; ignoring only + // those (instead of every active-player line) lets the paired player's own typed commands dispatch. + if (evt.FromActivePlayer && evt.Message.StartsWith(BotTeamChat.Prefix, StringComparison.Ordinal)) { return; } diff --git a/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs index f96eb325..7736a409 100644 --- a/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs @@ -23,6 +23,7 @@ public static IServiceCollection AddConnections(this IServiceCollection services services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); services.AddScoped(); diff --git a/src/RustPlusBot.Features.Connections/Listening/BotTeamChat.cs b/src/RustPlusBot.Features.Connections/Listening/BotTeamChat.cs new file mode 100644 index 00000000..355c32b2 --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Listening/BotTeamChat.cs @@ -0,0 +1,12 @@ +namespace RustPlusBot.Features.Connections.Listening; + +/// The marker prefix identifying bot-originated in-game team-chat lines. +public static class BotTeamChat +{ + /// + /// Marker leading every bot-originated team-chat line so the relay can drop the echo instead of + /// re-posting it to Discord. The constant carries no trailing space; + /// inserts one space between the prefix and the message. Never localized. + /// + public const string Prefix = "[R+]"; +} diff --git a/src/RustPlusBot.Features.Connections/Listening/BotTeamChatSender.cs b/src/RustPlusBot.Features.Connections/Listening/BotTeamChatSender.cs new file mode 100644 index 00000000..150b58d9 --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Listening/BotTeamChatSender.cs @@ -0,0 +1,18 @@ +using System.Globalization; + +namespace RustPlusBot.Features.Connections.Listening; + +/// Prepends and forwards to the raw . +/// The raw team-chat sender. +internal sealed class BotTeamChatSender(ITeamChatSender inner) : IBotTeamChatSender +{ + /// + public Task SendAsync(ulong guildId, + Guid serverId, + string message, + CancellationToken cancellationToken) => + inner.SendAsync(guildId, + serverId, + string.Create(CultureInfo.InvariantCulture, $"{BotTeamChat.Prefix} {message}"), + cancellationToken); +} diff --git a/src/RustPlusBot.Features.Connections/Listening/IBotTeamChatSender.cs b/src/RustPlusBot.Features.Connections/Listening/IBotTeamChatSender.cs new file mode 100644 index 00000000..5edb6c56 --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Listening/IBotTeamChatSender.cs @@ -0,0 +1,21 @@ +namespace RustPlusBot.Features.Connections.Listening; + +/// +/// Relays a bot-originated line (command reply, event/player/alarm notification) into a server's +/// in-game team chat, prefixed with so its echo is never re-posted +/// to the Discord #teamchat channel. Player speech bridged from Discord uses +/// instead. +/// +public interface IBotTeamChatSender +{ + /// Sends , prefixed, to the live socket for (, ). + /// The owning guild snowflake. + /// The target server id. + /// The unprefixed message text. + /// A cancellation token. + /// The send result. + Task SendAsync(ulong guildId, + Guid serverId, + string message, + CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs b/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs index 45ec85f8..b960f7f3 100644 --- a/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs +++ b/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs @@ -17,7 +17,7 @@ namespace RustPlusBot.Features.Events.Relaying; internal sealed record EventRelayChannels( IEventChannelLocator Locator, IEventChannelPoster Poster, - ITeamChatSender TeamChatSender); + IBotTeamChatSender TeamChatSender); /// Posts every live event to #events AND in-game team chat; tracks rig state. /// Classifies raw marker deltas into domain events. diff --git a/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs b/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs index 433daf18..7641cbda 100644 --- a/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs +++ b/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs @@ -18,7 +18,7 @@ internal sealed class PlayerEventRelay( PlayerEventRenderer renderer, IEventChannelLocator locator, IPlayerChannelPoster poster, - ITeamChatSender teamChatSender, + IBotTeamChatSender teamChatSender, IServiceScopeFactory scopeFactory) { /// Handles one : posts embeds and broadcasts in-game. diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmRegistrationTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmRegistrationTests.cs index 8f472948..05079c4a 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmRegistrationTests.cs @@ -50,7 +50,7 @@ public void AddAlarms_resolves_without_captive_dependency_errors() services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); - services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); // Discord var discordConfig = new DiscordSocketConfig(); diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs index d93de7ab..450c9145 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs @@ -44,7 +44,7 @@ private static Harness Create(SmartAlarm? alarm = null, ulong? channelId = 777UL .Returns(channelId); var poster = Substitute.For(); - var teamChatSender = Substitute.For(); + var teamChatSender = Substitute.For(); teamChatSender .SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(TeamChatSendResult.Sent); @@ -462,6 +462,6 @@ private sealed record Harness( IAlarmStore Store, IAlarmRefresher Refresher, IAlarmChannelPoster Poster, - ITeamChatSender TeamChatSender, + IBotTeamChatSender TeamChatSender, IConnectionStore Connections); } diff --git a/tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs index f771156c..635bb330 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs @@ -47,7 +47,7 @@ private static Harness Create() relayLocator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(777UL); var relayPoster = Substitute.For(); - var teamChatSender = Substitute.For(); + var teamChatSender = Substitute.For(); teamChatSender.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(TeamChatSendResult.Sent); diff --git a/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs b/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs index 909a080e..626c08af 100644 --- a/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs @@ -1,4 +1,5 @@ using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using NSubstitute.ExceptionExtensions; @@ -10,6 +11,7 @@ using RustPlusBot.Features.Chat.Webhooks; using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Commands; namespace RustPlusBot.Features.Chat.Tests.Hosting; @@ -27,7 +29,16 @@ private static (ChatHostedService Service, InMemoryEventBus Bus, ITeamChatWebhoo var locator = Substitute.For(); locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns((ulong?)555UL); - var relay = new TeamChatRelay(locator, poster, dedup); + // The relay reads the scoped IMuteStore command prefix per message; stub a scope that provides it. + var muteStore = Substitute.For(); + muteStore.GetPrefixAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns("!"); + var relayScopeFactory = Substitute.For(); + var relayScope = Substitute.For(); + var relayScopeProvider = Substitute.For(); + relayScopeProvider.GetService(typeof(IMuteStore)).Returns(muteStore); + relayScope.ServiceProvider.Returns(relayScopeProvider); + relayScopeFactory.CreateScope().Returns(relayScope); + var relay = new TeamChatRelay(locator, poster, dedup, relayScopeFactory); var inboundLocator = Substitute.For(); var sender = Substitute.For(); diff --git a/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs b/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs index 77dbac46..3fc9d44d 100644 --- a/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs +++ b/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs @@ -1,9 +1,11 @@ +using Microsoft.Extensions.DependencyInjection; using NSubstitute; using RustPlusBot.Abstractions.Events; using RustPlusBot.Abstractions.Time; using RustPlusBot.Features.Chat.Relaying; using RustPlusBot.Features.Chat.Webhooks; using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Commands; namespace RustPlusBot.Features.Chat.Tests; @@ -11,7 +13,7 @@ public sealed class TeamChatRelayTests { private static (TeamChatRelay Relay, ITeamChatWebhookPoster Poster, RelayDedupBuffer Dedup, ITeamChatChannelLocator Locator) - Build() + Build(string prefix = "!") { var clock = Substitute.For(); clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); @@ -20,7 +22,15 @@ private static (TeamChatRelay Relay, ITeamChatWebhookPoster Poster, RelayDedupBu var locator = Substitute.For(); locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns((ulong?)777UL); - var relay = new TeamChatRelay(locator, poster, dedup); + var muteStore = Substitute.For(); + muteStore.GetPrefixAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(prefix); + var scopeFactory = Substitute.For(); + var scope = Substitute.For(); + var scopeProvider = Substitute.For(); + scopeProvider.GetService(typeof(IMuteStore)).Returns(muteStore); + scope.ServiceProvider.Returns(scopeProvider); + scopeFactory.CreateScope().Returns(scope); + var relay = new TeamChatRelay(locator, poster, dedup, scopeFactory); return (relay, poster, dedup, locator); } @@ -74,4 +84,56 @@ public async Task Skips_when_channel_not_provisioned() await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } + + [Fact] + public async Task Drops_bot_originated_echo_by_prefix() + { + var (relay, poster, _, _) = Build(); + var evt = new TeamMessageReceivedEvent(10UL, Guid.Empty, 555UL, "BotPlayer", "[R+] Cargo Ship entered the map", + FromActivePlayer: true); + + await relay.RelayAsync(evt, CancellationToken.None); + + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Posts_teammate_message_even_with_prefix() + { + var (relay, poster, _, _) = Build(); + var evt = new TeamMessageReceivedEvent(10UL, Guid.Empty, 999UL, "Bob", "[R+] hi", FromActivePlayer: false); + + await relay.RelayAsync(evt, CancellationToken.None); + + await poster.Received(1).PostAsync(777UL, "Bob", "[R+] hi", Arg.Any()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Drops_command_shaped_player_lines(bool fromActivePlayer) + { + var (relay, poster, _, _) = Build(); + var evt = new TeamMessageReceivedEvent(10UL, Guid.Empty, 999UL, "Bob", "!pop", fromActivePlayer); + + await relay.RelayAsync(evt, CancellationToken.None); + + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Command_drop_honors_custom_prefix() + { + var (relay, poster, _, _) = Build(prefix: "."); + await relay.RelayAsync(new TeamMessageReceivedEvent(10UL, Guid.Empty, 999UL, "Bob", ".pop", false), + CancellationToken.None); + await relay.RelayAsync(new TeamMessageReceivedEvent(10UL, Guid.Empty, 999UL, "Bob", "!not a command", false), + CancellationToken.None); + + await poster.Received(1).PostAsync(777UL, "Bob", "!not a command", Arg.Any()); + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), ".pop", + Arg.Any()); + } } diff --git a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs index c6b4b6ce..6a7e3626 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs @@ -27,7 +27,7 @@ public void Dispatcher_and_handlers_resolve() services.AddLogging(); services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); - services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddSingleton(_ => Substitute.For()); services.AddSingleton(_ => Substitute.For()); @@ -85,7 +85,7 @@ public void Commands_contribute_an_interaction_module_assembly() services.AddLogging(); services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); - services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddSingleton(_ => Substitute.For()); services.AddSingleton(_ => Substitute.For()); diff --git a/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandDispatcherTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandDispatcherTests.cs index 7c1b4974..5cb7db89 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandDispatcherTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandDispatcherTests.cs @@ -12,12 +12,12 @@ namespace RustPlusBot.Features.Commands.Tests.Dispatching; public sealed class CommandDispatcherTests { - private static (CommandDispatcher Sut, ITeamChatSender Sender, StubHandler Handler) Build( + private static (CommandDispatcher Sut, IBotTeamChatSender Sender, StubHandler Handler) Build( bool muted = false, string prefix = "!", string handlerName = "pop") { - var sender = Substitute.For(); + var sender = Substitute.For(); var settings = Substitute.For(); settings.GetMutedAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(muted); settings.GetPrefixAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(prefix); @@ -44,10 +44,19 @@ public async Task RunsHandler_AndSendsReply() } [Fact] - public async Task Ignores_FromActivePlayer() + public async Task Runs_ActivePlayerCommand() { var (sut, sender, handler) = Build(); await sut.DispatchAsync(Evt("!pop", fromActive: true), CancellationToken.None); + Assert.Equal(1, handler.Calls); + await sender.Received(1).SendAsync(1, Arg.Any(), "reply", Arg.Any()); + } + + [Fact] + public async Task Ignores_BotPrefixedEcho() + { + var (sut, sender, handler) = Build(); + await sut.DispatchAsync(Evt("[R+] anything", fromActive: true), CancellationToken.None); Assert.Equal(0, handler.Calls); await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); diff --git a/tests/RustPlusBot.Features.Commands.Tests/Hosting/CommandsHostedServiceTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Hosting/CommandsHostedServiceTests.cs index 678fde03..7987f592 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Hosting/CommandsHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Hosting/CommandsHostedServiceTests.cs @@ -16,7 +16,7 @@ public sealed class CommandsHostedServiceTests { private static Harness Create(bool prefixThrows = false) { - var sender = Substitute.For(); + var sender = Substitute.For(); sender.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(TeamChatSendResult.Sent); @@ -63,7 +63,7 @@ public async Task TeamMessageReceivedEvent_dispatches_the_command_and_relays_the var deadline = DateTimeOffset.UtcNow.AddSeconds(20); while (DateTimeOffset.UtcNow < deadline && !h.Sender.ReceivedCalls().Any(c => - c.GetMethodInfo().Name == nameof(ITeamChatSender.SendAsync))) + c.GetMethodInfo().Name == nameof(IBotTeamChatSender.SendAsync))) { await h.Bus.PublishAsync( new TeamMessageReceivedEvent(10UL, serverId, 7UL, "alice", "!pop", FromActivePlayer: false)); @@ -115,7 +115,7 @@ await h.Sender.DidNotReceive().SendAsync( private sealed record Harness( CommandsHostedService Service, InMemoryEventBus Bus, - ITeamChatSender Sender, + IBotTeamChatSender Sender, StubHandler Handler, IMuteStore MuteStore); diff --git a/tests/RustPlusBot.Features.Connections.Tests/BotTeamChatSenderTests.cs b/tests/RustPlusBot.Features.Connections.Tests/BotTeamChatSenderTests.cs new file mode 100644 index 00000000..aa3d4fc6 --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/BotTeamChatSenderTests.cs @@ -0,0 +1,38 @@ +using NSubstitute; +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Features.Connections.Tests; + +public sealed class BotTeamChatSenderTests +{ + [Fact] + public async Task Prefixes_the_message_and_forwards() + { + var inner = Substitute.For(); + inner.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(TeamChatSendResult.Sent); + var serverId = Guid.NewGuid(); + var sut = new BotTeamChatSender(inner); + + var result = await sut.SendAsync(10UL, serverId, "hello", CancellationToken.None); + + Assert.Equal(TeamChatSendResult.Sent, result); + await inner.Received(1).SendAsync(10UL, serverId, "[R+] hello", Arg.Any()); + } + + [Theory] + [InlineData(TeamChatSendResult.Sent)] + [InlineData(TeamChatSendResult.NotConnected)] + [InlineData(TeamChatSendResult.Failed)] + public async Task Passes_the_result_through(TeamChatSendResult expected) + { + var inner = Substitute.For(); + inner.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(expected); + var sut = new BotTeamChatSender(inner); + + var result = await sut.SendAsync(1UL, Guid.Empty, "x", CancellationToken.None); + + Assert.Equal(expected, result); + } +} diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionRegistrationTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionRegistrationTests.cs index 0a12d577..97c4c8e0 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ConnectionRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionRegistrationTests.cs @@ -6,6 +6,7 @@ using RustPlusBot.Abstractions.Time; using RustPlusBot.Discord.Notifications; using RustPlusBot.Features.Connections; +using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Connections.Removal; using RustPlusBot.Features.Connections.Supervisor; using RustPlusBot.Features.Workspace.Teardown; @@ -37,6 +38,7 @@ public async Task Services_Resolve() }); Assert.NotNull(provider.GetRequiredService()); + Assert.NotNull(provider.GetRequiredService()); await using var scope = provider.CreateAsyncScope(); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); diff --git a/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs b/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs index 03dc9ffc..fd56348d 100644 --- a/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs @@ -42,7 +42,7 @@ private static ServiceProvider BuildProvider() services.AddSingleton(Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); - services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); services.AddSingleton(Options.Create(new ConnectionOptions())); services.AddEvents(); diff --git a/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs b/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs index 70f53104..02081439 100644 --- a/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs @@ -24,7 +24,7 @@ public sealed class EventRelayTests private static readonly Guid Server = Guid.NewGuid(); private static (EventRelay Relay, EventStateStore Store, IEventChannelPoster Poster, - ITeamChatSender Sender, RigStateStore RigStore) CreateRelay(ulong? channelId = 999UL) + IBotTeamChatSender Sender, RigStateStore RigStore) CreateRelay(ulong? channelId = 999UL) { var clock = Substitute.For(); clock.UtcNow.Returns(new DateTimeOffset(2026, 6, 17, 12, 0, 0, TimeSpan.Zero)); @@ -43,7 +43,7 @@ private static (EventRelay Relay, EventStateStore Store, IEventChannelPoster Pos services.AddScoped(_ => workspaceStore); var provider = services.BuildServiceProvider(); - var sender = Substitute.For(); + var sender = Substitute.For(); var rigStore = new RigStateStore(clock, Options.Create(new ConnectionOptions())); var renderer = new EventEmbedRenderer(new ResxLocalizer()); diff --git a/tests/RustPlusBot.Features.Players.Tests/Hosting/PlayersHostedServiceTests.cs b/tests/RustPlusBot.Features.Players.Tests/Hosting/PlayersHostedServiceTests.cs index 2deecf5c..02ec2391 100644 --- a/tests/RustPlusBot.Features.Players.Tests/Hosting/PlayersHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/Hosting/PlayersHostedServiceTests.cs @@ -30,7 +30,7 @@ private static Harness Create() locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns((ulong?)null); var poster = Substitute.For(); - var sender = Substitute.For(); + var sender = Substitute.For(); sender.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(TeamChatSendResult.Sent); @@ -57,7 +57,7 @@ public async Task PlayerStateChangedEvent_routes_to_relay_and_sends_ingame_line( var deadline = DateTimeOffset.UtcNow.AddSeconds(20); while (DateTimeOffset.UtcNow < deadline && !h.Sender.ReceivedCalls().Any(c => - c.GetMethodInfo().Name == nameof(ITeamChatSender.SendAsync))) + c.GetMethodInfo().Name == nameof(IBotTeamChatSender.SendAsync))) { await h.Bus.PublishAsync(new PlayerStateChangedEvent( 10UL, serverId, @@ -97,7 +97,7 @@ public async Task RelayLoop_faults_on_sender_exception_but_StopAsync_completes_c var deadline = DateTimeOffset.UtcNow.AddSeconds(20); while (DateTimeOffset.UtcNow < deadline && !h.Sender.ReceivedCalls().Any(c => - c.GetMethodInfo().Name == nameof(ITeamChatSender.SendAsync))) + c.GetMethodInfo().Name == nameof(IBotTeamChatSender.SendAsync))) { await h.Bus.PublishAsync(new PlayerStateChangedEvent( 10UL, Guid.NewGuid(), @@ -116,7 +116,7 @@ await h.Sender.Received().SendAsync( private sealed record Harness( PlayersHostedService Service, InMemoryEventBus Bus, - ITeamChatSender Sender, + IBotTeamChatSender Sender, IEventChannelLocator Locator, IPlayerChannelPoster Poster); } diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs index 54d07746..51d9312a 100644 --- a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs @@ -30,7 +30,7 @@ private static ServiceProvider BuildProvider() services.AddSingleton(new DiscordSocketClient()); services.AddSingleton(Substitute.For()); services.AddScoped(_ => Substitute.For()); - services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); services.AddPlayers(); return services.BuildServiceProvider(validateScopes: true); } diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs index 7cf88a11..59e3c042 100644 --- a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs @@ -16,7 +16,7 @@ public sealed class PlayerEventRelayTests { private readonly IEventChannelLocator _locator = Substitute.For(); private readonly IPlayerChannelPoster _poster = Substitute.For(); - private readonly ITeamChatSender _sender = Substitute.For(); + private readonly IBotTeamChatSender _sender = Substitute.For(); private readonly IWorkspaceStore _workspace = Substitute.For(); private PlayerEventRelay BuildRelay()