Skip to content

Commit 0153279

Browse files
HandyS11claude
andauthored
Server pairing: confirm in #setup before adding (Accept/Dismiss prompt) (#48)
* feat(workspace): add ISetupChannelLocator for the global #setup channel Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(pairing): server-pairing prompt renderer, component ids, localization Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(pairing): setup-channel poster + setup-missing owner notification Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(pairing): ServerPairingCoordinator with in-memory pending + DI wiring Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pairing): serialize server-pairing detection to prevent duplicate prompts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(pairing): gate new-server pairing behind #setup confirmation Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(localization): bump catalog count guard for server.prompt keys Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(pairing): Accept/Dismiss component module for server prompts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style: apply ReformatAndReorder Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pairing): drop pairing when the #setup prompt cannot be posted DiscordChannelMessenger.EnsureAsync returns null on any post failure (including a stale channel id from the locator's 30s TTL cache), but HandleDetectedAsync stored the pending pairing regardless. A null-MessageId pending permanently short-circuited every later in-game "Pair" press into the refresh branch, so no prompt ever existed and the endpoint was stuck until restart. Now a failed post logs a warning and drops the pairing so re-pairing in-game retries cleanly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pairing): re-ensure the #setup prompt on repeat detection so a deleted message self-heals A re-detected pairing refreshed the held token but returned without re-ensuring the prompt. If the original #setup prompt was deleted, every later re-pair kept the pairing pending yet never recreated the prompt, blocking acceptance until a restart cleared pending state. Seed EnsureAsync with the known message id (null on first detection) so an edit self-heals a deleted prompt (repost) and a repeat press never posts a duplicate; the gate still serializes detections. First-post failure still drops pending (retry on re-pair); a transient re-ensure failure keeps the live prompt/pending untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b7f046b commit 0153279

26 files changed

Lines changed: 1233 additions & 51 deletions
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
using Discord;
2+
using Discord.Interactions;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using RustPlusBot.Features.Pairing.Pairing;
5+
using RustPlusBot.Features.Pairing.Rendering;
6+
7+
namespace RustPlusBot.Features.Pairing.Modules;
8+
9+
/// <summary>Thin handler for the #setup server-pairing prompt buttons. Any guild member.</summary>
10+
/// <param name="scopeFactory">Creates a short-lived DI scope per interaction.</param>
11+
public sealed class ServerPairingComponentModule(IServiceScopeFactory scopeFactory)
12+
: InteractionModuleBase<SocketInteractionContext>
13+
{
14+
private const string InvalidControlMessage = "That control wasn't valid.";
15+
private const string ExpiredMessage = "This pairing expired — press \"Pair\" in-game again.";
16+
17+
/// <summary>Accepts a pending server pairing and triggers the full registration cycle.</summary>
18+
/// <param name="tail">The "{ip}:{port}" custom-id tail.</param>
19+
[ComponentInteraction(ServerPairingComponentIds.AcceptPrefix + "*")]
20+
public async Task AcceptAsync(string tail)
21+
{
22+
if (!ServerPairingComponentIds.TryParseTail(tail, out var ip, out var port) || Context.Guild is null)
23+
{
24+
await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
25+
return;
26+
}
27+
28+
await DeferAsync(ephemeral: true).ConfigureAwait(false);
29+
ServerPairingAcceptOutcome outcome;
30+
var scope = scopeFactory.CreateAsyncScope();
31+
await using (scope.ConfigureAwait(false))
32+
{
33+
var coordinator = scope.ServiceProvider.GetRequiredService<IServerPairingCoordinator>();
34+
outcome = await coordinator.TryAcceptAsync(Context.Guild.Id, ip, port, CancellationToken.None)
35+
.ConfigureAwait(false);
36+
}
37+
38+
if (outcome == ServerPairingAcceptOutcome.Expired)
39+
{
40+
// The prompt outlived its pending state (restart/double-click) — clean up the orphaned message.
41+
await DeletePromptMessageSafeAsync().ConfigureAwait(false);
42+
}
43+
44+
await FollowupAsync(outcome switch
45+
{
46+
ServerPairingAcceptOutcome.Added => "Server added.",
47+
ServerPairingAcceptOutcome.AlreadyAdded => "That server was already added.",
48+
_ => ExpiredMessage,
49+
}, ephemeral: true).ConfigureAwait(false);
50+
}
51+
52+
/// <summary>Dismisses a pending server pairing and removes the transient prompt message.</summary>
53+
/// <param name="tail">The "{ip}:{port}" custom-id tail.</param>
54+
[ComponentInteraction(ServerPairingComponentIds.DismissPrefix + "*")]
55+
public async Task DismissAsync(string tail)
56+
{
57+
if (!ServerPairingComponentIds.TryParseTail(tail, out var ip, out var port) || Context.Guild is null)
58+
{
59+
await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
60+
return;
61+
}
62+
63+
bool dismissed;
64+
var scope = scopeFactory.CreateAsyncScope();
65+
await using (scope.ConfigureAwait(false))
66+
{
67+
var coordinator = scope.ServiceProvider.GetRequiredService<IServerPairingCoordinator>();
68+
dismissed = coordinator.TryDismiss(Context.Guild.Id, ip, port);
69+
}
70+
71+
// Delete the actual prompt message that hosts this button (the component interaction's source
72+
// message) — not the ephemeral interaction response. Best-effort: a delete failure is non-fatal.
73+
await DeletePromptMessageSafeAsync().ConfigureAwait(false);
74+
await RespondAsync(dismissed ? "Dismissed." : ExpiredMessage, ephemeral: true).ConfigureAwait(false);
75+
}
76+
77+
private async Task DeletePromptMessageSafeAsync()
78+
{
79+
try
80+
{
81+
// The source message of a component interaction is the prompt that carries the button.
82+
if (Context.Interaction is IComponentInteraction component)
83+
{
84+
await component.Message.DeleteAsync().ConfigureAwait(false);
85+
}
86+
}
87+
#pragma warning disable CA1031 // Best-effort prompt cleanup; a delete failure is non-fatal.
88+
catch (Exception ex)
89+
#pragma warning restore CA1031
90+
{
91+
// Ignore: the prompt is transient and harmless if it lingers.
92+
_ = ex;
93+
}
94+
}
95+
}

