Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4374697
feat(abstractions): add TeamMessageReceivedEvent
HandyS11 Jun 15, 2026
46e15e9
test(abstractions): assert ServerId in TeamMessageReceivedEvent test
HandyS11 Jun 15, 2026
d358f55
feat(connections): add team-chat send + received-message to the socke…
HandyS11 Jun 15, 2026
175a8a1
docs(connections): note SendTeamMessageAsync surfaces send failures
HandyS11 Jun 15, 2026
8e4127d
feat(connections): publish TeamMessageReceivedEvent + implement ITeam…
HandyS11 Jun 15, 2026
999f33d
harden(connections): guard team-message publish against shutdown disp…
HandyS11 Jun 15, 2026
9cbb52f
feat(workspace): provision a per-server #teamchat channel (EN/FR)
HandyS11 Jun 15, 2026
6a2a9b3
test(connections): harden TeamChatSenderTests against parallel-load t…
HandyS11 Jun 15, 2026
24b25a9
feat(persistence): add GetChannelsByKeyAsync to the workspace store
HandyS11 Jun 15, 2026
4123e4b
test(workspace): implement GetChannelsByKeyAsync on FakeWorkspaceStore
HandyS11 Jun 15, 2026
56f60f5
feat(workspace): add ITeamChatChannelLocator with a TTL cache
HandyS11 Jun 15, 2026
7b0e679
test(workspace): cover TeamChatChannelLocator TTL refresh; tidy field…
HandyS11 Jun 15, 2026
bbe34ef
feat(chat): scaffold Features.Chat project + RelayDedupBuffer
HandyS11 Jun 15, 2026
da53123
feat(chat): add team-chat webhook poster seam + Discord.Net impl
HandyS11 Jun 15, 2026
bfe736f
feat(chat): add TeamChatRelay (game -> Discord with echo drop)
HandyS11 Jun 15, 2026
1c96bec
feat(chat): add TeamChatInboundProcessor (Discord -> game)
HandyS11 Jun 15, 2026
b87ff72
feat(chat): wire ChatHostedService, AddChat, and the Message Content …
HandyS11 Jun 15, 2026
db507c3
docs: document Message Content intent + Manage Webhooks for the chat …
HandyS11 Jun 15, 2026
e5005b7
fix(chat): address Copilot review on PR #8
HandyS11 Jun 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions RustPlusBot.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<Project Path="src/RustPlusBot.Host/RustPlusBot.Host.csproj" />
<Project Path="src/RustPlusBot.Features.Connections/RustPlusBot.Features.Connections.csproj" />
<Project Path="src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj" />
<Project Path="src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj" />
<Project Path="src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj" />
<Project Path="src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj" />
</Folder>
Expand All @@ -14,6 +15,7 @@
<Project Path="tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj" />
<Project Path="tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj" />
</Folder>
</Solution>
12 changes: 12 additions & 0 deletions docs/development/running-locally.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions src/RustPlusBot.Abstractions/Events/TeamMessageReceivedEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace RustPlusBot.Abstractions.Events;

/// <summary>Published when an in-game team chat line is received, so the chat bridge can relay it to Discord.</summary>
/// <param name="GuildId">The owning guild snowflake.</param>
/// <param name="ServerId">The server whose socket received the line.</param>
/// <param name="SenderSteamId">The Steam64 id of the in-game sender.</param>
/// <param name="SenderName">The in-game display name of the sender.</param>
/// <param name="Message">The message text.</param>
/// <param name="FromActivePlayer">True when the sender is the bot's active player (used to drop relay echoes).</param>
public sealed record TeamMessageReceivedEvent(
ulong GuildId,
Guid ServerId,
ulong SenderSteamId,
string SenderName,
string Message,
bool FromActivePlayer);
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions src/RustPlusBot.Features.Chat/ChatServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>DI registration for the team chat bridge.</summary>
public static class ChatServiceCollectionExtensions
{
/// <summary>Registers the dedup buffer, webhook poster, relay, inbound processor, and hosted service.</summary>
/// <param name="services">The service collection to add to.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddChat(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);

services.AddSingleton<RelayDedupBuffer>();
services.AddSingleton<ITeamChatWebhookPoster, DiscordTeamChatWebhookPoster>();
services.AddSingleton<TeamChatRelay>();
services.AddSingleton<TeamChatInboundProcessor>();
services.AddHostedService<ChatHostedService>();

return services;
}
}
114 changes: 114 additions & 0 deletions src/RustPlusBot.Features.Chat/Hosting/ChatHostedService.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Runs the game→Discord relay loop and the Discord→game #teamchat listener.</summary>
/// <param name="client">The Discord socket client (for MessageReceived).</param>
/// <param name="eventBus">The in-process event bus.</param>
/// <param name="relay">Relays received team messages into Discord.</param>
/// <param name="processor">Processes Discord #teamchat messages for relay into the game.</param>
/// <param name="logger">The logger.</param>
internal sealed partial class ChatHostedService(
DiscordSocketClient client,
IEventBus eventBus,
TeamChatRelay relay,
TeamChatInboundProcessor processor,
ILogger<ChatHostedService> logger) : IHostedService, IDisposable
{
private readonly CancellationTokenSource _cts = new();
private Task? _relayLoop;

/// <inheritdoc />
public void Dispose() => _cts.Dispose();

/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
client.MessageReceived += OnMessageReceivedAsync;
_relayLoop = Task.Run(() => ConsumeTeamMessagesAsync(_cts.Token), CancellationToken.None);
return Task.CompletedTask;
}

