From 1b93b5520a8b1888049e0adebaec6ab1484a5bdb Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 5 Jul 2026 18:09:15 +0200 Subject: [PATCH 1/9] feat(connections): add IBotTeamChatSender applying the [R+] bot prefix Co-Authored-By: Claude Fable 5 --- .../ConnectionServiceCollectionExtensions.cs | 1 + .../Listening/BotTeamChat.cs | 11 ++++++ .../Listening/BotTeamChatSender.cs | 18 +++++++++ .../Listening/IBotTeamChatSender.cs | 21 ++++++++++ .../BotTeamChatSenderTests.cs | 38 +++++++++++++++++++ .../ConnectionRegistrationTests.cs | 2 + 6 files changed, 91 insertions(+) create mode 100644 src/RustPlusBot.Features.Connections/Listening/BotTeamChat.cs create mode 100644 src/RustPlusBot.Features.Connections/Listening/BotTeamChatSender.cs create mode 100644 src/RustPlusBot.Features.Connections/Listening/IBotTeamChatSender.cs create mode 100644 tests/RustPlusBot.Features.Connections.Tests/BotTeamChatSenderTests.cs 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..6444f17d --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Listening/BotTeamChat.cs @@ -0,0 +1,11 @@ +namespace RustPlusBot.Features.Connections.Listening; + +/// The marker prefix identifying bot-originated in-game team-chat lines. +public static class BotTeamChat +{ + /// + /// Prepended (with a trailing space) to every bot-originated team-chat line so the relay can + /// drop the echo instead of re-posting it to Discord. 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/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()); From 414b9060cd4a7bb94bd35fe2a6ca7e1cfe9462b1 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 5 Jul 2026 18:14:27 +0200 Subject: [PATCH 2/9] feat(chat): drop [R+]-prefixed active-player echoes from #teamchat Co-Authored-By: Claude Fable 5 --- .../Relaying/TeamChatRelay.cs | 6 +++++ .../TeamChatRelayTests.cs | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs b/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs index 441ad69f..a9a3f70f 100644 --- a/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs +++ b/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs @@ -1,5 +1,6 @@ using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Chat.Webhooks; +using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Workspace.Locating; namespace RustPlusBot.Features.Chat.Relaying; @@ -19,6 +20,11 @@ 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. diff --git a/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs b/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs index 77dbac46..4b48706a 100644 --- a/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs +++ b/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs @@ -74,4 +74,28 @@ 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()); + } } From bb32c1ea815fd500d81fc668624b5a1f97c79ddc Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 5 Jul 2026 18:18:50 +0200 Subject: [PATCH 3/9] feat(commands): send in-game command replies via IBotTeamChatSender Co-Authored-By: Claude Fable 5 --- .../Dispatching/CommandDispatcher.cs | 4 ++-- .../CommandRegistrationTests.cs | 4 ++-- .../Dispatching/CommandDispatcherTests.cs | 4 ++-- .../Hosting/CommandsHostedServiceTests.cs | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs b/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs index 61c6d2b4..44054e58 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); 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..ce6e5a9f 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); 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); From d9ea1d4bd59465e5c339567ab47aad7fb94b65d6 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 5 Jul 2026 18:23:04 +0200 Subject: [PATCH 4/9] feat(events): broadcast event lines via IBotTeamChatSender Co-Authored-By: Claude Fable 5 --- src/RustPlusBot.Features.Events/Relaying/EventRelay.cs | 2 +- .../EventRegistrationTests.cs | 2 +- .../Relaying/EventRelayTests.cs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) 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/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()); From d4793e2578eb37562817a6a61cb38f57dfe82205 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 5 Jul 2026 18:26:08 +0200 Subject: [PATCH 5/9] feat(players): broadcast player transition lines via IBotTeamChatSender Co-Authored-By: Claude Fable 5 --- .../Relaying/PlayerEventRelay.cs | 2 +- .../Hosting/PlayersHostedServiceTests.cs | 8 ++++---- .../PlayerEventRegistrationTests.cs | 2 +- .../PlayerEventRelayTests.cs | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) 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.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() From b84003d9162c1f7467bac684d5076db823b73c1b Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 5 Jul 2026 18:33:41 +0200 Subject: [PATCH 6/9] feat(alarms): relay alarm lines via IBotTeamChatSender Co-Authored-By: Claude Fable 5 --- src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs | 2 +- .../AlarmRegistrationTests.cs | 2 +- .../RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs | 4 ++-- .../Hosting/AlarmsHostedServiceTests.cs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) 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/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); From 06bd47da2706bb076e7be48ac8f0d4d76b2c9250 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 6 Jul 2026 12:55:54 +0200 Subject: [PATCH 7/9] fix(commands): let the paired player's in-game commands dispatch Drop only [R+]-prefixed bot echoes instead of every active-player line; FromActivePlayer over-blocked the owner, so their !commands never ran. Co-Authored-By: Claude Fable 5 --- .../Dispatching/CommandDispatcher.cs | 4 +++- .../Dispatching/CommandDispatcherTests.cs | 11 ++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs b/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs index 44054e58..2a99dc1f 100644 --- a/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs +++ b/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs @@ -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.Message.StartsWith(BotTeamChat.Prefix, StringComparison.Ordinal)) { return; } diff --git a/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandDispatcherTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandDispatcherTests.cs index ce6e5a9f..5cb7db89 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandDispatcherTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandDispatcherTests.cs @@ -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()); From 13a6024d2333d82d84ec798dd98e288f6834f43f Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 6 Jul 2026 12:58:59 +0200 Subject: [PATCH 8/9] feat(chat): hide command invocations from #teamchat A command line (server-configurable prefix, default "!") gets its reply in game; the bare trigger is noise in Discord, so the relay drops it. Co-Authored-By: Claude Fable 5 --- .../Relaying/TeamChatRelay.cs | 30 ++++++++++++- .../Hosting/ChatHostedServiceTests.cs | 13 +++++- .../TeamChatRelayTests.cs | 42 ++++++++++++++++++- 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs b/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs index a9a3f70f..a0617644 100644 --- a/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs +++ b/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs @@ -1,18 +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. @@ -30,6 +37,14 @@ public async Task RelayAsync(TeamMessageReceivedEvent evt, CancellationToken can return; // Our own relayed line echoing back; do not re-post. } + // 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; + } + var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) .ConfigureAwait(false); if (channelId is null) @@ -39,4 +54,15 @@ public async Task RelayAsync(TeamMessageReceivedEvent evt, CancellationToken can 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/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 4b48706a..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); } @@ -98,4 +108,32 @@ public async Task Posts_teammate_message_even_with_prefix() 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()); + } } From 400d59efc0ae8e509a8891e5a9da7dd1631c8176 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 6 Jul 2026 13:23:54 +0200 Subject: [PATCH 9/9] review: address Copilot comments on PR #43 - CommandDispatcher: gate the [R+] guard on FromActivePlayer (matches TeamChatRelay's echo rule and the comment's intent) - BotTeamChat.Prefix: doc no longer implies the constant carries the trailing space (BotTeamChatSender inserts it) - TeamChatRelay: resolve the cached channel before the per-message prefix query so unmapped servers skip the DB lookup Co-Authored-By: Claude Fable 5 --- .../Relaying/TeamChatRelay.cs | 16 +++++++++------- .../Dispatching/CommandDispatcher.cs | 2 +- .../Listening/BotTeamChat.cs | 5 +++-- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs b/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs index a0617644..a640afb8 100644 --- a/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs +++ b/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs @@ -37,17 +37,19 @@ public async Task RelayAsync(TeamMessageReceivedEvent evt, CancellationToken can return; // Our own relayed line echoing back; do not re-post. } - // 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)) + // 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) { return; } - var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) - .ConfigureAwait(false); - if (channelId is null) + // 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; } diff --git a/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs b/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs index 2a99dc1f..e1fbaf97 100644 --- a/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs +++ b/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs @@ -56,7 +56,7 @@ public async Task DispatchAsync(TeamMessageReceivedEvent evt, CancellationToken // 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.Message.StartsWith(BotTeamChat.Prefix, StringComparison.Ordinal)) + if (evt.FromActivePlayer && evt.Message.StartsWith(BotTeamChat.Prefix, StringComparison.Ordinal)) { return; } diff --git a/src/RustPlusBot.Features.Connections/Listening/BotTeamChat.cs b/src/RustPlusBot.Features.Connections/Listening/BotTeamChat.cs index 6444f17d..355c32b2 100644 --- a/src/RustPlusBot.Features.Connections/Listening/BotTeamChat.cs +++ b/src/RustPlusBot.Features.Connections/Listening/BotTeamChat.cs @@ -4,8 +4,9 @@ namespace RustPlusBot.Features.Connections.Listening; public static class BotTeamChat { /// - /// Prepended (with a trailing space) to every bot-originated team-chat line so the relay can - /// drop the echo instead of re-posting it to Discord. Never localized. + /// 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+]"; }