src/RustPlusBot.Features.Pairing/Notifications/DiscordOwnerNotifier.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,14 @@ public Task NotifyCredentialsExpiredAsync(
1515
ownerUserId,
1616
"Your Rust+ credentials were rejected. Reconnect your account in #setup to keep receiving pairings.",
1717
cancellationToken);
18+
19+
/// <inheritdoc />
20+
public Task NotifySetupChannelMissingAsync(
21+
ulong guildId,
22+
ulong ownerUserId,
23+
CancellationToken cancellationToken = default) =>
24+
dmSender.SendAsync(
25+
ownerUserId,
26+
"A server pairing arrived but this guild has no #setup channel. Run /setup, then press \"Pair\" in-game again.",
27+
cancellationToken);
1828
}

src/RustPlusBot.Features.Pairing/Notifications/IOwnerNotifier.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,12 @@ internal interface IOwnerNotifier
88
/// <param name="ownerUserId">The Discord user to notify.</param>
99
/// <param name="cancellationToken">A cancellation token.</param>
1010
Task NotifyCredentialsExpiredAsync(ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default);
11+
12+
/// <summary>Tells the owner a server pairing arrived but no #setup channel exists to prompt in.</summary>
13+
/// <param name="guildId">The guild the pairing belongs to.</param>
14+
/// <param name="ownerUserId">The Discord user to notify.</param>
15+
/// <param name="cancellationToken">A cancellation token.</param>
16+
Task NotifySetupChannelMissingAsync(ulong guildId,
17+
ulong ownerUserId,
18+
CancellationToken cancellationToken = default);
1119
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using RustPlusBot.Features.Pairing.Listening;
2+
3+
namespace RustPlusBot.Features.Pairing.Pairing;
4+
5+
/// <summary>The outcome of accepting a pending server pairing.</summary>
6+
internal enum ServerPairingAcceptOutcome
7+
{
8+
/// <summary>The server was created; the full registration cycle was triggered.</summary>
9+
Added = 0,
10+
11+
/// <summary>The server already existed (race); the credential was upserted, no event fired.</summary>
12+
AlreadyAdded = 1,
13+
14+
/// <summary>No pending pairing was held (restart or double-click); nothing was persisted.</summary>
15+
Expired = 2,
16+
}
17+
18+
/// <summary>Coordinates the "Add this server?" #setup prompt for new-server pairings.</summary>
19+
internal interface IServerPairingCoordinator
20+
{
21+
/// <summary>Handles a new-server pairing: prompt in #setup and hold the notification pending.</summary>
22+
/// <param name="guildId">The guild id.</param>
23+
/// <param name="ownerUserId">The Discord user whose connected account produced the pairing.</param>
24+
/// <param name="notification">The transient pairing notification (holds the Rust+ token).</param>
25+
/// <param name="cancellationToken">A token to cancel the operation.</param>
26+
Task HandleDetectedAsync(
27+
ulong guildId,
28+
ulong ownerUserId,
29+
PairingNotification notification,
30+
CancellationToken cancellationToken);
31+
32+
/// <summary>Accepts a pending pairing: persist server + credential and trigger registration. Race-guarded.</summary>
33+
/// <param name="guildId">The guild id.</param>
34+
/// <param name="ip">The server host or ip.</param>
35+
/// <param name="port">The Rust+ app port.</param>
36+
/// <param name="cancellationToken">A token to cancel the operation.</param>
37+
/// <returns>The accept outcome.</returns>
38+
Task<ServerPairingAcceptOutcome> TryAcceptAsync(
39+
ulong guildId,
40+
string ip,
41+
int port,
42+
CancellationToken cancellationToken);
43+
44+
/// <summary>Drops a pending pairing; returns whether one was held.</summary>
45+
/// <param name="guildId">The guild id.</param>
46+
/// <param name="ip">The server host or ip.</param>
47+
/// <param name="port">The Rust+ app port.</param>
48+
/// <returns>True when a pending pairing was removed.</returns>
49+
bool TryDismiss(ulong guildId, string ip, int port);
50+
}

