Skip to content

Commit eb45027

Browse files
HandyS11claude
andcommitted
feat(2a): EventRelay, #events poster/locator, hosted service, DI + Workspace spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0b43641 commit eb45027

15 files changed

Lines changed: 642 additions & 1 deletion

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using RustPlusBot.Features.Events.Classifying;
3+
using RustPlusBot.Features.Events.Hosting;
4+
using RustPlusBot.Features.Events.Posting;
5+
using RustPlusBot.Features.Events.Relaying;
6+
using RustPlusBot.Features.Events.Rendering;
7+
using RustPlusBot.Features.Events.State;
8+
9+
namespace RustPlusBot.Features.Events;
10+
11+
/// <summary>DI registration for the live-events feature.</summary>
12+
public static class EventServiceCollectionExtensions
13+
{
14+
/// <summary>Registers the classifier, state store, renderer, relay, poster, and hosted service.</summary>
15+
/// <param name="services">The service collection to add to.</param>
16+
/// <returns>The same service collection, for chaining.</returns>
17+
public static IServiceCollection AddEvents(this IServiceCollection services)
18+
{
19+
ArgumentNullException.ThrowIfNull(services);
20+
21+
services.AddSingleton<EventStateStore>();
22+
services.AddSingleton<IEventState>(sp => sp.GetRequiredService<EventStateStore>());
23+
services.AddSingleton(EventLocalizationCatalog.Default);
24+
services.AddSingleton<IEventLocalizer, EventLocalizer>();
25+
services.AddSingleton<MarkerEventClassifier>();
26+
services.AddSingleton<EventEmbedRenderer>();
27+
services.AddSingleton<IEventChannelPoster, DiscordEventChannelPoster>();
28+
services.AddSingleton<EventRelay>();
29+
services.AddHostedService<EventsHostedService>();
30+
31+
return services;
32+
}
33+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using Microsoft.Extensions.Hosting;
3+
using Microsoft.Extensions.Logging;
4+
using RustPlusBot.Abstractions.Events;
5+
using RustPlusBot.Domain.Connections;
6+
using RustPlusBot.Features.Events.Relaying;
7+
using RustPlusBot.Features.Events.State;
8+
using RustPlusBot.Persistence.Connections;
9+
10+
namespace RustPlusBot.Features.Events.Hosting;
11+
12+
/// <summary>Runs the marker-change relay loop and the disconnect-clear loop.</summary>
13+
/// <param name="eventBus">The in-process event bus.</param>
14+
/// <param name="relay">Relays marker deltas into Discord #events.</param>
15+
/// <param name="store">Cleared on disconnect.</param>
16+
/// <param name="scopeFactory">Opens scopes to read connection state.</param>
17+
/// <param name="logger">The logger.</param>
18+
internal sealed partial class EventsHostedService(
19+
IEventBus eventBus,
20+
EventRelay relay,
21+
EventStateStore store,
22+
IServiceScopeFactory scopeFactory,
23+
ILogger<EventsHostedService> logger) : IHostedService, IDisposable
24+
{
25+
private readonly CancellationTokenSource _cts = new();
26+
private Task? _disconnectLoop;
27+
private Task? _relayLoop;
28+
29+
/// <inheritdoc />
30+
public void Dispose() => _cts.Dispose();
31+
32+
/// <inheritdoc />
33+
public Task StartAsync(CancellationToken cancellationToken)
34+
{
35+
_relayLoop = Task.Run(() => ConsumeMarkerEventsAsync(_cts.Token), CancellationToken.None);
36+
_disconnectLoop = Task.Run(() => ConsumeConnectionStatusEventsAsync(_cts.Token), CancellationToken.None);
37+
return Task.CompletedTask;
38+
}
39+
40+
/// <inheritdoc />
41+
public async Task StopAsync(CancellationToken cancellationToken)
42+
{
43+
await _cts.CancelAsync().ConfigureAwait(false);
44+
foreach (var loop in new[]
45+
{
46+
_relayLoop, _disconnectLoop
47+
}.Where(t => t is not null))
48+
{
49+
try
50+
{
51+
#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks — these are our own loop tasks, joined on stop.
52+
await loop!.ConfigureAwait(false);
53+
#pragma warning restore VSTHRD003
54+
}
55+
catch (OperationCanceledException)
56+
{
57+
// Expected on shutdown.
58+
}
59+
}
60+
}
61+
62+
private async Task ConsumeMarkerEventsAsync(CancellationToken cancellationToken)
63+
{
64+
try
65+
{
66+
await foreach (var evt in eventBus.SubscribeAsync<MapMarkersChangedEvent>(cancellationToken)
67+
.ConfigureAwait(false))
68+
{
69+
await relay.RelayAsync(evt, cancellationToken).ConfigureAwait(false);
70+
}
71+
}
72+
catch (OperationCanceledException)
73+
{
74+
// Shutting down.
75+
}
76+
#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
77+
catch (Exception ex)
78+
#pragma warning restore CA1031
79+
{
80+
LogRelayLoopFaulted(logger, ex);
81+
}
82+
}
83+
84+
private async Task ConsumeConnectionStatusEventsAsync(CancellationToken cancellationToken)
85+
{
86+
try
87+
{
88+
await foreach (var evt in eventBus.SubscribeAsync<ConnectionStatusChangedEvent>(cancellationToken)
89+
.ConfigureAwait(false))
90+
{
91+
await ClearIfDisconnectedAsync(evt, cancellationToken).ConfigureAwait(false);
92+
}
93+
}
94+
catch (OperationCanceledException)
95+
{
96+
// Shutting down.
97+
}
98+
#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
99+
catch (Exception ex)
100+
#pragma warning restore CA1031
101+
{
102+
LogDisconnectLoopFaulted(logger, ex);
103+
}
104+
}
105+
106+
private async Task ClearIfDisconnectedAsync(ConnectionStatusChangedEvent evt, CancellationToken cancellationToken)
107+
{
108+
var scope = scopeFactory.CreateAsyncScope();
109+
await using (scope.ConfigureAwait(false))
110+
{
111+
var connectionStore = scope.ServiceProvider.GetRequiredService<IConnectionStore>();
112+
var state = await connectionStore.GetStateAsync(evt.GuildId, evt.ServerId, cancellationToken)
113+
.ConfigureAwait(false);
114+
if (state is null || state.Status != ConnectionStatus.Connected)
115+
{
116+
store.Clear(evt.GuildId, evt.ServerId);
117+
}
118+
}
119+
}
120+
121+
[LoggerMessage(Level = LogLevel.Error, Message = "Event relay loop faulted.")]
122+
private static partial void LogRelayLoopFaulted(ILogger logger, Exception exception);
123+
124+
[LoggerMessage(Level = LogLevel.Error, Message = "Disconnect-clear loop faulted.")]
125+
private static partial void LogDisconnectLoopFaulted(ILogger logger, Exception exception);
126+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using Discord;
2+
using Discord.WebSocket;
3+
using Microsoft.Extensions.Logging;
4+
5+
namespace RustPlusBot.Features.Events.Posting;
6+
7+
/// <summary>Posts event embeds to Discord text channels via the gateway client.</summary>
8+
/// <param name="client">The Discord socket client.</param>
9+
/// <param name="logger">The logger.</param>
10+
internal sealed partial class DiscordEventChannelPoster(
11+
DiscordSocketClient client,
12+
ILogger<DiscordEventChannelPoster> logger)
13+
: IEventChannelPoster
14+
{
15+
/// <inheritdoc />
16+
public async Task PostAsync(ulong channelId, Embed embed, CancellationToken cancellationToken)
17+
{
18+
try
19+
{
20+
if (await client.GetChannelAsync(channelId).ConfigureAwait(false) is not ITextChannel channel)
21+
{
22+
return;
23+
}
24+
25+
await channel.SendMessageAsync(embed: embed).ConfigureAwait(false);
26+
}
27+
#pragma warning disable CA1031 // Broad catch: a Discord hiccup must not crash the relay.
28+
catch (Exception ex)
29+
#pragma warning restore CA1031
30+
{
31+
LogPostFailed(logger, ex, channelId);
32+
}
33+
}
34+
35+
[LoggerMessage(Level = LogLevel.Warning, Message = "Posting an event embed to channel {ChannelId} failed.")]
36+
private static partial void LogPostFailed(ILogger logger, Exception exception, ulong channelId);
37+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using Discord;
2+
3+
namespace RustPlusBot.Features.Events.Posting;
4+
5+
/// <summary>Posts an event embed to a Discord channel.</summary>
6+
internal interface IEventChannelPoster
7+
{
8+
/// <summary>Posts <paramref name="embed"/> to the channel.</summary>
9+
/// <param name="channelId">The target Discord channel id.</param>
10+
/// <param name="embed">The embed to post.</param>
11+
/// <param name="cancellationToken">A cancellation token.</param>
12+
/// <returns>A task that completes when the embed has been posted.</returns>
13+
Task PostAsync(ulong channelId, Embed embed, CancellationToken cancellationToken);
14+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using RustPlusBot.Abstractions.Events;
3+
using RustPlusBot.Features.Events.Classifying;
4+
using RustPlusBot.Features.Events.Posting;
5+
using RustPlusBot.Features.Events.Rendering;
6+
using RustPlusBot.Features.Events.State;
7+
using RustPlusBot.Features.Workspace.Locating;
8+
using RustPlusBot.Persistence.Workspace;
9+
10+
namespace RustPlusBot.Features.Events.Relaying;
11+
12+
/// <summary>Classifies one marker delta, updates state, and posts one embed per event to #events.</summary>
13+
/// <param name="classifier">Classifies raw marker deltas into domain events.</param>
14+
/// <param name="state">Tracks active markers and recent events per server.</param>
15+
/// <param name="renderer">Renders events as Discord embeds.</param>
16+
/// <param name="locator">Resolves the #events Discord channel id.</param>
17+
/// <param name="poster">Posts embeds to the Discord channel.</param>
18+
/// <param name="scopeFactory">Opens scopes to read guild culture.</param>
19+
internal sealed class EventRelay(
20+
MarkerEventClassifier classifier,
21+
EventStateStore state,
22+
EventEmbedRenderer renderer,
23+
IEventChannelLocator locator,
24+
IEventChannelPoster poster,
25+
IServiceScopeFactory scopeFactory)
26+
{
27+
/// <summary>Handles one <see cref="MapMarkersChangedEvent"/>.</summary>
28+
/// <param name="evt">The marker delta.</param>
29+
/// <param name="cancellationToken">A cancellation token.</param>
30+
/// <returns>A task that completes when the delta has been processed.</returns>
31+
public async Task RelayAsync(MapMarkersChangedEvent evt, CancellationToken cancellationToken)
32+
{
33+
ArgumentNullException.ThrowIfNull(evt);
34+
var events = classifier.Classify(evt);
35+
state.Apply(evt, events);
36+
if (events.Count == 0)
37+
{
38+
return;
39+
}
40+
41+
var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken)
42+
.ConfigureAwait(false);
43+
if (channelId is null)
44+
{
45+
return;
46+
}
47+
48+
var culture = await GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false);
49+
foreach (var e in events)
50+
{
51+
var embed = renderer.Render(e, culture);
52+
await poster.PostAsync(channelId.Value, embed, cancellationToken).ConfigureAwait(false);
53+
}
54+
}
55+
56+
private async Task<string> GetCultureAsync(ulong guildId, CancellationToken cancellationToken)
57+
{
58+
var scope = scopeFactory.CreateAsyncScope();
59+
await using (scope.ConfigureAwait(false))
60+
{
61+
var store = scope.ServiceProvider.GetRequiredService<IWorkspaceStore>();
62+
return await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false);
63+
}
64+
}
65+
}

src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ internal sealed class LocalizationCatalog
1919
["channel.settings.name"] = "settings",
2020
["channel.info.name"] = "info",
2121
["channel.teamchat.name"] = "teamchat",
22+
["channel.events.name"] = "events",
2223
["information.title"] = "RustPlusBot",
2324
["information.body"] = "Connect your Rust+ account in #setup, then pair a server in-game to begin.",
2425
["information.servers"] = "Servers registered: {0}",
@@ -52,6 +53,7 @@ internal sealed class LocalizationCatalog
5253
["channel.settings.name"] = "parametres",
5354
["channel.info.name"] = "info",
5455
["channel.teamchat.name"] = "tchat-equipe",
56+
["channel.events.name"] = "evenements",
5557
["information.title"] = "RustPlusBot",
5658
["information.body"] =
5759
"Connectez votre compte Rust+ dans #configuration, puis appairez un serveur en jeu.",
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using RustPlusBot.Abstractions.Time;
3+
using RustPlusBot.Persistence.Workspace;
4+
5+
namespace RustPlusBot.Features.Workspace.Locating;
6+
7+
/// <summary>
8+
/// Caches the small set of provisioned #events channels (rebuilt when the cache goes stale) and resolves
9+
/// the game-to-Discord direction only (there is no Discord→game path for events).
10+
/// </summary>
11+
/// <param name="scopeFactory">Opens scopes for the scoped workspace store.</param>
12+
/// <param name="clock">Drives the cache TTL.</param>
13+
internal sealed class EventChannelLocator(IServiceScopeFactory scopeFactory, IClock clock)
14+
: IEventChannelLocator, IDisposable
15+
{
16+
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
17+
private readonly SemaphoreSlim _refreshGate = new(1, 1);
18+
19+
private DateTimeOffset _builtAt = DateTimeOffset.MinValue;
20+
21+
private Dictionary<(ulong GuildId, Guid ServerId), ulong> _byServer = new();
22+
23+
/// <inheritdoc />
24+
public void Dispose() => _refreshGate.Dispose();
25+
26+
/// <inheritdoc />
27+
public async Task<ulong?> GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken)
28+
{
29+
await EnsureFreshAsync(cancellationToken).ConfigureAwait(false);
30+
return _byServer.TryGetValue((guildId, serverId), out var id) ? id : null;
31+
}
32+
33+
private async Task EnsureFreshAsync(CancellationToken cancellationToken)
34+
{
35+
if (clock.UtcNow - _builtAt < CacheTtl)
36+
{
37+
return;
38+
}
39+
40+
await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
41+
try
42+
{
43+
if (clock.UtcNow - _builtAt < CacheTtl)
44+
{
45+
return;
46+
}
47+
48+
var scope = scopeFactory.CreateAsyncScope();
49+
await using (scope.ConfigureAwait(false))
50+
{
51+
var store = scope.ServiceProvider.GetRequiredService<IWorkspaceStore>();
52+
var rows = await store.GetChannelsByKeyAsync(WorkspaceChannelKeys.ServerEvents, cancellationToken)
53+
.ConfigureAwait(false);
54+
55+
var byServer = new Dictionary<(ulong GuildId, Guid ServerId), ulong>();
56+
foreach (var row in rows)
57+
{
58+
if (row.RustServerId is not { } serverId)
59+
{
60+
continue;
61+
}
62+
63+
byServer[(row.GuildId, serverId)] = row.DiscordChannelId;
64+
}
65+
66+
_byServer = byServer;
67+
_builtAt = clock.UtcNow;
68+
}
69+
}
70+
finally
71+
{
72+
_refreshGate.Release();
73+
}
74+
}
75+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace RustPlusBot.Features.Workspace.Locating;
2+
3+
/// <summary>Resolves the per-server #events channel (game-to-Discord direction only).</summary>
4+
public interface IEventChannelLocator
5+
{
6+
/// <summary>Gets the Discord channel id of the #events for (<paramref name="guildId"/>, <paramref name="serverId"/>), or null.</summary>
7+
/// <param name="guildId">The guild snowflake.</param>
8+
/// <param name="serverId">The server id.</param>
9+
/// <param name="cancellationToken">A cancellation token.</param>
10+
/// <returns>The Discord channel id, or null if not provisioned.</returns>
11+
Task<ulong?> GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);
12+
}

src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ public IEnumerable<ChannelSpec> GetChannelSpecs() =>
1212
ChannelPermissionProfile.ReadOnly, 0),
1313
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerTeamChat, "channel.teamchat.name",
1414
ChannelPermissionProfile.Interactive, 1),
15+
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerEvents, "channel.events.name",
16+
ChannelPermissionProfile.ReadOnly, 2),
1517
];
1618

1719
/// <inheritdoc />

0 commit comments

Comments
 (0)