From 73d3158e4341eba960f30cad8cf15fa04b2a505a Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 27 Apr 2026 12:51:04 +0000 Subject: [PATCH 1/2] feat(discord): update approval prompt in-place after decision, matching Slack behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord approval buttons remained active after clicking — unlike Slack, which replaces the original message with a resolved status. Root cause was three gaps: PostReplyAsync didn't return the sent message ID, PendingApprovalRequest didn't store it, and IDiscordReplyClient had no message-update method. Changes: - Add MessageId to DiscordPostResult; capture from SendMessageAsync return - Add UpdateMessageAsync to IDiscordReplyClient using ModifyMessageAsync - Restructure PendingApprovalRequest to store ToolInteractionRequest + PromptMessageId - Add BuildResolvedPromptText for Discord-markdown resolved status rendering - Replace SafeReplyAsync(BuildDecisionStatus) with TryResolveApprovalPromptAsync in both button and text approval handlers --- .../DiscordApprovalPromptBuilderTests.cs | 66 +++++++++++++++++++ .../DiscordFileFlowIntegrationTests.cs | 10 ++- .../RecordingDiscordReplyClient.cs | 19 +++++- .../DiscordApprovalPromptBuilder.cs | 44 +++++++++++-- .../DiscordIngressMessages.cs | 15 +++-- .../DiscordSessionBindingActor.cs | 65 ++++++++++++++---- .../DiscordTransportContracts.cs | 15 ++++- .../Transport/DiscordNetReplyClient.cs | 43 +++++++++++- 8 files changed, 250 insertions(+), 27 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Channels/DiscordApprovalPromptBuilderTests.cs b/src/Netclaw.Actors.Tests/Channels/DiscordApprovalPromptBuilderTests.cs index 97ccd5f4a..c6b3995dc 100644 --- a/src/Netclaw.Actors.Tests/Channels/DiscordApprovalPromptBuilderTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/DiscordApprovalPromptBuilderTests.cs @@ -163,4 +163,70 @@ public void TryParseButtonValue_handles_missing_requester() Assert.Equal(ApprovalOptionKeys.Deny, selectedKey); Assert.Null(requesterSenderId); } + + [Fact] + public void BuildResolvedPromptText_approve_once_shows_checkmark() + { + var request = new ToolInteractionRequest + { + SessionId = new SessionId("test/session"), + Kind = "approval", + CallId = "call-r1", + ToolName = "git_push", + DisplayText = "push to origin/main", + Patterns = ["origin/main"], + Options = [new ToolInteractionOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel)] + }; + + var text = DiscordApprovalPromptBuilder.BuildResolvedPromptText( + request, ApprovalOptionKeys.ApproveOnce, "user-42"); + + Assert.Contains(":white_check_mark:", text); + Assert.Contains("git_push", text); + Assert.Contains("push to origin/main", text); + Assert.Contains("origin/main", text); + Assert.Contains(ApprovalOptionKeys.ApproveOnceLabel, text); + Assert.Contains("<@user-42>", text); + } + + [Fact] + public void BuildResolvedPromptText_deny_shows_no_entry() + { + var request = new ToolInteractionRequest + { + SessionId = new SessionId("test/session"), + Kind = "approval", + CallId = "call-r2", + ToolName = "rm_file", + DisplayText = "delete /etc/passwd", + Options = [new ToolInteractionOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel)] + }; + + var text = DiscordApprovalPromptBuilder.BuildResolvedPromptText( + request, ApprovalOptionKeys.Deny, "user-99"); + + Assert.Contains(":no_entry:", text); + Assert.Contains(ApprovalOptionKeys.DenyLabel, text); + Assert.DoesNotContain(":white_check_mark:", text); + } + + [Fact] + public void BuildResolvedPromptText_omits_patterns_when_empty() + { + var request = new ToolInteractionRequest + { + SessionId = new SessionId("test/session"), + Kind = "approval", + CallId = "call-r3", + ToolName = "read_file", + DisplayText = "read config.json", + Patterns = [], + Options = [new ToolInteractionOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel)] + }; + + var text = DiscordApprovalPromptBuilder.BuildResolvedPromptText( + request, ApprovalOptionKeys.ApproveOnce, "user-1"); + + Assert.DoesNotContain("Pattern", text); + } } diff --git a/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs b/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs index afd18f637..b1ea5e742 100644 --- a/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs @@ -404,17 +404,25 @@ public Task PostReplyAsync(DiscordPostMessage message, Cancel { Posts.Add(message); - DiscordPostResult result = DiscordPostResult.Default; + DiscordPostResult result; if (message.CreateThreadOnMessage is not null) { var threadId = new DiscordReplyChannelId($"thread-{message.CreateThreadOnMessage.Value.Value}"); result = new DiscordPostResult(CreatedThreadId: threadId); } + else + { + result = DiscordPostResult.Default; + } return Task.FromResult(result); } public Task SetThreadNameAsync(DiscordReplyChannelId threadChannelId, string name, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task UpdateMessageAsync(DiscordReplyChannelId channelId, DiscordMessageId messageId, string text, + bool removeComponents = false, CancellationToken cancellationToken = default) + => Task.CompletedTask; } } diff --git a/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingDiscordReplyClient.cs b/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingDiscordReplyClient.cs index c43cd3bf1..b6c958a53 100644 --- a/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingDiscordReplyClient.cs +++ b/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingDiscordReplyClient.cs @@ -6,8 +6,11 @@ internal sealed class RecordingDiscordReplyClient : IDiscordReplyClient { public List Posts { get; } = []; public List<(DiscordReplyChannelId ThreadId, string Name)> ThreadRenames { get; } = []; + public List<(DiscordReplyChannelId ChannelId, DiscordMessageId MessageId, string Text, bool RemoveComponents)> Updates { get; } = []; public Exception? ThrowOnPost { get; set; } + private int _messageCounter; + public Task PostReplyAsync(DiscordPostMessage message, CancellationToken cancellationToken = default) { if (ThrowOnPost is { } ex) @@ -15,11 +18,16 @@ public Task PostReplyAsync(DiscordPostMessage message, Cancel Posts.Add(message); - DiscordPostResult result = DiscordPostResult.Default; + var messageId = new DiscordMessageId($"msg-{Interlocked.Increment(ref _messageCounter)}"); + DiscordPostResult result; if (message.CreateThreadOnMessage is not null) { var threadId = new DiscordReplyChannelId($"thread-{message.CreateThreadOnMessage.Value.Value}"); - result = new DiscordPostResult(CreatedThreadId: threadId); + result = new DiscordPostResult(CreatedThreadId: threadId, MessageId: messageId); + } + else + { + result = new DiscordPostResult(MessageId: messageId); } return Task.FromResult(result); @@ -30,4 +38,11 @@ public Task SetThreadNameAsync(DiscordReplyChannelId threadChannelId, string nam ThreadRenames.Add((threadChannelId, name)); return Task.CompletedTask; } + + public Task UpdateMessageAsync(DiscordReplyChannelId channelId, DiscordMessageId messageId, string text, + bool removeComponents = false, CancellationToken cancellationToken = default) + { + Updates.Add((channelId, messageId, text, removeComponents)); + return Task.CompletedTask; + } } diff --git a/src/Netclaw.Channels.Discord/DiscordApprovalPromptBuilder.cs b/src/Netclaw.Channels.Discord/DiscordApprovalPromptBuilder.cs index 5b8eee5e4..e189221fb 100644 --- a/src/Netclaw.Channels.Discord/DiscordApprovalPromptBuilder.cs +++ b/src/Netclaw.Channels.Discord/DiscordApprovalPromptBuilder.cs @@ -61,7 +61,46 @@ public static (string Text, IReadOnlyList Buttons) BuildButto public static string BuildDecisionStatus(string selectedKey) { - var label = selectedKey switch + var label = GetDecisionLabel(selectedKey); + return $"Recorded approval decision: {label}."; + } + + public static string BuildResolvedPromptText( + ToolInteractionRequest request, + string selectedKey, + string senderId) + { + var statusEmoji = selectedKey == ApprovalOptionKeys.Deny + ? ":no_entry:" + : ":white_check_mark:"; + var decisionLabel = GetDecisionLabel(selectedKey); + + var sb = new StringBuilder(); + sb.Append(statusEmoji).AppendLine(" **Tool approval resolved**"); + sb.Append("**Tool:** `").Append(request.ToolName).AppendLine("`"); + sb.Append("**Action:** `").Append(request.DisplayText).AppendLine("`"); + + if (request.Patterns.Count > 0) + { + if (request.Patterns.Count == 1) + { + sb.Append("**Pattern:** `").Append(request.Patterns[0]).AppendLine("`"); + } + else + { + sb.AppendLine("**Patterns:**"); + foreach (var pattern in request.Patterns) + sb.Append(" • `").Append(pattern).AppendLine("`"); + } + } + + sb.Append("**Decision:** ").Append(decisionLabel); + sb.Append(" (by <@").Append(senderId).Append(">)"); + return sb.ToString(); + } + + private static string GetDecisionLabel(string selectedKey) + => selectedKey switch { ApprovalOptionKeys.ApproveOnce => ApprovalOptionKeys.ApproveOnceLabel, ApprovalOptionKeys.ApproveSession => ApprovalOptionKeys.ApproveSessionLabel, @@ -70,9 +109,6 @@ public static string BuildDecisionStatus(string selectedKey) _ => selectedKey }; - return $"Recorded approval decision: {label}."; - } - internal static string BuildButtonValue(ToolInteractionRequest request, ToolInteractionOption option) => ApprovalButtonValueCodec.Encode(request, option); diff --git a/src/Netclaw.Channels.Discord/DiscordIngressMessages.cs b/src/Netclaw.Channels.Discord/DiscordIngressMessages.cs index df1f35109..2d42fef33 100644 --- a/src/Netclaw.Channels.Discord/DiscordIngressMessages.cs +++ b/src/Netclaw.Channels.Discord/DiscordIngressMessages.cs @@ -33,8 +33,15 @@ public sealed record DiscordApprovalResponse( DiscordUserId SenderId, DiscordUserId? RequesterSenderId = null); -internal sealed record PendingApprovalRequest( - string CallId, - DiscordUserId? RequesterSenderId, - PrincipalClassification? RequesterPrincipal); +internal sealed class PendingApprovalRequest(ToolInteractionRequest request) +{ + public ToolInteractionRequest Request { get; } = request; + public string CallId => Request.CallId; + + public DiscordUserId? RequesterSenderId { get; } = + request.RequesterSenderId is not null ? new DiscordUserId(request.RequesterSenderId) : null; + + public PrincipalClassification? RequesterPrincipal => Request.RequesterPrincipal; + public DiscordMessageId? PromptMessageId { get; set; } +} diff --git a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs index 736d68f75..55d4f6a09 100644 --- a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs +++ b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs @@ -491,7 +491,7 @@ await _dependencies.Pipeline.SendFeedbackAsync(new ToolInteractionResponse SenderId = message.SenderId.Value }); - await SafeReplyAsync(DiscordApprovalPromptBuilder.BuildDecisionStatus(selectedKey)); + await TryResolveApprovalPromptAsync(pending!, selectedKey, message.SenderId.Value); return true; } @@ -522,7 +522,40 @@ await _dependencies.Pipeline.SendFeedbackAsync(new ToolInteractionResponse SenderId = message.SenderId.Value }); - await SafeReplyAsync(DiscordApprovalPromptBuilder.BuildDecisionStatus(message.SelectedKey)); + await TryResolveApprovalPromptAsync(pending!, message.SelectedKey, message.SenderId.Value); + } + + private async Task TryResolveApprovalPromptAsync( + PendingApprovalRequest pending, + string selectedKey, + string senderId) + { + if (pending.PromptMessageId is not { } promptMessageId) + return; + + try + { + var resolvedText = DiscordApprovalPromptBuilder.BuildResolvedPromptText( + pending.Request, + selectedKey, + senderId); + + using var cts = new CancellationTokenSource(OperationTimeout); + await _dependencies.ReplyClient.UpdateMessageAsync( + _replyChannelId, + promptMessageId, + resolvedText, + removeComponents: true, + cts.Token); + } + catch (Exception ex) + { + _log.Warning( + ex, + "Failed to update resolved approval prompt for call {CallId} messageId={MessageId}", + pending.CallId, + pending.PromptMessageId?.Value); + } } private async Task HandleTrustedReminderAsync(DeliverTrustedSessionTurn message) @@ -634,13 +667,18 @@ private async Task HandleOutputReceivedAsync(OutputReceived msg) break; case ToolInteractionRequest request when string.Equals(request.Kind, "approval", StringComparison.OrdinalIgnoreCase): - _pendingApprovalRequests.Add(new PendingApprovalRequest( - request.CallId, - request.RequesterSenderId is null ? null : new DiscordUserId(request.RequesterSenderId), - request.RequesterPrincipal)); + var pendingApproval = new PendingApprovalRequest(request); + _pendingApprovalRequests.Add(pendingApproval); - var (promptText, buttons) = DiscordApprovalPromptBuilder.BuildButtonPrompt(request); - await SafeReplyWithButtonsAsync(promptText, buttons, request); + var promptMessageId = await SafeReplyWithButtonsAsync(request); + if (promptMessageId is not null) + { + pendingApproval.PromptMessageId = promptMessageId; + } + else + { + _pendingApprovalRequests.Remove(pendingApproval); + } break; case SessionTitleOutput titleOutput: @@ -671,20 +709,19 @@ private async Task HandleOutputReceivedAsync(OutputReceived msg) } } - private async Task SafeReplyWithButtonsAsync( - string text, - IReadOnlyList buttons, - ToolInteractionRequest request) + private async Task SafeReplyWithButtonsAsync(ToolInteractionRequest request) { + var (promptText, buttons) = DiscordApprovalPromptBuilder.BuildButtonPrompt(request); var startedAt = _dependencies.TimeProvider.GetTimestamp(); try { - var postMessage = BuildPostMessage(text, buttons: buttons); + var postMessage = BuildPostMessage(promptText, buttons: buttons); var result = await _dependencies.ReplyClient.PostReplyAsync(postMessage); ApplyThreadPromotion(result); var duration = _dependencies.TimeProvider.GetElapsedTime(startedAt).TotalMilliseconds; ChannelTelemetry.RecordDiscordReplyPosted(duration); ChannelTelemetry.RecordDiscordApprovalFallbackActivated("button_prompt"); + return result.MessageId; } catch (Exception ex) { @@ -696,11 +733,13 @@ private async Task SafeReplyWithButtonsAsync( var postMessage = BuildPostMessage(fallbackText); var result = await _dependencies.ReplyClient.PostReplyAsync(postMessage); ApplyThreadPromotion(result); + return result.MessageId; } catch (Exception textEx) { _log.Error(textEx, "Failed posting text-only approval fallback; auto-denying request"); await SendApprovalDenyOnFailureAsync(request.CallId); + return null; } } } diff --git a/src/Netclaw.Channels.Discord/DiscordTransportContracts.cs b/src/Netclaw.Channels.Discord/DiscordTransportContracts.cs index b39daa6d9..687eb0a59 100644 --- a/src/Netclaw.Channels.Discord/DiscordTransportContracts.cs +++ b/src/Netclaw.Channels.Discord/DiscordTransportContracts.cs @@ -50,6 +50,13 @@ public interface IDiscordReplyClient Task PostReplyAsync(DiscordPostMessage message, CancellationToken cancellationToken = default); Task SetThreadNameAsync(DiscordReplyChannelId threadChannelId, string name, CancellationToken cancellationToken = default); + + Task UpdateMessageAsync( + DiscordReplyChannelId channelId, + DiscordMessageId messageId, + string text, + bool removeComponents = false, + CancellationToken cancellationToken = default); } public sealed record DiscordPostMessage( @@ -61,7 +68,8 @@ public sealed record DiscordPostMessage( string? ThreadName = null); public sealed record DiscordPostResult( - DiscordReplyChannelId? CreatedThreadId = null) + DiscordReplyChannelId? CreatedThreadId = null, + DiscordMessageId? MessageId = null) { public static readonly DiscordPostResult Default = new(); } @@ -121,4 +129,9 @@ public Task PostReplyAsync(DiscordPostMessage message, Cancel public Task SetThreadNameAsync(DiscordReplyChannelId threadChannelId, string name, CancellationToken cancellationToken = default) => throw new InvalidOperationException( "Discord channel attempted to set thread name, but no Discord reply client is configured."); + + public Task UpdateMessageAsync(DiscordReplyChannelId channelId, DiscordMessageId messageId, string text, + bool removeComponents = false, CancellationToken cancellationToken = default) + => throw new InvalidOperationException( + "Discord channel attempted to update a message, but no Discord reply client is configured."); } diff --git a/src/Netclaw.Channels.Discord/Transport/DiscordNetReplyClient.cs b/src/Netclaw.Channels.Discord/Transport/DiscordNetReplyClient.cs index 894987f2f..27c619203 100644 --- a/src/Netclaw.Channels.Discord/Transport/DiscordNetReplyClient.cs +++ b/src/Netclaw.Channels.Discord/Transport/DiscordNetReplyClient.cs @@ -108,12 +108,16 @@ public async Task PostReplyAsync(DiscordPostMessage message, if (message.CreateThreadOnMessage is null && message.RootMessageId is { } rootId) rootRef = new MessageReference(ParseSnowflake(rootId.Value, "root message ID")); - await targetChannel.SendMessageAsync( + var sentMessage = await targetChannel.SendMessageAsync( text: message.Text, messageReference: rootRef, components: components); - return new DiscordPostResult(CreatedThreadId: createdThreadId); + var sentMessageId = sentMessage is not null + ? new DiscordMessageId(sentMessage.Id.ToString()) + : (DiscordMessageId?)null; + + return new DiscordPostResult(CreatedThreadId: createdThreadId, MessageId: sentMessageId); } public async Task SetThreadNameAsync(DiscordReplyChannelId threadChannelId, string name, CancellationToken cancellationToken = default) @@ -129,6 +133,41 @@ await thread.ModifyAsync(props => }, new RequestOptions { CancelToken = cancellationToken }); } + public async Task UpdateMessageAsync( + DiscordReplyChannelId channelId, + DiscordMessageId messageId, + string text, + bool removeComponents = false, + CancellationToken cancellationToken = default) + { + var channelSnowflake = ParseSnowflake(channelId.Value, "reply channel ID"); + var messageSnowflake = ParseSnowflake(messageId.Value, "message ID"); + + IMessageChannel? messageChannel = _client.GetChannel(channelSnowflake) as IMessageChannel; + if (messageChannel is null && !_restChannelCache.TryGetValue(channelSnowflake, out messageChannel)) + { + var restChannel = await _client.Rest.GetChannelAsync(channelSnowflake); + messageChannel = restChannel as IMessageChannel; + if (messageChannel is not null) + { + if (_restChannelCache.Count >= MaxRestChannelCacheSize) + _restChannelCache.Clear(); + _restChannelCache[channelSnowflake] = messageChannel; + } + } + + if (messageChannel is null) + throw new InvalidOperationException( + $"Discord channel {channelId.Value} not found or is not a message channel."); + + await messageChannel.ModifyMessageAsync(messageSnowflake, props => + { + props.Content = text; + if (removeComponents) + props.Components = new ComponentBuilder().Build(); + }, new RequestOptions { CancelToken = cancellationToken }); + } + private static ulong ParseSnowflake(string value, string label) { if (!ulong.TryParse(value, out var id)) From f29ca6a21cde533afbccbc0a29880591e36bd0f3 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 27 Apr 2026 12:57:40 +0000 Subject: [PATCH 2/2] refactor(discord): extract shared helpers from approval prompt and reply client - Extract ResolveMessageChannelAsync in DiscordNetReplyClient to deduplicate the socket-cache + REST-fallback channel resolution between PostReplyAsync and UpdateMessageAsync - Cache empty MessageComponent as a static field instead of allocating per call - Extract AppendToolSummary in DiscordApprovalPromptBuilder to deduplicate tool/action/pattern rendering between BuildButtonPrompt and BuildResolvedPromptText - Fix redundant null-conditional on promptMessageId in catch block --- .../DiscordApprovalPromptBuilder.cs | 30 ++++------ .../DiscordSessionBindingActor.cs | 2 +- .../Transport/DiscordNetReplyClient.cs | 56 ++++++++----------- 3 files changed, 33 insertions(+), 55 deletions(-) diff --git a/src/Netclaw.Channels.Discord/DiscordApprovalPromptBuilder.cs b/src/Netclaw.Channels.Discord/DiscordApprovalPromptBuilder.cs index e189221fb..eb0be5f0d 100644 --- a/src/Netclaw.Channels.Discord/DiscordApprovalPromptBuilder.cs +++ b/src/Netclaw.Channels.Discord/DiscordApprovalPromptBuilder.cs @@ -29,22 +29,7 @@ public static (string Text, IReadOnlyList Buttons) BuildButto { var sb = new StringBuilder(); sb.AppendLine(":lock: **Tool approval required**"); - sb.Append("**Tool:** `").Append(request.ToolName).AppendLine("`"); - sb.Append("**Action:** `").Append(request.DisplayText).AppendLine("`"); - - if (request.Patterns.Count > 0) - { - if (request.Patterns.Count == 1) - { - sb.Append("**Pattern:** `").Append(request.Patterns[0]).AppendLine("`"); - } - else - { - sb.AppendLine("**Patterns:**"); - foreach (var pattern in request.Patterns) - sb.Append(" • `").Append(pattern).AppendLine("`"); - } - } + AppendToolSummary(sb, request); sb.AppendLine(); sb.Append("You can also reply with `A`, `B`, `C`, or `D` in this thread."); @@ -77,6 +62,15 @@ public static string BuildResolvedPromptText( var sb = new StringBuilder(); sb.Append(statusEmoji).AppendLine(" **Tool approval resolved**"); + AppendToolSummary(sb, request); + + sb.Append("**Decision:** ").Append(decisionLabel); + sb.Append(" (by <@").Append(senderId).Append(">)"); + return sb.ToString(); + } + + private static void AppendToolSummary(StringBuilder sb, ToolInteractionRequest request) + { sb.Append("**Tool:** `").Append(request.ToolName).AppendLine("`"); sb.Append("**Action:** `").Append(request.DisplayText).AppendLine("`"); @@ -93,10 +87,6 @@ public static string BuildResolvedPromptText( sb.Append(" • `").Append(pattern).AppendLine("`"); } } - - sb.Append("**Decision:** ").Append(decisionLabel); - sb.Append(" (by <@").Append(senderId).Append(">)"); - return sb.ToString(); } private static string GetDecisionLabel(string selectedKey) diff --git a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs index 55d4f6a09..1e06c5b3b 100644 --- a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs +++ b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs @@ -554,7 +554,7 @@ await _dependencies.ReplyClient.UpdateMessageAsync( ex, "Failed to update resolved approval prompt for call {CallId} messageId={MessageId}", pending.CallId, - pending.PromptMessageId?.Value); + promptMessageId.Value); } } diff --git a/src/Netclaw.Channels.Discord/Transport/DiscordNetReplyClient.cs b/src/Netclaw.Channels.Discord/Transport/DiscordNetReplyClient.cs index 27c619203..df14d4a7a 100644 --- a/src/Netclaw.Channels.Discord/Transport/DiscordNetReplyClient.cs +++ b/src/Netclaw.Channels.Discord/Transport/DiscordNetReplyClient.cs @@ -9,6 +9,7 @@ namespace Netclaw.Channels.Discord.Transport; internal sealed class DiscordNetReplyClient : IDiscordReplyClient { private const int MaxRestChannelCacheSize = 1000; + private static readonly MessageComponent EmptyComponents = new ComponentBuilder().Build(); private readonly DiscordSocketClient _client; private readonly ConcurrentDictionary _restChannelCache = new(); @@ -32,27 +33,7 @@ public DiscordNetReplyClient(DiscordSocketClient client) public async Task PostReplyAsync(DiscordPostMessage message, CancellationToken cancellationToken = default) { var channelId = ParseSnowflake(message.ReplyChannelId.Value, "reply channel ID"); - - // Socket cache misses for DM channels — fall back to REST API. - IMessageChannel? messageChannel = _client.GetChannel(channelId) as IMessageChannel; - if (messageChannel is null && !_restChannelCache.TryGetValue(channelId, out messageChannel)) - { - var restChannel = await _client.Rest.GetChannelAsync(channelId); - messageChannel = restChannel as IMessageChannel; - if (messageChannel is not null) - { - // Safety valve — evict stale entries if the cache grows too large. - // Full clear is acceptable here because the cache repopulates lazily - // and only DM channels (socket cache misses) land here. - if (_restChannelCache.Count >= MaxRestChannelCacheSize) - _restChannelCache.Clear(); - _restChannelCache[channelId] = messageChannel; - } - } - - if (messageChannel is null) - throw new InvalidOperationException( - $"Discord channel {message.ReplyChannelId.Value} not found or is not a message channel."); + var messageChannel = await ResolveMessageChannelAsync(channelId, message.ReplyChannelId.Value); IMessageChannel targetChannel = messageChannel; DiscordReplyChannelId? createdThreadId = null; @@ -142,30 +123,37 @@ public async Task UpdateMessageAsync( { var channelSnowflake = ParseSnowflake(channelId.Value, "reply channel ID"); var messageSnowflake = ParseSnowflake(messageId.Value, "message ID"); + var messageChannel = await ResolveMessageChannelAsync(channelSnowflake, channelId.Value); + + await messageChannel.ModifyMessageAsync(messageSnowflake, props => + { + props.Content = text; + if (removeComponents) + props.Components = EmptyComponents; + }, new RequestOptions { CancelToken = cancellationToken }); + } - IMessageChannel? messageChannel = _client.GetChannel(channelSnowflake) as IMessageChannel; - if (messageChannel is null && !_restChannelCache.TryGetValue(channelSnowflake, out messageChannel)) + private async Task ResolveMessageChannelAsync(ulong channelSnowflake, string channelIdForError) + { + // Socket cache misses for DM channels — fall back to REST API. + IMessageChannel? channel = _client.GetChannel(channelSnowflake) as IMessageChannel; + if (channel is null && !_restChannelCache.TryGetValue(channelSnowflake, out channel)) { var restChannel = await _client.Rest.GetChannelAsync(channelSnowflake); - messageChannel = restChannel as IMessageChannel; - if (messageChannel is not null) + channel = restChannel as IMessageChannel; + if (channel is not null) { if (_restChannelCache.Count >= MaxRestChannelCacheSize) _restChannelCache.Clear(); - _restChannelCache[channelSnowflake] = messageChannel; + _restChannelCache[channelSnowflake] = channel; } } - if (messageChannel is null) + if (channel is null) throw new InvalidOperationException( - $"Discord channel {channelId.Value} not found or is not a message channel."); + $"Discord channel {channelIdForError} not found or is not a message channel."); - await messageChannel.ModifyMessageAsync(messageSnowflake, props => - { - props.Content = text; - if (removeComponents) - props.Components = new ComponentBuilder().Build(); - }, new RequestOptions { CancelToken = cancellationToken }); + return channel; } private static ulong ParseSnowflake(string value, string label)