src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,18 @@
66

77
namespace RustPlusBot.Features.Pairing.Pairing;
88

9-
/// <summary>Default <see cref="IPairingHandler"/>: resolve-or-create the server, upsert the credential, announce new servers.</summary>
10-
/// <param name="servers">Server resolve-or-create.</param>
9+
/// <summary>Default <see cref="IPairingHandler"/>: known servers get a silent credential upsert; new servers
10+
/// are routed to the #setup confirmation prompt; entity pairings fan out to their feature events.</summary>
11+
/// <param name="servers">Server lookup/backfill.</param>
1112
/// <param name="credentials">The credential pool store.</param>
12-
/// <param name="eventBus">Publishes the real ServerRegisteredEvent on new-server creation.</param>
13+
/// <param name="eventBus">Publishes the entity paired events.</param>
14+
/// <param name="serverPairings">Prompts for confirmation before a new server is registered.</param>
1315
/// <param name="logger">The logger.</param>
1416
internal sealed partial class PairingHandler(
1517
IServerService servers,
1618
ICredentialStore credentials,
1719
IEventBus eventBus,
20+
IServerPairingCoordinator serverPairings,
1821
ILogger<PairingHandler> logger) : IPairingHandler
1922
{
2023
/// <inheritdoc />
@@ -31,29 +34,29 @@ public async Task HandleAsync(
3134
return;
3235
}
3336

34-
var (server, created) = await servers.ResolveOrCreateByEndpointAsync(
35-
guildId, ownerUserId, notification.ServerName, notification.Ip, notification.Port, cancellationToken)
37+
var existing = await servers.GetByEndpointAsync(guildId, notification.Ip, notification.Port, cancellationToken)
3638
.ConfigureAwait(false);
39+
if (existing is null)
40+
{
41+
// New server: nothing is persisted until the user accepts the #setup prompt.
42+
await serverPairings.HandleDetectedAsync(guildId, ownerUserId, notification, cancellationToken)
43+
.ConfigureAwait(false);
44+
return;
45+
}
3746

3847
// Only backfill a real Facepunch GUID. Persisting Guid.Empty would make every server that paired
3948
// without one share the same id, breaking GUID-based entity-pairing attribution.
4049
if (notification.FacepunchServerId != Guid.Empty)
4150
{
42-
await servers.SetFacepunchServerIdAsync(server.Id, notification.FacepunchServerId, cancellationToken)
51+
await servers.SetFacepunchServerIdAsync(existing.Id, notification.FacepunchServerId, cancellationToken)
4352
.ConfigureAwait(false);
4453
}
4554

4655
await credentials.UpsertFromPairingAsync(
47-
new StoreCredentialRequest(guildId, server.Id, ownerUserId, notification.PlayerId,
56+
new StoreCredentialRequest(guildId, existing.Id, ownerUserId, notification.PlayerId,
4857
notification.PlayerToken),
49-
markActive: created,
58+
markActive: false,
5059
cancellationToken).ConfigureAwait(false);
51-
52-
if (created)
53-
{
54-
await eventBus.PublishAsync(new ServerRegisteredEvent(guildId, server.Id), cancellationToken)
55-
.ConfigureAwait(false);
56-
}
5760
}
5861

5962
private async Task HandleEntityAsync(

0 commit comments

Comments
 (0)