From 4374697fe2e4f35a3ae4a4f4cc6f602e974b0c17 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 15 Jun 2026 23:42:43 +0200 Subject: [PATCH 01/19] feat(abstractions): add TeamMessageReceivedEvent --- .../Events/TeamMessageReceivedEvent.cs | 16 ++++++++++++++++ .../Events/TeamMessageReceivedEventTests.cs | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 src/RustPlusBot.Abstractions/Events/TeamMessageReceivedEvent.cs create mode 100644 tests/RustPlusBot.Abstractions.Tests/Events/TeamMessageReceivedEventTests.cs diff --git a/src/RustPlusBot.Abstractions/Events/TeamMessageReceivedEvent.cs b/src/RustPlusBot.Abstractions/Events/TeamMessageReceivedEvent.cs new file mode 100644 index 00000000..e52d2adf --- /dev/null +++ b/src/RustPlusBot.Abstractions/Events/TeamMessageReceivedEvent.cs @@ -0,0 +1,16 @@ +namespace RustPlusBot.Abstractions.Events; + +/// Published when an in-game team chat line is received, so the chat bridge can relay it to Discord. +/// The owning guild snowflake. +/// The server whose socket received the line. +/// The Steam64 id of the in-game sender. +/// The in-game display name of the sender. +/// The message text. +/// True when the sender is the bot's active player (used to drop relay echoes). +public sealed record TeamMessageReceivedEvent( + ulong GuildId, + Guid ServerId, + ulong SenderSteamId, + string SenderName, + string Message, + bool FromActivePlayer); diff --git a/tests/RustPlusBot.Abstractions.Tests/Events/TeamMessageReceivedEventTests.cs b/tests/RustPlusBot.Abstractions.Tests/Events/TeamMessageReceivedEventTests.cs new file mode 100644 index 00000000..d49205b3 --- /dev/null +++ b/tests/RustPlusBot.Abstractions.Tests/Events/TeamMessageReceivedEventTests.cs @@ -0,0 +1,18 @@ +using RustPlusBot.Abstractions.Events; + +namespace RustPlusBot.Abstractions.Tests.Events; + +public sealed class TeamMessageReceivedEventTests +{ + [Fact] + public void Carries_all_fields() + { + var evt = new TeamMessageReceivedEvent(10UL, Guid.Empty, 7656119UL, "Alice", "hello", FromActivePlayer: true); + + Assert.Equal(10UL, evt.GuildId); + Assert.Equal(7656119UL, evt.SenderSteamId); + Assert.Equal("Alice", evt.SenderName); + Assert.Equal("hello", evt.Message); + Assert.True(evt.FromActivePlayer); + } +} From 46e15e975ae2cd72bbccd2e4e8c6a663929c7d6b Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 15 Jun 2026 23:46:01 +0200 Subject: [PATCH 02/19] test(abstractions): assert ServerId in TeamMessageReceivedEvent test --- .../Events/TeamMessageReceivedEventTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/RustPlusBot.Abstractions.Tests/Events/TeamMessageReceivedEventTests.cs b/tests/RustPlusBot.Abstractions.Tests/Events/TeamMessageReceivedEventTests.cs index d49205b3..405c4b51 100644 --- a/tests/RustPlusBot.Abstractions.Tests/Events/TeamMessageReceivedEventTests.cs +++ b/tests/RustPlusBot.Abstractions.Tests/Events/TeamMessageReceivedEventTests.cs @@ -10,6 +10,7 @@ public void Carries_all_fields() var evt = new TeamMessageReceivedEvent(10UL, Guid.Empty, 7656119UL, "Alice", "hello", FromActivePlayer: true); Assert.Equal(10UL, evt.GuildId); + Assert.Equal(Guid.Empty, evt.ServerId); Assert.Equal(7656119UL, evt.SenderSteamId); Assert.Equal("Alice", evt.SenderName); Assert.Equal("hello", evt.Message); From d358f557c372ddc6e5f9b0a4dd1cdbdc1ec88d59 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 15 Jun 2026 23:50:27 +0200 Subject: [PATCH 03/19] feat(connections): add team-chat send + received-message to the socket seam Co-Authored-By: Claude Sonnet 4.6 --- .../Listening/IRustServerConnection.cs | 9 +++++++ .../Listening/RustPlusSocketSource.cs | 23 +++++++++++++++++ .../Listening/TeamChatLine.cs | 7 ++++++ .../Fakes/FakeRustSocketSource.cs | 25 +++++++++++++++++-- 4 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 src/RustPlusBot.Features.Connections/Listening/TeamChatLine.cs diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs index cbbc5a97..32e18b54 100644 --- a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs +++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs @@ -14,4 +14,13 @@ internal interface IRustServerConnection : IAsyncDisposable /// A cancellation token. /// The heartbeat result. Task GetInfoAsync(TimeSpan timeout, CancellationToken cancellationToken); + + /// Sends a message to in-game team chat. + /// The message text to send. + /// A cancellation token. + /// A task that completes when the send has been issued. + Task SendTeamMessageAsync(string message, CancellationToken cancellationToken); + + /// Raised for every in-game team chat line received on this socket. + event EventHandler? TeamMessageReceived; } diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index 81090e54..472189f2 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -33,6 +33,15 @@ public Task ConnectAsync(TimeSpan timeout, CancellationTok public Task GetInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.FromResult(HeartbeatResult.AuthRejected); + public Task SendTeamMessageAsync(string message, CancellationToken cancellationToken) => + Task.CompletedTask; + + public event EventHandler? TeamMessageReceived + { + add { _ = value; } + remove { _ = value; } + } + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } @@ -55,6 +64,7 @@ public RustPlusServerConnection(string ip, int port, ulong steamId, int playerTo // CONFIRMED: RustPlusConnection(string Server, int Port, ulong PlayerId, int PlayerToken, bool UseFacepunchProxy). var connection = new RustPlusConnection(ip, port, steamId, playerToken, UseFacepunchProxy: false); _rustPlus = new RustPlus(connection); + _rustPlus.OnTeamChatReceived += OnTeamChatReceived; } public async Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken) @@ -130,8 +140,21 @@ public async Task GetInfoAsync(TimeSpan timeout, CancellationTo } } + public event EventHandler? TeamMessageReceived; + + public async Task SendTeamMessageAsync(string message, CancellationToken cancellationToken) + { + // CONFIRMED: SendTeamMessageAsync(string, CancellationToken) in 2.0.0-beta.1 returns Task>. + // Awaiting it discards the response; the interface contract is bare Task. + await _rustPlus.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false); + } + + private void OnTeamChatReceived(object? sender, RustPlusApi.Data.Events.TeamMessageEventArg e) => + TeamMessageReceived?.Invoke(this, new TeamChatLine(e.SteamId, e.Name, e.Message)); + public async ValueTask DisposeAsync() { + _rustPlus.OnTeamChatReceived -= OnTeamChatReceived; try { // CONFIRMED: RustPlusSocket implements IAsyncDisposable in 2.0.0-beta.1. diff --git a/src/RustPlusBot.Features.Connections/Listening/TeamChatLine.cs b/src/RustPlusBot.Features.Connections/Listening/TeamChatLine.cs new file mode 100644 index 00000000..b72511d2 --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Listening/TeamChatLine.cs @@ -0,0 +1,7 @@ +namespace RustPlusBot.Features.Connections.Listening; + +/// A raw team chat line as received from a socket (before active-player classification). +/// The Steam64 id of the sender. +/// The in-game display name of the sender. +/// The message text. +internal sealed record TeamChatLine(ulong SteamId, string Name, string Message); diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs index e9bb9047..73b4d152 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs @@ -32,13 +32,18 @@ internal sealed class FakeRustSocketSource : IRustSocketSource /// The Steam ID passed to the most recent call. Read after the operation under test has settled. public ulong LastSteamId { get; private set; } + /// The connection produced by the most recent call (for driving inbound/inspecting sends). + internal FakeConnection? LastConnection { get; private set; } + public IRustServerConnection Create(string ip, int port, ulong steamId, string playerToken) { Interlocked.Increment(ref _createCount); LastIp = ip; LastSteamId = steamId; var outcome = _connectOutcomes.TryDequeue(out var next) ? next : SocketConnectOutcome.Connected; - return new FakeConnection(outcome, this); + var connection = new FakeConnection(outcome, this); + LastConnection = connection; + return connection; } public void EnqueueConnect(SocketConnectOutcome outcome) => _connectOutcomes.Enqueue(outcome); @@ -55,15 +60,31 @@ internal HeartbeatResult NextHeartbeat() return _lastHeartbeat; } - private sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocketSource source) + internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocketSource source) : IRustServerConnection { + /// Gets the messages sent via . + public List SentMessages { get; } = []; + + /// Raised when a team chat message arrives on this connection. + public event EventHandler? TeamMessageReceived; + public Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.FromResult(outcome); public Task GetInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.FromResult(source.NextHeartbeat()); + public Task SendTeamMessageAsync(string message, CancellationToken cancellationToken) + { + SentMessages.Add(message); + return Task.CompletedTask; + } + + /// Raises to simulate an inbound team chat line. + /// The team chat line to raise. + public void RaiseTeamMessage(TeamChatLine line) => TeamMessageReceived?.Invoke(this, line); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } From 175a8a171bb66be3ef96026b455063a306b59177 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 15 Jun 2026 23:55:15 +0200 Subject: [PATCH 04/19] docs(connections): note SendTeamMessageAsync surfaces send failures Unlike ConnectAsync/GetInfoAsync which use broad catches to return typed outcomes, SendTeamMessageAsync intentionally lets exceptions propagate so the upstream supervisor can classify them as a failed send result. Co-Authored-By: Claude Sonnet 4.6 --- .../Listening/IRustServerConnection.cs | 1 + .../Listening/RustPlusSocketSource.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs index 32e18b54..f756a1ad 100644 --- a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs +++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs @@ -19,6 +19,7 @@ internal interface IRustServerConnection : IAsyncDisposable /// The message text to send. /// A cancellation token. /// A task that completes when the send has been issued. + /// Unlike the probe methods, this surfaces send failures to the caller (the supervisor maps them to a failed send result). Task SendTeamMessageAsync(string message, CancellationToken cancellationToken); /// Raised for every in-game team chat line received on this socket. diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index 472189f2..cbb0a558 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -146,6 +146,7 @@ public async Task SendTeamMessageAsync(string message, CancellationToken cancell { // CONFIRMED: SendTeamMessageAsync(string, CancellationToken) in 2.0.0-beta.1 returns Task>. // Awaiting it discards the response; the interface contract is bare Task. + // Intentional: send failures propagate to the caller (the supervisor classifies them), unlike the broad-catch probes. await _rustPlus.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false); } From 8e4127d3b59b8e8a0ccc34d3114b31a13e7ed9c8 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 00:03:40 +0200 Subject: [PATCH 05/19] feat(connections): publish TeamMessageReceivedEvent + implement ITeamChatSender Co-Authored-By: Claude Opus 4.8 --- .../ConnectionServiceCollectionExtensions.cs | 4 +- .../Listening/ITeamChatSender.cs | 26 +++ .../Supervisor/ConnectionSupervisor.cs | 123 ++++++++++++-- .../TeamChatSenderTests.cs | 157 ++++++++++++++++++ 4 files changed, 293 insertions(+), 17 deletions(-) create mode 100644 src/RustPlusBot.Features.Connections/Listening/ITeamChatSender.cs create mode 100644 tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs diff --git a/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs index bd19e028..186c8cdc 100644 --- a/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs @@ -18,7 +18,9 @@ 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()); services.AddScoped(); // Contribute this assembly's interaction modules to the Discord layer. diff --git a/src/RustPlusBot.Features.Connections/Listening/ITeamChatSender.cs b/src/RustPlusBot.Features.Connections/Listening/ITeamChatSender.cs new file mode 100644 index 00000000..a0565e66 --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Listening/ITeamChatSender.cs @@ -0,0 +1,26 @@ +namespace RustPlusBot.Features.Connections.Listening; + +/// The outcome of relaying a Discord message into in-game team chat. +public enum TeamChatSendResult +{ + /// The message was handed to the live socket. + Sent = 0, + + /// There is no live socket for that (guild, server) right now. + NotConnected = 1, + + /// A live socket exists but the send failed. + Failed = 2, +} + +/// Relays a message into a server's in-game team chat (implemented by the connection supervisor). +public interface ITeamChatSender +{ + /// Sends to the live socket for (, ). + /// The owning guild snowflake. + /// The target server id. + /// The message text to relay. + /// A cancellation token. + /// The send result. + Task SendAsync(ulong guildId, Guid serverId, string message, CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index 57edffb9..a222acf3 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -29,16 +29,26 @@ internal sealed partial class ConnectionSupervisor( ICredentialProtector protector, IEventBus eventBus, IOptions options, - ILogger logger) : IConnectionSupervisor, IAsyncDisposable + ILogger logger) : IConnectionSupervisor, ITeamChatSender, IAsyncDisposable { private readonly ConcurrentDictionary<(ulong Guild, Guid Server), Handle> _connections = new(); + private readonly ConcurrentDictionary<(ulong Guild, Guid Server), LiveSocket> _liveSockets = new(); private readonly SemaphoreSlim _gate = new(1, 1); private readonly ConnectionOptions _options = options.Value; private readonly CancellationTokenSource _shutdown = new(); + private bool _disposed; /// public async ValueTask DisposeAsync() { + // The supervisor is registered as one singleton backing three service types (IConnectionSupervisor, + // ITeamChatSender, and the concrete type), so the DI container may invoke DisposeAsync more than once. + if (_disposed) + { + return; + } + + _disposed = true; await StopAllAsync().ConfigureAwait(false); _shutdown.Dispose(); _gate.Dispose(); @@ -187,7 +197,8 @@ await PublishStatusAsync(key, ConnectionStatus.Unreachable, null, p.CredentialId ReconnectReason reason; try { - reason = await RunConnectedAsync(key, connection, p.CredentialId, ct).ConfigureAwait(false); + reason = await RunConnectedAsync(key, connection, p.CredentialId, p.SteamId, ct) + .ConfigureAwait(false); } finally { @@ -224,6 +235,7 @@ private async Task RunConnectedAsync( (ulong Guild, Guid Server) key, IRustServerConnection connection, Guid credentialId, + ulong activeSteamId, CancellationToken ct) { var first = await connection.GetInfoAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false); @@ -240,24 +252,39 @@ private async Task RunConnectedAsync( await PublishStatusAsync(key, ConnectionStatus.Connected, first.PlayerCount, credentialId, ct) .ConfigureAwait(false); - while (!ct.IsCancellationRequested) +#pragma warning disable RCS1163 // Unused 'sender': required by the EventHandler delegate shape. + void OnTeamMessage(object? sender, TeamChatLine line) => + _ = PublishTeamMessageAsync(key, activeSteamId, line); +#pragma warning restore RCS1163 + + connection.TeamMessageReceived += OnTeamMessage; + _liveSockets[key] = new LiveSocket(connection, activeSteamId); + try { - await Task.Delay(_options.HeartbeatInterval, ct).ConfigureAwait(false); - var beat = await connection.GetInfoAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false); - switch (beat.Kind) + while (!ct.IsCancellationRequested) { - 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; + 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 ReconnectReason.Stopped; + } + finally + { + _liveSockets.TryRemove(key, out _); + connection.TeamMessageReceived -= OnTeamMessage; + } } private async Task PrepareAsync((ulong Guild, Guid Server) key, CancellationToken ct) @@ -355,6 +382,68 @@ await eventBus.PublishAsync(new ConnectionStatusChangedEvent(key.Guild, key.Serv } } + /// Test seam: true when a live socket is currently registered for the key. + /// The owning guild snowflake. + /// The target server id. + /// True when a live socket is registered for (, ). + internal bool HasLiveSocket(ulong guildId, Guid serverId) => _liveSockets.ContainsKey((guildId, serverId)); + + /// + public async Task SendAsync( + ulong guildId, + Guid serverId, + string message, + CancellationToken cancellationToken) + { + if (!_liveSockets.TryGetValue((guildId, serverId), out var live)) + { + return TeamChatSendResult.NotConnected; + } + + try + { + await live.Connection.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false); + return TeamChatSendResult.Sent; + } + catch (OperationCanceledException) + { + throw; + } +#pragma warning disable CA1031 // Broad catch: a failed relay send must not crash the caller; report Failed. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogSendFailed(logger, ex, serverId); + return TeamChatSendResult.Failed; + } + } + + private async Task PublishTeamMessageAsync((ulong Guild, Guid Server) key, ulong activeSteamId, TeamChatLine line) + { + try + { + var evt = new TeamMessageReceivedEvent( + key.Guild, key.Server, line.SteamId, line.Name, line.Message, line.SteamId == activeSteamId); + await eventBus.PublishAsync(evt, _shutdown.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a publish failure must not crash the socket callback. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogPublishTeamMessageFailed(logger, ex, key.Server); + } + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Relaying a message to team chat for server {ServerId} failed.")] + private static partial void LogSendFailed(ILogger logger, Exception exception, Guid serverId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Publishing a received team message for server {ServerId} failed.")] + private static partial void LogPublishTeamMessageFailed(ILogger logger, Exception exception, Guid serverId); + private TimeSpan NextDelay(TimeSpan delay) => delay < _options.MaxRetryDelay ? TimeSpan.FromTicks(Math.Min(delay.Ticks * 2, _options.MaxRetryDelay.Ticks)) @@ -376,6 +465,8 @@ private readonly record struct Prepared( ulong SteamId, string PlayerToken); + private sealed record LiveSocket(IRustServerConnection Connection, ulong ActiveSteamId); + private sealed class Handle(CancellationTokenSource cts, Task runTask) : IAsyncDisposable { public ValueTask DisposeAsync() diff --git a/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs b/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs new file mode 100644 index 00000000..7f53582e --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs @@ -0,0 +1,157 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NSubstitute; +using RustPlusBot.Abstractions.Credentials; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Discord.Notifications; +using RustPlusBot.Domain.Credentials; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Connections.Supervisor; +using RustPlusBot.Features.Connections.Tests.Fakes; +using RustPlusBot.Persistence; +using RustPlusBot.Persistence.Connections; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Connections.Tests; + +public sealed class TeamChatSenderTests +{ + private static (ServiceProvider Provider, ConnectionSupervisor Supervisor, IEventBus Bus) CreateHarness( + FakeRustSocketSource source) + { + var protector = Substitute.For(); + protector.Unprotect(Arg.Any()).Returns(c => c.Arg()); + var dm = Substitute.For(); + + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(clock); + services.AddSingleton(protector); + services.AddSingleton(dm); + services.AddSingleton(); + + var cs = $"DataSource=teamchat-{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(); + } + + services.AddSingleton(keepAlive); + services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(source); + services.AddSingleton(Options.Create(new ConnectionOptions + { + ConnectTimeout = TimeSpan.FromSeconds(1), + InitialRetryDelay = TimeSpan.FromMilliseconds(5), + MaxRetryDelay = TimeSpan.FromMilliseconds(20), + HeartbeatInterval = TimeSpan.FromMilliseconds(20), + HeartbeatTimeout = TimeSpan.FromMilliseconds(200), + })); + services.AddSingleton(); + + var provider = services.BuildServiceProvider(); + return (provider, provider.GetRequiredService(), + provider.GetRequiredService()); + } + + private static async Task SeedServerWithActiveAsync(ServiceProvider provider, ulong steamId) + { + using var scope = provider.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var server = new RustServer { GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 }; + ctx.RustServers.Add(server); + ctx.PlayerCredentials.Add(new PlayerCredential + { + GuildId = 10UL, RustServerId = server.Id, OwnerUserId = 1UL, SteamId = steamId, + ProtectedPlayerToken = "123", Status = CredentialStatus.Active, + }); + await ctx.SaveChangesAsync(); + return server.Id; + } + + [Fact] + public async Task Inbound_line_publishes_event_with_active_player_flag() + { + var source = new FakeRustSocketSource(); + var (provider, supervisor, bus) = CreateHarness(source); + await using var _ = provider; + var serverId = await SeedServerWithActiveAsync(provider, steamId: 555UL); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var subscription = Task.Run(async () => + { + await foreach (var e in bus.SubscribeAsync(cts.Token)) + { + if (received.TrySetResult(e)) + { + break; + } + } + }, cts.Token); + + await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => source.LastConnection is not null, cts.Token); + await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); + source.LastConnection!.RaiseTeamMessage(new TeamChatLine(999UL, "Bob", "[Alice] hi")); + + var evt = await received.Task.WaitAsync(cts.Token); + Assert.Equal("Bob", evt.SenderName); + Assert.Equal("[Alice] hi", evt.Message); + Assert.False(evt.FromActivePlayer); + + await supervisor.StopAllAsync(); + await subscription; + } + + [Fact] + public async Task SendAsync_routes_to_live_socket_when_connected() + { + var source = new FakeRustSocketSource(); + var (provider, supervisor, _) = CreateHarness(source); + await using var _p = provider; + var serverId = await SeedServerWithActiveAsync(provider, steamId: 555UL); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); + + var result = await supervisor.SendAsync(10UL, serverId, "[Alice] hi", cts.Token); + + Assert.Equal(TeamChatSendResult.Sent, result); + Assert.Contains("[Alice] hi", source.LastConnection!.SentMessages); + await supervisor.StopAllAsync(); + } + + [Fact] + public async Task SendAsync_returns_NotConnected_when_no_socket() + { + var source = new FakeRustSocketSource(); + var (provider, supervisor, _) = CreateHarness(source); + await using var _p = provider; + + var result = await supervisor.SendAsync(10UL, Guid.NewGuid(), "hi", CancellationToken.None); + + Assert.Equal(TeamChatSendResult.NotConnected, result); + } + + private static async Task WaitUntilAsync(Func condition, CancellationToken ct) + { + while (!condition()) + { + ct.ThrowIfCancellationRequested(); + await Task.Delay(10, ct); + } + } +} From 999f33d3643d23ece2759ee7bf6d59975addd38d Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 00:09:30 +0200 Subject: [PATCH 06/19] harden(connections): guard team-message publish against shutdown dispose race Add an early _disposed check in PublishTeamMessageAsync to prevent reading _shutdown.Token after DisposeAsync has disposed it. Also add explanatory comments on the shutdown-token choice and the fire-and-forget pattern. --- .../Supervisor/ConnectionSupervisor.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index a222acf3..a817e82e 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -253,8 +253,12 @@ await PublishStatusAsync(key, ConnectionStatus.Connected, first.PlayerCount, cre .ConfigureAwait(false); #pragma warning disable RCS1163 // Unused 'sender': required by the EventHandler delegate shape. - void OnTeamMessage(object? sender, TeamChatLine line) => + void OnTeamMessage(object? sender, TeamChatLine line) + { + // Fire-and-forget: PublishTeamMessageAsync catches everything internally, so the discarded task + // never surfaces an unobserved exception. Team chat is low-volume, so unbounded concurrency is fine. _ = PublishTeamMessageAsync(key, activeSteamId, line); + } #pragma warning restore RCS1163 connection.TeamMessageReceived += OnTeamMessage; @@ -420,10 +424,17 @@ public async Task SendAsync( private async Task PublishTeamMessageAsync((ulong Guild, Guid Server) key, ulong activeSteamId, TeamChatLine line) { + if (_disposed) + { + return; + } + try { var evt = new TeamMessageReceivedEvent( key.Guild, key.Server, line.SteamId, line.Name, line.Message, line.SteamId == activeSteamId); + // Use the supervisor-wide shutdown token (not a per-connection ct): an inbound line should publish + // regardless of one connection's reconnect cycle, stopping only on global shutdown. await eventBus.PublishAsync(evt, _shutdown.Token).ConfigureAwait(false); } catch (OperationCanceledException) From 9cbb52f4c0a97222f1a2a2d5af264293a3bf4cac Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 00:12:12 +0200 Subject: [PATCH 07/19] feat(workspace): provision a per-server #teamchat channel (EN/FR) Co-Authored-By: Claude Sonnet 4.6 --- .../Localization/LocalizationCatalog.cs | 2 ++ .../Specs/ServerWorkspaceSpecProvider.cs | 2 ++ .../WorkspaceKeys.cs | 3 +++ .../Specs/ServerWorkspaceSpecProviderTests.cs | 21 +++++++++++++++++++ 4 files changed, 28 insertions(+) create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Specs/ServerWorkspaceSpecProviderTests.cs diff --git a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs index 65789731..827177b4 100644 --- a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs +++ b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs @@ -18,6 +18,7 @@ internal sealed class LocalizationCatalog ["channel.setup.name"] = "setup", ["channel.settings.name"] = "settings", ["channel.info.name"] = "info", + ["channel.teamchat.name"] = "teamchat", ["information.title"] = "RustPlusBot", ["information.body"] = "Connect your Rust+ account in #setup, then pair a server in-game to begin.", ["information.servers"] = "Servers registered: {0}", @@ -48,6 +49,7 @@ internal sealed class LocalizationCatalog ["channel.setup.name"] = "configuration", ["channel.settings.name"] = "parametres", ["channel.info.name"] = "info", + ["channel.teamchat.name"] = "tchat-equipe", ["information.title"] = "RustPlusBot", ["information.body"] = "Connectez votre compte Rust+ dans #configuration, puis appairez un serveur en jeu.", diff --git a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs index 9863e8c4..53c01f25 100644 --- a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs +++ b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs @@ -10,6 +10,8 @@ public IEnumerable GetChannelSpecs() => [ new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerInfo, "channel.info.name", ChannelPermissionProfile.ReadOnly, 0), + new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerTeamChat, "channel.teamchat.name", + ChannelPermissionProfile.Interactive, 1), ]; /// diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs index dc6f3c3f..4cc29159 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs @@ -14,6 +14,9 @@ internal static class WorkspaceChannelKeys /// Key for the per-server #info channel. public const string ServerInfo = "info"; + + /// Key for the per-server #teamchat channel. + public const string ServerTeamChat = "teamchat"; } /// Stable message keys persisted as ProvisionedMessage.MessageKey. diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Specs/ServerWorkspaceSpecProviderTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Specs/ServerWorkspaceSpecProviderTests.cs new file mode 100644 index 00000000..173a22b0 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Specs/ServerWorkspaceSpecProviderTests.cs @@ -0,0 +1,21 @@ +using RustPlusBot.Features.Workspace; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Features.Workspace.Specs; + +namespace RustPlusBot.Features.Workspace.Tests; + +public sealed class ServerWorkspaceSpecProviderTests +{ + [Fact] + public void Contributes_teamchat_as_interactive_per_server_channel() + { + var provider = new ServerWorkspaceSpecProvider(); + + var teamchat = provider.GetChannelSpecs() + .Single(c => c.Key == WorkspaceChannelKeys.ServerTeamChat); + + Assert.Equal(WorkspaceScope.PerServer, teamchat.Scope); + Assert.Equal(ChannelPermissionProfile.Interactive, teamchat.Permissions); + Assert.Equal("channel.teamchat.name", teamchat.NameKey); + } +} From 6a2a9b32de27428973f30cd1b5dcdd8458175cf0 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 00:17:08 +0200 Subject: [PATCH 08/19] test(connections): harden TeamChatSenderTests against parallel-load timeouts Co-Authored-By: Claude Sonnet 4.6 --- .../TeamChatSenderTests.cs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs b/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs index 7f53582e..70ddbdd3 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs @@ -88,7 +88,7 @@ public async Task Inbound_line_publishes_event_with_active_player_flag() await using var _ = provider; var serverId = await SeedServerWithActiveAsync(provider, steamId: 555UL); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var subscription = Task.Run(async () => { @@ -112,7 +112,14 @@ public async Task Inbound_line_publishes_event_with_active_player_flag() Assert.False(evt.FromActivePlayer); await supervisor.StopAllAsync(); - await subscription; + try + { + await subscription; + } + catch (OperationCanceledException) + { + // Subscription ended by shutdown; expected. + } } [Fact] @@ -123,7 +130,7 @@ public async Task SendAsync_routes_to_live_socket_when_connected() await using var _p = provider; var serverId = await SeedServerWithActiveAsync(provider, steamId: 555UL); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); From 24b25a9b9f62571ee32ee6d5439c7bc9dac0fd88 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 00:20:06 +0200 Subject: [PATCH 09/19] feat(persistence): add GetChannelsByKeyAsync to the workspace store Adds IWorkspaceStore.GetChannelsByKeyAsync and its EF Core implementation for a cross-guild reverse lookup of ProvisionedChannel rows by ChannelKey, needed by the upcoming team-chat channel locator (subsystem 3a Task 6). Co-Authored-By: Claude Sonnet 4.6 --- .../Workspace/IWorkspaceStore.cs | 8 ++ .../Workspace/WorkspaceStore.cs | 9 ++ .../Workspace/WorkspaceStoreByKeyTests.cs | 84 +++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreByKeyTests.cs diff --git a/src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs b/src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs index 55ad44df..9d8ed85c 100644 --- a/src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs +++ b/src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs @@ -78,4 +78,12 @@ Task> GetAllCategoriesAsync(ulong guildId, /// A cancellation token. /// The distinct guild snowflakes that have at least one provisioned category. Task> GetProvisionedGuildIdsAsync(CancellationToken cancellationToken = default); + + /// Gets every provisioned channel with the given key across all guilds and scopes. + /// The stable channel key (e.g. "teamchat"). + /// A cancellation token. + /// All provisioned channels with that key. + Task> GetChannelsByKeyAsync( + string channelKey, + CancellationToken cancellationToken = default); } diff --git a/src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs b/src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs index 1a7eaa7e..f48a039b 100644 --- a/src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs +++ b/src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs @@ -169,4 +169,13 @@ await context.ProvisionedCategories .Distinct() .ToListAsync(cancellationToken) .ConfigureAwait(false); + + /// + public async Task> GetChannelsByKeyAsync( + string channelKey, + CancellationToken cancellationToken = default) => + await context.ProvisionedChannels + .Where(c => c.ChannelKey == channelKey) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); } diff --git a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreByKeyTests.cs b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreByKeyTests.cs new file mode 100644 index 00000000..8992a415 --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreByKeyTests.cs @@ -0,0 +1,84 @@ +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Domain.Workspace; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Persistence.Tests.Workspace; + +public sealed class WorkspaceStoreByKeyTests +{ + private static WorkspaceStore NewStore(out BotDbContext context, out IDisposable cleanup) + { + var (ctx, connection) = SqliteContextFixture.Create(); + context = ctx; + cleanup = connection; + return new WorkspaceStore(ctx, new FixedClock(DateTimeOffset.UnixEpoch)); + } + + [Fact] + public async Task GetChannelsByKeyAsync_returns_all_rows_with_that_key() + { + var store = NewStore(out var context, out var cleanup); + using var _cleanup = cleanup; + + // RustServerId is a FK to RustServers, so insert a real server first. + var server = new RustServer { GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + + await store.SaveChannelAsync(new ProvisionedChannel + { + GuildId = 10UL, RustServerId = server.Id, ChannelKey = "teamchat", DiscordChannelId = 777UL, + }); + await store.SaveChannelAsync(new ProvisionedChannel + { + GuildId = 10UL, RustServerId = server.Id, ChannelKey = "info", DiscordChannelId = 888UL, + }); + + var rows = await store.GetChannelsByKeyAsync("teamchat"); + + Assert.Single(rows); + Assert.Equal(777UL, rows[0].DiscordChannelId); + } + + [Fact] + public async Task GetChannelsByKeyAsync_returns_rows_across_multiple_guilds() + { + var store = NewStore(out _, out var cleanup); + using var _cleanup = cleanup; + + await store.SaveChannelAsync(new ProvisionedChannel + { + GuildId = 1UL, RustServerId = null, ChannelKey = "teamchat", DiscordChannelId = 100UL, + }); + await store.SaveChannelAsync(new ProvisionedChannel + { + GuildId = 2UL, RustServerId = null, ChannelKey = "teamchat", DiscordChannelId = 200UL, + }); + await store.SaveChannelAsync(new ProvisionedChannel + { + GuildId = 1UL, RustServerId = null, ChannelKey = "info", DiscordChannelId = 300UL, + }); + + var rows = await store.GetChannelsByKeyAsync("teamchat"); + + Assert.Equal(2, rows.Count); + Assert.All(rows, r => Assert.Equal("teamchat", r.ChannelKey)); + } + + [Fact] + public async Task GetChannelsByKeyAsync_returns_empty_when_no_match() + { + var store = NewStore(out _, out var cleanup); + using var _cleanup = cleanup; + + var rows = await store.GetChannelsByKeyAsync("teamchat"); + + Assert.Empty(rows); + } + + private sealed class FixedClock(DateTimeOffset now) : IClock + { + public DateTimeOffset UtcNow { get; } = now; + } +} From 4123e4bee951bc9a6473c74980755cef290b873a Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 00:29:55 +0200 Subject: [PATCH 10/19] test(workspace): implement GetChannelsByKeyAsync on FakeWorkspaceStore FakeWorkspaceStore was missing the new IWorkspaceStore member added in the previous commit, causing the Workspace.Tests assembly to fail to build (CS0535) and silently skip all 38 tests. Adds the in-memory implementation filtered by ChannelKey over _channels.Values. Co-Authored-By: Claude Sonnet 4.6 --- .../Fakes/FakeWorkspaceStore.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs index e2a6f8a4..1e7c24da 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs @@ -39,6 +39,15 @@ public Task> GetChannelsAsync(ulong guildId, return Task.FromResult(list); } + public Task> GetChannelsByKeyAsync( + string channelKey, + CancellationToken cancellationToken = default) + { + IReadOnlyList result = + _channels.Values.Where(c => c.ChannelKey == channelKey).ToList(); + return Task.FromResult(result); + } + public Task SaveChannelAsync(ProvisionedChannel channel, CancellationToken cancellationToken = default) { _channels[$"{Scope(channel.GuildId, channel.RustServerId)}|{channel.ChannelKey}"] = channel; From 56f60f51a9dd447ccc9c22b9d559d9ad79154fef Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 00:35:46 +0200 Subject: [PATCH 11/19] feat(workspace): add ITeamChatChannelLocator with a TTL cache Co-Authored-By: Claude Sonnet 4.6 --- .../Locating/ITeamChatChannelLocator.cs | 18 ++++ .../Locating/TeamChatChannelLocator.cs | 90 +++++++++++++++++ .../WorkspaceServiceCollectionExtensions.cs | 4 + .../Locating/TeamChatChannelLocatorTests.cs | 98 +++++++++++++++++++ 4 files changed, 210 insertions(+) create mode 100644 src/RustPlusBot.Features.Workspace/Locating/ITeamChatChannelLocator.cs create mode 100644 src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs diff --git a/src/RustPlusBot.Features.Workspace/Locating/ITeamChatChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/ITeamChatChannelLocator.cs new file mode 100644 index 00000000..f8841004 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Locating/ITeamChatChannelLocator.cs @@ -0,0 +1,18 @@ +namespace RustPlusBot.Features.Workspace.Locating; + +/// Resolves the per-server #teamchat channel in both directions (for the chat bridge). +public interface ITeamChatChannelLocator +{ + /// Gets the Discord channel id of the #teamchat for (, ), or null. + /// The guild snowflake. + /// The server id. + /// A cancellation token. + /// The Discord channel id, or null if not provisioned. + Task GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken); + + /// Resolves a Discord channel id to its (guild, server) if it is a #teamchat channel, else null. + /// The Discord channel snowflake. + /// A cancellation token. + /// The owning (guild, server), or null if the channel is not a #teamchat. + Task<(ulong GuildId, Guid ServerId)?> ResolveAsync(ulong channelId, CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs new file mode 100644 index 00000000..9acc99cb --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs @@ -0,0 +1,90 @@ +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. +/// +/// Opens scopes for the scoped workspace store. +/// Drives the cache TTL. +internal sealed class TeamChatChannelLocator(IServiceScopeFactory scopeFactory, IClock clock) + : ITeamChatChannelLocator, IDisposable +{ + private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30); + private readonly SemaphoreSlim _refreshGate = new(1, 1); + + private DateTimeOffset _builtAt = DateTimeOffset.MinValue; + + private Dictionary _byChannelId = + new Dictionary(); + + private Dictionary<(ulong GuildId, Guid ServerId), ulong> _byServer = + new Dictionary<(ulong GuildId, Guid ServerId), ulong>(); + + /// + 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 + { + 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.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; + } + } + finally + { + _refreshGate.Release(); + } + } +} diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs index 40034e6b..d2e1405e 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using RustPlusBot.Discord; using RustPlusBot.Features.Workspace.Gateway; using RustPlusBot.Features.Workspace.Localization; +using RustPlusBot.Features.Workspace.Locating; using RustPlusBot.Features.Workspace.Messages; using RustPlusBot.Features.Workspace.Reconciler; using RustPlusBot.Features.Workspace.Registry; @@ -54,6 +55,9 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services) // Contribute this assembly's interaction modules to the Discord layer. services.AddSingleton(new InteractionModuleAssembly(typeof(WorkspaceServiceCollectionExtensions).Assembly)); + // Channel locator (singleton with TTL cache; IClock + IServiceScopeFactory provided by the host). + services.AddSingleton(); + services.AddHostedService(); return services; diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs new file mode 100644 index 00000000..0708c453 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs @@ -0,0 +1,98 @@ +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; + +public sealed class TeamChatChannelLocatorTests +{ + private static (TeamChatChannelLocator Locator, ServiceProvider Provider, string ConnectionString) + CreateLocator() + { + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + + var cs = $"DataSource=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 TeamChatChannelLocator(provider.GetRequiredService(), clock); + return (locator, provider, cs); + } + + 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 = "teamchat", + DiscordChannelId = 777UL, + CreatedAt = DateTimeOffset.UnixEpoch, + }); + await context.SaveChangesAsync(); + + return server.Id; + } + + [Fact] + public async Task GetChannelIdAsync_returns_provisioned_channel() + { + var (locator, provider, cs) = CreateLocator(); + await using var _ = provider; + var serverId = await SeedAsync(cs); + + var channelId = await locator.GetChannelIdAsync(10UL, serverId, CancellationToken.None); + + Assert.Equal(777UL, channelId); + } + + [Fact] + public async Task ResolveAsync_maps_channel_to_guild_and_server() + { + var (locator, provider, cs) = CreateLocator(); + await using var _ = provider; + var serverId = await SeedAsync(cs); + + var resolved = await locator.ResolveAsync(777UL, CancellationToken.None); + + Assert.NotNull(resolved); + Assert.Equal(10UL, resolved!.Value.GuildId); + Assert.Equal(serverId, resolved.Value.ServerId); + } + + [Fact] + public async Task ResolveAsync_returns_null_for_unknown_channel() + { + var (locator, provider, cs) = CreateLocator(); + await using var _p = provider; + await SeedAsync(cs); + + Assert.Null(await locator.ResolveAsync(123456UL, CancellationToken.None)); + } +} From 7b0e679fbb4275bedc6e5cda6a2a9799f4239e22 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 00:40:16 +0200 Subject: [PATCH 12/19] test(workspace): cover TeamChatChannelLocator TTL refresh; tidy field initializers Add Cache_refreshes_after_ttl_expires test proving the locator serves stale cache within the 30 s TTL and rebuilds after expiry. Change the two explicit Dictionary<...> field initializers to target-typed new(). Co-Authored-By: Claude Sonnet 4.6 --- .../Locating/TeamChatChannelLocator.cs | 6 +-- .../Locating/TeamChatChannelLocatorTests.cs | 51 ++++++++++++++++--- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs index 9acc99cb..03a745bf 100644 --- a/src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs +++ b/src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs @@ -19,11 +19,9 @@ internal sealed class TeamChatChannelLocator(IServiceScopeFactory scopeFactory, private DateTimeOffset _builtAt = DateTimeOffset.MinValue; - private Dictionary _byChannelId = - new Dictionary(); + private Dictionary _byChannelId = new(); - private Dictionary<(ulong GuildId, Guid ServerId), ulong> _byServer = - new Dictionary<(ulong GuildId, Guid ServerId), ulong>(); + private Dictionary<(ulong GuildId, Guid ServerId), ulong> _byServer = new(); /// public void Dispose() => _refreshGate.Dispose(); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs index 0708c453..45d175cd 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs @@ -13,7 +13,7 @@ namespace RustPlusBot.Features.Workspace.Tests.Locating; public sealed class TeamChatChannelLocatorTests { - private static (TeamChatChannelLocator Locator, ServiceProvider Provider, string ConnectionString) + private static (TeamChatChannelLocator Locator, ServiceProvider Provider, string ConnectionString, IClock Clock) CreateLocator() { var clock = Substitute.For(); @@ -35,7 +35,7 @@ private static (TeamChatChannelLocator Locator, ServiceProvider Provider, string var provider = services.BuildServiceProvider(); var locator = new TeamChatChannelLocator(provider.GetRequiredService(), clock); - return (locator, provider, cs); + return (locator, provider, cs, clock); } private static async Task SeedAsync(string connectionString) @@ -63,8 +63,8 @@ private static async Task SeedAsync(string connectionString) [Fact] public async Task GetChannelIdAsync_returns_provisioned_channel() { - var (locator, provider, cs) = CreateLocator(); - await using var _ = provider; + var (locator, provider, cs, _) = CreateLocator(); + await using var _p = provider; var serverId = await SeedAsync(cs); var channelId = await locator.GetChannelIdAsync(10UL, serverId, CancellationToken.None); @@ -75,8 +75,8 @@ public async Task GetChannelIdAsync_returns_provisioned_channel() [Fact] public async Task ResolveAsync_maps_channel_to_guild_and_server() { - var (locator, provider, cs) = CreateLocator(); - await using var _ = provider; + var (locator, provider, cs, _) = CreateLocator(); + await using var _p = provider; var serverId = await SeedAsync(cs); var resolved = await locator.ResolveAsync(777UL, CancellationToken.None); @@ -89,10 +89,47 @@ public async Task ResolveAsync_maps_channel_to_guild_and_server() [Fact] public async Task ResolveAsync_returns_null_for_unknown_channel() { - var (locator, provider, cs) = CreateLocator(); + var (locator, provider, cs, _) = CreateLocator(); await using var _p = provider; await SeedAsync(cs); Assert.Null(await locator.ResolveAsync(123456UL, CancellationToken.None)); } + + [Fact] + public async Task Cache_refreshes_after_ttl_expires() + { + 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 = "teamchat", + DiscordChannelId = 888UL, + 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(888UL, afterTtlResult); + } } From bbe34ef6fb9ccd83aef4931699fec1a2f50d2a9a Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 00:42:59 +0200 Subject: [PATCH 13/19] feat(chat): scaffold Features.Chat project + RelayDedupBuffer Co-Authored-By: Claude Sonnet 4.6 --- RustPlusBot.slnx | 2 + .../Relaying/RelayDedupBuffer.cs | 62 +++++++++++++++++++ .../RustPlusBot.Features.Chat.csproj | 22 +++++++ .../RelayDedupBufferTests.cs | 61 ++++++++++++++++++ .../RustPlusBot.Features.Chat.Tests.csproj | 19 ++++++ 5 files changed, 166 insertions(+) create mode 100644 src/RustPlusBot.Features.Chat/Relaying/RelayDedupBuffer.cs create mode 100644 src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj create mode 100644 tests/RustPlusBot.Features.Chat.Tests/RelayDedupBufferTests.cs create mode 100644 tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj diff --git a/RustPlusBot.slnx b/RustPlusBot.slnx index 52903ff4..29b829c5 100644 --- a/RustPlusBot.slnx +++ b/RustPlusBot.slnx @@ -6,6 +6,7 @@ + @@ -14,6 +15,7 @@ + diff --git a/src/RustPlusBot.Features.Chat/Relaying/RelayDedupBuffer.cs b/src/RustPlusBot.Features.Chat/Relaying/RelayDedupBuffer.cs new file mode 100644 index 00000000..397901dd --- /dev/null +++ b/src/RustPlusBot.Features.Chat/Relaying/RelayDedupBuffer.cs @@ -0,0 +1,62 @@ +using System.Collections.Concurrent; +using RustPlusBot.Abstractions.Time; + +namespace RustPlusBot.Features.Chat.Relaying; + +/// +/// Short-lived record of lines the bridge relayed into the game, keyed by (guild, server). When the bot's +/// active player echoes a relayed line back on the socket, matches and removes one +/// entry so the relay drops the echo instead of re-posting it to Discord. +/// +/// Drives entry expiry. +internal sealed class RelayDedupBuffer(IClock clock) +{ + private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(15); + private readonly ConcurrentDictionary<(ulong Guild, Guid Server), List> _entries = new(); + + /// Records that was relayed into the game for . + /// The (guild, server) the line was relayed to. + /// The exact formatted text that was sent. + public void Record((ulong Guild, Guid Server) key, string text) + { + var list = _entries.GetOrAdd(key, _ => []); + lock (list) + { + Prune(list); + list.Add(new Entry(text, clock.UtcNow + Ttl)); + } + } + + /// Removes and returns true for the first live entry exactly matching . + /// The (guild, server) the echo arrived on. + /// The echoed text to match. + /// True if a matching entry was consumed (the line is our own echo). + public bool TryConsume((ulong Guild, Guid Server) key, string text) + { + if (!_entries.TryGetValue(key, out var list)) + { + return false; + } + + lock (list) + { + Prune(list); + var index = list.FindIndex(e => string.Equals(e.Text, text, StringComparison.Ordinal)); + if (index < 0) + { + return false; + } + + list.RemoveAt(index); + return true; + } + } + + private void Prune(List list) + { + var now = clock.UtcNow; + list.RemoveAll(e => e.ExpiresAt <= now); + } + + private readonly record struct Entry(string Text, DateTimeOffset ExpiresAt); +} diff --git a/src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj b/src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj new file mode 100644 index 00000000..b86a9cd2 --- /dev/null +++ b/src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/RustPlusBot.Features.Chat.Tests/RelayDedupBufferTests.cs b/tests/RustPlusBot.Features.Chat.Tests/RelayDedupBufferTests.cs new file mode 100644 index 00000000..68a9ddb8 --- /dev/null +++ b/tests/RustPlusBot.Features.Chat.Tests/RelayDedupBufferTests.cs @@ -0,0 +1,61 @@ +using NSubstitute; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Chat.Relaying; + +namespace RustPlusBot.Features.Chat.Tests; + +public sealed class RelayDedupBufferTests +{ + private static (RelayDedupBuffer Buffer, IClock Clock) Build() + { + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + return (new RelayDedupBuffer(clock), clock); + } + + [Fact] + public void Consumes_a_matching_entry_once() + { + var (buffer, _) = Build(); + var key = (10UL, Guid.Empty); + buffer.Record(key, "[Alice] hi"); + + Assert.True(buffer.TryConsume(key, "[Alice] hi")); + Assert.False(buffer.TryConsume(key, "[Alice] hi")); // already consumed + } + + [Fact] + public void Does_not_match_other_text() + { + var (buffer, _) = Build(); + var key = (10UL, Guid.Empty); + buffer.Record(key, "[Alice] hi"); + + Assert.False(buffer.TryConsume(key, "[Bob] hi")); + } + + [Fact] + public void Entry_expires_after_ttl() + { + var (buffer, clock) = Build(); + var key = (10UL, Guid.Empty); + buffer.Record(key, "[Alice] hi"); + + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch + TimeSpan.FromMinutes(1)); + + Assert.False(buffer.TryConsume(key, "[Alice] hi")); + } + + [Fact] + public void Two_identical_records_consume_independently() + { + var (buffer, _) = Build(); + var key = (10UL, Guid.Empty); + buffer.Record(key, "[Alice] hi"); + buffer.Record(key, "[Alice] hi"); + + Assert.True(buffer.TryConsume(key, "[Alice] hi")); + Assert.True(buffer.TryConsume(key, "[Alice] hi")); + Assert.False(buffer.TryConsume(key, "[Alice] hi")); + } +} diff --git a/tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj b/tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj new file mode 100644 index 00000000..f4e51ce5 --- /dev/null +++ b/tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + From da531238ab2a6d2fc8356703554fcf03796201a7 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 00:48:06 +0200 Subject: [PATCH 14/19] feat(chat): add team-chat webhook poster seam + Discord.Net impl Co-Authored-By: Claude Sonnet 4.6 --- .../Webhooks/DiscordTeamChatWebhookPoster.cs | 77 +++++++++++++++++++ .../Webhooks/ITeamChatWebhookPoster.cs | 13 ++++ 2 files changed, 90 insertions(+) create mode 100644 src/RustPlusBot.Features.Chat/Webhooks/DiscordTeamChatWebhookPoster.cs create mode 100644 src/RustPlusBot.Features.Chat/Webhooks/ITeamChatWebhookPoster.cs diff --git a/src/RustPlusBot.Features.Chat/Webhooks/DiscordTeamChatWebhookPoster.cs b/src/RustPlusBot.Features.Chat/Webhooks/DiscordTeamChatWebhookPoster.cs new file mode 100644 index 00000000..dfac28ae --- /dev/null +++ b/src/RustPlusBot.Features.Chat/Webhooks/DiscordTeamChatWebhookPoster.cs @@ -0,0 +1,77 @@ +using System.Collections.Concurrent; +using Discord; +using Discord.Webhook; +using Discord.WebSocket; +using Microsoft.Extensions.Logging; + +namespace RustPlusBot.Features.Chat.Webhooks; + +/// +/// Real . Ensures one webhook named per channel +/// (created if missing, re-discovered by name on restart) and caches the webhook client. Untested integration shim. +/// +/// The Discord socket client. +/// The logger. +internal sealed partial class DiscordTeamChatWebhookPoster( + DiscordSocketClient client, + ILogger logger) + : ITeamChatWebhookPoster, IAsyncDisposable +{ + private const string WebhookName = "RustPlusBot TeamChat"; + private readonly ConcurrentDictionary _clients = new(); + + /// + public async Task PostAsync(ulong channelId, string username, string message, CancellationToken cancellationToken) + { + try + { + var webhook = await GetOrCreateClientAsync(channelId).ConfigureAwait(false); + if (webhook is null) + { + return; + } + + await webhook.SendMessageAsync(message, username: username, allowedMentions: AllowedMentions.None) + .ConfigureAwait(false); + } +#pragma warning disable CA1031 // Broad catch: a webhook post failure must not crash the relay loop. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogPostFailed(logger, ex, channelId); + } + } + + /// + public ValueTask DisposeAsync() + { + foreach (var c in _clients.Values) + { + c.Dispose(); + } + + return ValueTask.CompletedTask; + } + + private async Task GetOrCreateClientAsync(ulong channelId) + { + if (_clients.TryGetValue(channelId, out var cached)) + { + return cached; + } + + if (await client.GetChannelAsync(channelId).ConfigureAwait(false) is not ITextChannel channel) + { + return null; + } + + var hooks = await channel.GetWebhooksAsync().ConfigureAwait(false); + var hook = hooks.FirstOrDefault(h => h.Name == WebhookName) + ?? await channel.CreateWebhookAsync(WebhookName).ConfigureAwait(false); + var webhookClient = new DiscordWebhookClient(hook); + return _clients.GetOrAdd(channelId, webhookClient); + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Posting a team chat line to channel {ChannelId} failed.")] + private static partial void LogPostFailed(ILogger logger, Exception exception, ulong channelId); +} diff --git a/src/RustPlusBot.Features.Chat/Webhooks/ITeamChatWebhookPoster.cs b/src/RustPlusBot.Features.Chat/Webhooks/ITeamChatWebhookPoster.cs new file mode 100644 index 00000000..45e5cbf2 --- /dev/null +++ b/src/RustPlusBot.Features.Chat/Webhooks/ITeamChatWebhookPoster.cs @@ -0,0 +1,13 @@ +namespace RustPlusBot.Features.Chat.Webhooks; + +/// Posts an in-game team chat line into a Discord #teamchat channel as the player (via a webhook). +public interface ITeamChatWebhookPoster +{ + /// Posts to channel impersonating . + /// The target Discord channel snowflake. + /// The webhook display name (the in-game player's Steam name). + /// The message text. + /// A cancellation token. + /// A task that completes when the message has been posted. + Task PostAsync(ulong channelId, string username, string message, CancellationToken cancellationToken); +} From bfe736f2f83ec082281b5739b27b20b29d1b5026 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 00:52:26 +0200 Subject: [PATCH 15/19] feat(chat): add TeamChatRelay (game -> Discord with echo drop) --- .../Relaying/TeamChatRelay.cs | 36 +++++++++ .../TeamChatRelayTests.cs | 76 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs create mode 100644 tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs diff --git a/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs b/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs new file mode 100644 index 00000000..441ad69f --- /dev/null +++ b/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs @@ -0,0 +1,36 @@ +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Chat.Webhooks; +using RustPlusBot.Features.Workspace.Locating; + +namespace RustPlusBot.Features.Chat.Relaying; + +/// Relays one received in-game team message into its Discord #teamchat channel, dropping our own echoes. +/// Resolves the target #teamchat channel. +/// Posts the line via webhook. +/// Tracks lines the bridge relayed into the game so their echoes can be dropped. +internal sealed class TeamChatRelay( + ITeamChatChannelLocator locator, + ITeamChatWebhookPoster poster, + RelayDedupBuffer dedup) +{ + /// Handles one . + /// The received team message. + /// A cancellation token. + /// A task that completes when the line has been relayed or dropped. + public async Task RelayAsync(TeamMessageReceivedEvent evt, CancellationToken cancellationToken) + { + if (evt.FromActivePlayer && dedup.TryConsume((evt.GuildId, evt.ServerId), evt.Message)) + { + return; // Our own relayed line echoing back; do not re-post. + } + + var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) + .ConfigureAwait(false); + if (channelId is null) + { + return; + } + + await poster.PostAsync(channelId.Value, evt.SenderName, evt.Message, cancellationToken).ConfigureAwait(false); + } +} diff --git a/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs b/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs new file mode 100644 index 00000000..a5cd4fa6 --- /dev/null +++ b/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs @@ -0,0 +1,76 @@ +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; + +namespace RustPlusBot.Features.Chat.Tests; + +public sealed class TeamChatRelayTests +{ + private static (TeamChatRelay Relay, ITeamChatWebhookPoster Poster, RelayDedupBuffer Dedup, ITeamChatChannelLocator Locator) + Build() + { + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + var dedup = new RelayDedupBuffer(clock); + var poster = Substitute.For(); + var locator = Substitute.For(); + locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((ulong?)777UL); + var relay = new TeamChatRelay(locator, poster, dedup); + return (relay, poster, dedup, locator); + } + + [Fact] + public async Task Posts_a_normal_message_via_webhook() + { + var (relay, poster, _, _) = Build(); + var evt = new TeamMessageReceivedEvent(10UL, Guid.Empty, 999UL, "Bob", "hello", FromActivePlayer: false); + + await relay.RelayAsync(evt, CancellationToken.None); + + await poster.Received(1).PostAsync(777UL, "Bob", "hello", Arg.Any()); + } + + [Fact] + public async Task Drops_our_own_echo() + { + var (relay, poster, dedup, _) = Build(); + var key = (10UL, Guid.Empty); + dedup.Record(key, "[Alice] hello"); + var echo = new TeamMessageReceivedEvent(10UL, Guid.Empty, 555UL, "BotPlayer", "[Alice] hello", + FromActivePlayer: true); + + await relay.RelayAsync(echo, CancellationToken.None); + + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Posts_active_player_message_that_is_not_an_echo() + { + var (relay, poster, _, _) = Build(); + var evt = new TeamMessageReceivedEvent(10UL, Guid.Empty, 555UL, "BotPlayer", "genuine", FromActivePlayer: true); + + await relay.RelayAsync(evt, CancellationToken.None); + + await poster.Received(1).PostAsync(777UL, "BotPlayer", "genuine", Arg.Any()); + } + + [Fact] + public async Task Skips_when_channel_not_provisioned() + { + var (relay, poster, _, locator) = Build(); + locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((ulong?)null); + var evt = new TeamMessageReceivedEvent(10UL, Guid.Empty, 999UL, "Bob", "hello", FromActivePlayer: false); + + await relay.RelayAsync(evt, CancellationToken.None); + + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()); + } +} From 1c96bec63a635243faabca140f884f77e02ea5df Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 00:56:54 +0200 Subject: [PATCH 16/19] feat(chat): add TeamChatInboundProcessor (Discord -> game) Co-Authored-By: Claude Sonnet 4.6 --- .../Inbound/InboundMessage.cs | 8 ++ .../Inbound/InboundOutcome.cs | 14 +++ .../Inbound/TeamChatInboundProcessor.cs | 43 +++++++++ .../TeamChatInboundProcessorTests.cs | 93 +++++++++++++++++++ 4 files changed, 158 insertions(+) create mode 100644 src/RustPlusBot.Features.Chat/Inbound/InboundMessage.cs create mode 100644 src/RustPlusBot.Features.Chat/Inbound/InboundOutcome.cs create mode 100644 src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs create mode 100644 tests/RustPlusBot.Features.Chat.Tests/TeamChatInboundProcessorTests.cs diff --git a/src/RustPlusBot.Features.Chat/Inbound/InboundMessage.cs b/src/RustPlusBot.Features.Chat/Inbound/InboundMessage.cs new file mode 100644 index 00000000..8e263fab --- /dev/null +++ b/src/RustPlusBot.Features.Chat/Inbound/InboundMessage.cs @@ -0,0 +1,8 @@ +namespace RustPlusBot.Features.Chat.Inbound; + +/// A Discord message observed in a channel, reduced to what the bridge needs (no Discord.Net types). +/// True if the author is a bot or a webhook (ignored to avoid loops). +/// The channel the message was posted in. +/// The author's guild display name. +/// The message text. +internal sealed record InboundMessage(bool AuthorIsBotOrWebhook, ulong ChannelId, string DisplayName, string Content); diff --git a/src/RustPlusBot.Features.Chat/Inbound/InboundOutcome.cs b/src/RustPlusBot.Features.Chat/Inbound/InboundOutcome.cs new file mode 100644 index 00000000..30264e97 --- /dev/null +++ b/src/RustPlusBot.Features.Chat/Inbound/InboundOutcome.cs @@ -0,0 +1,14 @@ +namespace RustPlusBot.Features.Chat.Inbound; + +/// What the inbound processor did with a Discord message. +internal enum InboundOutcome +{ + /// Not a #teamchat message, empty, or a bot/webhook author — nothing relayed. + Ignored = 0, + + /// Relayed into the game. + Sent = 1, + + /// Belongs to a #teamchat but could not be relayed (no live socket or send failed). + Failed = 2, +} diff --git a/src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs b/src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs new file mode 100644 index 00000000..63eee0cf --- /dev/null +++ b/src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs @@ -0,0 +1,43 @@ +using System.Globalization; +using RustPlusBot.Features.Chat.Relaying; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Workspace.Locating; + +namespace RustPlusBot.Features.Chat.Inbound; + +/// Turns a Discord #teamchat message into an in-game relay (record-then-send), reporting the outcome. +/// Resolves whether/which server a channel maps to. +/// Relays the formatted line into the game. +/// Records the relayed line so its in-game echo can be dropped. +internal sealed class TeamChatInboundProcessor( + ITeamChatChannelLocator locator, + ITeamChatSender sender, + RelayDedupBuffer dedup) +{ + /// Processes one observed Discord message. + /// The reduced message. + /// A cancellation token. + /// What was done with the message. + public async Task ProcessAsync(InboundMessage message, CancellationToken cancellationToken) + { + if (message.AuthorIsBotOrWebhook || string.IsNullOrWhiteSpace(message.Content)) + { + return InboundOutcome.Ignored; + } + + var target = await locator.ResolveAsync(message.ChannelId, cancellationToken).ConfigureAwait(false); + if (target is not { } t) + { + return InboundOutcome.Ignored; + } + + var key = (t.GuildId, t.ServerId); + var text = string.Create(CultureInfo.InvariantCulture, $"[{message.DisplayName}] {message.Content}"); + + // Record BEFORE sending: the in-game echo can arrive before SendAsync returns. Unused entries expire. + dedup.Record(key, text); + var result = await sender.SendAsync(t.GuildId, t.ServerId, text, cancellationToken).ConfigureAwait(false); + + return result == TeamChatSendResult.Sent ? InboundOutcome.Sent : InboundOutcome.Failed; + } +} diff --git a/tests/RustPlusBot.Features.Chat.Tests/TeamChatInboundProcessorTests.cs b/tests/RustPlusBot.Features.Chat.Tests/TeamChatInboundProcessorTests.cs new file mode 100644 index 00000000..4751f024 --- /dev/null +++ b/tests/RustPlusBot.Features.Chat.Tests/TeamChatInboundProcessorTests.cs @@ -0,0 +1,93 @@ +using NSubstitute; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Chat.Inbound; +using RustPlusBot.Features.Chat.Relaying; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Workspace.Locating; + +namespace RustPlusBot.Features.Chat.Tests; + +public sealed class TeamChatInboundProcessorTests +{ + private static readonly Guid ServerId = Guid.NewGuid(); + + private static (TeamChatInboundProcessor Processor, ITeamChatSender Sender, RelayDedupBuffer Dedup) Build( + TeamChatSendResult sendResult = TeamChatSendResult.Sent) + { + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + var dedup = new RelayDedupBuffer(clock); + var sender = Substitute.For(); + sender.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(sendResult); + var locator = Substitute.For(); + locator.ResolveAsync(777UL, Arg.Any()).Returns(((ulong, Guid)?)(10UL, ServerId)); + locator.ResolveAsync(Arg.Is(c => c != 777UL), Arg.Any()) + .Returns(((ulong, Guid)?)null); + var processor = new TeamChatInboundProcessor(locator, sender, dedup); + return (processor, sender, dedup); + } + + [Fact] + public async Task Ignores_bot_or_webhook_authors() + { + var (processor, sender, _) = Build(); + var msg = new InboundMessage(AuthorIsBotOrWebhook: true, 777UL, "Alice", "hi"); + + var outcome = await processor.ProcessAsync(msg, CancellationToken.None); + + Assert.Equal(InboundOutcome.Ignored, outcome); + await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Ignores_non_teamchat_channels() + { + var (processor, sender, _) = Build(); + var msg = new InboundMessage(AuthorIsBotOrWebhook: false, 555UL, "Alice", "hi"); + + var outcome = await processor.ProcessAsync(msg, CancellationToken.None); + + Assert.Equal(InboundOutcome.Ignored, outcome); + await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Ignores_empty_content() + { + var (processor, sender, _) = Build(); + var msg = new InboundMessage(AuthorIsBotOrWebhook: false, 777UL, "Alice", " "); + + var outcome = await processor.ProcessAsync(msg, CancellationToken.None); + + Assert.Equal(InboundOutcome.Ignored, outcome); + await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Formats_records_and_sends() + { + var (processor, sender, dedup) = Build(); + var msg = new InboundMessage(AuthorIsBotOrWebhook: false, 777UL, "Alice", "hello"); + + var outcome = await processor.ProcessAsync(msg, CancellationToken.None); + + Assert.Equal(InboundOutcome.Sent, outcome); + await sender.Received(1).SendAsync(10UL, ServerId, "[Alice] hello", Arg.Any()); + Assert.True(dedup.TryConsume((10UL, ServerId), "[Alice] hello")); + } + + [Fact] + public async Task Reports_failed_when_not_connected() + { + var (processor, _, _) = Build(TeamChatSendResult.NotConnected); + var msg = new InboundMessage(AuthorIsBotOrWebhook: false, 777UL, "Alice", "hello"); + + var outcome = await processor.ProcessAsync(msg, CancellationToken.None); + + Assert.Equal(InboundOutcome.Failed, outcome); + } +} From b87ff728242d25b3b3b34d05b890ca2c5cd06df9 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 01:02:24 +0200 Subject: [PATCH 17/19] feat(chat): wire ChatHostedService, AddChat, and the Message Content intent Co-Authored-By: Claude Opus 4.8 --- .../DiscordServiceCollectionExtensions.cs | 3 +- .../ChatServiceCollectionExtensions.cs | 27 +++++ .../Hosting/ChatHostedService.cs | 114 ++++++++++++++++++ src/RustPlusBot.Host/Program.cs | 2 + src/RustPlusBot.Host/RustPlusBot.Host.csproj | 1 + .../ChatRegistrationTests.cs | 48 ++++++++ 6 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 src/RustPlusBot.Features.Chat/ChatServiceCollectionExtensions.cs create mode 100644 src/RustPlusBot.Features.Chat/Hosting/ChatHostedService.cs create mode 100644 tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs diff --git a/src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs b/src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs index d215e0d7..0168b492 100644 --- a/src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs @@ -18,7 +18,8 @@ public static IServiceCollection AddDiscordBot(this IServiceCollection services) var socketConfig = new DiscordSocketConfig { - GatewayIntents = GatewayIntents.Guilds, AlwaysDownloadUsers = false, + GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMessages | GatewayIntents.MessageContent, + AlwaysDownloadUsers = false, }; services.AddSingleton(socketConfig); diff --git a/src/RustPlusBot.Features.Chat/ChatServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Chat/ChatServiceCollectionExtensions.cs new file mode 100644 index 00000000..2e9f2f7a --- /dev/null +++ b/src/RustPlusBot.Features.Chat/ChatServiceCollectionExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Features.Chat.Hosting; +using RustPlusBot.Features.Chat.Inbound; +using RustPlusBot.Features.Chat.Relaying; +using RustPlusBot.Features.Chat.Webhooks; + +namespace RustPlusBot.Features.Chat; + +/// DI registration for the team chat bridge. +public static class ChatServiceCollectionExtensions +{ + /// Registers the dedup buffer, webhook poster, relay, inbound processor, and hosted service. + /// The service collection to add to. + /// The same service collection, for chaining. + public static IServiceCollection AddChat(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(); + + return services; + } +} diff --git a/src/RustPlusBot.Features.Chat/Hosting/ChatHostedService.cs b/src/RustPlusBot.Features.Chat/Hosting/ChatHostedService.cs new file mode 100644 index 00000000..629c4918 --- /dev/null +++ b/src/RustPlusBot.Features.Chat/Hosting/ChatHostedService.cs @@ -0,0 +1,114 @@ +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Chat.Inbound; +using RustPlusBot.Features.Chat.Relaying; + +namespace RustPlusBot.Features.Chat.Hosting; + +/// Runs the game→Discord relay loop and the Discord→game #teamchat listener. +/// The Discord socket client (for MessageReceived). +/// The in-process event bus. +/// Relays received team messages into Discord. +/// Processes Discord #teamchat messages for relay into the game. +/// The logger. +internal sealed partial class ChatHostedService( + DiscordSocketClient client, + IEventBus eventBus, + TeamChatRelay relay, + TeamChatInboundProcessor processor, + ILogger logger) : IHostedService, IDisposable +{ + private readonly CancellationTokenSource _cts = new(); + private Task? _relayLoop; + + /// + public void Dispose() => _cts.Dispose(); + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + client.MessageReceived += OnMessageReceivedAsync; + _relayLoop = Task.Run(() => ConsumeTeamMessagesAsync(_cts.Token), CancellationToken.None); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + client.MessageReceived -= OnMessageReceivedAsync; + await _cts.CancelAsync().ConfigureAwait(false); + if (_relayLoop is not null) + { + try + { +#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks — this is our own loop task, joined on stop. + await _relayLoop.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + } + } + + private async Task ConsumeTeamMessagesAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await relay.RelayAsync(evt, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogRelayLoopFaulted(logger, ex); + } + } + + private async Task OnMessageReceivedAsync(SocketMessage message) + { + try + { + var displayName = (message.Author as SocketGuildUser)?.DisplayName ?? message.Author.Username; + var inbound = new InboundMessage( + message.Author.IsBot || message.Author.IsWebhook, + message.Channel.Id, + displayName, + message.Content); + + var outcome = await processor.ProcessAsync(inbound, _cts.Token).ConfigureAwait(false); + if (outcome == InboundOutcome.Failed && message is IUserMessage userMessage) + { + await userMessage.AddReactionAsync(new Emoji("❌")).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a listener failure must not crash the gateway dispatcher. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogListenerFaulted(logger, ex); + } + } + + [LoggerMessage(Level = LogLevel.Error, Message = "Team message relay loop faulted.")] + private static partial void LogRelayLoopFaulted(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "Team chat inbound listener faulted.")] + private static partial void LogListenerFaulted(ILogger logger, Exception exception); +} diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs index 3f96477f..a18dd193 100644 --- a/src/RustPlusBot.Host/Program.cs +++ b/src/RustPlusBot.Host/Program.cs @@ -6,6 +6,7 @@ using RustPlusBot.Abstractions.Events; using RustPlusBot.Abstractions.Time; using RustPlusBot.Discord; +using RustPlusBot.Features.Chat; using RustPlusBot.Features.Connections; using RustPlusBot.Features.Pairing; using RustPlusBot.Features.Workspace; @@ -50,6 +51,7 @@ "Connections:HeartbeatTimeout must be less than HeartbeatInterval.") .ValidateOnStart(); builder.Services.AddConnections(); +builder.Services.AddChat(); var host = builder.Build(); diff --git a/src/RustPlusBot.Host/RustPlusBot.Host.csproj b/src/RustPlusBot.Host/RustPlusBot.Host.csproj index c8b36719..1bbc0120 100644 --- a/src/RustPlusBot.Host/RustPlusBot.Host.csproj +++ b/src/RustPlusBot.Host/RustPlusBot.Host.csproj @@ -21,5 +21,6 @@ + diff --git a/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs b/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs new file mode 100644 index 00000000..02bd325a --- /dev/null +++ b/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs @@ -0,0 +1,48 @@ +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NSubstitute; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Chat.Hosting; +using RustPlusBot.Features.Chat.Inbound; +using RustPlusBot.Features.Chat.Relaying; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Workspace.Locating; + +namespace RustPlusBot.Features.Chat.Tests; + +public sealed class ChatRegistrationTests +{ + [Fact] + public async Task Services_Resolve_And_Share_A_Single_Dedup_Buffer() + { + var services = new ServiceCollection(); + services.AddSingleton(new DiscordSocketClient()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddLogging(); + services.AddChat(); + + await using var provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateScopes = true + }); + + var relay = provider.GetRequiredService(); + var processor = provider.GetRequiredService(); + var hostedServices = provider.GetServices(); + + Assert.NotNull(relay); + Assert.NotNull(processor); + Assert.Contains(hostedServices, h => h is ChatHostedService); + + // The relay and the inbound processor MUST share the same dedup buffer instance for the + // record/consume echo pairing to work; the registration uses a single AddSingleton. + Assert.Same( + provider.GetRequiredService(), + provider.GetRequiredService()); + } +} From db507c336db2e6d2b7a208cae0b6e531421e7b9d Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 01:07:58 +0200 Subject: [PATCH 18/19] docs: document Message Content intent + Manage Webhooks for the chat bridge Add ## Discord application setup (team chat bridge) section to running-locally.md explaining the required privileged gateway intent and Manage Webhooks permission. Apply jb ReformatAndReorder to all chat-bridge source files (these were never jb-formatted before this finalization pass). Co-Authored-By: Claude Sonnet 4.6 --- docs/development/running-locally.md | 12 ++++ .../Webhooks/DiscordTeamChatWebhookPoster.cs | 22 +++---- .../Listening/ITeamChatSender.cs | 5 +- .../Listening/RustPlusSocketSource.cs | 6 +- .../Supervisor/ConnectionSupervisor.cs | 65 ++++++++++--------- .../Locating/TeamChatChannelLocator.cs | 3 +- .../TeamChatRelayTests.cs | 3 +- .../Fakes/FakeRustSocketSource.cs | 4 +- .../TeamChatSenderTests.cs | 16 +++-- .../Locating/TeamChatChannelLocatorTests.cs | 10 ++- .../Workspace/WorkspaceStoreByKeyTests.cs | 5 +- 11 files changed, 93 insertions(+), 58 deletions(-) diff --git a/docs/development/running-locally.md b/docs/development/running-locally.md index a797eb56..0f1440b9 100644 --- a/docs/development/running-locally.md +++ b/docs/development/running-locally.md @@ -25,6 +25,18 @@ A missing or empty `Discord:Token` makes the host fail fast at startup with a clear `OptionsValidationException`. +## Discord application setup (team chat bridge) + +The team chat bridge reads messages typed in each server's `#teamchat` channel, which requires a +privileged gateway intent: + +1. Open the [Discord Developer Portal](https://discord.com/developers/applications) → your application + → **Bot**. +2. Enable **Message Content Intent** (under *Privileged Gateway Intents*). No verification is required + while the bot is in fewer than 100 servers. +3. Ensure the bot's role/invite grants **Manage Webhooks** (used to post in-game lines as each player) + and **Send Messages** in the provisioned channels. + ## Clearing stale slash commands If the Discord application was previously used by another bot, leftover **global** commands can diff --git a/src/RustPlusBot.Features.Chat/Webhooks/DiscordTeamChatWebhookPoster.cs b/src/RustPlusBot.Features.Chat/Webhooks/DiscordTeamChatWebhookPoster.cs index dfac28ae..c8f67413 100644 --- a/src/RustPlusBot.Features.Chat/Webhooks/DiscordTeamChatWebhookPoster.cs +++ b/src/RustPlusBot.Features.Chat/Webhooks/DiscordTeamChatWebhookPoster.cs @@ -20,6 +20,17 @@ internal sealed partial class DiscordTeamChatWebhookPoster( private const string WebhookName = "RustPlusBot TeamChat"; private readonly ConcurrentDictionary _clients = new(); + /// + public ValueTask DisposeAsync() + { + foreach (var c in _clients.Values) + { + c.Dispose(); + } + + return ValueTask.CompletedTask; + } + /// public async Task PostAsync(ulong channelId, string username, string message, CancellationToken cancellationToken) { @@ -42,17 +53,6 @@ await webhook.SendMessageAsync(message, username: username, allowedMentions: All } } - /// - public ValueTask DisposeAsync() - { - foreach (var c in _clients.Values) - { - c.Dispose(); - } - - return ValueTask.CompletedTask; - } - private async Task GetOrCreateClientAsync(ulong channelId) { if (_clients.TryGetValue(channelId, out var cached)) diff --git a/src/RustPlusBot.Features.Connections/Listening/ITeamChatSender.cs b/src/RustPlusBot.Features.Connections/Listening/ITeamChatSender.cs index a0565e66..283dccd7 100644 --- a/src/RustPlusBot.Features.Connections/Listening/ITeamChatSender.cs +++ b/src/RustPlusBot.Features.Connections/Listening/ITeamChatSender.cs @@ -22,5 +22,8 @@ public interface ITeamChatSender /// The message text to relay. /// A cancellation token. /// The send result. - Task SendAsync(ulong guildId, Guid serverId, string message, CancellationToken cancellationToken); + Task SendAsync(ulong guildId, + Guid serverId, + string message, + CancellationToken cancellationToken); } diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index cbb0a558..26c84077 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -150,9 +150,6 @@ public async Task SendTeamMessageAsync(string message, CancellationToken cancell await _rustPlus.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false); } - private void OnTeamChatReceived(object? sender, RustPlusApi.Data.Events.TeamMessageEventArg e) => - TeamMessageReceived?.Invoke(this, new TeamChatLine(e.SteamId, e.Name, e.Message)); - public async ValueTask DisposeAsync() { _rustPlus.OnTeamChatReceived -= OnTeamChatReceived; @@ -170,6 +167,9 @@ public async ValueTask DisposeAsync() } } + private void OnTeamChatReceived(object? sender, RustPlusApi.Data.Events.TeamMessageEventArg e) => + TeamMessageReceived?.Invoke(this, new TeamChatLine(e.SteamId, e.Name, e.Message)); + [LoggerMessage(Level = LogLevel.Warning, Message = "Rust+ socket connect failed.")] private static partial void LogConnectFailed(ILogger logger, Exception ex); diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index a817e82e..9c1b2eeb 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -32,8 +32,8 @@ internal sealed partial class ConnectionSupervisor( ILogger logger) : IConnectionSupervisor, ITeamChatSender, IAsyncDisposable { private readonly ConcurrentDictionary<(ulong Guild, Guid Server), Handle> _connections = new(); - private readonly ConcurrentDictionary<(ulong Guild, Guid Server), LiveSocket> _liveSockets = new(); private readonly SemaphoreSlim _gate = new(1, 1); + private readonly ConcurrentDictionary<(ulong Guild, Guid Server), LiveSocket> _liveSockets = new(); private readonly ConnectionOptions _options = options.Value; private readonly CancellationTokenSource _shutdown = new(); private bool _disposed; @@ -125,6 +125,36 @@ public async Task StopAllAsync() } } + /// + public async Task SendAsync( + ulong guildId, + Guid serverId, + string message, + CancellationToken cancellationToken) + { + if (!_liveSockets.TryGetValue((guildId, serverId), out var live)) + { + return TeamChatSendResult.NotConnected; + } + + try + { + await live.Connection.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false); + return TeamChatSendResult.Sent; + } + catch (OperationCanceledException) + { + throw; + } +#pragma warning disable CA1031 // Broad catch: a failed relay send must not crash the caller; report Failed. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogSendFailed(logger, ex, serverId); + return TeamChatSendResult.Failed; + } + } + [LoggerMessage(Level = LogLevel.Error, Message = "Connection loop for server {ServerId} faulted.")] private static partial void LogLoopFaulted(ILogger logger, Exception exception, Guid serverId); @@ -392,36 +422,6 @@ await eventBus.PublishAsync(new ConnectionStatusChangedEvent(key.Guild, key.Serv /// True when a live socket is registered for (, ). internal bool HasLiveSocket(ulong guildId, Guid serverId) => _liveSockets.ContainsKey((guildId, serverId)); - /// - public async Task SendAsync( - ulong guildId, - Guid serverId, - string message, - CancellationToken cancellationToken) - { - if (!_liveSockets.TryGetValue((guildId, serverId), out var live)) - { - return TeamChatSendResult.NotConnected; - } - - try - { - await live.Connection.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false); - return TeamChatSendResult.Sent; - } - catch (OperationCanceledException) - { - throw; - } -#pragma warning disable CA1031 // Broad catch: a failed relay send must not crash the caller; report Failed. - catch (Exception ex) -#pragma warning restore CA1031 - { - LogSendFailed(logger, ex, serverId); - return TeamChatSendResult.Failed; - } - } - private async Task PublishTeamMessageAsync((ulong Guild, Guid Server) key, ulong activeSteamId, TeamChatLine line) { if (_disposed) @@ -452,7 +452,8 @@ private async Task PublishTeamMessageAsync((ulong Guild, Guid Server) key, ulong [LoggerMessage(Level = LogLevel.Warning, Message = "Relaying a message to team chat for server {ServerId} failed.")] private static partial void LogSendFailed(ILogger logger, Exception exception, Guid serverId); - [LoggerMessage(Level = LogLevel.Warning, Message = "Publishing a received team message for server {ServerId} failed.")] + [LoggerMessage(Level = LogLevel.Warning, + Message = "Publishing a received team message for server {ServerId} failed.")] private static partial void LogPublishTeamMessageFailed(ILogger logger, Exception exception, Guid serverId); private TimeSpan NextDelay(TimeSpan delay) => diff --git a/src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs index 03a745bf..b12bb8b3 100644 --- a/src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs +++ b/src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs @@ -34,7 +34,8 @@ internal sealed class TeamChatChannelLocator(IServiceScopeFactory scopeFactory, } /// - public async Task<(ulong GuildId, Guid ServerId)?> ResolveAsync(ulong channelId, CancellationToken cancellationToken) + 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; diff --git a/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs b/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs index a5cd4fa6..77dbac46 100644 --- a/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs +++ b/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs @@ -9,7 +9,8 @@ namespace RustPlusBot.Features.Chat.Tests; public sealed class TeamChatRelayTests { - private static (TeamChatRelay Relay, ITeamChatWebhookPoster Poster, RelayDedupBuffer Dedup, ITeamChatChannelLocator Locator) + private static (TeamChatRelay Relay, ITeamChatWebhookPoster Poster, RelayDedupBuffer Dedup, ITeamChatChannelLocator + Locator) Build() { var clock = Substitute.For(); diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs index 73b4d152..15c37196 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs @@ -81,10 +81,10 @@ public Task SendTeamMessageAsync(string message, CancellationToken cancellationT return Task.CompletedTask; } + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + /// Raises to simulate an inbound team chat line. /// The team chat line to raise. public void RaiseTeamMessage(TeamChatLine line) => TeamMessageReceived?.Invoke(this, line); - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } diff --git a/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs b/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs index 70ddbdd3..db488a2b 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs @@ -69,12 +69,19 @@ private static async Task SeedServerWithActiveAsync(ServiceProvider provid { using var scope = provider.CreateScope(); var ctx = scope.ServiceProvider.GetRequiredService(); - var server = new RustServer { GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 }; + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; ctx.RustServers.Add(server); ctx.PlayerCredentials.Add(new PlayerCredential { - GuildId = 10UL, RustServerId = server.Id, OwnerUserId = 1UL, SteamId = steamId, - ProtectedPlayerToken = "123", Status = CredentialStatus.Active, + GuildId = 10UL, + RustServerId = server.Id, + OwnerUserId = 1UL, + SteamId = steamId, + ProtectedPlayerToken = "123", + Status = CredentialStatus.Active, }); await ctx.SaveChangesAsync(); return server.Id; @@ -89,7 +96,8 @@ public async Task Inbound_line_publishes_event_with_active_player_flag() var serverId = await SeedServerWithActiveAsync(provider, steamId: 555UL); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var received = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var subscription = Task.Run(async () => { await foreach (var e in bus.SubscribeAsync(cts.Token)) diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs index 45d175cd..da96f408 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs @@ -43,7 +43,10 @@ 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 }; + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; context.RustServers.Add(server); await context.SaveChangesAsync(); @@ -109,7 +112,10 @@ public async Task Cache_refreshes_after_ttl_expires() // 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 }; + 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 diff --git a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreByKeyTests.cs b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreByKeyTests.cs index 8992a415..b4b9f462 100644 --- a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreByKeyTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreByKeyTests.cs @@ -22,7 +22,10 @@ public async Task GetChannelsByKeyAsync_returns_all_rows_with_that_key() using var _cleanup = cleanup; // RustServerId is a FK to RustServers, so insert a real server first. - var server = new RustServer { GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 }; + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; context.RustServers.Add(server); await context.SaveChangesAsync(); From e5005b7f003060fd9e893ebc3ff9ad54ed203178 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 01:31:33 +0200 Subject: [PATCH 19/19] fix(chat): address Copilot review on PR #8 - DiscordTeamChatWebhookPoster: dispose the redundant DiscordWebhookClient when it loses the GetOrAdd cache race (no longer leaks a client/HttpClient). - ChatRegistrationTests: replace the weak twice-resolve singleton assertion with a behavioral test proving the DI-resolved relay and processor share the same RelayDedupBuffer (processor records a line; the relay drops its echo). Co-Authored-By: Claude Opus 4.8 --- .../Webhooks/DiscordTeamChatWebhookPoster.cs | 9 ++- .../ChatRegistrationTests.cs | 71 ++++++++++++++----- 2 files changed, 61 insertions(+), 19 deletions(-) diff --git a/src/RustPlusBot.Features.Chat/Webhooks/DiscordTeamChatWebhookPoster.cs b/src/RustPlusBot.Features.Chat/Webhooks/DiscordTeamChatWebhookPoster.cs index c8f67413..dc65b10f 100644 --- a/src/RustPlusBot.Features.Chat/Webhooks/DiscordTeamChatWebhookPoster.cs +++ b/src/RustPlusBot.Features.Chat/Webhooks/DiscordTeamChatWebhookPoster.cs @@ -69,7 +69,14 @@ await webhook.SendMessageAsync(message, username: username, allowedMentions: All var hook = hooks.FirstOrDefault(h => h.Name == WebhookName) ?? await channel.CreateWebhookAsync(WebhookName).ConfigureAwait(false); var webhookClient = new DiscordWebhookClient(hook); - return _clients.GetOrAdd(channelId, webhookClient); + var stored = _clients.GetOrAdd(channelId, webhookClient); + if (!ReferenceEquals(stored, webhookClient)) + { + // Lost the race to cache the client (another caller cached one first); dispose the redundant one. + webhookClient.Dispose(); + } + + return stored; } [LoggerMessage(Level = LogLevel.Warning, Message = "Posting a team chat line to channel {ChannelId} failed.")] diff --git a/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs b/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs index 02bd325a..f6ef6036 100644 --- a/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs @@ -7,6 +7,7 @@ using RustPlusBot.Features.Chat.Hosting; using RustPlusBot.Features.Chat.Inbound; using RustPlusBot.Features.Chat.Relaying; +using RustPlusBot.Features.Chat.Webhooks; using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Workspace.Locating; @@ -15,34 +16,68 @@ namespace RustPlusBot.Features.Chat.Tests; public sealed class ChatRegistrationTests { [Fact] - public async Task Services_Resolve_And_Share_A_Single_Dedup_Buffer() + public async Task Services_resolve() { + await using var provider = BuildProvider(out _, out _, out _); + + Assert.NotNull(provider.GetRequiredService()); + Assert.NotNull(provider.GetRequiredService()); + Assert.Contains(provider.GetServices(), h => h is ChatHostedService); + } + + [Fact] + public async Task Relay_and_processor_share_the_dedup_buffer() + { + const ulong guild = 10UL; + const ulong channelId = 777UL; + var server = Guid.NewGuid(); + + await using var provider = BuildProvider(out var locator, out var sender, out var poster); + locator.ResolveAsync(channelId, Arg.Any()).Returns(((ulong, Guid)?)(guild, server)); + locator.GetChannelIdAsync(guild, server, Arg.Any()).Returns((ulong?)channelId); + sender.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(TeamChatSendResult.Sent); + + var processor = provider.GetRequiredService(); + var relay = provider.GetRequiredService(); + + // The processor records the exact relayed line "[Alice] hi" into the dedup buffer it was constructed with. + await processor.ProcessAsync(new InboundMessage(false, channelId, "Alice", "hi"), CancellationToken.None); + + // The bot player's echo of that same line must be dropped by the relay — which only happens if the + // relay was constructed with the SAME buffer instance (a per-service buffer would miss and post). + await relay.RelayAsync( + new TeamMessageReceivedEvent(guild, server, 555UL, "BotPlayer", "[Alice] hi", FromActivePlayer: true), + CancellationToken.None); + + await poster.DidNotReceive() + .PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + private static ServiceProvider BuildProvider( + out ITeamChatChannelLocator locator, + out ITeamChatSender sender, + out ITeamChatWebhookPoster poster) + { + locator = Substitute.For(); + sender = Substitute.For(); + poster = Substitute.For(); + var services = new ServiceCollection(); services.AddSingleton(new DiscordSocketClient()); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(Substitute.For()); - services.AddSingleton(Substitute.For()); + services.AddSingleton(locator); + services.AddSingleton(sender); services.AddLogging(); services.AddChat(); - await using var provider = services.BuildServiceProvider(new ServiceProviderOptions + // Override the real webhook poster so the relay's (non-)posting can be asserted. + services.AddSingleton(poster); + + return services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); - - var relay = provider.GetRequiredService(); - var processor = provider.GetRequiredService(); - var hostedServices = provider.GetServices(); - - Assert.NotNull(relay); - Assert.NotNull(processor); - Assert.Contains(hostedServices, h => h is ChatHostedService); - - // The relay and the inbound processor MUST share the same dedup buffer instance for the - // record/consume echo pairing to work; the registration uses a single AddSingleton. - Assert.Same( - provider.GetRequiredService(), - provider.GetRequiredService()); } }