Skip to content

Commit 8132240

Browse files
HandyS11claude
andauthored
Subsystem 3a: team chat bridge (two-way #teamchat ↔ in-game) (#8)
* feat(abstractions): add TeamMessageReceivedEvent * test(abstractions): assert ServerId in TeamMessageReceivedEvent test * feat(connections): add team-chat send + received-message to the socket seam Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(connections): note SendTeamMessageAsync surfaces send failures Unlike ConnectAsync/GetInfoAsync which use broad catches to return typed outcomes, SendTeamMessageAsync intentionally lets exceptions propagate so the upstream supervisor can classify them as a failed send result. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(connections): publish TeamMessageReceivedEvent + implement ITeamChatSender Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * harden(connections): guard team-message publish against shutdown dispose race Add an early _disposed check in PublishTeamMessageAsync to prevent reading _shutdown.Token after DisposeAsync has disposed it. Also add explanatory comments on the shutdown-token choice and the fire-and-forget pattern. * feat(workspace): provision a per-server #teamchat channel (EN/FR) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(connections): harden TeamChatSenderTests against parallel-load timeouts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(persistence): add GetChannelsByKeyAsync to the workspace store Adds IWorkspaceStore.GetChannelsByKeyAsync and its EF Core implementation for a cross-guild reverse lookup of ProvisionedChannel rows by ChannelKey, needed by the upcoming team-chat channel locator (subsystem 3a Task 6). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(workspace): implement GetChannelsByKeyAsync on FakeWorkspaceStore FakeWorkspaceStore was missing the new IWorkspaceStore member added in the previous commit, causing the Workspace.Tests assembly to fail to build (CS0535) and silently skip all 38 tests. Adds the in-memory implementation filtered by ChannelKey over _channels.Values. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(workspace): add ITeamChatChannelLocator with a TTL cache Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(workspace): cover TeamChatChannelLocator TTL refresh; tidy field initializers Add Cache_refreshes_after_ttl_expires test proving the locator serves stale cache within the 30 s TTL and rebuilds after expiry. Change the two explicit Dictionary<...> field initializers to target-typed new(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(chat): scaffold Features.Chat project + RelayDedupBuffer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(chat): add team-chat webhook poster seam + Discord.Net impl Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(chat): add TeamChatRelay (game -> Discord with echo drop) * feat(chat): add TeamChatInboundProcessor (Discord -> game) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(chat): wire ChatHostedService, AddChat, and the Message Content intent Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: document Message Content intent + Manage Webhooks for the chat bridge Add ## Discord application setup (team chat bridge) section to running-locally.md explaining the required privileged gateway intent and Manage Webhooks permission. Apply jb ReformatAndReorder to all chat-bridge source files (these were never jb-formatted before this finalization pass). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(chat): address Copilot review on PR #8 - DiscordTeamChatWebhookPoster: dispose the redundant DiscordWebhookClient when it loses the GetOrAdd cache race (no longer leaks a client/HttpClient). - ChatRegistrationTests: replace the weak twice-resolve singleton assertion with a behavioral test proving the DI-resolved relay and processor share the same RelayDedupBuffer (processor records a line; the relay drops its echo). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 44d3447 commit 8132240

42 files changed

Lines changed: 1590 additions & 20 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

RustPlusBot.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
<Project Path="src/RustPlusBot.Host/RustPlusBot.Host.csproj" />
77
<Project Path="src/RustPlusBot.Features.Connections/RustPlusBot.Features.Connections.csproj" />
88
<Project Path="src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj" />
9+
<Project Path="src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj" />
910
<Project Path="src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj" />
1011
<Project Path="src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj" />
1112
</Folder>
@@ -14,6 +15,7 @@
1415
<Project Path="tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj" />
1516
<Project Path="tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj" />
1617
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
18+
<Project Path="tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj" />
1719
<Project Path="tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj" />
1820
</Folder>
1921
</Solution>

docs/development/running-locally.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@
2525

2626
A missing or empty `Discord:Token` makes the host fail fast at startup with a clear `OptionsValidationException`.
2727

28+
## Discord application setup (team chat bridge)
29+
30+
The team chat bridge reads messages typed in each server's `#teamchat` channel, which requires a
31+
privileged gateway intent:
32+
33+
1. Open the [Discord Developer Portal](https://discord.com/developers/applications) → your application
34+
**Bot**.
35+
2. Enable **Message Content Intent** (under *Privileged Gateway Intents*). No verification is required
36+
while the bot is in fewer than 100 servers.
37+
3. Ensure the bot's role/invite grants **Manage Webhooks** (used to post in-game lines as each player)
38+
and **Send Messages** in the provisioned channels.
39+
2840
## Clearing stale slash commands
2941

3042
If the Discord application was previously used by another bot, leftover **global** commands can
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace RustPlusBot.Abstractions.Events;
2+
3+
/// <summary>Published when an in-game team chat line is received, so the chat bridge can relay it to Discord.</summary>
4+
/// <param name="GuildId">The owning guild snowflake.</param>
5+
/// <param name="ServerId">The server whose socket received the line.</param>
6+
/// <param name="SenderSteamId">The Steam64 id of the in-game sender.</param>
7+
/// <param name="SenderName">The in-game display name of the sender.</param>
8+
/// <param name="Message">The message text.</param>
9+
/// <param name="FromActivePlayer">True when the sender is the bot's active player (used to drop relay echoes).</param>
10+
public sealed record TeamMessageReceivedEvent(
11+
ulong GuildId,
12+
Guid ServerId,
13+
ulong SenderSteamId,
14+
string SenderName,
15+
string Message,
16+
bool FromActivePlayer);

src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ public static IServiceCollection AddDiscordBot(this IServiceCollection services)
1818

1919
var socketConfig = new DiscordSocketConfig
2020
{
21-
GatewayIntents = GatewayIntents.Guilds, AlwaysDownloadUsers = false,
21+
GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMessages | GatewayIntents.MessageContent,
22+
AlwaysDownloadUsers = false,
2223
};
2324

2425
services.AddSingleton(socketConfig);
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using RustPlusBot.Features.Chat.Hosting;
3+
using RustPlusBot.Features.Chat.Inbound;
4+
using RustPlusBot.Features.Chat.Relaying;
5+
using RustPlusBot.Features.Chat.Webhooks;
6+
7+
namespace RustPlusBot.Features.Chat;
8+
9+
/// <summary>DI registration for the team chat bridge.</summary>
10+
public static class ChatServiceCollectionExtensions
11+
{
12+
/// <summary>Registers the dedup buffer, webhook poster, relay, inbound processor, and hosted service.</summary>
13+
/// <param name="services">The service collection to add to.</param>
14+
/// <returns>The same service collection, for chaining.</returns>
15+
public static IServiceCollection AddChat(this IServiceCollection services)
16+
{
17+
ArgumentNullException.ThrowIfNull(services);
18+
19+
services.AddSingleton<RelayDedupBuffer>();
20+
services.AddSingleton<ITeamChatWebhookPoster, DiscordTeamChatWebhookPoster>();
21+
services.AddSingleton<TeamChatRelay>();
22+
services.AddSingleton<TeamChatInboundProcessor>();
23+
services.AddHostedService<ChatHostedService>();
24+
25+
return services;
26+
}
27+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
using Discord;
2+
using Discord.WebSocket;
3+
using Microsoft.Extensions.Hosting;
4+
using Microsoft.Extensions.Logging;
5+
using RustPlusBot.Abstractions.Events;
6+
using RustPlusBot.Features.Chat.Inbound;
7+
using RustPlusBot.Features.Chat.Relaying;
8+
9+
namespace RustPlusBot.Features.Chat.Hosting;
10+
11+
/// <summary>Runs the game→Discord relay loop and the Discord→game #teamchat listener.</summary>
12+
/// <param name="client">The Discord socket client (for MessageReceived).</param>
13+
/// <param name="eventBus">The in-process event bus.</param>
14+
/// <param name="relay">Relays received team messages into Discord.</param>
15+
/// <param name="processor">Processes Discord #teamchat messages for relay into the game.</param>
16+
/// <param name="logger">The logger.</param>
17+
internal sealed partial class ChatHostedService(
18+
DiscordSocketClient client,
19+
IEventBus eventBus,
20+
TeamChatRelay relay,
21+
TeamChatInboundProcessor processor,
22+
ILogger<ChatHostedService> logger) : IHostedService, IDisposable
23+
{
24+
private readonly CancellationTokenSource _cts = new();
25+
private Task? _relayLoop;
26+
27+
/// <inheritdoc />
28+
public void Dispose() => _cts.Dispose();
29+
30+
/// <inheritdoc />
31+
public Task StartAsync(CancellationToken cancellationToken)
32+
{
33+
client.MessageReceived += OnMessageReceivedAsync;
34+
_relayLoop = Task.Run(() => ConsumeTeamMessagesAsync(_cts.Token), CancellationToken.None);
35+
return Task.CompletedTask;
36+
}
37+
38+
/// <inheritdoc />
39+
public async Task StopAsync(CancellationToken cancellationToken)
40+
{
41+
client.MessageReceived -= OnMessageReceivedAsync;
42+
await _cts.CancelAsync().ConfigureAwait(false);
43+
if (_relayLoop is not null)
44+
{
45+
try
46+
{
47+
#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks — this is our own loop task, joined on stop.
48+
await _relayLoop.ConfigureAwait(false);
49+
#pragma warning restore VSTHRD003
50+
}
51+
catch (OperationCanceledException)
52+
{
53+
// Expected on shutdown.
54+
}
55+
}
56+
}
57+
58+
private async Task ConsumeTeamMessagesAsync(CancellationToken cancellationToken)
59+
{
60+
try
61+
{
62+
await foreach (var evt in eventBus.SubscribeAsync<TeamMessageReceivedEvent>(cancellationToken)
63+
.ConfigureAwait(false))
64+
{
65+
await relay.RelayAsync(evt, cancellationToken).ConfigureAwait(false);
66+
}
67+
}
68+
catch (OperationCanceledException)
69+
{
70+
// Shutting down.
71+
}
72+
#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
73+
catch (Exception ex)
74+
#pragma warning restore CA1031
75+
{
76+
LogRelayLoopFaulted(logger, ex);
77+
}
78+
}
79+
80+
private async Task OnMessageReceivedAsync(SocketMessage message)
81+
{
82+
try
83+
{
84+
var displayName = (message.Author as SocketGuildUser)?.DisplayName ?? message.Author.Username;
85+
var inbound = new InboundMessage(
86+
message.Author.IsBot || message.Author.IsWebhook,
87+
message.Channel.Id,
88+
displayName,
89+
message.Content);
90+
91+
var outcome = await processor.ProcessAsync(inbound, _cts.Token).ConfigureAwait(false);
92+
if (outcome == InboundOutcome.Failed && message is IUserMessage userMessage)
93+
{
94+
await userMessage.AddReactionAsync(new Emoji("❌")).ConfigureAwait(false);
95+
}
96+
}
97+
catch (OperationCanceledException)
98+
{
99+
// Shutting down.
100+
}
101+
#pragma warning disable CA1031 // Broad catch: a listener failure must not crash the gateway dispatcher.
102+
catch (Exception ex)
103+
#pragma warning restore CA1031
104+
{
105+
LogListenerFaulted(logger, ex);
106+
}
107+
}
108+
109+
[LoggerMessage(Level = LogLevel.Error, Message = "Team message relay loop faulted.")]
110+
private static partial void LogRelayLoopFaulted(ILogger logger, Exception exception);
111+
112+
[LoggerMessage(Level = LogLevel.Error, Message = "Team chat inbound listener faulted.")]
113+
private static partial void LogListenerFaulted(ILogger logger, Exception exception);
114+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace RustPlusBot.Features.Chat.Inbound;
2+
3+
/// <summary>A Discord message observed in a channel, reduced to what the bridge needs (no Discord.Net types).</summary>
4+
/// <param name="AuthorIsBotOrWebhook">True if the author is a bot or a webhook (ignored to avoid loops).</param>
5+
/// <param name="ChannelId">The channel the message was posted in.</param>
6+
/// <param name="DisplayName">The author's guild display name.</param>
7+
/// <param name="Content">The message text.</param>
8+
internal sealed record InboundMessage(bool AuthorIsBotOrWebhook, ulong ChannelId, string DisplayName, string Content);
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
namespace RustPlusBot.Features.Chat.Inbound;
2+
3+
/// <summary>What the inbound processor did with a Discord message.</summary>
4+
internal enum InboundOutcome
5+
{
6+
/// <summary>Not a #teamchat message, empty, or a bot/webhook author — nothing relayed.</summary>
7+
Ignored = 0,
8+
9+
/// <summary>Relayed into the game.</summary>
10+
Sent = 1,
11+
12+
/// <summary>Belongs to a #teamchat but could not be relayed (no live socket or send failed).</summary>
13+
Failed = 2,
14+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using System.Globalization;
2+
using RustPlusBot.Features.Chat.Relaying;
3+
using RustPlusBot.Features.Connections.Listening;
4+
using RustPlusBot.Features.Workspace.Locating;
5+
6+
namespace RustPlusBot.Features.Chat.Inbound;
7+
8+
/// <summary>Turns a Discord #teamchat message into an in-game relay (record-then-send), reporting the outcome.</summary>
9+
/// <param name="locator">Resolves whether/which server a channel maps to.</param>
10+
/// <param name="sender">Relays the formatted line into the game.</param>
11+
/// <param name="dedup">Records the relayed line so its in-game echo can be dropped.</param>
12+
internal sealed class TeamChatInboundProcessor(
13+
ITeamChatChannelLocator locator,
14+
ITeamChatSender sender,
15+
RelayDedupBuffer dedup)
16+
{
17+
/// <summary>Processes one observed Discord message.</summary>
18+
/// <param name="message">The reduced message.</param>
19+
/// <param name="cancellationToken">A cancellation token.</param>
20+
/// <returns>What was done with the message.</returns>
21+
public async Task<InboundOutcome> ProcessAsync(InboundMessage message, CancellationToken cancellationToken)
22+
{
23+
if (message.AuthorIsBotOrWebhook || string.IsNullOrWhiteSpace(message.Content))
24+
{
25+
return InboundOutcome.Ignored;
26+
}
27+
28+
var target = await locator.ResolveAsync(message.ChannelId, cancellationToken).ConfigureAwait(false);
29+
if (target is not { } t)
30+
{
31+
return InboundOutcome.Ignored;
32+
}
33+
34+
var key = (t.GuildId, t.ServerId);
35+
var text = string.Create(CultureInfo.InvariantCulture, $"[{message.DisplayName}] {message.Content}");
36+
37+
// Record BEFORE sending: the in-game echo can arrive before SendAsync returns. Unused entries expire.
38+
dedup.Record(key, text);
39+
var result = await sender.SendAsync(t.GuildId, t.ServerId, text, cancellationToken).ConfigureAwait(false);
40+
41+
return result == TeamChatSendResult.Sent ? InboundOutcome.Sent : InboundOutcome.Failed;
42+
}
43+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
using System.Collections.Concurrent;
2+
using RustPlusBot.Abstractions.Time;
3+
4+
namespace RustPlusBot.Features.Chat.Relaying;
5+
6+
/// <summary>
7+
/// Short-lived record of lines the bridge relayed into the game, keyed by (guild, server). When the bot's
8+
/// active player echoes a relayed line back on the socket, <see cref="TryConsume"/> matches and removes one
9+
/// entry so the relay drops the echo instead of re-posting it to Discord.
10+
/// </summary>
11+
/// <param name="clock">Drives entry expiry.</param>
12+
internal sealed class RelayDedupBuffer(IClock clock)
13+
{
14+
private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(15);
15+
private readonly ConcurrentDictionary<(ulong Guild, Guid Server), List<Entry>> _entries = new();
16+
17+
/// <summary>Records that <paramref name="text"/> was relayed into the game for <paramref name="key"/>.</summary>
18+
/// <param name="key">The (guild, server) the line was relayed to.</param>
19+
/// <param name="text">The exact formatted text that was sent.</param>
20+
public void Record((ulong Guild, Guid Server) key, string text)
21+
{
22+
var list = _entries.GetOrAdd(key, _ => []);
23+
lock (list)
24+
{
25+
Prune(list);
26+
list.Add(new Entry(text, clock.UtcNow + Ttl));
27+
}
28+
}
29+
30+
/// <summary>Removes and returns true for the first live entry exactly matching <paramref name="text"/>.</summary>
31+
/// <param name="key">The (guild, server) the echo arrived on.</param>
32+
/// <param name="text">The echoed text to match.</param>
33+
/// <returns>True if a matching entry was consumed (the line is our own echo).</returns>
34+
public bool TryConsume((ulong Guild, Guid Server) key, string text)
35+
{
36+
if (!_entries.TryGetValue(key, out var list))
37+
{
38+
return false;
39+
}
40+
41+
lock (list)
42+
{
43+
Prune(list);
44+
var index = list.FindIndex(e => string.Equals(e.Text, text, StringComparison.Ordinal));
45+
if (index < 0)
46+
{
47+
return false;
48+
}
49+
50+
list.RemoveAt(index);
51+
return true;
52+
}
53+
}
54+
55+
private void Prune(List<Entry> list)
56+
{
57+
var now = clock.UtcNow;
58+
list.RemoveAll(e => e.ExpiresAt <= now);
59+
}
60+
61+
private readonly record struct Entry(string Text, DateTimeOffset ExpiresAt);
62+
}

0 commit comments

Comments
 (0)