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/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.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/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.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/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/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/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/src/RustPlusBot.Features.Chat/Webhooks/DiscordTeamChatWebhookPoster.cs b/src/RustPlusBot.Features.Chat/Webhooks/DiscordTeamChatWebhookPoster.cs
new file mode 100644
index 00000000..dc65b10f
--- /dev/null
+++ b/src/RustPlusBot.Features.Chat/Webhooks/DiscordTeamChatWebhookPoster.cs
@@ -0,0 +1,84 @@
+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 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)
+ {
+ 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);
+ }
+ }
+
+ 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);
+ 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.")]
+ 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);
+}
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/IRustServerConnection.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs
index cbbc5a97..f756a1ad 100644
--- a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs
+++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs
@@ -14,4 +14,14 @@ 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.
+ /// 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.
+ event EventHandler? TeamMessageReceived;
}
diff --git a/src/RustPlusBot.Features.Connections/Listening/ITeamChatSender.cs b/src/RustPlusBot.Features.Connections/Listening/ITeamChatSender.cs
new file mode 100644
index 00000000..283dccd7
--- /dev/null
+++ b/src/RustPlusBot.Features.Connections/Listening/ITeamChatSender.cs
@@ -0,0 +1,29 @@
+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/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
index 81090e54..26c84077 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,19 @@ 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.
+ // Intentional: send failures propagate to the caller (the supervisor classifies them), unlike the broad-catch probes.
+ await _rustPlus.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false);
+ }
+
public async ValueTask DisposeAsync()
{
+ _rustPlus.OnTeamChatReceived -= OnTeamChatReceived;
try
{
// CONFIRMED: RustPlusSocket implements IAsyncDisposable in 2.0.0-beta.1.
@@ -146,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/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/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
index 57edffb9..9c1b2eeb 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 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;
///
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();
@@ -115,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);
@@ -187,7 +227,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 +265,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 +282,43 @@ 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)
{
- await Task.Delay(_options.HeartbeatInterval, ct).ConfigureAwait(false);
- var beat = await connection.GetInfoAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false);
- switch (beat.Kind)
+ // 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;
+ _liveSockets[key] = new LiveSocket(connection, activeSteamId);
+ try
+ {
+ 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 +416,46 @@ 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));
+
+ 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)
+ {
+ // 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 +477,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/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/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..b12bb8b3
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs
@@ -0,0 +1,89 @@
+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();
+
+ private Dictionary<(ulong GuildId, Guid ServerId), ulong> _byServer = new();
+
+ ///
+ public void Dispose() => _refreshGate.Dispose();
+
+ ///
+ public async Task GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken)
+ {
+ await EnsureFreshAsync(cancellationToken).ConfigureAwait(false);
+ return _byServer.TryGetValue((guildId, serverId), out var id) ? id : null;
+ }
+
+ ///
+ public async Task<(ulong GuildId, Guid ServerId)?> ResolveAsync(ulong channelId,
+ CancellationToken cancellationToken)
+ {
+ await EnsureFreshAsync(cancellationToken).ConfigureAwait(false);
+ return _byChannelId.TryGetValue(channelId, out var pair) ? pair : null;
+ }
+
+ private async Task EnsureFreshAsync(CancellationToken cancellationToken)
+ {
+ if (clock.UtcNow - _builtAt < CacheTtl)
+ {
+ return;
+ }
+
+ await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ 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/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/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/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/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.Abstractions.Tests/Events/TeamMessageReceivedEventTests.cs b/tests/RustPlusBot.Abstractions.Tests/Events/TeamMessageReceivedEventTests.cs
new file mode 100644
index 00000000..405c4b51
--- /dev/null
+++ b/tests/RustPlusBot.Abstractions.Tests/Events/TeamMessageReceivedEventTests.cs
@@ -0,0 +1,19 @@
+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(Guid.Empty, evt.ServerId);
+ Assert.Equal(7656119UL, evt.SenderSteamId);
+ Assert.Equal("Alice", evt.SenderName);
+ Assert.Equal("hello", evt.Message);
+ Assert.True(evt.FromActivePlayer);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs b/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs
new file mode 100644
index 00000000..f6ef6036
--- /dev/null
+++ b/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs
@@ -0,0 +1,83 @@
+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.Chat.Webhooks;
+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()
+ {
+ 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(locator);
+ services.AddSingleton(sender);
+ services.AddLogging();
+ services.AddChat();
+
+ // Override the real webhook poster so the relay's (non-)posting can be asserted.
+ services.AddSingleton(poster);
+
+ return services.BuildServiceProvider(new ServiceProviderOptions
+ {
+ ValidateScopes = true
+ });
+ }
+}
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs b/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs
new file mode 100644
index 00000000..77dbac46
--- /dev/null
+++ b/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs
@@ -0,0 +1,77 @@
+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());
+ }
+}
diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs
index e9bb9047..15c37196 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;
+ }
+
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);
}
}
diff --git a/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs b/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs
new file mode 100644
index 00000000..db488a2b
--- /dev/null
+++ b/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs
@@ -0,0 +1,172 @@
+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(30));
+ 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();
+ try
+ {
+ await subscription;
+ }
+ catch (OperationCanceledException)
+ {
+ // Subscription ended by shutdown; expected.
+ }
+ }
+
+ [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(30));
+ 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);
+ }
+ }
+}
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;
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..da96f408
--- /dev/null
+++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs
@@ -0,0 +1,141 @@
+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, IClock Clock)
+ 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, clock);
+ }
+
+ private static async Task SeedAsync(string connectionString)
+ {
+ await using var context =
+ new BotDbContext(new DbContextOptionsBuilder().UseSqlite(connectionString).Options);
+
+ var server = new RustServer
+ {
+ GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015
+ };
+ context.RustServers.Add(server);
+ await context.SaveChangesAsync();
+
+ context.ProvisionedChannels.Add(new ProvisionedChannel
+ {
+ GuildId = 10UL,
+ RustServerId = server.Id,
+ ChannelKey = "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 _p = 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 _p = 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));
+ }
+
+ [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);
+ }
+}
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);
+ }
+}
diff --git a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreByKeyTests.cs b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreByKeyTests.cs
new file mode 100644
index 00000000..b4b9f462
--- /dev/null
+++ b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreByKeyTests.cs
@@ -0,0 +1,87 @@
+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;
+ }
+}