/// <inheritdoc />
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<TeamMessageReceivedEvent>(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);
}
8 changes: 8 additions & 0 deletions src/RustPlusBot.Features.Chat/Inbound/InboundMessage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace RustPlusBot.Features.Chat.Inbound;

/// <summary>A Discord message observed in a channel, reduced to what the bridge needs (no Discord.Net types).</summary>
/// <param name="AuthorIsBotOrWebhook">True if the author is a bot or a webhook (ignored to avoid loops).</param>
/// <param name="ChannelId">The channel the message was posted in.</param>
/// <param name="DisplayName">The author's guild display name.</param>
/// <param name="Content">The message text.</param>
internal sealed record InboundMessage(bool AuthorIsBotOrWebhook, ulong ChannelId, string DisplayName, string Content);
14 changes: 14 additions & 0 deletions src/RustPlusBot.Features.Chat/Inbound/InboundOutcome.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace RustPlusBot.Features.Chat.Inbound;

/// <summary>What the inbound processor did with a Discord message.</summary>
internal enum InboundOutcome
{
/// <summary>Not a #teamchat message, empty, or a bot/webhook author — nothing relayed.</summary>
Ignored = 0,

/// <summary>Relayed into the game.</summary>
Sent = 1,

/// <summary>Belongs to a #teamchat but could not be relayed (no live socket or send failed).</summary>
Failed = 2,
}
43 changes: 43 additions & 0 deletions src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Turns a Discord #teamchat message into an in-game relay (record-then-send), reporting the outcome.</summary>
/// <param name="locator">Resolves whether/which server a channel maps to.</param>
/// <param name="sender">Relays the formatted line into the game.</param>
/// <param name="dedup">Records the relayed line so its in-game echo can be dropped.</param>
internal sealed class TeamChatInboundProcessor(
ITeamChatChannelLocator locator,
ITeamChatSender sender,
RelayDedupBuffer dedup)
{
/// <summary>Processes one observed Discord message.</summary>
/// <param name="message">The reduced message.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>What was done with the message.</returns>
public async Task<InboundOutcome> 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;
}
}
62 changes: 62 additions & 0 deletions src/RustPlusBot.Features.Chat/Relaying/RelayDedupBuffer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System.Collections.Concurrent;
using RustPlusBot.Abstractions.Time;

namespace RustPlusBot.Features.Chat.Relaying;

/// <summary>
/// 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, <see cref="TryConsume"/> matches and removes one
/// entry so the relay drops the echo instead of re-posting it to Discord.
/// </summary>
/// <param name="clock">Drives entry expiry.</param>
internal sealed class RelayDedupBuffer(IClock clock)
{
private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(15);
private readonly ConcurrentDictionary<(ulong Guild, Guid Server), List<Entry>> _entries = new();

/// <summary>Records that <paramref name="text"/> was relayed into the game for <paramref name="key"/>.</summary>
/// <param name="key">The (guild, server) the line was relayed to.</param>
/// <param name="text">The exact formatted text that was sent.</param>
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));
}
}

/// <summary>Removes and returns true for the first live entry exactly matching <paramref name="text"/>.</summary>
/// <param name="key">The (guild, server) the echo arrived on.</param>
/// <param name="text">The echoed text to match.</param>
/// <returns>True if a matching entry was consumed (the line is our own echo).</returns>
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<Entry> list)
{
var now = clock.UtcNow;
list.RemoveAll(e => e.ExpiresAt <= now);
}

private readonly record struct Entry(string Text, DateTimeOffset ExpiresAt);
}
36 changes: 36 additions & 0 deletions src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Features.Chat.Webhooks;
using RustPlusBot.Features.Workspace.Locating;

namespace RustPlusBot.Features.Chat.Relaying;

/// <summary>Relays one received in-game team message into its Discord #teamchat channel, dropping our own echoes.</summary>
/// <param name="locator">Resolves the target #teamchat channel.</param>
/// <param name="poster">Posts the line via webhook.</param>
/// <param name="dedup">Tracks lines the bridge relayed into the game so their echoes can be dropped.</param>
internal sealed class TeamChatRelay(
ITeamChatChannelLocator locator,
ITeamChatWebhookPoster poster,
RelayDedupBuffer dedup)
{
/// <summary>Handles one <see cref="TeamMessageReceivedEvent"/>.</summary>
/// <param name="evt">The received team message.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A task that completes when the line has been relayed or dropped.</returns>
public async Task RelayAsync(TeamMessageReceivedEvent evt, CancellationToken cancellationToken)
{
if (evt.FromActivePlayer && 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);
}
}
22 changes: 22 additions & 0 deletions src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">

<ItemGroup>
<InternalsVisibleTo Include="RustPlusBot.Features.Chat.Tests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\RustPlusBot.Discord\RustPlusBot.Discord.csproj" />
<ProjectReference Include="..\RustPlusBot.Persistence\RustPlusBot.Persistence.csproj" />
<ProjectReference Include="..\RustPlusBot.Domain\RustPlusBot.Domain.csproj" />
<ProjectReference Include="..\RustPlusBot.Abstractions\RustPlusBot.Abstractions.csproj" />
<ProjectReference Include="..\RustPlusBot.Features.Workspace\RustPlusBot.Features.Workspace.csproj" />
<ProjectReference Include="..\RustPlusBot.Features.Connections\RustPlusBot.Features.Connections.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Discord.Net" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
</ItemGroup>

</Project>
Loading
Loading