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; + } +}