Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ namespace RustPlusBot.Features.Alarms.Relaying;
internal sealed record AlarmRelayChannels(
IAlarmChannelLocator Locator,
IAlarmChannelPoster Poster,
ITeamChatSender TeamChatSender);
IBotTeamChatSender TeamChatSender);

/// <summary>
/// Keeps alarm embeds in sync with live socket events: updates state and re-renders on trigger; marks
Expand Down
38 changes: 36 additions & 2 deletions src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs
Original file line number Diff line number Diff line change
@@ -1,36 +1,70 @@
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;

/// <summary>Relays one received in-game team message into its Discord #teamchat channel, dropping our own echoes.</summary>
/// <summary>
/// Relays one received in-game team message into its Discord #teamchat channel, dropping our own
/// echoes and command invocations.
/// </summary>
/// <param name="locator">Resolves the target #teamchat channel.</param>
/// <param name="poster">Posts the line via webhook.</param>
/// <param name="dedup">Tracks lines the bridge relayed into the game so their echoes can be dropped.</param>
/// <param name="scopeFactory">Opens a scope to read the scoped <see cref="IMuteStore"/> command prefix.</param>
internal sealed class TeamChatRelay(
ITeamChatChannelLocator locator,
ITeamChatWebhookPoster poster,
RelayDedupBuffer dedup)
RelayDedupBuffer dedup,
IServiceScopeFactory scopeFactory)
{
/// <summary>Handles one <see cref="TeamMessageReceivedEvent"/>.</summary>
/// <param name="evt">The received team message.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A task that completes when the line has been relayed or dropped.</returns>
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)
{
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<string> 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<IMuteStore>();
return await muteStore.GetPrefixAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ internal sealed partial class CommandDispatcher
private readonly Dictionary<string, ICommandHandler> _handlers;
private readonly ILogger<CommandDispatcher> _logger;
private readonly IMuteStore _muteStore;
private readonly ITeamChatSender _sender;
private readonly IBotTeamChatSender _sender;
private readonly IWorkspaceStore _workspace;

/// <summary>Initializes the dispatcher.</summary>
Expand All @@ -34,7 +34,7 @@ public CommandDispatcher(
CommandCooldown cooldown,
IMuteStore muteStore,
IWorkspaceStore workspace,
ITeamChatSender sender,
IBotTeamChatSender sender,
ILogger<CommandDispatcher> logger)
{
ArgumentNullException.ThrowIfNull(handlers);
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public static IServiceCollection AddConnections(this IServiceCollection services
services.AddSingleton<ConnectionSupervisor>();
services.AddSingleton<IConnectionSupervisor>(sp => sp.GetRequiredService<ConnectionSupervisor>());
services.AddSingleton<ITeamChatSender>(sp => sp.GetRequiredService<ConnectionSupervisor>());
services.AddSingleton<IBotTeamChatSender, BotTeamChatSender>();
services.AddSingleton<IRustServerQuery>(sp => sp.GetRequiredService<ConnectionSupervisor>());
services.AddSingleton<IAfkState>(sp => sp.GetRequiredService<ConnectionSupervisor>());
services.AddScoped<IServerRemovalService, ServerRemovalService>();
Expand Down
12 changes: 12 additions & 0 deletions src/RustPlusBot.Features.Connections/Listening/BotTeamChat.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace RustPlusBot.Features.Connections.Listening;

/// <summary>The marker prefix identifying bot-originated in-game team-chat lines.</summary>
public static class BotTeamChat
{
/// <summary>
/// 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; <see cref="BotTeamChatSender"/>
/// inserts one space between the prefix and the message. Never localized.
/// </summary>
public const string Prefix = "[R+]";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System.Globalization;

namespace RustPlusBot.Features.Connections.Listening;

/// <summary>Prepends <see cref="BotTeamChat.Prefix"/> and forwards to the raw <see cref="ITeamChatSender"/>.</summary>
/// <param name="inner">The raw team-chat sender.</param>
internal sealed class BotTeamChatSender(ITeamChatSender inner) : IBotTeamChatSender
{
/// <inheritdoc />
public Task<TeamChatSendResult> SendAsync(ulong guildId,
Guid serverId,
string message,
CancellationToken cancellationToken) =>
inner.SendAsync(guildId,
serverId,
string.Create(CultureInfo.InvariantCulture, $"{BotTeamChat.Prefix} {message}"),
cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace RustPlusBot.Features.Connections.Listening;

/// <summary>
/// Relays a bot-originated line (command reply, event/player/alarm notification) into a server's
/// in-game team chat, prefixed with <see cref="BotTeamChat.Prefix"/> so its echo is never re-posted
/// to the Discord #teamchat channel. Player speech bridged from Discord uses
/// <see cref="ITeamChatSender"/> instead.
/// </summary>
public interface IBotTeamChatSender
{
/// <summary>Sends <paramref name="message"/>, prefixed, to the live socket for (<paramref name="guildId"/>, <paramref name="serverId"/>).</summary>
/// <param name="guildId">The owning guild snowflake.</param>
/// <param name="serverId">The target server id.</param>
/// <param name="message">The unprefixed message text.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The send result.</returns>
Task<TeamChatSendResult> SendAsync(ulong guildId,
Guid serverId,
string message,
CancellationToken cancellationToken);
}
2 changes: 1 addition & 1 deletion src/RustPlusBot.Features.Events/Relaying/EventRelay.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ namespace RustPlusBot.Features.Events.Relaying;
internal sealed record EventRelayChannels(
IEventChannelLocator Locator,
IEventChannelPoster Poster,
ITeamChatSender TeamChatSender);
IBotTeamChatSender TeamChatSender);

/// <summary>Posts every live event to #events AND in-game team chat; tracks rig state.</summary>
/// <param name="classifier">Classifies raw marker deltas into domain events.</param>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ internal sealed class PlayerEventRelay(
PlayerEventRenderer renderer,
IEventChannelLocator locator,
IPlayerChannelPoster poster,
ITeamChatSender teamChatSender,
IBotTeamChatSender teamChatSender,
IServiceScopeFactory scopeFactory)
{
/// <summary>Handles one <see cref="PlayerStateChangedEvent"/>: posts embeds and broadcasts in-game.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public void AddAlarms_resolves_without_captive_dependency_errors()
services.AddSingleton(Substitute.For<IClock>());
services.AddSingleton(Substitute.For<IEventBus>());
services.AddSingleton(Substitute.For<IAlarmChannelLocator>());
services.AddSingleton(Substitute.For<ITeamChatSender>());
services.AddSingleton(Substitute.For<IBotTeamChatSender>());

// Discord
var discordConfig = new DiscordSocketConfig();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ private static Harness Create(SmartAlarm? alarm = null, ulong? channelId = 777UL
.Returns(channelId);

var poster = Substitute.For<IAlarmChannelPoster>();
var teamChatSender = Substitute.For<ITeamChatSender>();
var teamChatSender = Substitute.For<IBotTeamChatSender>();
teamChatSender
.SendAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(TeamChatSendResult.Sent);
Expand Down Expand Up @@ -462,6 +462,6 @@ private sealed record Harness(
IAlarmStore Store,
IAlarmRefresher Refresher,
IAlarmChannelPoster Poster,
ITeamChatSender TeamChatSender,
IBotTeamChatSender TeamChatSender,
IConnectionStore Connections);
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ private static Harness Create()
relayLocator.GetChannelIdAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(777UL);
var relayPoster = Substitute.For<IAlarmChannelPoster>();
var teamChatSender = Substitute.For<ITeamChatSender>();
var teamChatSender = Substitute.For<IBotTeamChatSender>();
teamChatSender.SendAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(TeamChatSendResult.Sent);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Discord.WebSocket;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
Expand All @@ -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;

Expand All @@ -27,7 +29,16 @@ private static (ChatHostedService Service, InMemoryEventBus Bus, ITeamChatWebhoo
var locator = Substitute.For<ITeamChatChannelLocator>();
locator.GetChannelIdAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.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<IMuteStore>();
muteStore.GetPrefixAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>()).Returns("!");
var relayScopeFactory = Substitute.For<IServiceScopeFactory>();
var relayScope = Substitute.For<IServiceScope>();
var relayScopeProvider = Substitute.For<IServiceProvider>();
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<ITeamChatChannelLocator>();
var sender = Substitute.For<ITeamChatSender>();
Expand Down
66 changes: 64 additions & 2 deletions tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
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;

public sealed class TeamChatRelayTests
{
private static (TeamChatRelay Relay, ITeamChatWebhookPoster Poster, RelayDedupBuffer Dedup, ITeamChatChannelLocator
Locator)
Build()
Build(string prefix = "!")
{
var clock = Substitute.For<IClock>();
clock.UtcNow.Returns(DateTimeOffset.UnixEpoch);
Expand All @@ -20,7 +22,15 @@ private static (TeamChatRelay Relay, ITeamChatWebhookPoster Poster, RelayDedupBu
var locator = Substitute.For<ITeamChatChannelLocator>();
locator.GetChannelIdAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns((ulong?)777UL);
var relay = new TeamChatRelay(locator, poster, dedup);
var muteStore = Substitute.For<IMuteStore>();
muteStore.GetPrefixAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>()).Returns(prefix);
var scopeFactory = Substitute.For<IServiceScopeFactory>();
var scope = Substitute.For<IServiceScope>();
var scopeProvider = Substitute.For<IServiceProvider>();
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);
}

Expand Down Expand Up @@ -74,4 +84,56 @@ public async Task Skips_when_channel_not_provisioned()
await poster.DidNotReceive().PostAsync(Arg.Any<ulong>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<CancellationToken>());
}

[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<ulong>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<CancellationToken>());
}

[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<CancellationToken>());
}

[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<ulong>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<CancellationToken>());
}

[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<CancellationToken>());
await poster.DidNotReceive().PostAsync(Arg.Any<ulong>(), Arg.Any<string>(), ".pop",
Arg.Any<CancellationToken>());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public void Dispatcher_and_handlers_resolve()
services.AddLogging();
services.AddSingleton<IClock>(Substitute.For<IClock>());
services.AddSingleton<IEventBus>(Substitute.For<IEventBus>());
services.AddSingleton<ITeamChatSender>(Substitute.For<ITeamChatSender>());
services.AddSingleton<IBotTeamChatSender>(Substitute.For<IBotTeamChatSender>());
services.AddSingleton<IRustServerQuery>(Substitute.For<IRustServerQuery>());
services.AddSingleton<IEventState>(_ => Substitute.For<IEventState>());
services.AddSingleton<IRigState>(_ => Substitute.For<IRigState>());
Expand Down Expand Up @@ -85,7 +85,7 @@ public void Commands_contribute_an_interaction_module_assembly()
services.AddLogging();
services.AddSingleton<IClock>(Substitute.For<IClock>());
services.AddSingleton<IEventBus>(Substitute.For<IEventBus>());
services.AddSingleton<ITeamChatSender>(Substitute.For<ITeamChatSender>());
services.AddSingleton<IBotTeamChatSender>(Substitute.For<IBotTeamChatSender>());
services.AddSingleton<IRustServerQuery>(Substitute.For<IRustServerQuery>());
services.AddSingleton<IEventState>(_ => Substitute.For<IEventState>());
services.AddSingleton<IRigState>(_ => Substitute.For<IRigState>());
Expand Down
Loading
Loading